2026-05-01 20:39:09 +08:00
|
|
|
import { Buffer } from 'node:buffer'
|
2026-04-27 00:57:13 +08:00
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
import { nativeImage } from 'electron'
|
2026-04-27 00:57:13 +08:00
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client'
|
2026-05-30 19:52:17 +08:00
|
|
|
import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common'
|
2026-04-27 00:57:13 +08:00
|
|
|
|
|
|
|
|
export interface ImageAssetBlob {
|
2026-05-01 20:39:09 +08:00
|
|
|
blob: Blob
|
|
|
|
|
width: number
|
|
|
|
|
height: number
|
|
|
|
|
fileName: string
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CropRect {
|
2026-05-01 20:39:09 +08:00
|
|
|
x: number
|
|
|
|
|
y: number
|
|
|
|
|
width: number
|
|
|
|
|
height: number
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const passthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif'])
|
2026-04-27 00:57:13 +08:00
|
|
|
|
2026-04-27 12:21:02 +08:00
|
|
|
function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null {
|
2026-05-01 20:39:09 +08:00
|
|
|
if (!publicAssetUrl.pathname.startsWith('/api/public/assets/')) {
|
|
|
|
|
return null
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const token = publicAssetUrl.pathname.slice('/api/public/assets/'.length)
|
2026-04-27 12:21:02 +08:00
|
|
|
if (!token) {
|
2026-05-01 20:39:09 +08:00
|
|
|
return null
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const fallbackURL = new URL(resolveDesktopApiURL(`/api/desktop/content/assets/${token}`))
|
2026-04-27 12:21:02 +08:00
|
|
|
publicAssetUrl.searchParams.forEach((value, key) => {
|
2026-05-01 20:39:09 +08:00
|
|
|
fallbackURL.searchParams.set(key, value)
|
|
|
|
|
})
|
|
|
|
|
fallbackURL.searchParams.set('format', 'png')
|
|
|
|
|
return fallbackURL.toString()
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 00:57:13 +08:00
|
|
|
export function buildAssetURLCandidates(sourceUrl: string): string[] {
|
2026-05-01 20:39:09 +08:00
|
|
|
const trimmed = sourceUrl.trim()
|
2026-04-27 00:57:13 +08:00
|
|
|
if (!trimmed) {
|
2026-05-01 20:39:09 +08:00
|
|
|
return []
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (/^(data|blob):/i.test(trimmed)) {
|
2026-05-01 20:39:09 +08:00
|
|
|
return [trimmed]
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const candidates: string[] = []
|
2026-04-27 00:57:13 +08:00
|
|
|
const pushCandidate = (value: string) => {
|
|
|
|
|
if (!candidates.includes(value)) {
|
2026-05-01 20:39:09 +08:00
|
|
|
candidates.push(value)
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
2026-05-01 20:39:09 +08:00
|
|
|
}
|
2026-04-27 00:57:13 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-05-01 20:39:09 +08:00
|
|
|
const parsed = new URL(trimmed, resolveDesktopApiURL('/'))
|
2026-04-27 00:57:13 +08:00
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
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')
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
2026-05-01 20:39:09 +08:00
|
|
|
pushCandidate(apiAssetUrl.toString())
|
2026-04-27 12:21:02 +08:00
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl)
|
2026-04-27 12:21:02 +08:00
|
|
|
if (desktopAssetFallbackURL) {
|
2026-05-01 20:39:09 +08:00
|
|
|
pushCandidate(desktopAssetFallbackURL)
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
pushCandidate(parsed.toString())
|
2026-04-27 00:57:13 +08:00
|
|
|
} catch {
|
2026-05-01 20:39:09 +08:00
|
|
|
pushCandidate(trimmed)
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
return candidates
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 12:21:02 +08:00
|
|
|
function shouldUseDesktopAssetFetch(candidate: string): boolean {
|
|
|
|
|
try {
|
2026-05-01 20:39:09 +08:00
|
|
|
return new URL(candidate).pathname.startsWith('/api/desktop/content/assets/')
|
2026-04-27 12:21:02 +08:00
|
|
|
} catch {
|
2026-05-01 20:39:09 +08:00
|
|
|
return false
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileNameForImageType(sourceType: string): string {
|
2026-05-01 20:39:09 +08:00
|
|
|
if (sourceType === 'image/gif') {
|
|
|
|
|
return 'image.gif'
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
2026-05-01 20:39:09 +08:00
|
|
|
if (sourceType === 'image/jpeg' || sourceType === 'image/jpg') {
|
|
|
|
|
return 'image.jpg'
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
2026-05-01 20:39:09 +08:00
|
|
|
return 'image.png'
|
2026-04-27 12:21:02 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:52:17 +08:00
|
|
|
export async function fetchImageAssetBlob(
|
|
|
|
|
sourceUrl: string,
|
|
|
|
|
options: AdapterFetchOptions = {},
|
|
|
|
|
): Promise<ImageAssetBlob | null> {
|
2026-04-27 00:57:13 +08:00
|
|
|
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
2026-04-27 12:21:02 +08:00
|
|
|
const response = await (
|
2026-05-30 19:52:17 +08:00
|
|
|
shouldUseDesktopAssetFetch(candidate)
|
|
|
|
|
? fetchDesktopApiURL(candidate, {
|
2026-06-08 11:56:18 +08:00
|
|
|
signal: mergeAbortSignals([options.signal, timeoutSignal(options.timeoutMs ?? 10_000)]),
|
2026-05-30 19:52:17 +08:00
|
|
|
})
|
2026-06-08 11:56:18 +08:00
|
|
|
: adapterFetch(
|
|
|
|
|
candidate,
|
|
|
|
|
{},
|
|
|
|
|
{ timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal },
|
|
|
|
|
)
|
2026-05-01 20:39:09 +08:00
|
|
|
).catch(() => null)
|
2026-04-27 00:57:13 +08:00
|
|
|
if (!response?.ok) {
|
2026-05-01 20:39:09 +08:00
|
|
|
continue
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const sourceBlob = await response.blob().catch(() => null)
|
|
|
|
|
if (!sourceBlob || !sourceBlob.type.startsWith('image/')) {
|
|
|
|
|
continue
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer()))
|
2026-04-27 00:57:13 +08:00
|
|
|
if (image.isEmpty()) {
|
2026-05-01 20:39:09 +08:00
|
|
|
continue
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const size = image.getSize()
|
|
|
|
|
const sourceType = sourceBlob.type.toLowerCase()
|
2026-04-27 00:57:13 +08:00
|
|
|
if (passthroughImageTypes.has(sourceType)) {
|
|
|
|
|
return {
|
|
|
|
|
blob: sourceBlob,
|
|
|
|
|
width: size.width,
|
|
|
|
|
height: size.height,
|
2026-04-27 12:21:02 +08:00
|
|
|
fileName: fileNameForImageType(sourceType),
|
2026-05-01 20:39:09 +08:00
|
|
|
}
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-01 20:39:09 +08:00
|
|
|
blob: new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }),
|
2026-04-27 00:57:13 +08:00
|
|
|
width: size.width,
|
|
|
|
|
height: size.height,
|
2026-05-01 20:39:09 +08:00
|
|
|
fileName: 'image.png',
|
|
|
|
|
}
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
return null
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect {
|
2026-05-01 20:39:09 +08:00
|
|
|
let cropWidth = width
|
|
|
|
|
let cropHeight = cropWidth / ratio
|
2026-04-27 00:57:13 +08:00
|
|
|
if (cropHeight > height) {
|
2026-05-01 20:39:09 +08:00
|
|
|
cropHeight = height
|
|
|
|
|
cropWidth = cropHeight * ratio
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
x: (width - cropWidth) / 2,
|
|
|
|
|
y: (height - cropHeight) / 2,
|
|
|
|
|
width: cropWidth,
|
|
|
|
|
height: cropHeight,
|
2026-05-01 20:39:09 +08:00
|
|
|
}
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
export async function cropImageAssetBlob(
|
|
|
|
|
source: ImageAssetBlob,
|
|
|
|
|
ratio: number,
|
|
|
|
|
): Promise<ImageAssetBlob | null> {
|
|
|
|
|
const image = nativeImage.createFromBuffer(Buffer.from(await source.blob.arrayBuffer()))
|
2026-04-27 00:57:13 +08:00
|
|
|
if (image.isEmpty()) {
|
2026-05-01 20:39:09 +08:00
|
|
|
return null
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const crop = getCenteredCropRect(source.width, source.height, ratio)
|
2026-04-27 00:57:13 +08:00
|
|
|
const cropped = image.crop({
|
|
|
|
|
x: Math.max(0, Math.round(crop.x)),
|
|
|
|
|
y: Math.max(0, Math.round(crop.y)),
|
|
|
|
|
width: Math.max(1, Math.round(crop.width)),
|
|
|
|
|
height: Math.max(1, Math.round(crop.height)),
|
2026-05-01 20:39:09 +08:00
|
|
|
})
|
2026-04-27 00:57:13 +08:00
|
|
|
if (cropped.isEmpty()) {
|
2026-05-01 20:39:09 +08:00
|
|
|
return null
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 20:39:09 +08:00
|
|
|
const size = cropped.getSize()
|
2026-04-27 00:57:13 +08:00
|
|
|
return {
|
2026-05-01 20:39:09 +08:00
|
|
|
blob: new Blob([new Uint8Array(cropped.toPNG())], { type: 'image/png' }),
|
2026-04-27 00:57:13 +08:00
|
|
|
width: size.width,
|
|
|
|
|
height: size.height,
|
2026-05-01 20:39:09 +08:00
|
|
|
fileName: 'image.png',
|
|
|
|
|
}
|
2026-04-27 00:57:13 +08:00
|
|
|
}
|