import { aiGenerationClient } from "../../api/aiGenerationClient"; export interface PersistedWorkbenchResultAsset { url: string; originalUrl: string; ossKey?: string; mimeType?: string; } interface PersistWorkbenchResultAssetInput { title: string; sourceUrl: string; resultType: "image" | "video"; taskId?: string; prompt?: string; originalUrl?: string; existingOssKey?: string | null; mimeType?: string; client?: WorkbenchResultPersistenceClient; } interface DownloadedTaskAsset { blob: Blob; filename?: string; contentType?: string; } interface FetchLikeResponse { ok: boolean; status: number; headers: { get(name: string): string | null; }; blob(): Promise; } interface WorkbenchResultPersistenceClient { downloadTaskResult(taskId: string): Promise; uploadAsset(input: { dataUrl: string; name?: string; mimeType?: string; scope?: string; }): Promise<{ url: string; signedUrl?: string; ossKey?: string }>; uploadAssetByUrl(input: { sourceUrl: string; name?: string; mimeType?: string; scope?: string; }): Promise<{ url: string; signedUrl?: string; ossKey?: string }>; fetchAsset?: (url: string) => Promise; } function getDefaultClient(): WorkbenchResultPersistenceClient { return { downloadTaskResult: (taskId) => aiGenerationClient.downloadTaskResult(taskId), uploadAsset: (input) => aiGenerationClient.uploadAsset(input), uploadAssetByUrl: (input) => aiGenerationClient.uploadAssetByUrl(input), fetchAsset: (url) => fetch(url, { credentials: "omit" }), }; } function getGeneratedResultOssKey(url: string): string | null { try { const key = decodeURIComponent(new URL(url, globalThis.location?.href || "http://localhost").pathname.replace(/^\/+/, "")); return /^users\/[^/]+\/generation-results\/.+/i.test(key) ? key : null; } catch { return null; } } function createFallbackResult(input: PersistWorkbenchResultAssetInput): PersistedWorkbenchResultAsset { return { url: input.sourceUrl, originalUrl: input.originalUrl || input.sourceUrl, ossKey: input.existingOssKey || getGeneratedResultOssKey(input.sourceUrl) || undefined, mimeType: input.mimeType, }; } function getResultExtension(url: string, contentType: string | undefined, resultType: "image" | "video") { const mime = (contentType || "").split(";")[0]?.trim().toLowerCase(); if (mime === "image/jpeg") return "jpg"; if (mime === "image/png") return "png"; if (mime === "image/webp") return "webp"; if (mime === "image/gif") return "gif"; if (mime === "video/webm") return "webm"; if (mime === "video/quicktime") return "mov"; if (mime === "video/mp4") return "mp4"; try { const pathname = new URL(url, globalThis.location?.href || "http://localhost").pathname; const matched = pathname.match(/\.([a-z0-9]{2,5})$/i); if (matched?.[1]) return matched[1].toLowerCase(); } catch { // Fall through to media-type fallback. } return resultType === "video" ? "mp4" : "png"; } export function buildWorkbenchResultAssetName( title: string, sourceUrl: string, resultType: "image" | "video", contentType?: string, ): string { const normalized = title .trim() .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, " ") .slice(0, 80) .trim(); const base = normalized || (resultType === "video" ? "generated-video" : "generated-image"); return `${base}.${getResultExtension(sourceUrl, contentType, resultType)}`; } function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error || new Error("资源读取失败")); reader.readAsDataURL(blob); }); } async function assertMediaBlobIsUsable(blob: Blob, resultType: "image" | "video") { const mime = String(blob.type || "").toLowerCase(); if (/^(?:application|text)\/(?:json|xml|html|plain)|\+xml/.test(mime)) { throw new Error("结果资源已过期或返回了错误内容,请重新生成。"); } const header = new Uint8Array(await blob.slice(0, 16).arrayBuffer()); const asText = new TextDecoder("utf-8", { fatal: false }).decode(header); const isPng = header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47; const isJpeg = header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff; const isGif = asText.startsWith("GIF8"); const isWebp = asText.startsWith("RIFF") && asText.slice(8, 12) === "WEBP"; const isMp4 = asText.slice(4, 8) === "ftyp"; const looksLikeErrorDocument = asText.startsWith(" { if (input.taskId) { try { const result = await client.downloadTaskResult(input.taskId); return { blob: result.blob, contentType: result.contentType || result.blob.type || undefined, filename: result.filename, }; } catch { // Some older tasks may not support the proxy download route; fall back to the URL while it is still valid. } } const fetchAsset = client.fetchAsset || ((url: string) => fetch(url, { credentials: "omit" })); const response = await fetchAsset(input.sourceUrl); if (!response.ok) throw new Error(`结果资源请求失败:${response.status}`); const blob = await response.blob(); return { blob, contentType: blob.type || response.headers.get("content-type") || undefined, }; } export async function persistWorkbenchResultAsset(input: PersistWorkbenchResultAssetInput): Promise { const fallbackResult = createFallbackResult(input); if (fallbackResult.ossKey) return fallbackResult; const client = input.client || getDefaultClient(); const mimeType = input.mimeType || (input.resultType === "video" ? "video/mp4" : "image/png"); const name = buildWorkbenchResultAssetName(input.title, input.sourceUrl, input.resultType, mimeType); // Try server-side URL-to-OSS first (no base64, no client download) try { const uploaded = await client.uploadAssetByUrl({ sourceUrl: input.sourceUrl, name, mimeType, scope: "workbench-result", }); return { url: uploaded.url, originalUrl: input.sourceUrl, ossKey: uploaded.ossKey, mimeType, }; } catch (urlError) { console.warn("[workbench] URL upload failed, falling back to download+base64:", urlError instanceof Error ? urlError.message : urlError); } // Fallback: download → base64 → upload try { const downloaded = await downloadResultBlob(input, client); if (!downloaded.blob.size) throw new Error("结果资源内容为空"); await assertMediaBlobIsUsable(downloaded.blob, input.resultType); const fallbackMime = downloaded.contentType || downloaded.blob.type || mimeType; const fallbackName = downloaded.filename || name; const dataUrl = await blobToDataUrl(downloaded.blob); const uploaded = await client.uploadAsset({ dataUrl, name: fallbackName, mimeType: fallbackMime, scope: "workbench-result", }); return { url: uploaded.url, originalUrl: input.sourceUrl, ossKey: uploaded.ossKey, mimeType: fallbackMime, }; } catch (error) { const msg = error instanceof Error ? error.message : String(error || ""); if (/413|too large/i.test(msg)) { console.warn("[workbench] OSS upload rejected (file too large), using temporary URL:", msg); } else { console.warn("[workbench] result persistence fallback:", error); } return fallbackResult; } }