diff --git a/packages/adapters/src/media/image-operations.test.ts b/packages/adapters/src/media/image-operations.test.ts index e028552..f414c08 100644 --- a/packages/adapters/src/media/image-operations.test.ts +++ b/packages/adapters/src/media/image-operations.test.ts @@ -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"); }); diff --git a/packages/adapters/src/media/image-operations.ts b/packages/adapters/src/media/image-operations.ts index 68c08aa..f19ab09 100644 --- a/packages/adapters/src/media/image-operations.ts +++ b/packages/adapters/src/media/image-operations.ts @@ -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({ diff --git a/packages/contracts/src/media-generation.ts b/packages/contracts/src/media-generation.ts index 2e61c42..cbec033 100644 --- a/packages/contracts/src/media-generation.ts +++ b/packages/contracts/src/media-generation.ts @@ -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; 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() diff --git a/packages/contracts/src/media-video.ts b/packages/contracts/src/media-video.ts index f0aaf5b..a12bf51 100644 --- a/packages/contracts/src/media-video.ts +++ b/packages/contracts/src/media-video.ts @@ -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