Merge image edit titles in the user's language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:11:48 +00:00
commit 6d7d46024d
No known key found for this signature in database
4 changed files with 36 additions and 9 deletions

View File

@ -223,6 +223,13 @@ describe("image operations", () => {
expect((await sharp(outputs[0]!.path).metadata()).format).toBe("webp");
expect(result.summary).toBe("Edited photo.jpg into photo-edit.webp (1080×1920).");
expect(emulator.requests).toHaveLength(0);
const titled = await run(
operation,
"edit_image",
{ artifactId: "photo-1", title: "Titelbild: Sommer", rotate: 180 },
{ "photo-1": photo },
);
expect(titled.outputs[0]?.name).toBe("Titelbild Sommer.jpg");
await expect(
run(operation, "edit_image", { artifactId: "photo-1", prompt: "snow" }, { "photo-1": photo }),
).rejects.toThrow("ComfyUI is not configured");
@ -267,7 +274,17 @@ describe("helpers", () => {
resize: { aspect: "vertical", fit: "crop" },
format: "png",
}),
).toBe("crop · 90° · 9:16 · PNG");
).toBe("1×1 · 90° · 9:16 · PNG");
expect(
editImageTitle({
artifactId: "a",
flip: "vertical",
extend: { top: 4, right: 0, bottom: 0, left: 0 },
}),
).toBe("↕");
expect(
editImageTitle({ artifactId: "a", title: "Titelbild", prompt: "snow", rotate: 90 }),
).toBe("Titelbild");
expect(editImageTitle({ artifactId: "a", resize: { width: 800, fit: "crop" } })).toBe("800×…");
expect(editImageTitle({ artifactId: "a", prompt: "make it snow" })).toBe("make it snow");
});

View File

@ -36,6 +36,7 @@ import {
imageSize,
sharpAvailable,
} from "./image-edit.js";
import { cleanFileName } from "./video-operations.js";
/** Free GPU memory a generation needs before it starts; fixed, so a custom workflow cannot lower it. */
export const IMAGE_GENERATION_MIN_VRAM_MB = 6_000;
@ -75,19 +76,22 @@ export function fileStem(text: string, fallback: string): string {
return words?.slice(0, 6).join("-").slice(0, 48).replace(/-+$/, "") || fallback;
}
/** Short card title for an edit without a prompt, such as "crop · 9:16 · PNG". */
/**
* Card title: the bot's title or repaint prompt, else language-neutral tokens for what the edit
* does, such as "300×300 · 90° · ↔ · 9:16 · PNG".
*/
export function editImageTitle(input: EditImageInput): string {
if (input.title) return input.title;
if (input.prompt) return input.prompt;
const { resize } = input;
const { crop, resize } = input;
const parts = [
input.crop && "crop",
crop && `${crop.width}×${crop.height}`,
input.rotate && `${input.rotate}°`,
input.flip && "flip",
input.flip && (input.flip === "horizontal" ? "↔" : "↕"),
resize &&
(resize.aspect
? MEDIA_PRESETS[resize.aspect].aspect
: `${resize.width ?? "…"}×${resize.height ?? "…"}`),
input.extend && "extend",
input.format?.toUpperCase(),
];
return parts.filter(Boolean).join(" · ");
@ -256,8 +260,10 @@ async function editImage(
const format = input.format ?? defaultFormat(source.mimeType);
const encoded = await encodeImage(png, format);
const mimeType = IMAGE_FORMAT_MIME_TYPES[format];
const stem = source.name.replace(/\.[^.]+$/, "") || "image";
const name = `${stem}-edit${attachmentExtensionForMimeType(mimeType)}`;
const stem = input.title
? cleanFileName(input.title, "image")
: `${cleanFileName(source.name.replace(/\.[^.]+$/, ""), "image")}-edit`;
const name = `${stem}${attachmentExtensionForMimeType(mimeType)}`;
const path = join(context.tempDir, name);
await writeFile(path, encoded.bytes);
await context.addOutput({

View File

@ -1,6 +1,7 @@
import * as z from "zod";
import { Id } from "./ids.js";
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
import { MediaTitleSchema } from "./media-video.js";
export const MEDIA_IMAGE_MAX_COUNT = 4;
export const MEDIA_VIDEO_MAX_SECONDS = 10;
@ -55,6 +56,9 @@ export type ImageFormat = z.infer<typeof ImageFormatSchema>;
export const EditImageInputSchema = z
.object({
artifactId: Id.describe("Image artifact to edit."),
title: MediaTitleSchema.optional().describe(
"A few words in the user's language for the card and file name.",
),
crop: z
.object({ left: Pixels, top: Pixels, width: Pixels.min(1), height: Pixels.min(1) })
.optional()

View File

@ -14,7 +14,7 @@ const TimeRangeSchema = z
.refine((range) => range.end > range.start, "end must be after start");
/** Written by the bot in the user's language; shown on the media card and used as file name. */
const MediaTitleSchema = z.string().trim().min(1).max(120);
export const MediaTitleSchema = z.string().trim().min(1).max(120);
export const EditVideoStepSchema = z.discriminatedUnion("op", [
z