Files
workinf_Blender_Wasm/web/protocol/sequencer.ts
2026-08-12 04:47:48 -04:00

252 lines
14 KiB
TypeScript

import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
import { normalizeProjectAssetPath } from "./asset-path";
export const SEQUENCER_SCHEMA = 1 as const;
export const SEQUENCER_BUDGET = {
maxStrips: 100_000,
maxChannels: 128,
maxFrames: 1_000_000,
maxDependencies: 64,
maxImageElements: 100_000,
} as const;
export type SequencerStripType = "SCENE" | "MOVIE" | "IMAGE" | "SOUND" | "EFFECT" | "META";
export interface SequencerProxyIR {
status: "MISSING" | "AVAILABLE" | "STALE";
assetId?: string;
width?: number;
height?: number;
quality?: number;
sha256?: string;
}
export interface SequencerStripIR {
id: string;
name: string;
type: SequencerStripType;
channel: number;
frameStart: number;
frameEnd: number;
sourceStart: number;
sourceEnd: number;
speed: number;
muted: boolean;
locked: boolean;
sourceId?: string;
sourcePath?: string;
mimeType?: string;
imageAssetIds?: string[];
effectType?: "CROSS" | "GAMMA_CROSS" | "ADD" | "MULTIPLY" | "TRANSFORM" | "COLOR";
inputStripIds?: string[];
childStripIds?: string[];
proxy?: SequencerProxyIR;
}
export interface SequencerTimelineIR {
schemaVersion: typeof SEQUENCER_SCHEMA;
id: string;
revision: number;
frameStart: number;
frameEnd: number;
fpsNumerator: number;
fpsDenominator: number;
strips: SequencerStripIR[];
}
export type SequencerEditIR =
| { type: "MOVE"; revision: number; stripId: string; frameDelta: number; channel?: number }
| { type: "TRIM"; revision: number; stripId: string; frameStart?: number; frameEnd?: number }
| { type: "SPLIT"; revision: number; stripId: string; frame: number; rightStripId: string };
export interface SequencerRuntimeCapabilityIR {
webCodecsVideo: "PROBE_REQUIRED" | "UNAVAILABLE";
webCodecsAudio: "PROBE_REQUIRED" | "UNAVAILABLE";
htmlMedia: "PROBE_REQUIRED" | "UNAVAILABLE";
localEncoding: "BLOCKED";
}
export class SequencerValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "SequencerValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const STRIP_TYPES = new Set<SequencerStripType>(["SCENE", "MOVIE", "IMAGE", "SOUND", "EFFECT", "META"]);
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(value: unknown, name: string, maximum = 256): string {
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${name} is invalid`);
return value;
}
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${name} is outside the bounded range`);
return value;
}
function parseProxy(value: unknown, index: number): SequencerProxyIR | undefined {
if (value === undefined) return undefined;
if (!record(value) || !["MISSING", "AVAILABLE", "STALE"].includes(value.status as string)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].proxy is invalid`);
const proxy: SequencerProxyIR = { status: value.status as SequencerProxyIR["status"] };
if (value.assetId !== undefined) proxy.assetId = text(value.assetId, `strips[${index}].proxy.assetId`);
if (value.width !== undefined) proxy.width = integer(value.width, `strips[${index}].proxy.width`, 1, 16_384);
if (value.height !== undefined) proxy.height = integer(value.height, `strips[${index}].proxy.height`, 1, 16_384);
if (value.quality !== undefined) proxy.quality = integer(value.quality, `strips[${index}].proxy.quality`, 0, 100);
if (value.sha256 !== undefined) {
if (typeof value.sha256 !== "string" || !SHA256.test(value.sha256)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].proxy.sha256 is invalid`);
proxy.sha256 = value.sha256;
}
if (proxy.status === "AVAILABLE" && (!proxy.assetId || !proxy.sha256)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] available proxy requires assetId and sha256`);
return proxy;
}
export function parseSequencerTimeline(value: unknown): SequencerTimelineIR {
if (!record(value) || value.schemaVersion !== SEQUENCER_SCHEMA || !Array.isArray(value.strips)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported Sequencer timeline schema");
if (value.strips.length > SEQUENCER_BUDGET.maxStrips) throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer strip count exceeds the budget");
const frameStart = integer(value.frameStart, "frameStart", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const frameEnd = integer(value.frameEnd, "frameEnd", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
if (frameEnd < frameStart) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Timeline frame range is invalid");
const ids = new Set<string>();
const strips = value.strips.map((item, index): SequencerStripIR => {
if (!record(item) || !STRIP_TYPES.has(item.type as SequencerStripType)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] is invalid`);
const id = text(item.id, `strips[${index}].id`);
if (ids.has(id)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Duplicate strip ${id}`);
ids.add(id);
const start = integer(item.frameStart, `strips[${index}].frameStart`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const end = integer(item.frameEnd, `strips[${index}].frameEnd`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const sourceStart = integer(item.sourceStart, `strips[${index}].sourceStart`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const sourceEnd = integer(item.sourceEnd, `strips[${index}].sourceEnd`, -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
if (end <= start || sourceEnd < sourceStart || end - start > SEQUENCER_BUDGET.maxFrames) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] frame range is invalid`);
const speed = typeof item.speed === "number" && Number.isFinite(item.speed) && item.speed > 0 && item.speed <= 1_000 ? item.speed : NaN;
if (!Number.isFinite(speed)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].speed is invalid`);
const strip: SequencerStripIR = {
id,
name: text(item.name, `strips[${index}].name`),
type: item.type as SequencerStripType,
channel: integer(item.channel, `strips[${index}].channel`, 1, SEQUENCER_BUDGET.maxChannels),
frameStart: start,
frameEnd: end,
sourceStart,
sourceEnd,
speed,
muted: typeof item.muted === "boolean" ? item.muted : false,
locked: typeof item.locked === "boolean" ? item.locked : false,
proxy: parseProxy(item.proxy, index),
};
if (item.sourceId !== undefined) strip.sourceId = text(item.sourceId, `strips[${index}].sourceId`);
if (item.sourcePath !== undefined) {
try { strip.sourcePath = normalizeProjectAssetPath(text(item.sourcePath, `strips[${index}].sourcePath`, 2048)); }
catch { throw new SequencerValidationError("SEQUENCER_RESOURCE_OUTSIDE_PROJECT", `strips[${index}].sourcePath is outside the project`); }
}
if (item.mimeType !== undefined) strip.mimeType = text(item.mimeType, `strips[${index}].mimeType`, 128);
if (item.imageAssetIds !== undefined) {
if (!Array.isArray(item.imageAssetIds) || item.imageAssetIds.length > SEQUENCER_BUDGET.maxImageElements || item.imageAssetIds.some((asset) => typeof asset !== "string" || asset.length === 0)) throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", `strips[${index}].imageAssetIds exceeds the budget`);
strip.imageAssetIds = [...item.imageAssetIds] as string[];
}
for (const field of ["inputStripIds", "childStripIds"] as const) if (item[field] !== undefined) {
if (!Array.isArray(item[field]) || item[field].length > SEQUENCER_BUDGET.maxDependencies || item[field].some((dependency) => typeof dependency !== "string" || dependency.length === 0)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}].${field} is invalid`);
strip[field] = [...item[field]] as string[];
}
if (strip.type === "EFFECT") {
if (!["CROSS", "GAMMA_CROSS", "ADD", "MULTIPLY", "TRANSFORM", "COLOR"].includes(item.effectType as string) || !strip.inputStripIds?.length) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] effect dependencies are invalid`);
strip.effectType = item.effectType as SequencerStripIR["effectType"];
}
if (strip.type === "META" && !strip.childStripIds) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `strips[${index}] META requires childStripIds`);
if (["MOVIE", "IMAGE", "SOUND"].includes(strip.type) && !strip.sourceId && !strip.sourcePath && !strip.imageAssetIds?.length) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `strips[${index}] has no source resource`);
return strip;
});
const byId = new Map(strips.map((strip) => [strip.id, strip]));
const active = new Set<string>();
const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new SequencerValidationError("SEQUENCER_DEPENDENCY_CYCLE", `Sequencer dependency cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
const strip = byId.get(id);
for (const dependency of [...(strip?.inputStripIds ?? []), ...(strip?.childStripIds ?? [])]) {
if (!byId.has(dependency)) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${id} references missing strip ${dependency}`);
visit(dependency);
}
active.delete(id);
complete.add(id);
};
strips.forEach((strip) => visit(strip.id));
return {
schemaVersion: SEQUENCER_SCHEMA,
id: text(value.id, "id"),
revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER),
frameStart,
frameEnd,
fpsNumerator: integer(value.fpsNumerator, "fpsNumerator", 1, 1_000_000),
fpsDenominator: integer(value.fpsDenominator, "fpsDenominator", 1, 1_000_000),
strips,
};
}
export function applySequencerEdit(value: unknown, edit: SequencerEditIR): SequencerTimelineIR {
const timeline = parseSequencerTimeline(value);
if (edit.revision !== timeline.revision) throw new SequencerValidationError("REVISION_CONFLICT", "Sequencer edit revision is stale");
const strips = timeline.strips.map((strip) => ({ ...strip, inputStripIds: strip.inputStripIds && [...strip.inputStripIds], childStripIds: strip.childStripIds && [...strip.childStripIds] }));
const strip = strips.find((candidate) => candidate.id === edit.stripId);
if (!strip) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Unknown strip ${edit.stripId}`);
if (strip.locked) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${edit.stripId} is locked`);
if (edit.type === "MOVE") {
integer(edit.frameDelta, "frameDelta", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
strip.frameStart += edit.frameDelta;
strip.frameEnd += edit.frameDelta;
if (edit.channel !== undefined) strip.channel = integer(edit.channel, "channel", 1, SEQUENCER_BUDGET.maxChannels);
}
else if (edit.type === "TRIM") {
if (edit.frameStart === undefined && edit.frameEnd === undefined) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "TRIM requires a boundary");
const nextStart = edit.frameStart === undefined ? strip.frameStart : integer(edit.frameStart, "frameStart", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
const nextEnd = edit.frameEnd === undefined ? strip.frameEnd : integer(edit.frameEnd, "frameEnd", -SEQUENCER_BUDGET.maxFrames, SEQUENCER_BUDGET.maxFrames);
if (nextStart >= nextEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "TRIM would create an empty strip");
const originalFrameStart = strip.frameStart;
const originalSourceStart = strip.sourceStart;
strip.sourceStart = originalSourceStart + Math.round((nextStart - originalFrameStart) * strip.speed);
strip.sourceEnd = Math.min(strip.sourceEnd, originalSourceStart + Math.round((nextEnd - originalFrameStart) * strip.speed));
strip.frameStart = nextStart;
strip.frameEnd = nextEnd;
}
else {
const splitFrame = integer(edit.frame, "frame", strip.frameStart + 1, strip.frameEnd - 1);
if (strips.some((candidate) => candidate.id === edit.rightStripId)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Duplicate split strip ${edit.rightStripId}`);
const originalEnd = strip.frameEnd;
const originalSourceEnd = strip.sourceEnd;
const rightSourceStart = strip.sourceStart + Math.round((splitFrame - strip.frameStart) * strip.speed);
strip.frameEnd = splitFrame;
strip.sourceEnd = rightSourceStart;
strips.push({ ...strip, id: text(edit.rightStripId, "rightStripId"), name: `${strip.name} Right`, frameStart: splitFrame, frameEnd: originalEnd, sourceStart: rightSourceStart, sourceEnd: originalSourceEnd });
}
return parseSequencerTimeline({ ...timeline, revision: timeline.revision + 1, strips });
}
export function sequencerSourceFrame(strip: SequencerStripIR, timelineFrame: number): number {
if (!Number.isFinite(timelineFrame) || timelineFrame < strip.frameStart || timelineFrame >= strip.frameEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${strip.id} is inactive at frame ${timelineFrame}`);
return Math.min(strip.sourceEnd, Math.max(strip.sourceStart, strip.sourceStart + (timelineFrame - strip.frameStart) * strip.speed));
}
export function sequencerRuntimeCapabilities(scope: typeof globalThis = globalThis): SequencerRuntimeCapabilityIR {
return {
webCodecsVideo: "VideoDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
webCodecsAudio: "AudioDecoder" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
htmlMedia: "HTMLMediaElement" in scope ? "PROBE_REQUIRED" : "UNAVAILABLE",
localEncoding: "BLOCKED",
};
}
export function gateSequencerCodec(mimeType: string, verifiedMimeTypes: ReadonlySet<string>): CapabilityGateResult {
if (verifiedMimeTypes.has(mimeType)) return readyGate("N-021", `CODEC_${mimeType}`);
return blockedGate("N-021", `CODEC_${mimeType}`, [capabilityIssue("SEQUENCER_CODEC_UNSUPPORTED", `Codec ${mimeType} has not passed an exact seek/decode probe`)]);
}