evOo-local/packages/contracts/src/media-video.ts
Claude 31f4c2531d
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
2026-10-03 14:11:27 +00:00

81 lines
3.1 KiB
TypeScript

import * as z from "zod";
import { Id } from "./ids.js";
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
/**
* Inputs of the `edit_video` and `export_video` media operations, which are also the arguments
* of the tools with those names. Times are seconds on the timeline left by the previous steps.
*/
const Seconds = z.number().min(0).max(86_400);
const TimeRangeSchema = z
.object({ start: Seconds, end: Seconds })
.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. */
export const MediaTitleSchema = z.string().trim().min(1).max(120);
export const EditVideoStepSchema = z.discriminatedUnion("op", [
z
.object({ op: z.literal("trim"), start: Seconds, end: Seconds })
.refine((step) => step.end > step.start, "end must be after start"),
/** Keeps these parts and cuts everything between them. */
z.object({ op: z.literal("keep"), segments: z.array(TimeRangeSchema).min(1).max(50) }),
/** Appends these videos, fitted to the current frame. */
z.object({ op: z.literal("concat"), artifact_ids: z.array(Id).min(1).max(20) }),
z.object({
op: z.literal("reframe"),
preset: MediaPresetIdSchema,
fit: MediaFitSchema.default("crop"),
}),
z.object({ op: z.literal("speed"), factor: z.number().min(0.25).max(4) }),
/** Linear gain: 0.5 halves the loudness, 2 doubles it. */
z.object({ op: z.literal("volume"), gain: z.number().min(0).max(8) }),
z.object({ op: z.literal("mute") }),
/** Burns in an SRT or VTT artifact. */
z.object({ op: z.literal("subtitles"), artifact_id: Id }),
/** Last step only: the result is the audio as M4A. */
z.object({ op: z.literal("extract_audio") }),
/** Cover frame at this second of the result; as the only kind of step, the result is that image. */
z.object({ op: z.literal("poster"), at: Seconds }),
]);
export type EditVideoStep = z.infer<typeof EditVideoStepSchema>;
export const EditVideoInputSchema = z
.object({
artifact_id: Id,
steps: z.array(EditVideoStepSchema).min(1).max(20),
title: MediaTitleSchema,
})
.superRefine((input, context) => {
const ops = input.steps.map((step) => step.op);
const extract = ops.indexOf("extract_audio");
if (extract >= 0 && extract !== ops.length - 1) {
context.addIssue({
code: "custom",
path: ["steps", extract],
message: "extract_audio must be the last step",
});
}
if (extract >= 0 && ops.includes("poster")) {
context.addIssue({
code: "custom",
path: ["steps"],
message: "poster cannot be combined with extract_audio",
});
}
if (ops.filter((op) => op === "poster").length > 1) {
context.addIssue({ code: "custom", path: ["steps"], message: "use at most one poster step" });
}
});
export type EditVideoInput = z.infer<typeof EditVideoInputSchema>;
export const ExportVideoInputSchema = z.object({
artifact_id: Id,
presets: z.array(MediaPresetIdSchema).min(1).max(6),
fit: MediaFitSchema.default("crop"),
title: MediaTitleSchema.optional(),
});
export type ExportVideoInput = z.infer<typeof ExportVideoInputSchema>;