feat(canvas): produce and consume transparent renderContent textures

Rasterize imported SVG/CAD sources to a transparent WebP render texture and
carry it as an optional renderContent alongside the original content through
the domain node, snapshot normalization, and reference images. Add
canvasNodeRenderContent and prefer it for artboard, layer, generated-file,
and project thumbnail previews while keeping content as the crop/download/
model-reference source. Generalize the SVG rasterizer with a render purpose
and add upload/reference coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:46:12 +08:00
parent ee2c52bbc6
commit 8e1506e4c4
12 changed files with 137 additions and 21 deletions
+1
View File
@@ -15,6 +15,7 @@ export type CanvasNode = {
width: number; width: number;
height: number; height: number;
content: string; content: string;
renderContent?: string;
tone: string; tone: string;
status: string; status: string;
parentId?: string; parentId?: string;
@@ -166,6 +166,7 @@ function normalizeCanvasNode(node: Partial<CanvasNode> | null | undefined, index
width: numberValue(fieldValue(source, "width", "Width"), 320), width: numberValue(fieldValue(source, "width", "Width"), 320),
height: numberValue(fieldValue(source, "height", "Height"), 240), height: numberValue(fieldValue(source, "height", "Height"), 240),
content: stringValue(fieldValue(source, "content", "Content")), content: stringValue(fieldValue(source, "content", "Content")),
renderContent: stringValue(fieldValue(source, "renderContent", "RenderContent")) || undefined,
tone: stringValue(fieldValue(source, "tone", "Tone")), tone: stringValue(fieldValue(source, "tone", "Tone")),
status: stringValue(fieldValue(source, "status", "Status")) || "success", status: stringValue(fieldValue(source, "status", "Status")) || "success",
parentId: stringValue(fieldValue(source, "parentId", "ParentID")) || undefined, parentId: stringValue(fieldValue(source, "parentId", "ParentID")) || undefined,
@@ -264,7 +265,7 @@ function base64ToBytes(value: string) {
function projectCoverListFromNodes(nodes: CanvasNode[]) { function projectCoverListFromNodes(nodes: CanvasNode[]) {
return nodes return nodes
.filter((node) => (node.type === "image" || node.type === "frame") && node.status === "success" && isCanvasCoverUrl(node.content)) .filter((node) => (node.type === "image" || node.type === "frame") && node.status === "success" && isCanvasCoverUrl(node.content))
.map((node) => node.content.trim()) .map((node) => node.renderContent?.trim() || node.content.trim())
.slice(0, 12); .slice(0, 12);
} }
@@ -7,7 +7,7 @@ import { useI18n } from "@/i18n/i18n";
import { CanvasWorkOverlay } from "@/ui/components/CanvasWorkOverlay"; import { CanvasWorkOverlay } from "@/ui/components/CanvasWorkOverlay";
import { LayerMergeSkeleton } from "@/ui/components/LayerMergeSkeleton"; import { LayerMergeSkeleton } from "@/ui/components/LayerMergeSkeleton";
import { imageAdjustmentImageStyle, imageAdjustmentLayerStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments"; import { imageAdjustmentImageStyle, imageAdjustmentLayerStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
import { canvasImageUrl } from "@/ui/lib/imageDelivery"; import { canvasImageUrl, canvasNodeRenderContent } from "@/ui/lib/imageDelivery";
import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle"; import { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
import { imageGeneratorTargetSizeFromNode, type ImageGeneratorTargetSize } from "@/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing"; import { imageGeneratorTargetSizeFromNode, type ImageGeneratorTargetSize } from "@/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing";
@@ -20,7 +20,7 @@ export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetS
if (node.status === "generating" && node.layerRole === "merge-layer") { if (node.status === "generating" && node.layerRole === "merge-layer") {
return ( return (
<div className="layer-merge-preview"> <div className="layer-merge-preview">
{isImageUrl(node.content) && <img className="layer-merge-preview-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} />} {isImageUrl(node.content) && <img className="layer-merge-preview-image" src={canvasImageUrl(canvasNodeRenderContent(node))} alt={node.title} draggable={false} />}
<LayerMergeSkeleton title={node.title} style={overlayUiStyle} /> <LayerMergeSkeleton title={node.title} style={overlayUiStyle} />
</div> </div>
); );
@@ -58,7 +58,7 @@ export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetS
if (node.layerRole === "background-removed" || node.tone === "transparent-image") { if (node.layerRole === "background-removed" || node.tone === "transparent-image") {
return <TransparentImagePreview node={node} />; return <TransparentImagePreview node={node} />;
} }
return <img className="artboard-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} />; return <img className="artboard-image" src={canvasImageUrl(canvasNodeRenderContent(node))} alt={node.title} draggable={false} />;
} }
if (node.type === "image" && node.layerRole === "image-generator") { if (node.type === "image" && node.layerRole === "image-generator") {
@@ -78,14 +78,14 @@ export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetS
function TransparentImagePreview({ node }: { node: CanvasNode }) { function TransparentImagePreview({ node }: { node: CanvasNode }) {
return ( return (
<div className="transparent-artboard-preview"> <div className="transparent-artboard-preview">
<img className="artboard-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} /> <img className="artboard-image" src={canvasImageUrl(canvasNodeRenderContent(node))} alt={node.title} draggable={false} />
</div> </div>
); );
} }
function AdjustedImagePreview({ node }: { node: CanvasNode }) { function AdjustedImagePreview({ node }: { node: CanvasNode }) {
const adjustments = parseImageAdjustments(node.imageAdjustments); const adjustments = parseImageAdjustments(node.imageAdjustments);
const source = canvasImageUrl(node.content); const source = canvasImageUrl(canvasNodeRenderContent(node));
return ( return (
<div className="adjusted-artboard-image" style={imageAdjustmentLayerStyle(adjustments)}> <div className="adjusted-artboard-image" style={imageAdjustmentLayerStyle(adjustments)}>
<img className="artboard-image" src={source} alt={node.title} draggable={false} style={imageAdjustmentImageStyle(adjustments)} /> <img className="artboard-image" src={source} alt={node.title} draggable={false} style={imageAdjustmentImageStyle(adjustments)} />
@@ -148,7 +148,7 @@ function ImageGeneratorPreview({ node, viewportScale, targetSize }: { node: Canv
function WorkingImagePreview({ node, label, style }: { node: CanvasNode; label: string; style?: CSSProperties }) { function WorkingImagePreview({ node, label, style }: { node: CanvasNode; label: string; style?: CSSProperties }) {
return ( return (
<CanvasWorkOverlay label={label} ariaLabel={node.title} style={style}> <CanvasWorkOverlay label={label} ariaLabel={node.title} style={style}>
<img className="artboard-image" src={canvasImageUrl(node.content)} alt={node.title} draggable={false} /> <img className="artboard-image" src={canvasImageUrl(canvasNodeRenderContent(node))} alt={node.title} draggable={false} />
</CanvasWorkOverlay> </CanvasWorkOverlay>
); );
} }
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, ChevronUp, Download, Eye, EyeOff, Frame, Gri
import { Fragment, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from "react"; import { Fragment, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from "react";
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design"; import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
import { useI18n } from "@/i18n/i18n"; import { useI18n } from "@/i18n/i18n";
import { thumbnailImageUrl } from "@/ui/lib/imageDelivery"; import { canvasNodeRenderContent, thumbnailImageUrl } from "@/ui/lib/imageDelivery";
import { isFrameContainerNode, type LayerReorderItem, type LayerReorderPlacement, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { isFrameContainerNode, type LayerReorderItem, type LayerReorderPlacement, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview"; import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
@@ -95,7 +95,7 @@ export function CanvasGeneratedFilesPanel({
}} }}
> >
<span className="generated-file-thumb"> <span className="generated-file-thumb">
<img src={thumbnailImageUrl(node.content)} alt={node.title} draggable={false} /> <img src={thumbnailImageUrl(canvasNodeRenderContent(node))} alt={node.title} draggable={false} />
</span> </span>
<span className="generated-file-name">{layerTitle(node, t)}</span> <span className="generated-file-name">{layerTitle(node, t)}</span>
<button <button
@@ -727,7 +727,7 @@ function layerThumb(node: CanvasNode) {
return <ShapeNodePreview node={node} />; return <ShapeNodePreview node={node} />;
} }
if (node.type === "image" && isImageUrl(node.content)) { if (node.type === "image" && isImageUrl(node.content)) {
return <img src={thumbnailImageUrl(node.content)} alt="" draggable={false} />; return <img src={thumbnailImageUrl(canvasNodeRenderContent(node))} alt="" draggable={false} />;
} }
if (node.type === "text") return <Type size={17} />; if (node.type === "text") return <Type size={17} />;
if (node.type === "frame") return <Frame size={17} />; if (node.type === "frame") return <Frame size={17} />;
@@ -17,6 +17,7 @@ type ProjectCanvasPreviewNode = {
width: number; width: number;
height: number; height: number;
content?: string; content?: string;
renderContent?: string;
tone?: string; tone?: string;
layerRole?: string; layerRole?: string;
fillColor?: string; fillColor?: string;
@@ -94,7 +95,8 @@ function ProjectPreviewNode({ node, bounds }: { node: ProjectCanvasPreviewNode;
textDecoration: node.textDecoration && node.textDecoration !== "none" ? node.textDecoration : undefined, textDecoration: node.textDecoration && node.textDecoration !== "none" ? node.textDecoration : undefined,
textTransform: node.textTransform && node.textTransform !== "none" ? (node.textTransform as CSSProperties["textTransform"]) : undefined textTransform: node.textTransform && node.textTransform !== "none" ? (node.textTransform as CSSProperties["textTransform"]) : undefined
}; };
const imageContent = node.content && isImageUrl(node.content) ? node.content : ""; const source = node.renderContent?.trim() || node.content || "";
const imageContent = isImageUrl(source) ? source : "";
return ( return (
<span className={`project-preview-node preview-${node.type}`} style={style}> <span className={`project-preview-node preview-${node.type}`} style={style}>
+15 -5
View File
@@ -1,9 +1,11 @@
import { cadSemanticContext, cadSemanticMetadataFromSvg } from "./cadSemantics"; import { cadSemanticContext, cadSemanticMetadataFromSvg } from "./cadSemantics";
import { rasterizeSvgToTransparentWebp } from "./modelImageReferences";
export type CadImportErrorCode = "conversion" | "empty" | "timeout" | "too-large" | "unsupported"; export type CadImportErrorCode = "conversion" | "empty" | "timeout" | "too-large" | "unsupported";
export type PreparedCanvasImportAsset = { export type PreparedCanvasImportAsset = {
file: File; file: File;
renderFile?: File;
semanticContext?: string; semanticContext?: string;
}; };
@@ -92,7 +94,9 @@ export async function prepareCanvasImportFile(file: File) {
} }
export async function prepareCanvasImportAsset(file: File): Promise<PreparedCanvasImportAsset> { export async function prepareCanvasImportAsset(file: File): Promise<PreparedCanvasImportAsset> {
if (!isCadFile(file)) return { file }; if (!isCadFile(file)) {
return isSvgFile(file) ? { file, renderFile: await rasterizeSvgToTransparentWebp(file, file.name, "render") } : { file };
}
if (file.size > maxCadFileBytes) { if (file.size > maxCadFileBytes) {
throw new CadImportError("too-large", "CAD files must be 24 MB or smaller"); throw new CadImportError("too-large", "CAD files must be 24 MB or smaller");
} }
@@ -102,15 +106,21 @@ export async function prepareCanvasImportAsset(file: File): Promise<PreparedCanv
const rawSvg = await convertCadInWorker(buffer, format, file.name); const rawSvg = await convertCadInWorker(buffer, format, file.name);
const normalized = normalizeCadSvg(rawSvg, file.name); const normalized = normalizeCadSvg(rawSvg, file.name);
const baseName = file.name.replace(/\.(dwg|dxf)$/i, "").trim() || "drawing"; const baseName = file.name.replace(/\.(dwg|dxf)$/i, "").trim() || "drawing";
const sourceFile = new File([normalized.svg], `${baseName} (${format.toUpperCase()}).svg`, {
type: "image/svg+xml",
lastModified: file.lastModified || Date.now()
});
return { return {
file: new File([normalized.svg], `${baseName} (${format.toUpperCase()}).svg`, { file: sourceFile,
type: "image/svg+xml", renderFile: await rasterizeSvgToTransparentWebp(sourceFile, sourceFile.name, "render"),
lastModified: file.lastModified || Date.now()
}),
semanticContext: normalized.semanticContext || undefined semanticContext: normalized.semanticContext || undefined
}; };
} }
function isSvgFile(file: File) {
return file.type.toLowerCase().split(";", 1)[0] === "image/svg+xml" || /\.svg$/i.test(file.name.trim());
}
function cadFormat(fileName: string): CadFormat { function cadFormat(fileName: string): CadFormat {
const extension = fileName.trim().toLowerCase().split(".").pop(); const extension = fileName.trim().toLowerCase().split(".").pop();
if (extension === "dwg" || extension === "dxf") return extension; if (extension === "dwg" || extension === "dxf") return extension;
+31
View File
@@ -0,0 +1,31 @@
import type { PreparedCanvasImportAsset } from "./cadImport";
type CanvasImportUploader = (file: File) => Promise<string>;
type CanvasImportCleanup = (publicUrl: string) => Promise<unknown>;
export type UploadedCanvasImportAsset = {
content: string;
renderContent?: string;
};
export async function uploadPreparedCanvasImportAsset(
asset: PreparedCanvasImportAsset,
upload: CanvasImportUploader,
cleanup: CanvasImportCleanup
): Promise<UploadedCanvasImportAsset> {
const files = asset.renderFile ? [asset.file, asset.renderFile] : [asset.file];
const results = await Promise.allSettled(files.map(upload));
const failed = results.find((result) => result.status === "rejected");
const uploadedUrls = results.flatMap((result) => (result.status === "fulfilled" && result.value.trim() ? [result.value.trim()] : []));
if (failed || uploadedUrls.length !== files.length) {
await Promise.allSettled(uploadedUrls.map(cleanup));
if (failed) throw failed.reason;
throw new Error("Canvas asset upload returned an empty URL");
}
return {
content: uploadedUrls[0],
renderContent: asset.renderFile ? uploadedUrls[1] : undefined
};
}
+6
View File
@@ -1,5 +1,11 @@
import type { CanvasNode } from "@/domain/design";
const imageProcessParam = "x-oss-process"; const imageProcessParam = "x-oss-process";
export function canvasNodeRenderContent(node: CanvasNode) {
return node.renderContent?.trim() || node.content;
}
export function imageProcessUrl(source: string, width: number) { export function imageProcessUrl(source: string, width: number) {
const trimmed = source.trim(); const trimmed = source.trim();
if (!trimmed || trimmed.startsWith("blob:") || trimmed.startsWith("data:")) return source; if (!trimmed || trimmed.startsWith("blob:") || trimmed.startsWith("data:")) return source;
+5 -4
View File
@@ -9,6 +9,7 @@ type ModelImageReferenceDependencies = {
}; };
type ImageFormatHint = "svg" | "raster" | "unknown"; type ImageFormatHint = "svg" | "raster" | "unknown";
type TransparentWebpPurpose = "model" | "render";
const modelReferenceMaxEdge = 2048; const modelReferenceMaxEdge = 2048;
const svgMimeType = "image/svg+xml"; const svgMimeType = "image/svg+xml";
@@ -45,7 +46,7 @@ export function createModelAgentContentPreparer(dependencies: ModelImageReferenc
); );
} }
export async function rasterizeSvgToTransparentWebp(source: Blob, fileName: string) { export async function rasterizeSvgToTransparentWebp(source: Blob, fileName: string, purpose: TransparentWebpPurpose = "model") {
const svgSource = source.type.toLowerCase() === svgMimeType ? source : new Blob([source], { type: svgMimeType }); const svgSource = source.type.toLowerCase() === svgMimeType ? source : new Blob([source], { type: svgMimeType });
const svgText = await svgSource.text(); const svgText = await svgSource.text();
const documentNode = new DOMParser().parseFromString(svgText, svgMimeType); const documentNode = new DOMParser().parseFromString(svgText, svgMimeType);
@@ -71,7 +72,7 @@ export async function rasterizeSvgToTransparentWebp(source: Blob, fileName: stri
if (webp.type.toLowerCase() !== "image/webp") { if (webp.type.toLowerCase() !== "image/webp") {
throw new Error("This browser cannot encode WebP reference images."); throw new Error("This browser cannot encode WebP reference images.");
} }
return new File([webp], modelWebpFileName(fileName), { type: "image/webp", lastModified: Date.now() }); return new File([webp], transparentWebpFileName(fileName, purpose), { type: "image/webp", lastModified: Date.now() });
} finally { } finally {
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
@@ -134,10 +135,10 @@ function referenceFileName(content: ImageContent) {
} }
} }
function modelWebpFileName(fileName: string) { function transparentWebpFileName(fileName: string, purpose: TransparentWebpPurpose) {
const safeName = fileName.replace(/[\\/]/g, "_").trim() || "reference.svg"; const safeName = fileName.replace(/[\\/]/g, "_").trim() || "reference.svg";
const baseName = safeName.replace(/\.svg$/i, ""); const baseName = safeName.replace(/\.svg$/i, "");
return `${baseName}.model.webp`; return `${baseName}.${purpose}.webp`;
} }
function svgAspectRatio(root: Element, image: HTMLImageElement) { function svgAspectRatio(root: Element, image: HTMLImageElement) {
@@ -12,7 +12,7 @@ export function canvasNodeToReferenceImage(node: CanvasNode): UploadedReferenceI
id: `${canvasNodeReferencePrefix}${node.id}`, id: `${canvasNodeReferencePrefix}${node.id}`,
name: node.title.trim() || "Image", name: node.title.trim() || "Image",
publicUrl: content, publicUrl: content,
previewUrl: content, previewUrl: node.renderContent?.trim() || content,
semanticContext: node.semanticContext semanticContext: node.semanticContext
}; };
} }
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { uploadPreparedCanvasImportAsset } from "../src/ui/lib/canvasImportUpload.ts";
test("commits source and transparent render texture only after both uploads succeed", async () => {
const source = { name: "drawing.svg" };
const render = { name: "drawing.render.webp" };
const uploaded = await uploadPreparedCanvasImportAsset(
{ file: source, renderFile: render },
async (file) => `https://assets.example/${file.name}`,
async () => {
throw new Error("cleanup should not run");
}
);
assert.deepEqual(uploaded, {
content: "https://assets.example/drawing.svg",
renderContent: "https://assets.example/drawing.render.webp"
});
});
test("removes a partial source upload when the render texture upload fails", async () => {
const cleaned = [];
await assert.rejects(
uploadPreparedCanvasImportAsset(
{ file: { name: "drawing.svg" }, renderFile: { name: "drawing.render.webp" } },
async (file) => {
if (file.name.endsWith(".webp")) throw new Error("preview upload failed");
return "https://assets.example/drawing.svg";
},
async (url) => cleaned.push(url)
),
/preview upload failed/
);
assert.deepEqual(cleaned, ["https://assets.example/drawing.svg"]);
});
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { canvasNodeToReferenceImage } from "../src/ui/pages/CanvasWorkspace/canvas/nodeReferenceImages.ts";
test("keeps the SVG source for model preparation and uses WebP for the visible capsule", () => {
const reference = canvasNodeToReferenceImage({
id: "cad-1",
type: "image",
title: "drawing.svg",
x: 0,
y: 0,
width: 1200,
height: 800,
content: "https://assets.example/drawing.svg",
renderContent: "https://assets.example/drawing.render.webp",
tone: "transparent-image",
status: "success",
layerRole: "cad-vector",
semanticContext: "CAD context"
});
assert.equal(reference.publicUrl, "https://assets.example/drawing.svg");
assert.equal(reference.previewUrl, "https://assets.example/drawing.render.webp");
assert.equal(reference.semanticContext, "CAD context");
});