fix desktop smzdm webp image upload
Frontend CI / Frontend (push) Successful in 3m13s
Desktop Client Build / Resolve Build Metadata (push) Successful in 25s
Desktop Client Build / Build Desktop Client (push) Successful in 21m47s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 25s
Frontend CI / Frontend (push) Successful in 3m13s
Desktop Client Build / Resolve Build Metadata (push) Successful in 25s
Desktop Client Build / Build Desktop Client (push) Successful in 21m47s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 25s
This commit is contained in:
@@ -80,26 +80,16 @@ const platformMap = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const platformEntries = computed<PublishPlatformEntry[]>(() => {
|
const platformEntries = computed<PublishPlatformEntry[]>(() => {
|
||||||
const seen = new Set<string>()
|
|
||||||
const entries: PublishPlatformEntry[] = []
|
const entries: PublishPlatformEntry[] = []
|
||||||
|
|
||||||
for (const record of publishRecordsQuery.data.value ?? []) {
|
for (const record of publishRecordsQuery.data.value ?? []) {
|
||||||
const targetType = String(record.target_type ?? 'platform_account')
|
const targetType = String(record.target_type ?? 'platform_account')
|
||||||
const key =
|
|
||||||
targetType === 'enterprise_site'
|
|
||||||
? `${targetType}:${record.target_connection_id ?? record.id}`
|
|
||||||
: `${record.platform_id}:${record.platform_account_id}`
|
|
||||||
if (seen.has(key)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen.add(key)
|
|
||||||
|
|
||||||
const normalizedId = normalizePublishPlatformId(record.platform_id)
|
const normalizedId = normalizePublishPlatformId(record.platform_id)
|
||||||
const platform = platformMap.value.get(normalizedId)
|
const platform = platformMap.value.get(normalizedId)
|
||||||
const fallback = enterprisePlatformFallback(normalizedId)
|
const fallback = enterprisePlatformFallback(normalizedId)
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
key,
|
key: `${targetType}:${record.id}`,
|
||||||
name: record.platform_name || platform?.name || fallback.name,
|
name: record.platform_name || platform?.name || fallback.name,
|
||||||
shortName: platform?.short_name || fallback.shortName,
|
shortName: platform?.short_name || fallback.shortName,
|
||||||
accent: platform?.accent_color || fallback.accent,
|
accent: platform?.accent_color || fallback.accent,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ vi.mock('../transport/api-client', () => ({
|
|||||||
resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`,
|
resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { buildAssetURLCandidates, getCenteredCropRect } from './media-image'
|
import { buildAssetURLCandidates, getCenteredCropRect, normalizeImageAssetBlob } from './media-image'
|
||||||
|
|
||||||
describe('media image helpers', () => {
|
describe('media image helpers', () => {
|
||||||
it('resolves public asset URLs through public and authenticated desktop candidates', () => {
|
it('resolves public asset URLs through public and authenticated desktop candidates', () => {
|
||||||
@@ -41,6 +41,21 @@ describe('media image helpers', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('normalizes protocol-relative image URLs to https like browser fetches on HTTPS pages', () => {
|
||||||
|
expect(buildAssetURLCandidates('//res.smzdm.com/cover.png')).toEqual([
|
||||||
|
'https://res.smzdm.com/cover.png',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts webp blobs to png when webp is not a platform passthrough type', async () => {
|
||||||
|
const image = await normalizeImageAssetBlob(new Blob(['webp'], { type: 'image/webp' }), {
|
||||||
|
passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(image?.blob.type).toBe('image/png')
|
||||||
|
expect(image?.fileName).toBe('image.png')
|
||||||
|
})
|
||||||
|
|
||||||
it('centers crop rectangles at the requested ratio', () => {
|
it('centers crop rectangles at the requested ratio', () => {
|
||||||
const rect = getCenteredCropRect(1600, 1000, 4 / 3)
|
const rect = getCenteredCropRect(1600, 1000, 4 / 3)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer'
|
|||||||
import { nativeImage } from 'electron'
|
import { nativeImage } from 'electron'
|
||||||
|
|
||||||
import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client'
|
import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client'
|
||||||
|
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from '../user-agent'
|
||||||
import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common'
|
import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common'
|
||||||
|
|
||||||
export interface ImageAssetBlob {
|
export interface ImageAssetBlob {
|
||||||
@@ -12,6 +13,11 @@ export interface ImageAssetBlob {
|
|||||||
fileName: string
|
fileName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawImageAssetBlob {
|
||||||
|
blob: Blob
|
||||||
|
fileName: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CropRect {
|
export interface CropRect {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -24,6 +30,7 @@ export type AssetImageFormat = 'png' | 'jpg' | 'jpeg'
|
|||||||
export interface FetchImageAssetBlobOptions extends AdapterFetchOptions {
|
export interface FetchImageAssetBlobOptions extends AdapterFetchOptions {
|
||||||
preferredFormat?: AssetImageFormat
|
preferredFormat?: AssetImageFormat
|
||||||
passthroughTypes?: string[]
|
passthroughTypes?: string[]
|
||||||
|
headers?: HeadersInit
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultPassthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif'])
|
const defaultPassthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif'])
|
||||||
@@ -66,6 +73,10 @@ export function buildAssetURLCandidates(
|
|||||||
return [trimmed]
|
return [trimmed]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/^\/\//.test(trimmed)) {
|
||||||
|
return [`https:${trimmed}`]
|
||||||
|
}
|
||||||
|
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
const pushCandidate = (value: string) => {
|
const pushCandidate = (value: string) => {
|
||||||
if (!candidates.includes(value)) {
|
if (!candidates.includes(value)) {
|
||||||
@@ -113,30 +124,43 @@ function fileNameForImageType(sourceType: string): string {
|
|||||||
if (sourceType === 'image/jpeg' || sourceType === 'image/jpg') {
|
if (sourceType === 'image/jpeg' || sourceType === 'image/jpg') {
|
||||||
return 'image.jpg'
|
return 'image.jpg'
|
||||||
}
|
}
|
||||||
|
if (sourceType === 'image/webp') {
|
||||||
|
return 'image.webp'
|
||||||
|
}
|
||||||
return 'image.png'
|
return 'image.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchImageAssetBlob(
|
function imageFetchHeaders(options: Pick<FetchImageAssetBlobOptions, 'headers'>): Headers {
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
if (!headers.has('accept')) {
|
||||||
|
headers.set('accept', 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8')
|
||||||
|
}
|
||||||
|
if (!headers.has('accept-language')) {
|
||||||
|
headers.set('accept-language', STANDARD_ACCEPT_LANGUAGES)
|
||||||
|
}
|
||||||
|
if (!headers.has('user-agent')) {
|
||||||
|
headers.set('user-agent', STANDARD_USER_AGENT)
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRawImageAssetBlob(
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
options: FetchImageAssetBlobOptions = {},
|
options: FetchImageAssetBlobOptions = {},
|
||||||
): Promise<ImageAssetBlob | null> {
|
): Promise<RawImageAssetBlob | null> {
|
||||||
const passthroughImageTypes = new Set(
|
|
||||||
(options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) =>
|
|
||||||
type.toLowerCase(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const candidate of buildAssetURLCandidates(sourceUrl, {
|
for (const candidate of buildAssetURLCandidates(sourceUrl, {
|
||||||
preferredFormat: options.preferredFormat,
|
preferredFormat: options.preferredFormat,
|
||||||
})) {
|
})) {
|
||||||
|
const headers = imageFetchHeaders(options)
|
||||||
const response = await (
|
const response = await (
|
||||||
shouldUseDesktopAssetFetch(candidate)
|
shouldUseDesktopAssetFetch(candidate)
|
||||||
? fetchDesktopApiURL(candidate, {
|
? fetchDesktopApiURL(candidate, {
|
||||||
signal: mergeAbortSignals([options.signal, timeoutSignal(options.timeoutMs ?? 10_000)]),
|
signal: mergeAbortSignals([options.signal, timeoutSignal(options.timeoutMs ?? 10_000)]),
|
||||||
|
headers,
|
||||||
})
|
})
|
||||||
: adapterFetch(
|
: adapterFetch(
|
||||||
candidate,
|
candidate,
|
||||||
{},
|
{ headers },
|
||||||
{ timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal },
|
{ timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal },
|
||||||
)
|
)
|
||||||
).catch(() => null)
|
).catch(() => null)
|
||||||
@@ -149,33 +173,60 @@ export async function fetchImageAssetBlob(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer()))
|
|
||||||
if (image.isEmpty()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const size = image.getSize()
|
|
||||||
const sourceType = sourceBlob.type.toLowerCase()
|
|
||||||
if (passthroughImageTypes.has(sourceType)) {
|
|
||||||
return {
|
|
||||||
blob: sourceBlob,
|
|
||||||
width: size.width,
|
|
||||||
height: size.height,
|
|
||||||
fileName: fileNameForImageType(sourceType),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blob: new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }),
|
blob: sourceBlob,
|
||||||
width: size.width,
|
fileName: fileNameForImageType(sourceBlob.type.toLowerCase()),
|
||||||
height: size.height,
|
|
||||||
fileName: 'image.png',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageAssetBlob(
|
||||||
|
sourceBlob: Blob,
|
||||||
|
options: Pick<FetchImageAssetBlobOptions, 'passthroughTypes'> = {},
|
||||||
|
): Promise<ImageAssetBlob | null> {
|
||||||
|
if (!sourceBlob.type.startsWith('image/')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer()))
|
||||||
|
if (image.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = image.getSize()
|
||||||
|
const passthroughImageTypes = new Set(
|
||||||
|
(options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) =>
|
||||||
|
type.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const sourceType = sourceBlob.type.toLowerCase()
|
||||||
|
if (passthroughImageTypes.has(sourceType)) {
|
||||||
|
return {
|
||||||
|
blob: sourceBlob,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
fileName: fileNameForImageType(sourceType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }),
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
fileName: 'image.png',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImageAssetBlob(
|
||||||
|
sourceUrl: string,
|
||||||
|
options: FetchImageAssetBlobOptions = {},
|
||||||
|
): Promise<ImageAssetBlob | null> {
|
||||||
|
const raw = await fetchRawImageAssetBlob(sourceUrl, options)
|
||||||
|
return raw ? await normalizeImageAssetBlob(raw.blob, options) : null
|
||||||
|
}
|
||||||
|
|
||||||
export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect {
|
export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect {
|
||||||
let cropWidth = width
|
let cropWidth = width
|
||||||
let cropHeight = cropWidth / ratio
|
let cropHeight = cropWidth / ratio
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
sessionCookieValue,
|
sessionCookieValue,
|
||||||
sessionFetchJson,
|
sessionFetchJson,
|
||||||
} from './common'
|
} from './common'
|
||||||
import { fetchImageAssetBlob } from './media-image'
|
import {
|
||||||
|
fetchRawImageAssetBlob,
|
||||||
|
normalizeImageAssetBlob,
|
||||||
|
type FetchImageAssetBlobOptions,
|
||||||
|
type ImageAssetBlob,
|
||||||
|
} from './media-image'
|
||||||
|
|
||||||
type SmzdmPublishType = 'publish'
|
type SmzdmPublishType = 'publish'
|
||||||
|
|
||||||
@@ -475,13 +480,127 @@ export function getSmzdmCoverCropRect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const smzdmImageFetchOptions: FetchImageAssetBlobOptions = {
|
||||||
|
preferredFormat: 'jpg',
|
||||||
|
passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'],
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
headers: {
|
||||||
|
referer: SMZDM_TOU_GAO_URL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageAssetFromDataUrl(dataUrl: string): ImageAssetBlob | null {
|
||||||
|
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([Buffer.from(match[2], 'base64')], { type: match[1].toLowerCase() })
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
fileName: match[1].toLowerCase() === 'image/jpeg' ? 'image.jpg' : 'image.png',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertImageInBrowser(
|
||||||
|
context: PublishAdapterContext,
|
||||||
|
sourceUrl: string,
|
||||||
|
): Promise<ImageAssetBlob | null> {
|
||||||
|
const page = context.playwright?.page
|
||||||
|
if (!page) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await raceWithAbort(
|
||||||
|
page.evaluate(async (url) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
if (!blob.type.startsWith('image/')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(blob)
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = bitmap.width
|
||||||
|
canvas.height = bitmap.height
|
||||||
|
const context2d = canvas.getContext('2d')
|
||||||
|
if (!context2d) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
context2d.drawImage(bitmap, 0, 0)
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
return {
|
||||||
|
dataUrl,
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
}, sourceUrl),
|
||||||
|
{ signal: context.signal, timeoutMs: 30_000 },
|
||||||
|
).catch(() => null)
|
||||||
|
|
||||||
|
if (!result?.dataUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = imageAssetFromDataUrl(result.dataUrl)
|
||||||
|
if (!image) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...image,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSmzdmImageAssetBlob(
|
||||||
|
context: PublishAdapterContext,
|
||||||
|
sourceUrl: string,
|
||||||
|
): Promise<ImageAssetBlob | null> {
|
||||||
|
const raw = await fetchRawImageAssetBlob(sourceUrl, smzdmImageFetchOptions)
|
||||||
|
if (!raw) {
|
||||||
|
return await convertImageInBrowser(context, sourceUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = await normalizeImageAssetBlob(raw.blob, smzdmImageFetchOptions)
|
||||||
|
if (normalized) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawType = raw.blob.type.toLowerCase()
|
||||||
|
if (rawType === 'image/webp' || raw.fileName.endsWith('.webp')) {
|
||||||
|
const dataUrl = `data:${raw.blob.type};base64,${Buffer.from(await raw.blob.arrayBuffer()).toString(
|
||||||
|
'base64',
|
||||||
|
)}`
|
||||||
|
return await convertImageInBrowser(context, dataUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadImage(
|
async function uploadImage(
|
||||||
context: PublishAdapterContext,
|
context: PublishAdapterContext,
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
articleId: string,
|
articleId: string,
|
||||||
cropCover: boolean,
|
cropCover: boolean,
|
||||||
): Promise<SmzdmUploadedImage | null> {
|
): Promise<SmzdmUploadedImage | null> {
|
||||||
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
|
const image = await fetchSmzdmImageAssetBlob(context, sourceUrl)
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed')
|
throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user