import * as z from "zod"; import { Id } from "./ids.js"; import { RemoveSilenceStepSchema } from "./media-analysis.js"; import { MediaPlatformIdSchema } from "./media-platforms.js"; import { MediaFitSchema, MediaPresetIdSchema } from "./media-presets.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); /** Lays an image, a logo or watermark say, over the video: the whole time or from start to end. */ const OverlayStepSchema = z .object({ op: z.literal("overlay"), artifact_id: Id, position: z .enum(["top_left", "top_right", "bottom_left", "bottom_right", "center"]) .default("top_right"), /** Share of the frame width. */ width: z.number().min(0.02).max(1).default(0.15), opacity: z.number().min(0.05).max(1).default(1), start: Seconds.optional(), end: Seconds.optional(), }) .refine( (step) => step.start === undefined || step.end === undefined || step.end > step.start, "end must be after start", ); 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) }), /** Adds these videos at the end, or at the start for an intro, fitted to the current frame. */ z.object({ op: z.literal("concat"), artifact_ids: z.array(Id).min(1).max(20), at: z.enum(["end", "start"]).default("end"), }), 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 }), RemoveSilenceStepSchema, OverlayStepSchema, ]); export type EditVideoStep = z.infer; 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; export const MAX_EXPORT_TARGETS = 6; /** One output per platform and per preset: at least one, at most MAX_EXPORT_TARGETS in all. */ export const ExportVideoInputSchema = z .object({ artifact_id: Id, platforms: z.array(MediaPlatformIdSchema).max(MAX_EXPORT_TARGETS).optional(), presets: z.array(MediaPresetIdSchema).max(MAX_EXPORT_TARGETS).optional(), fit: MediaFitSchema.default("crop"), title: MediaTitleSchema.optional(), }) .refine( (input) => { const targets = (input.platforms?.length ?? 0) + (input.presets?.length ?? 0); return targets >= 1 && targets <= MAX_EXPORT_TARGETS; }, { path: ["platforms"], message: `name 1 to ${MAX_EXPORT_TARGETS} platforms and presets in all`, }, ); export type ExportVideoInput = z.infer;