evOo-local/packages/contracts/src/media-video.ts
Claude fa3bc601b7
Export videos for named platforms and check each file against its limits
Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-05 09:50:54 +00:00

97 lines
3.8 KiB
TypeScript

import * as z from "zod";
import { Id } from "./ids.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);
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 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<typeof ExportVideoInputSchema>;