Advance M8-M11 parity workflows
This commit is contained in:
184
web/protocol/sequencer-export.ts
Normal file
184
web/protocol/sequencer-export.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user