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;
height: number;
content: string;
renderContent?: string;
tone: string;
status: string;
parentId?: string;
@@ -166,6 +166,7 @@ function normalizeCanvasNode(node: Partial<CanvasNode> | null | undefined, index
width: numberValue(fieldValue(source, "width", "Width"), 320),
height: numberValue(fieldValue(source, "height", "Height"), 240),
content: stringValue(fieldValue(source, "content", "Content")),
renderContent: stringValue(fieldValue(source, "renderContent", "RenderContent")) || undefined,
tone: stringValue(fieldValue(source, "tone", "Tone")),
status: stringValue(fieldValue(source, "status", "Status")) || "success",
parentId: stringValue(fieldValue(source, "parentId", "ParentID")) || undefined,
@@ -264,7 +265,7 @@ function base64ToBytes(value: string) {
function projectCoverListFromNodes(nodes: CanvasNode[]) {
return nodes
.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);
}
@@ -7,7 +7,7 @@ import { useI18n } from "@/i18n/i18n";
import { CanvasWorkOverlay } from "@/ui/components/CanvasWorkOverlay";
import { LayerMergeSkeleton } from "@/ui/components/LayerMergeSkeleton";
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 { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
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") {
return (
<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} />
</div>
);
@@ -58,7 +58,7 @@ export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetS
if (node.layerRole === "background-removed" || node.tone === "transparent-image") {
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") {
@@ -78,14 +78,14 @@ export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetS
function TransparentImagePreview({ node }: { node: CanvasNode }) {
return (
<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>
);
}
function AdjustedImagePreview({ node }: { node: CanvasNode }) {
const adjustments = parseImageAdjustments(node.imageAdjustments);
const source = canvasImageUrl(node.content);
const source = canvasImageUrl(canvasNodeRenderContent(node));
return (
<div className="adjusted-artboard-image" style={imageAdjustmentLayerStyle(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 }) {
return (
<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>
);
}
@@ -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 type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
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 { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
@@ -95,7 +95,7 @@ export function CanvasGeneratedFilesPanel({
}}
>
<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 className="generated-file-name">{layerTitle(node, t)}</span>
<button
@@ -727,7 +727,7 @@ function layerThumb(node: CanvasNode) {
return <ShapeNodePreview node={node} />;
}
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 === "frame") return <Frame size={17} />;
@@ -17,6 +17,7 @@ type ProjectCanvasPreviewNode = {
width: number;
height: number;
content?: string;
renderContent?: string;
tone?: string;
layerRole?: string;
fillColor?: string;
@@ -94,7 +95,8 @@ function ProjectPreviewNode({ node, bounds }: { node: ProjectCanvasPreviewNode;
textDecoration: node.textDecoration && node.textDecoration !== "none" ? node.textDecoration : 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 (
<span className={`project-preview-node preview-${node.type}`} style={style}>
+15 -5
View File
@@ -1,9 +1,11 @@
import { cadSemanticContext, cadSemanticMetadataFromSvg } from "./cadSemantics";
import { rasterizeSvgToTransparentWebp } from "./modelImageReferences";
export type CadImportErrorCode = "conversion" | "empty" | "timeout" | "too-large" | "unsupported";
export type PreparedCanvasImportAsset = {
file: File;
renderFile?: File;
semanticContext?: string;
};
@@ -92,7 +94,9 @@ export async function prepareCanvasImportFile(file: File) {
}
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) {
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 normalized = normalizeCadSvg(rawSvg, file.name);
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 {
file: new File([normalized.svg], `${baseName} (${format.toUpperCase()}).svg`, {
type: "image/svg+xml",
lastModified: file.lastModified || Date.now()
}),
file: sourceFile,
renderFile: await rasterizeSvgToTransparentWebp(sourceFile, sourceFile.name, "render"),
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 {
const extension = fileName.trim().toLowerCase().split(".").pop();
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";
export function canvasNodeRenderContent(node: CanvasNode) {
return node.renderContent?.trim() || node.content;
}
export function imageProcessUrl(source: string, width: number) {
const trimmed = source.trim();
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 TransparentWebpPurpose = "model" | "render";
const modelReferenceMaxEdge = 2048;
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 svgText = await svgSource.text();
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") {
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 {
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 baseName = safeName.replace(/\.svg$/i, "");
return `${baseName}.model.webp`;
return `${baseName}.${purpose}.webp`;
}
function svgAspectRatio(root: Element, image: HTMLImageElement) {
@@ -12,7 +12,7 @@ export function canvasNodeToReferenceImage(node: CanvasNode): UploadedReferenceI
id: `${canvasNodeReferencePrefix}${node.id}`,
name: node.title.trim() || "Image",
publicUrl: content,
previewUrl: content,
previewUrl: node.renderContent?.trim() || content,
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");
});