185 lines
8.0 KiB
TypeScript
185 lines
8.0 KiB
TypeScript
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<string, unknown> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, fields: ReadonlySet<string>, 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<T extends string>(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<string> {
|
|
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<string, unknown> {
|
|
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<SequencerFinalExportRouteIR> {
|
|
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,
|
|
};
|
|
}
|