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:
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user