import type { ErrorCode } from "./error"; export const SEQUENCER_FINAL_EXPORT_SCHEMA = 1 as const; export type SequencerFinalContainer = "MPEG4" | "WEBM" | "QUICKTIME"; export type SequencerFinalVideoCodec = "H264" | "VP9" | "PRORES"; export type SequencerFinalAudioCodec = "AAC" | "OPUS" | "PCM" | "NONE"; export interface SequencerFinalExportRequestIR { schemaVersion: typeof SEQUENCER_FINAL_EXPORT_SCHEMA; timelineId: string; timelineRevision: number; sourceBlendSha256: string; frameStart: number; frameEnd: number; fpsNumerator: number; fpsDenominator: number; width: number; height: number; container: SequencerFinalContainer; videoCodec: SequencerFinalVideoCodec; audioCodec: SequencerFinalAudioCodec; } export interface SequencerFinalExportEnvironmentIR { serverExportAvailable: boolean; browserVideoEncoderAvailable: boolean; } export interface SequencerFinalExportRouteIR { schemaVersion: typeof SEQUENCER_FINAL_EXPORT_SCHEMA; requestSha256: string; settingsSha256: string; route: "SERVER_EXPORT"; status: "SERVER_EXPORT_REQUIRED" | "BLOCKED"; code: ErrorCode | null; localEncoding: "BLOCKED"; browserVideoEncoderDetected: boolean; } export class SequencerFinalExportValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "SequencerFinalExportValidationError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/; const REQUEST_KEYS = new Set([ "schemaVersion", "timelineId", "timelineRevision", "sourceBlendSha256", "frameStart", "frameEnd", "fpsNumerator", "fpsDenominator", "width", "height", "container", "videoCodec", "audioCodec", ]); const ENVIRONMENT_KEYS = new Set(["serverExportAvailable", "browserVideoEncoderAvailable"]); const CODEC_COMBINATIONS = new Set(["MPEG4:H264:AAC", "MPEG4:H264:NONE", "WEBM:VP9:OPUS", "WEBM:VP9:NONE", "QUICKTIME:PRORES:PCM", "QUICKTIME:PRORES:NONE"]); function record(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} must be an object`); } return value as Record; } function exactKeys(value: Record, fields: ReadonlySet, label: string): void { if (Object.keys(value).length !== fields.size || Object.keys(value).some((field) => !fields.has(field))) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} fields are invalid`); } } function integer(value: unknown, label: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} is outside the bounded range`); } return value; } function enumValue(value: unknown, values: readonly T[], label: string): T { if (typeof value !== "string" || !values.includes(value as T)) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", `${label} is unsupported`); } return value as T; } async function sha256(value: string): Promise { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""); } export function parseSequencerFinalExportRequest(value: unknown): SequencerFinalExportRequestIR { const input = record(value, "Sequencer final export request"); exactKeys(input, REQUEST_KEYS, "Sequencer final export request"); if (input.schemaVersion !== SEQUENCER_FINAL_EXPORT_SCHEMA) { throw new SequencerFinalExportValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer final export schema"); } if (typeof input.timelineId !== "string" || !ID.test(input.timelineId) || typeof input.sourceBlendSha256 !== "string" || !SHA256.test(input.sourceBlendSha256)) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export source identity is invalid"); } const frameStart = integer(input.frameStart, "frameStart", -1_000_000, 1_000_000); const frameEnd = integer(input.frameEnd, "frameEnd", -1_000_000, 1_000_000); if (frameEnd < frameStart || frameEnd - frameStart + 1 > 1_000_000) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export frame range is invalid"); } const width = integer(input.width, "width", 1, 16_384); const height = integer(input.height, "height", 1, 16_384); if (width * height > 67_108_864) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export pixel dimensions exceed the budget"); } const container = enumValue(input.container, ["MPEG4", "WEBM", "QUICKTIME"] as const, "container"); const videoCodec = enumValue(input.videoCodec, ["H264", "VP9", "PRORES"] as const, "videoCodec"); const audioCodec = enumValue(input.audioCodec, ["AAC", "OPUS", "PCM", "NONE"] as const, "audioCodec"); if (!CODEC_COMBINATIONS.has(`${container}:${videoCodec}:${audioCodec}`)) { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export codec combination is unsupported"); } return { schemaVersion: SEQUENCER_FINAL_EXPORT_SCHEMA, timelineId: input.timelineId, timelineRevision: integer(input.timelineRevision, "timelineRevision", 0, Number.MAX_SAFE_INTEGER), sourceBlendSha256: input.sourceBlendSha256, frameStart, frameEnd, fpsNumerator: integer(input.fpsNumerator, "fpsNumerator", 1, 1_000_000), fpsDenominator: integer(input.fpsDenominator, "fpsDenominator", 1, 1_000_000), width, height, container, videoCodec, audioCodec, }; } export function parseSequencerFinalExportEnvironment(value: unknown): SequencerFinalExportEnvironmentIR { const input = record(value, "Sequencer final export environment"); exactKeys(input, ENVIRONMENT_KEYS, "Sequencer final export environment"); if (typeof input.serverExportAvailable !== "boolean" || typeof input.browserVideoEncoderAvailable !== "boolean") { throw new SequencerFinalExportValidationError("SEQUENCER_EXPORT_REQUEST_INVALID", "Sequencer final export environment is invalid"); } return { serverExportAvailable: input.serverExportAvailable, browserVideoEncoderAvailable: input.browserVideoEncoderAvailable, }; } function canonicalSettings(request: SequencerFinalExportRequestIR): Record { return { frameStart: request.frameStart, frameEnd: request.frameEnd, fpsNumerator: request.fpsNumerator, fpsDenominator: request.fpsDenominator, width: request.width, height: request.height, container: request.container, videoCodec: request.videoCodec, audioCodec: request.audioCodec, }; } export async function routeSequencerFinalExport( requestValue: unknown, environmentValue: unknown, ): Promise { const request = parseSequencerFinalExportRequest(requestValue); const environment = parseSequencerFinalExportEnvironment(environmentValue); const settingsSha256 = await sha256(JSON.stringify(canonicalSettings(request))); const requestSha256 = await sha256(JSON.stringify({ schemaVersion: request.schemaVersion, timelineId: request.timelineId, timelineRevision: request.timelineRevision, sourceBlendSha256: request.sourceBlendSha256, settingsSha256, })); return { schemaVersion: SEQUENCER_FINAL_EXPORT_SCHEMA, requestSha256, settingsSha256, route: "SERVER_EXPORT", status: environment.serverExportAvailable ? "SERVER_EXPORT_REQUIRED" : "BLOCKED", code: environment.serverExportAvailable ? null : "SEQUENCER_EXPORT_SERVER_UNAVAILABLE", localEncoding: "BLOCKED", browserVideoEncoderDetected: environment.browserVideoEncoderAvailable, }; }