feat: complete TASK-WP4-05 client export

This commit is contained in:
suyx
2026-08-03 14:41:04 +08:00
parent 457e1147e4
commit d09fda13c5
9 changed files with 753 additions and 26 deletions
+118
View File
@@ -0,0 +1,118 @@
import type { CanvasState } from "@dada/shared-contracts";
import { renderCanvasStateForExport } from "./editor-stage.js";
import { P0A_TEXT_TEMPLATES, TextEditSession } from "./text-assets.js";
import type { ArchivedFontStatus } from "./text-font-loader.js";
export type ExportFormat = "jpg" | "png";
export type ExportRatio = CanvasState["ratio"];
export const EXPORT_DIMENSIONS: Readonly<Record<ExportRatio, { height: number; width: number }>> = {
"1:1": { height: 1080, width: 1080 },
"3:4": { height: 1440, width: 1080 },
"4:3": { height: 1080, width: 1440 },
"9:16": { height: 1920, width: 1080 },
};
export interface ExportSettings {
format: ExportFormat;
height: number;
mimeType: "image/jpeg" | "image/png";
quality?: number;
width: number;
}
export function resolveExportSettings(input: { format: ExportFormat; quality?: number; ratio: ExportRatio }): ExportSettings {
const dimensions = EXPORT_DIMENSIONS[input.ratio];
if (input.format === "png") return { format: "png", height: dimensions.height, mimeType: "image/png", width: dimensions.width };
const quality = input.quality ?? 92;
if (!Number.isInteger(quality) || quality < 80 || quality > 100) throw new Error("export_quality_invalid");
return { format: "jpg", height: dimensions.height, mimeType: "image/jpeg", quality, width: dimensions.width };
}
export function completePendingTextForExport(canvasState: CanvasState, draft: CanvasState["elements"][number]) {
if (draft.type !== "text_template") throw new Error("export_pending_text_invalid");
const index = canvasState.elements.findIndex((element) => element.element_id === draft.element_id && element.type === "text_template");
if (index < 0) throw new Error("export_pending_text_missing");
const complete = new TextEditSession(draft, P0A_TEXT_TEMPLATES).complete();
const next = structuredClone(canvasState);
next.elements[index] = complete;
return next;
}
export async function sha256Blob(blob: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
export function encodeCanvasExport(canvas: HTMLCanvasElement, settings: ExportSettings) {
if (canvas.width !== settings.width || canvas.height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
return new Promise<Blob>((resolve, reject) => {
const quality = settings.format === "jpg" ? settings.quality! / 100 : undefined;
canvas.toBlob((blob) => {
if (!blob || blob.type !== settings.mimeType) reject(new Error("export_encode_failed"));
else resolve(blob);
}, settings.mimeType, quality);
});
}
export async function composeCanvasExport(input: {
canvasState: CanvasState;
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
format: ExportFormat;
projectId: string;
quality?: number;
}) {
const settings = resolveExportSettings({ format: input.format, ratio: input.canvasState.ratio, ...(input.quality === undefined ? {} : { quality: input.quality }) });
if (input.canvasState.pixel_width !== settings.width || input.canvasState.pixel_height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
const canvas = await renderCanvasStateForExport({ canvasState: input.canvasState, fontStatuses: input.fontStatuses, projectId: input.projectId });
return encodeCanvasExport(canvas, settings);
}
export async function downloadExportBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
try {
const anchor = document.createElement("a");
anchor.download = filename;
anchor.href = url;
anchor.style.display = "none";
document.body.append(anchor);
anchor.click();
anchor.remove();
} finally {
setTimeout(() => URL.revokeObjectURL(url), 0);
}
}
export function exportFilename(projectName: string, format: ExportFormat) {
const safeName = projectName.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").slice(0, 80) || "Dada成品";
return `${safeName}.${format === "jpg" ? "jpg" : "png"}`;
}
export async function saveLatestExport(input: {
blob: Blob;
csrfToken: string;
format: ExportFormat;
height: number;
projectId: string;
stateVersion: number;
width: number;
}) {
const sha256 = await sha256Blob(input.blob);
const form = new FormData();
form.append("format", input.format);
form.append("export_id", crypto.randomUUID());
form.append("sha256", sha256);
form.append("byte_size", String(input.blob.size));
form.append("pixel_width", String(input.width));
form.append("pixel_height", String(input.height));
form.append("state_version", String(input.stateVersion));
form.append("export_file", input.blob, `latest.${input.format}`);
const response = await fetch(`/api/v1/projects/${encodeURIComponent(input.projectId)}/latest-exports/${input.format}`, {
body: form,
credentials: "same-origin",
headers: { "X-CSRF-Token": input.csrfToken },
method: "PUT",
});
if (!response.ok) throw new Error(response.status === 409 ? "latest_export_conflict" : "latest_export_failed");
}