bedee3ba8d
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
155 lines
5.6 KiB
TypeScript
155 lines
5.6 KiB
TypeScript
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
|
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
|
|
|
|
function sanitizeDownloadFilename(value: string, fallback: string) {
|
|
const normalized = value
|
|
.trim()
|
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
.replace(/\s+/g, " ")
|
|
.slice(0, 80)
|
|
.trim();
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function getResultDownloadExtension(url: string, contentType: string | null, isVideo: boolean) {
|
|
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, window.location.href).pathname;
|
|
const matched = pathname.match(/\.([a-z0-9]{2,5})$/i);
|
|
if (matched?.[1]) return matched[1].toLowerCase();
|
|
} catch {
|
|
// Keep the fallback below for malformed but still browser-loadable URLs.
|
|
}
|
|
|
|
return isVideo ? "mp4" : "png";
|
|
}
|
|
|
|
type LocalFilePickerWritable = {
|
|
write: (data: Blob) => Promise<void>;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
type LocalFilePickerHandle = {
|
|
createWritable: () => Promise<LocalFilePickerWritable>;
|
|
};
|
|
|
|
type LocalFilePickerWindow = Window & {
|
|
showSaveFilePicker?: (options?: {
|
|
suggestedName?: string;
|
|
types?: Array<{
|
|
description: string;
|
|
accept: Record<string, string[]>;
|
|
}>;
|
|
}) => Promise<LocalFilePickerHandle>;
|
|
};
|
|
|
|
async function saveBlobToLocal(blob: Blob, filename: string, isVideo: boolean): Promise<"saved" | "started"> {
|
|
const startBrowserDownload = () => {
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = objectUrl;
|
|
anchor.download = filename;
|
|
anchor.rel = "noopener";
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
|
return "started" as const;
|
|
};
|
|
|
|
const savePicker = (window as LocalFilePickerWindow).showSaveFilePicker;
|
|
if (savePicker) {
|
|
try {
|
|
const pickerTypes = [
|
|
isVideo
|
|
? { description: "Video", accept: { [blob.type || "video/mp4"]: [`.${filename.split(".").pop() || "mp4"}`] } }
|
|
: { description: "Image", accept: { [blob.type || "image/png"]: [`.${filename.split(".").pop() || "png"}`] } },
|
|
];
|
|
const handle = await savePicker({ suggestedName: filename, types: pickerTypes });
|
|
const writable = await handle.createWritable();
|
|
await writable.write(blob);
|
|
await writable.close();
|
|
return "saved";
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
return startBrowserDownload();
|
|
}
|
|
}
|
|
|
|
return startBrowserDownload();
|
|
}
|
|
|
|
async function assertDownloadBlobIsUsable(blob: Blob, isVideo: boolean) {
|
|
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("<?xml") || asText.startsWith("<Error") || asText.startsWith("{\"") || asText.startsWith("<!DO");
|
|
|
|
if (looksLikeErrorDocument) {
|
|
throw new Error("下载失败:结果链接已过期或返回了错误内容,请重新生成后再下载。");
|
|
}
|
|
|
|
const hasExpectedMagic = isVideo ? isMp4 || mime.startsWith("video/") : isPng || isJpeg || isGif || isWebp || mime.startsWith("image/");
|
|
if (!hasExpectedMagic) {
|
|
throw new Error("下载失败:未收到有效的媒体文件。");
|
|
}
|
|
}
|
|
|
|
export async function downloadResultAsset(url: string, filenameBase: string, isVideo: boolean, taskId?: string): Promise<"saved" | "started"> {
|
|
let blob: Blob | null = null;
|
|
let contentType: string | null = null;
|
|
|
|
if (taskId) {
|
|
try {
|
|
const result = await aiGenerationClient.downloadTaskResult(taskId);
|
|
blob = result.blob;
|
|
contentType = result.contentType || result.blob.type || null;
|
|
} catch {
|
|
blob = null;
|
|
}
|
|
}
|
|
|
|
if (!blob) {
|
|
const needsProxy = /aliyuncs\.com/i.test(url);
|
|
const fetchUrl = needsProxy
|
|
? buildApiUrl(`ai/proxy-download?url=${encodeURIComponent(url)}`)
|
|
: url;
|
|
const fetchOpts: RequestInit = needsProxy
|
|
? { headers: buildAuthHeaders() }
|
|
: { credentials: "omit" };
|
|
const response = await fetch(fetchUrl, fetchOpts);
|
|
if (!response.ok) {
|
|
throw new Error(`下载失败:资源请求返回 ${response.status}`);
|
|
}
|
|
blob = await response.blob();
|
|
contentType = blob.type || response.headers.get("content-type");
|
|
}
|
|
|
|
if (!blob.size) {
|
|
throw new Error("下载失败:资源内容为空");
|
|
}
|
|
await assertDownloadBlobIsUsable(blob, isVideo);
|
|
|
|
const extension = getResultDownloadExtension(url, contentType, isVideo);
|
|
const filename = `${sanitizeDownloadFilename(filenameBase, isVideo ? "generated-video" : "generated-image")}.${extension}`;
|
|
return saveBlobToLocal(blob, filename, isVideo);
|
|
}
|