fix(media): let bots title image edits in the user's language
Edit cards showed English words such as "crop · flip · extend" to every user. edit_image now takes an optional title written by the bot in the user's language, used for the card and the file name like edit_video. Without one, the card falls back to the repaint prompt or to language-neutral tokens only: sizes, ratios, degrees, arrows and format names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
4cda7f79f7
commit
31f4c2531d
@ -223,6 +223,13 @@ describe("image operations", () => {
|
|||||||
expect((await sharp(outputs[0]!.path).metadata()).format).toBe("webp");
|
expect((await sharp(outputs[0]!.path).metadata()).format).toBe("webp");
|
||||||
expect(result.summary).toBe("Edited photo.jpg into photo-edit.webp (1080×1920).");
|
expect(result.summary).toBe("Edited photo.jpg into photo-edit.webp (1080×1920).");
|
||||||
expect(emulator.requests).toHaveLength(0);
|
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(
|
await expect(
|
||||||
run(operation, "edit_image", { artifactId: "photo-1", prompt: "snow" }, { "photo-1": photo }),
|
run(operation, "edit_image", { artifactId: "photo-1", prompt: "snow" }, { "photo-1": photo }),
|
||||||
).rejects.toThrow("ComfyUI is not configured");
|
).rejects.toThrow("ComfyUI is not configured");
|
||||||
@ -267,7 +274,17 @@ describe("helpers", () => {
|
|||||||
resize: { aspect: "vertical", fit: "crop" },
|
resize: { aspect: "vertical", fit: "crop" },
|
||||||
format: "png",
|
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", resize: { width: 800, fit: "crop" } })).toBe("800×…");
|
||||||
expect(editImageTitle({ artifactId: "a", prompt: "make it snow" })).toBe("make it snow");
|
expect(editImageTitle({ artifactId: "a", prompt: "make it snow" })).toBe("make it snow");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
imageSize,
|
imageSize,
|
||||||
sharpAvailable,
|
sharpAvailable,
|
||||||
} from "./image-edit.js";
|
} 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. */
|
/** 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;
|
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;
|
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 {
|
export function editImageTitle(input: EditImageInput): string {
|
||||||
|
if (input.title) return input.title;
|
||||||
if (input.prompt) return input.prompt;
|
if (input.prompt) return input.prompt;
|
||||||
const { resize } = input;
|
const { crop, resize } = input;
|
||||||
const parts = [
|
const parts = [
|
||||||
input.crop && "crop",
|
crop && `${crop.width}×${crop.height}`,
|
||||||
input.rotate && `${input.rotate}°`,
|
input.rotate && `${input.rotate}°`,
|
||||||
input.flip && "flip",
|
input.flip && (input.flip === "horizontal" ? "↔" : "↕"),
|
||||||
resize &&
|
resize &&
|
||||||
(resize.aspect
|
(resize.aspect
|
||||||
? MEDIA_PRESETS[resize.aspect].aspect
|
? MEDIA_PRESETS[resize.aspect].aspect
|
||||||
: `${resize.width ?? "…"}×${resize.height ?? "…"}`),
|
: `${resize.width ?? "…"}×${resize.height ?? "…"}`),
|
||||||
input.extend && "extend",
|
|
||||||
input.format?.toUpperCase(),
|
input.format?.toUpperCase(),
|
||||||
];
|
];
|
||||||
return parts.filter(Boolean).join(" · ");
|
return parts.filter(Boolean).join(" · ");
|
||||||
@ -256,8 +260,10 @@ async function editImage(
|
|||||||
const format = input.format ?? defaultFormat(source.mimeType);
|
const format = input.format ?? defaultFormat(source.mimeType);
|
||||||
const encoded = await encodeImage(png, format);
|
const encoded = await encodeImage(png, format);
|
||||||
const mimeType = IMAGE_FORMAT_MIME_TYPES[format];
|
const mimeType = IMAGE_FORMAT_MIME_TYPES[format];
|
||||||
const stem = source.name.replace(/\.[^.]+$/, "") || "image";
|
const stem = input.title
|
||||||
const name = `${stem}-edit${attachmentExtensionForMimeType(mimeType)}`;
|
? cleanFileName(input.title, "image")
|
||||||
|
: `${cleanFileName(source.name.replace(/\.[^.]+$/, ""), "image")}-edit`;
|
||||||
|
const name = `${stem}${attachmentExtensionForMimeType(mimeType)}`;
|
||||||
const path = join(context.tempDir, name);
|
const path = join(context.tempDir, name);
|
||||||
await writeFile(path, encoded.bytes);
|
await writeFile(path, encoded.bytes);
|
||||||
await context.addOutput({
|
await context.addOutput({
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Id } from "./ids.js";
|
import { Id } from "./ids.js";
|
||||||
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
|
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
|
||||||
|
import { MediaTitleSchema } from "./media-video.js";
|
||||||
|
|
||||||
export const MEDIA_IMAGE_MAX_COUNT = 4;
|
export const MEDIA_IMAGE_MAX_COUNT = 4;
|
||||||
export const MEDIA_VIDEO_MAX_SECONDS = 10;
|
export const MEDIA_VIDEO_MAX_SECONDS = 10;
|
||||||
@ -55,6 +56,9 @@ export type ImageFormat = z.infer<typeof ImageFormatSchema>;
|
|||||||
export const EditImageInputSchema = z
|
export const EditImageInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
artifactId: Id.describe("Image artifact to edit."),
|
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
|
crop: z
|
||||||
.object({ left: Pixels, top: Pixels, width: Pixels.min(1), height: Pixels.min(1) })
|
.object({ left: Pixels, top: Pixels, width: Pixels.min(1), height: Pixels.min(1) })
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const TimeRangeSchema = z
|
|||||||
.refine((range) => range.end > range.start, "end must be after start");
|
.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. */
|
/** 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", [
|
export const EditVideoStepSchema = z.discriminatedUnion("op", [
|
||||||
z
|
z
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user