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> = { "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((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>; 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"); }