Files
geo/apps/desktop-client/src/main/adapters/media-image.ts
T
root 24a62fd52c
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
fix desktop smzdm webp image upload
2026-06-25 01:11:46 +08:00

274 lines
7.3 KiB
TypeScript

import { Buffer } from 'node:buffer'
import { nativeImage } from 'electron'
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'
export interface ImageAssetBlob {
blob: Blob
width: number
height: number
fileName: string
}
export interface RawImageAssetBlob {
blob: Blob
fileName: string
}
export interface CropRect {
x: number
y: number
width: number
height: number
}
export type AssetImageFormat = 'png' | 'jpg' | 'jpeg'
export interface FetchImageAssetBlobOptions extends AdapterFetchOptions {
preferredFormat?: AssetImageFormat
passthroughTypes?: string[]
headers?: HeadersInit
}
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
}
const token = publicAssetUrl.pathname.slice('/api/public/assets/'.length)
if (!token) {
return null
}
const fallbackURL = new URL(resolveDesktopApiURL(`/api/desktop/content/assets/${token}`))
publicAssetUrl.searchParams.forEach((value, key) => {
fallbackURL.searchParams.set(key, value)
})
fallbackURL.searchParams.set('format', preferredFormat)
return fallbackURL.toString()
}
export function buildAssetURLCandidates(
sourceUrl: string,
options: { preferredFormat?: AssetImageFormat } = {},
): string[] {
const trimmed = sourceUrl.trim()
if (!trimmed) {
return []
}
if (/^(data|blob):/i.test(trimmed)) {
return [trimmed]
}
if (/^\/\//.test(trimmed)) {
return [`https:${trimmed}`]
}
const candidates: string[] = []
const pushCandidate = (value: string) => {
if (!candidates.includes(value)) {
candidates.push(value)
}
}
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', preferredFormat)
}
pushCandidate(apiAssetUrl.toString())
const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl, preferredFormat)
if (desktopAssetFallbackURL) {
pushCandidate(desktopAssetFallbackURL)
}
}
pushCandidate(parsed.toString())
} catch {
pushCandidate(trimmed)
}
return candidates
}
function shouldUseDesktopAssetFetch(candidate: string): boolean {
try {
return new URL(candidate).pathname.startsWith('/api/desktop/content/assets/')
} catch {
return false
}
}
function fileNameForImageType(sourceType: string): string {
if (sourceType === 'image/gif') {
return 'image.gif'
}
if (sourceType === 'image/jpeg' || sourceType === 'image/jpg') {
return 'image.jpg'
}
if (sourceType === 'image/webp') {
return 'image.webp'
}
return 'image.png'
}
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,
options: FetchImageAssetBlobOptions = {},
): Promise<RawImageAssetBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl, {
preferredFormat: options.preferredFormat,
})) {
const headers = imageFetchHeaders(options)
const response = await (
shouldUseDesktopAssetFetch(candidate)
? fetchDesktopApiURL(candidate, {
signal: mergeAbortSignals([options.signal, timeoutSignal(options.timeoutMs ?? 10_000)]),
headers,
})
: adapterFetch(
candidate,
{ headers },
{ timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal },
)
).catch(() => null)
if (!response?.ok) {
continue
}
const sourceBlob = await response.blob().catch(() => null)
if (!sourceBlob || !sourceBlob.type.startsWith('image/')) {
continue
}
return {
blob: sourceBlob,
fileName: fileNameForImageType(sourceBlob.type.toLowerCase()),
}
}
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 {
let cropWidth = width
let cropHeight = cropWidth / ratio
if (cropHeight > height) {
cropHeight = height
cropWidth = cropHeight * ratio
}
return {
x: (width - cropWidth) / 2,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
}
}
export async function cropImageAssetBlob(
source: ImageAssetBlob,
ratio: number,
): Promise<ImageAssetBlob | null> {
const image = nativeImage.createFromBuffer(Buffer.from(await source.blob.arrayBuffer()))
if (image.isEmpty()) {
return null
}
const crop = getCenteredCropRect(source.width, source.height, ratio)
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)),
})
if (cropped.isEmpty()) {
return null
}
const size = cropped.getSize()
return {
blob: new Blob([new Uint8Array(cropped.toPNG())], { type: 'image/png' }),
width: size.width,
height: size.height,
fileName: 'image.png',
}
}