Files
workinf_Blender_Wasm/web/protocol/sequencer-export.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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,
};
}