diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index 4b192c2..f13d0ea 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@geo/desktop-client", - "version": "0.1.1", + "version": "0.1.2", "private": true, "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。", "author": { diff --git a/apps/desktop-client/src/main/adapters/media-image.test.ts b/apps/desktop-client/src/main/adapters/media-image.test.ts index 1e0c413..acbbe21 100644 --- a/apps/desktop-client/src/main/adapters/media-image.test.ts +++ b/apps/desktop-client/src/main/adapters/media-image.test.ts @@ -33,6 +33,14 @@ describe('media image helpers', () => { ]) }) + it('can prefer jpeg conversion for platforms that reject large png uploads', () => { + expect(buildAssetURLCandidates('/api/public/assets/12', { preferredFormat: 'jpg' })).toEqual([ + 'http://127.0.0.1:8080/api/public/assets/12?format=jpg', + 'http://127.0.0.1:8080/api/desktop/content/assets/12?format=jpg', + 'http://127.0.0.1:8080/api/public/assets/12', + ]) + }) + it('centers crop rectangles at the requested ratio', () => { const rect = getCenteredCropRect(1600, 1000, 4 / 3) diff --git a/apps/desktop-client/src/main/adapters/media-image.ts b/apps/desktop-client/src/main/adapters/media-image.ts index c51902a..1117467 100644 --- a/apps/desktop-client/src/main/adapters/media-image.ts +++ b/apps/desktop-client/src/main/adapters/media-image.ts @@ -19,9 +19,23 @@ export interface CropRect { height: number } -const passthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif']) +export type AssetImageFormat = 'png' | 'jpg' | 'jpeg' -function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null { +export interface FetchImageAssetBlobOptions extends AdapterFetchOptions { + preferredFormat?: AssetImageFormat + passthroughTypes?: string[] +} + +const defaultPassthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif']) + +function normalizeAssetImageFormat(format: AssetImageFormat | undefined): AssetImageFormat { + return format === 'jpg' || format === 'jpeg' ? 'jpg' : 'png' +} + +function buildDesktopAssetFallbackURL( + publicAssetUrl: URL, + preferredFormat: AssetImageFormat, +): string | null { if (!publicAssetUrl.pathname.startsWith('/api/public/assets/')) { return null } @@ -35,11 +49,14 @@ function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null { publicAssetUrl.searchParams.forEach((value, key) => { fallbackURL.searchParams.set(key, value) }) - fallbackURL.searchParams.set('format', 'png') + fallbackURL.searchParams.set('format', preferredFormat) return fallbackURL.toString() } -export function buildAssetURLCandidates(sourceUrl: string): string[] { +export function buildAssetURLCandidates( + sourceUrl: string, + options: { preferredFormat?: AssetImageFormat } = {}, +): string[] { const trimmed = sourceUrl.trim() if (!trimmed) { return [] @@ -57,16 +74,17 @@ export function buildAssetURLCandidates(sourceUrl: string): string[] { } try { + const preferredFormat = normalizeAssetImageFormat(options.preferredFormat) const parsed = new URL(trimmed, resolveDesktopApiURL('/')) if (parsed.pathname.startsWith('/api/')) { const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)) if (apiAssetUrl.pathname.startsWith('/api/public/assets/')) { - apiAssetUrl.searchParams.set('format', 'png') + apiAssetUrl.searchParams.set('format', preferredFormat) } pushCandidate(apiAssetUrl.toString()) - const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl) + const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl, preferredFormat) if (desktopAssetFallbackURL) { pushCandidate(desktopAssetFallbackURL) } @@ -100,9 +118,17 @@ function fileNameForImageType(sourceType: string): string { export async function fetchImageAssetBlob( sourceUrl: string, - options: AdapterFetchOptions = {}, + options: FetchImageAssetBlobOptions = {}, ): Promise { - for (const candidate of buildAssetURLCandidates(sourceUrl)) { + const passthroughImageTypes = new Set( + (options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) => + type.toLowerCase(), + ), + ) + + for (const candidate of buildAssetURLCandidates(sourceUrl, { + preferredFormat: options.preferredFormat, + })) { const response = await ( shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate, { diff --git a/apps/desktop-client/src/main/adapters/toutiao.test.ts b/apps/desktop-client/src/main/adapters/toutiao.test.ts index 08f7d96..1ccea02 100644 --- a/apps/desktop-client/src/main/adapters/toutiao.test.ts +++ b/apps/desktop-client/src/main/adapters/toutiao.test.ts @@ -62,11 +62,19 @@ describe('toutiao adapter', () => { expect(coverBlob?.type).toBe('image/png') expect(fetchImageAssetBlob).toHaveBeenCalledWith( '/api/public/assets/body-token', - expect.anything(), + expect.objectContaining({ + preferredFormat: 'jpg', + passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'], + timeoutMs: 30_000, + }), ) expect(fetchImageAssetBlob).toHaveBeenCalledWith( '/api/public/assets/cover-token', - expect.anything(), + expect.objectContaining({ + preferredFormat: 'jpg', + passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'], + timeoutMs: 30_000, + }), ) }) }) diff --git a/apps/desktop-client/src/main/adapters/toutiao.ts b/apps/desktop-client/src/main/adapters/toutiao.ts index fa950df..b5f0c35 100644 --- a/apps/desktop-client/src/main/adapters/toutiao.ts +++ b/apps/desktop-client/src/main/adapters/toutiao.ts @@ -9,7 +9,14 @@ async function fetchToutiaoImageBlob( sourceUrl: string, signal?: AbortSignal, ): Promise { - return (await fetchImageAssetBlob(sourceUrl, { signal }))?.blob ?? null + return ( + (await fetchImageAssetBlob(sourceUrl, { + signal, + preferredFormat: 'jpg', + passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'], + timeoutMs: 30_000, + }))?.blob ?? null + ) } function buildResultPayload( @@ -47,7 +54,11 @@ export const toutiaoAdapter: PublishAdapter = { sessionFetchJson(context.session, input, init, { signal: context.signal }), fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal), uploadHtmlImages: (sourceHtml, uploader) => - uploadHtmlImages(sourceHtml, uploader, { signal: context.signal }), + uploadHtmlImages(sourceHtml, uploader, { + signal: context.signal, + imageTimeoutMs: 30_000, + timeoutMs: 120_000, + }), async reportProgress(stage) { await context.reportProgress(`toutiao.${stage}`) }, diff --git a/packages/publisher-platforms/src/toutiao.ts b/packages/publisher-platforms/src/toutiao.ts index 638535a..c79a702 100644 --- a/packages/publisher-platforms/src/toutiao.ts +++ b/packages/publisher-platforms/src/toutiao.ts @@ -138,7 +138,7 @@ async function uploadCover( if (publishType === 'publish') { const form = new FormData() - form.append('upfile', blob, 'cover.png') + form.append('upfile', blob, imageFileName(blob, 'cover')) const uploaded = await transport .fetchJson( 'https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture', @@ -164,7 +164,7 @@ async function uploadCover( } const firstForm = new FormData() - firstForm.append('image', blob, 'cover.png') + firstForm.append('image', blob, imageFileName(blob, 'cover')) const first = await transport .fetchJson( 'https://mp.toutiao.com/spice/image?device_platform=web', @@ -220,7 +220,7 @@ async function uploadContentImage( } const form = new FormData() - form.append('image', blob, 'image.png') + form.append('image', blob, imageFileName(blob, 'image')) const uploaded = await transport .fetchJson( 'https://mp.toutiao.com/spice/image?device_platform=web', @@ -234,6 +234,14 @@ async function uploadContentImage( return uploaded?.data?.image_url ?? null } +function imageFileName(blob: Blob, baseName: string): string { + const type = blob.type.toLowerCase() + if (type === 'image/jpeg' || type === 'image/jpg') { + return `${baseName}.jpg` + } + return `${baseName}.png` +} + export async function publishToutiaoArticle( input: ToutiaoPublishArticleInput, transport: ToutiaoPublishTransport,