feat: complete TASK-WP4-05 client export
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user