← Files Pray Production StudioARCHIVED FILE
skills/pps-ads/packages/remotion-kit/src/trailer/schema.ts
5.83 KB · Oct 8, 2026 · 06:29 UTC
import { z } from 'zod'
export const TRAILER_FPS = 30
export const TRAILER_COMPOSITION_ID = 'TrailerComposition'
export function createTrailerSchemas(z: typeof import('zod').z) {
const frame = z.number().int().min(0).max(1800)
const id = z.string().trim().min(1).max(64)
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/)
const MOTION_KEY = z.object({
frame,
x: z.number().min(-200).max(200).default(0),
y: z.number().min(-200).max(200).default(0),
scale: z.number().min(0.01).max(10).default(1),
rotate: z.number().min(-720).max(720).default(0),
opacity: z.number().min(0).max(1).default(1),
reveal: z.number().min(0).max(1).default(1),
ease: z.enum(['linear', 'in', 'out', 'in-out']).default('out'),
}).strict()
const TRAILER_LAYER = z.object({
kind: z.enum(['text', 'rect', 'ellipse', 'cursor', 'image', 'video']),
startFrame: frame,
durationFrames: frame.min(1),
// Percent of the composition's width/height, top-left anchored. Order is back to front.
x: z.number().min(-100).max(100).default(0),
y: z.number().min(-100).max(100).default(0),
width: z.number().positive().max(200).default(100),
height: z.number().positive().max(200).default(100),
text: z.string().min(1).max(600).optional(),
color: color.default('#ffffff'),
background: color.optional(),
font: z.enum(['display', 'sans', 'mono']).default('sans'),
fontSize: z.number().min(0.5).max(30).default(5),
weight: z.enum(['400', '600', '700', '800']).default('700'),
align: z.enum(['left', 'center', 'right']).default('left'),
radius: z.number().min(0).max(100).default(0),
borderWidth: z.number().min(0).max(20).default(0),
borderColor: color.default('#ffffff'),
fit: z.enum(['cover', 'contain']).default('cover'),
imageId: id.optional(),
videoId: id.optional(),
trimStartFrame: frame.default(0),
volume: z.number().min(0).max(1).default(1),
// Local frames. x/y are offsets in canvas percentages; ease belongs to the destination key.
keys: z.array(MOTION_KEY).min(1).max(16).default([{ frame: 0, x: 0, y: 0, scale: 1, rotate: 0, opacity: 1, reveal: 1, ease: 'out' }]),
}).strict()
const TRAILER_AUDIO = z.object({
kind: z.enum(['music', 'voiceover', 'scene-audio']),
assetId: id,
startFrame: frame,
durationFrames: frame.min(1),
trimStartFrame: frame.default(0),
volume: z.number().min(0).max(1).default(1),
fadeInFrames: frame.default(0),
fadeOutFrames: frame.default(12),
// Explicit local gain envelope for ducks, score drops and impacts. No guessed beat detection.
gainKeys: z.array(z.object({ frame, gain: z.number().min(0).max(1) }).strict()).max(32).default([]),
}).strict()
const TRAILER_SPEC = z.object({
title: z.string().trim().min(1).max(120),
mode: z.enum(['cinematic', 'product-hype']),
durationFrames: frame.min(150),
aspectRatio: z.enum(['9:16', '16:9']).default('16:9'),
background: color.default('#080a12'),
layers: z.array(TRAILER_LAYER).min(1).max(80),
audio: z.array(TRAILER_AUDIO).max(16).default([]),
}).strict()
return { TRAILER_SPEC, TRAILER_LAYER, TRAILER_AUDIO, MOTION_KEY }
}
export const { TRAILER_SPEC, TRAILER_LAYER, TRAILER_AUDIO, MOTION_KEY } = createTrailerSchemas(z)
export type TrailerSpec = z.infer<typeof TRAILER_SPEC>
export type TrailerLayer = z.infer<typeof TRAILER_LAYER>
export type TrailerAudio = z.infer<typeof TRAILER_AUDIO>
export type MotionKey = z.infer<typeof MOTION_KEY>
export function validateTrailer(spec: TrailerSpec): string[] {
const errors: string[] = []
for (const [i, layer] of spec.layers.entries()) {
const label = `Layer ${i + 1}`
if (layer.startFrame + layer.durationFrames > spec.durationFrames) errors.push(`${label} exceeds the trailer.`)
if (layer.kind === 'text' && !layer.text) errors.push(`${label} needs text.`)
if (layer.kind === 'image' && !layer.imageId) errors.push(`${label} needs imageId.`)
if (layer.kind === 'video' && !layer.videoId) errors.push(`${label} needs videoId.`)
if (layer.kind !== 'image' && layer.imageId) errors.push(`${label}: imageId only belongs on an image.`)
if (layer.kind !== 'video' && layer.videoId) errors.push(`${label}: videoId only belongs on a video.`)
if (layer.keys[0].frame !== 0) errors.push(`${label}: first key must be at local frame 0.`)
layer.keys.forEach((key, j) => {
if (key.frame >= layer.durationFrames) errors.push(`${label}: key exceeds its local duration.`)
if (j > 0 && key.frame <= layer.keys[j - 1].frame) errors.push(`${label}: keys must increase strictly.`)
})
}
for (const [i, audio] of spec.audio.entries()) {
const label = `Audio ${i + 1}`
if (audio.startFrame + audio.durationFrames > spec.durationFrames) errors.push(`${label} exceeds the trailer.`)
if (audio.fadeInFrames + audio.fadeOutFrames > audio.durationFrames) errors.push(`${label}: fades exceed duration.`)
audio.gainKeys.forEach((key, j) => {
if (key.frame >= audio.durationFrames) errors.push(`${label}: gain key exceeds duration.`)
if (j > 0 && key.frame <= audio.gainKeys[j - 1].frame) errors.push(`${label}: gain keys must increase strictly.`)
})
}
const assetIds = new Set([
...spec.layers.flatMap((l) => [l.imageId, l.videoId].filter(Boolean)),
...spec.audio.map((a) => a.assetId),
])
if (assetIds.size > 80) errors.push('At most 80 distinct media assets may be used.')
// Bound concurrent decoding independently of total layer count.
const videos = spec.layers.filter((l) => l.kind === 'video')
if (videos.some((a) => videos.filter((b) =>
b.startFrame <= a.startFrame && b.startFrame + b.durationFrames > a.startFrame).length > 4)) {
errors.push('At most four videos may play at once.')
}
return errors
}
export const TRAILER_PROPS = z.object({ spec: TRAILER_SPEC, assets: z.record(z.string()) })
export type TrailerProps = z.infer<typeof TRAILER_PROPS>
SHA-256: bbf8132f2c217bfa9a84af921e011f3487c7a50b08dcd2aefb6838c127c5e4ea