Compare commits

...

3 Commits

Author SHA1 Message Date
root f23c847e13 fix(agent-threads): hide prompt reference directives in thread titles
Add a shared visiblePromptText helper and strip reference tokens,
image-generator directives, and model-selection lines from agent thread
summaries and the assistant panel header, so titles show the user's
actual prompt instead of internal directives. Covered by a server test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:55:08 +08:00
root 2ede253b39 feat(clipboard): paste images and text from the system clipboard
Replace the internal single-node clipboard with real clipboard paste:
the prompt composer uploads pasted image files as references, and the
canvas pastes clipboard images as nodes and clipboard text as text
nodes anchored at the pointer, reading from the async clipboard API on
the context-menu paste action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:54:56 +08:00
root 07aa33d30e feat(image): preserve transparency for alpha images end to end
Skip WebP re-encoding for PNG/AVIF/BMP uploads and any bitmap that
actually contains alpha, tag transparency-capable canvas uploads with
the transparent-image tone, and detect alpha in merged layers so the
composed node keeps a transparent tone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:54:32 +08:00
10 changed files with 364 additions and 70 deletions
@@ -3,6 +3,8 @@ export async function encodeUploadImage(file: File) {
const bitmap = await createBitmap(file);
try {
if (imageMayContainAlpha(file) && bitmapHasAlpha(bitmap)) return file;
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
@@ -29,9 +31,39 @@ function shouldEncodeAsWebp(file: File) {
if (type === "image/webp" || name.endsWith(".webp")) return false;
if (type === "image/gif" || name.endsWith(".gif")) return false;
if (type === "image/svg+xml" || name.endsWith(".svg")) return false;
if (type === "image/png" || name.endsWith(".png")) return false;
if (type === "image/avif" || name.endsWith(".avif")) return false;
if (type === "image/bmp" || name.endsWith(".bmp")) return false;
return true;
}
function imageMayContainAlpha(file: File) {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type.includes("png") || name.endsWith(".png")) return true;
if (type.includes("avif") || name.endsWith(".avif")) return true;
if (type.includes("bmp") || name.endsWith(".bmp")) return true;
return !type.includes("jpeg") && !type.includes("jpg") && !name.endsWith(".jpg") && !name.endsWith(".jpeg");
}
function bitmapHasAlpha(bitmap: ImageBitmap | HTMLImageElement) {
const width = "naturalWidth" in bitmap ? bitmap.naturalWidth || bitmap.width : bitmap.width;
const height = "naturalHeight" in bitmap ? bitmap.naturalHeight || bitmap.height : bitmap.height;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return true;
context.clearRect(0, 0, width, height);
context.drawImage(bitmap, 0, 0);
const data = context.getImageData(0, 0, width, height).data;
for (let index = 3; index < data.length; index += 4) {
if (data[index] < 255) return true;
}
return false;
}
function webpFileName(fileName: string) {
const name = fileName.trim() || "upload";
const dotIndex = name.lastIndexOf(".");
@@ -188,6 +188,7 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
onReferenceRemoved={onReferenceRemoved}
onAnnotationsChange={onAnnotationsChange}
onAnnotationPreviewChange={onAnnotationPreviewChange}
onPasteFile={onUploadFile}
onSubmit={onSubmit}
/>
{previewSlot}
@@ -311,6 +311,45 @@ function pastedReferenceImage(reference: PromptImageReference, index: number): U
};
}
function isImageFile(file: File | null): file is File {
return Boolean(file && (file.type.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name)));
}
function imageExtension(type: string) {
const subtype = type.split("/")[1]?.toLowerCase().split(";")[0];
if (!subtype) return "png";
if (subtype === "jpeg") return "jpg";
if (subtype === "svg+xml") return "svg";
return subtype.replace(/[^a-z0-9]+/g, "") || "png";
}
function namedClipboardImageFile(file: File, index: number) {
if (file.name.trim()) return file;
return new File([file], `pasted-image-${index + 1}.${imageExtension(file.type)}`, {
type: file.type || "image/png",
lastModified: file.lastModified || Date.now()
});
}
function latestClipboardImageFile(dataTransfer: DataTransfer) {
const seen = new Set<string>();
const files = [
...Array.from(dataTransfer.files),
...Array.from(dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
]
.filter(isImageFile)
.filter((file) => {
const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(namedClipboardImageFile);
return files.at(-1) ?? null;
}
function closestAnnotationChip(target: EventTarget | Node | null) {
if (isElement(target)) return target.closest<HTMLElement>("[data-annotation-id]");
return target instanceof Node ? target.parentElement?.closest<HTMLElement>("[data-annotation-id]") ?? null : null;
@@ -549,6 +588,7 @@ export const PromptComposer = forwardRef<
onReferenceRemoved: (reference: UploadedReferenceImage) => void;
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
onPasteFile?: (file: File | null) => void | Promise<void>;
onSubmit?: () => void;
}
>(function PromptComposer(
@@ -565,6 +605,7 @@ export const PromptComposer = forwardRef<
onReferenceRemoved,
onAnnotationsChange,
onAnnotationPreviewChange,
onPasteFile,
onSubmit
},
ref
@@ -849,7 +890,25 @@ export const PromptComposer = forwardRef<
onAnnotationPreviewChange?.(null);
};
const uploadPastedImageFile = async (file: File) => {
if (!onPasteFile) return;
await onPasteFile(file);
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedImage = latestClipboardImageFile(event.clipboardData);
if (pastedImage) {
event.preventDefault();
const editor = editorRef.current;
const selection = window.getSelection();
if (editor && selection?.rangeCount) {
const range = selection.getRangeAt(0);
if (rangeBelongsToEditor(range, editor)) savedRangeRef.current = range.cloneRange();
}
void uploadPastedImageFile(pastedImage);
return;
}
const pastedText = event.clipboardData.getData("text/plain");
if (!pastedText) return;
event.preventDefault();
@@ -75,6 +75,22 @@ export function serializePromptImageReference(reference: Pick<PromptImageReferen
return `[@image:#${fallbackIndex}:${name}:${reference.url}]`;
}
export function visiblePromptText(content: string) {
const text = parsePromptImageReferences(content)
.map((part) => (part.type === "text" ? part.text : ""))
.join("");
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => {
const lower = line.toLowerCase();
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择");
})
.join(" ")
.replace(/\s{2,}/g, " ")
.trim();
}
function collectReferenceMatches(content: string) {
const matches: ReferenceMatch[] = [];
+119 -63
View File
@@ -30,6 +30,7 @@ import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCata
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
@@ -600,7 +601,6 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
const [canvasFileDragging, setCanvasFileDragging] = useState(false);
const [error, setError] = useState("");
const [clipboardNode, setClipboardNode] = useState<CanvasNode | null>(null);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(() => new Set());
const [lockedNodeIds, setLockedNodeIds] = useState<Set<string>>(() => new Set());
const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility);
@@ -1837,20 +1837,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
const copyNode = (node: CanvasNode) => {
setClipboardNode(node);
void writeCanvasNodeToSystemClipboard(node)
.then(() => showCanvasToast(t("clipboardCopied")))
.catch(() => showCanvasToast(t("clipboardCopyFailed"), "error"));
};
const pasteNode = (sourceNode = clipboardNode) => {
if (!sourceNode) return;
const copy = cloneCanvasNode(sourceNode, cryptoFallbackId());
setNodes((current) => [...current, copy]);
selectOnlyNode(copy.id);
setDirty(true);
};
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
setNodes((current) => reorderNodeOp(current, nodeId, action));
setDirty(true);
@@ -2037,6 +2028,74 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setDirty(true);
};
const clipboardPasteAnchor = () => {
const pointer = lastCanvasPointerRef.current;
return pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
};
const pasteClipboardText = (value: string, anchor = clipboardPasteAnchor()) => {
const content = value.replace(/\u00a0/g, " ");
if (!content.trim()) return false;
const currentViewport = viewportRef.current;
const zoom = Math.max(currentViewport.k, 0.01);
const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom };
const node = fitTextNodeToContent({
id: cryptoFallbackId(),
type: "text",
title: t("newTextNode"),
x: anchor.x - size.width / 2,
y: anchor.y - size.height / 2,
width: size.width,
height: size.height,
content,
tone: "ink",
status: "draft",
layerRole: "editable-text",
fontFamily: "Inter",
fontWeight: "Regular",
fontSize: defaultTextVisibleFontSize / zoom,
color: "#000000",
textAlign: "left",
opacity: 1,
strokeColor: "#000000",
strokeWidth: 0,
lineHeight: 0,
letterSpacing: 0,
textDecoration: "none",
textTransform: "none"
});
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
selectOnlyNode(node.id);
setDirty(true);
return true;
};
const pasteFromClipboardData = (dataTransfer: DataTransfer) => {
const anchor = clipboardPasteAnchor();
const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image");
if (imageFile) {
void uploadImagesToCanvas([imageFile], anchor);
return true;
}
return pasteClipboardText(dataTransfer.getData("text/plain"), anchor);
};
const pasteFromSystemClipboard = async () => {
const anchor = clipboardPasteAnchor();
const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null);
if (imageFile) {
await uploadImagesToCanvas([imageFile], anchor);
return;
}
const text = await navigator.clipboard?.readText?.().catch(() => "");
if (text) pasteClipboardText(text, anchor);
};
const sendNodeToAgentComposer = (node: CanvasNode) => {
if (isRealCanvasImageNode(node)) {
insertCanvasImageReference(node, { focus: true });
@@ -2056,7 +2115,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
copyNode(node);
removeNode(node.id);
},
paste: pasteNode,
paste: () => void pasteFromSystemClipboard(),
duplicate: () => duplicateNode(node),
bringForward: () => reorderNode(node.id, "forward"),
sendBackward: () => reorderNode(node.id, "backward"),
@@ -2143,33 +2202,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (isEditableShortcutTarget(event.target)) return;
const imageFiles = event.clipboardData ? dataTransferImageFiles(event.clipboardData, "clipboard-image") : [];
const pasteImageFiles = () => {
const pointer = lastCanvasPointerRef.current;
const anchor = pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
void uploadImagesToCanvas(imageFiles, anchor);
};
if (clipboardNode && imageFiles.length > 0 && isTransparentClipboardNode(clipboardNode)) {
if (!event.clipboardData) return;
if (pasteFromClipboardData(event.clipboardData)) {
event.preventDefault();
void shouldUseInternalClipboardNode(clipboardNode, imageFiles).then((useInternalNode) => {
if (useInternalNode) {
pasteNode(clipboardNode);
return;
}
pasteImageFiles();
});
return;
}
if (imageFiles.length > 0) {
event.preventDefault();
pasteImageFiles();
return;
}
if (!clipboardNode) return;
event.preventDefault();
pasteNode();
};
window.addEventListener("paste", handlePaste);
@@ -3095,6 +3131,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const previewUrl = URL.createObjectURL(file);
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
const size = fitCanvasImageSize(dimensions.width, dimensions.height);
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
preparedUploads.push({
file,
previewUrl,
@@ -3107,7 +3144,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
width: size.width,
height: size.height,
content: previewUrl,
tone: "natural",
tone,
status: "uploading"
}
});
@@ -3321,7 +3358,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}}
selectionScale={viewport.k}
viewportScale={viewport.k}
hasClipboard={Boolean(clipboardNode)}
hasClipboard
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
onPointerDown={(event) => handleNodePointerDown(event, node)}
onContextMenu={() => {
@@ -3720,7 +3757,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
<aside className="assistant-panel">
<div className="assistant-header">
<div className="assistant-header-title">
<strong>{project.brief || project.title || t("canvasUntitled")}</strong>
<strong>{agentHeaderTitle(messages, project, t("canvasUntitled"))}</strong>
</div>
<div className="assistant-header-actions">
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
@@ -5024,21 +5061,6 @@ function canvasBackgroundStorageKey(projectId: string) {
return `fluxo:canvas-background:${projectId}`;
}
function isTransparentClipboardNode(node: CanvasNode) {
return node.type === "image" && (node.layerRole === "background-removed" || node.tone === "transparent-image");
}
async function shouldUseInternalClipboardNode(node: CanvasNode, imageFiles: File[]) {
const imageFile = imageFiles[0];
if (!imageFile) return false;
try {
const dimensions = await readImageDimensions(imageFile);
return dimensions.width === Math.max(1, Math.round(node.width)) && dimensions.height === Math.max(1, Math.round(node.height));
} catch {
return false;
}
}
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
if (node.type === "text") {
await writeClipboardText(node.content || node.title);
@@ -5095,6 +5117,23 @@ function isImageFile(file: File) {
return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name);
}
function imageMaySupportTransparency(file: File) {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
return (
type.includes("png") ||
type.includes("webp") ||
type.includes("avif") ||
type.includes("gif") ||
type.includes("svg") ||
name.endsWith(".png") ||
name.endsWith(".webp") ||
name.endsWith(".avif") ||
name.endsWith(".gif") ||
name.endsWith(".svg")
);
}
function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
const candidates = [
...Array.from(dataTransfer.files),
@@ -5114,6 +5153,23 @@ function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "
}, []);
}
function latestDataTransferImageFile(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null;
}
async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") {
if (!navigator.clipboard?.read) return null;
const items = await navigator.clipboard.read();
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex];
const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1);
if (!type) continue;
const blob = await item.getType(type);
return ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`);
}
return null;
}
function dataTransferHasImage(dataTransfer: DataTransfer) {
const files = dataTransferImageFiles(dataTransfer);
if (files.length > 0) return true;
@@ -5234,7 +5290,7 @@ function fallbackAgentThreads(project: Project | null, hiddenThreadIds: Readonly
}
function agentThreadTitle(thread: AgentThreadSummary) {
return thread.text.trim() || "新对话";
return visiblePromptText(thread.text) || "新对话";
}
function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
@@ -5243,14 +5299,14 @@ function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlyS
}
function agentThreadTitleText(content: string) {
const lines = content
.split("\n")
.map((line) => line.trim())
.filter((line) => {
const lower = line.toLowerCase();
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择");
});
return lines.join(" ").trim() || content.trim();
return visiblePromptText(content);
}
function agentHeaderTitle(messages: AgentMessage[], project: Project, fallback: string) {
const firstUserMessage = messages.find((message) => message.role === "user");
const firstUserText = firstUserMessage ? visiblePromptText(firstUserMessage.content) : "";
if (firstUserText) return firstUserText;
return visiblePromptText(project.brief) || project.title.trim() || fallback;
}
function isProjectGenerating(project: Project) {
+1
View File
@@ -734,6 +734,7 @@ export function HomePage() {
onTextChange={setPrompt}
onReferencesChange={syncReferenceImages}
onReferenceRemoved={handleReferenceRemoved}
onPasteFile={attachImage}
onSubmit={() => createProject()}
/>
<div className="prompt-actions">
@@ -44,6 +44,44 @@ func TestAgentThreadsOnlyListsAgentConversationThreads(t *testing.T) {
}
}
func TestAgentThreadsSummaryHidesPromptReferenceDirectives(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
now := time.Now()
project := design.Project{
ID: "project-agent-reference-history",
Title: "Agent history",
Status: design.StatusReady,
LastThreadID: "thread-agent",
UpdatedAt: now,
Messages: []design.Message{
{
ID: "agent-user",
Role: "user",
Type: "user",
Content: "Reference image 1 (image): http://localhost:19000/canvas-assets/reference.png 灰裤子拆成单件衣服,为了后面电商配图。\nSelected model: GPT Image 2",
ThreadID: "thread-agent",
CreatedAt: now,
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
threads, err := service.AgentThreads(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if len(threads) != 1 {
t.Fatalf("expected one agent thread, got %#v", threads)
}
if threads[0].Text != "灰裤子拆成单件衣服,为了后面电商配图。" {
t.Fatalf("expected visible prompt text only, got %q", threads[0].Text)
}
}
func TestProjectScopedToAgentThreadKeepsOnlyCurrentConversationContext(t *testing.T) {
now := time.Now()
project := design.Project{
@@ -824,6 +824,7 @@ func isNonAgentConversationMessage(message design.Message) bool {
}
func agentThreadSummaryText(content string) string {
content = stripPromptReferenceDirectives(content)
lines := strings.Split(content, "\n")
kept := make([]string, 0, len(lines))
for _, line := range lines {
@@ -837,7 +838,7 @@ func agentThreadSummaryText(content string) string {
if text := strings.TrimSpace(strings.Join(kept, " ")); text != "" {
return text
}
return strings.TrimSpace(content)
return ""
}
func projectScopedToAgentThread(project design.Project, threadID string) design.Project {
@@ -4530,7 +4531,7 @@ func hasUserPromptTextInAgentMessages(messages []design.AgentChatMessage) bool {
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
continue
}
text := stripPromptReferenceDirectiveLines(stripImageGeneratorDirectiveLines(content.Text))
text := stripPromptReferenceDirectives(stripImageGeneratorDirectiveLines(content.Text))
if design.NormalizePrompt(text) != "" {
return true
}
@@ -4551,12 +4552,14 @@ func stripImageGeneratorDirectiveLines(text string) string {
return strings.TrimSpace(strings.Join(filtered, "\n"))
}
func stripPromptReferenceDirectiveLines(text string) string {
func stripPromptReferenceDirectives(text string) string {
text = promptImageTokenPattern.ReplaceAllString(text, "")
text = promptImageDirectivePattern.ReplaceAllString(text, "")
lines := strings.Split(text, "\n")
filtered := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(strings.ReplaceAll(line, "\u00a0", " "))
if isPromptReferenceDirectiveLine(trimmed) {
if trimmed == "" || isPromptReferenceDirectiveLine(trimmed) {
continue
}
filtered = append(filtered, line)
+22 -3
View File
@@ -309,6 +309,7 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
drawTextLayer(canvas, node, bounds, scale)
}
}
hasTransparency := mergedLayerHasTransparency(canvas)
var output bytes.Buffer
if err := png.Encode(&output, canvas); err != nil {
@@ -338,6 +339,10 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
if title == "" {
title = "Merged Layer"
}
tone := "natural"
if hasTransparency {
tone = "transparent-image"
}
return design.Node{
ID: newID(),
Type: design.NodeTypeImage,
@@ -347,11 +352,25 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
Width: bounds.Width,
Height: bounds.Height,
Content: content,
Tone: "natural",
Tone: tone,
Status: "success",
}, nil
}
func mergedLayerHasTransparency(img *image.NRGBA) bool {
bounds := img.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
offset := img.PixOffset(bounds.Min.X, y)
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if img.Pix[offset+3] < 255 {
return true
}
offset += 4
}
}
return false
}
func boundsForLayerMerge(nodes []design.Node) layerMergeBounds {
left := math.Inf(1)
top := math.Inf(1)
@@ -446,8 +465,8 @@ func sampleLayerColor(src image.Image, srcBounds image.Rectangle, srcWidth int,
}
u = clampFloat(u, 0, 1)
v = clampFloat(v, 0, 1)
fx := u * float64(srcWidth-1)
fy := v * float64(srcHeight-1)
fx := clampFloat(u*float64(srcWidth)-0.5, 0, float64(srcWidth-1))
fy := clampFloat(v*float64(srcHeight)-0.5, 0, float64(srcHeight-1))
x0 := int(math.Floor(fx))
y0 := int(math.Floor(fy))
x1 := clampInt(x0+1, 0, srcWidth-1)
@@ -129,6 +129,71 @@ func TestMergeLayersFlattensTextOverImage(t *testing.T) {
}
}
func TestMergeLayersPreservesTransparentPixels(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
transparentLayer := image.NewNRGBA(image.Rect(0, 0, 3, 2))
transparentLayer.SetNRGBA(1, 0, color.NRGBA{R: 255, A: 255})
project := design.Project{
ID: "project-merge-transparent",
Title: "Merge transparent",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{
ID: "transparent",
Type: design.NodeTypeImage,
Title: "Transparent",
X: 0,
Y: 0,
Width: 3,
Height: 2,
Content: testPNGDataURLFromImage(transparentLayer),
Status: "success",
},
{
ID: "blue",
Type: design.NodeTypeImage,
Title: "Blue",
X: 2,
Y: 1,
Width: 1,
Height: 1,
Content: testPNGDataURL(color.NRGBA{B: 255, A: 255}, 1, 1),
Status: "success",
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if _, err := service.MergeLayers(ctx, project.ID, []string{"transparent", "blue"}, "Transparent merge"); err != nil {
t.Fatal(err)
}
merged := waitForMergedProject(t, repo, project.ID)
node := merged.Nodes[0]
if node.Tone != "transparent-image" {
t.Fatalf("expected transparent merged image tone, got %q", node.Tone)
}
imageData, _, _, ok, err := decodeDataImage(node.Content)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatalf("expected merged content data URL, got %s", node.Content)
}
decoded, err := png.Decode(bytes.NewReader(imageData))
if err != nil {
t.Fatal(err)
}
assertPixel(t, decoded, 0, 0, color.NRGBA{})
assertPixel(t, decoded, 1, 0, color.NRGBA{R: 255, A: 255})
assertPixel(t, decoded, 2, 1, color.NRGBA{B: 255, A: 255})
}
func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project {
t.Helper()
deadline := time.After(2 * time.Second)
@@ -160,6 +225,10 @@ func testPNGDataURL(fill color.NRGBA, width int, height int) string {
img.SetNRGBA(x, y, fill)
}
}
return testPNGDataURLFromImage(img)
}
func testPNGDataURLFromImage(img image.Image) string {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
panic(err)