315 lines
18 KiB
TypeScript
315 lines
18 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 interface SequencerFrameStripIR {
|
|
stripId: string;
|
|
channel: number;
|
|
sourceFrame: number;
|
|
dependencyStripIds: string[];
|
|
}
|
|
|
|
export interface SequencerTransitionFrameIR {
|
|
effectStripId: string;
|
|
effectType: "CROSS" | "GAMMA_CROSS";
|
|
factor: number;
|
|
from: { stripId: string; sourceFrame: number };
|
|
to: { stripId: string; sourceFrame: number };
|
|
}
|
|
|
|
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 resolveSequencerFrame(value: unknown, timelineFrame: number): SequencerFrameStripIR[] {
|
|
const timeline = parseSequencerTimeline(value);
|
|
if (!Number.isFinite(timelineFrame) || timelineFrame < timeline.frameStart || timelineFrame > timeline.frameEnd) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Timeline frame ${timelineFrame} is outside the scene range`);
|
|
const active = timeline.strips.filter((strip) => !strip.muted && timelineFrame >= strip.frameStart && timelineFrame < strip.frameEnd);
|
|
const activeIds = new Set(active.map((strip) => strip.id));
|
|
const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? []));
|
|
const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => {
|
|
const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])];
|
|
if (dependencies.some((id) => !activeIds.has(id))) throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive frame dependency`);
|
|
return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, timelineFrame), dependencyStripIds: [...dependencies] };
|
|
});
|
|
result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId));
|
|
return result;
|
|
}
|
|
|
|
/** Resolves the ordered inputs and bounded progress for the verified cross-transition subset. */
|
|
export function resolveSequencerTransitionFrame(
|
|
value: unknown,
|
|
effectStripId: string,
|
|
timelineFrame: number,
|
|
): SequencerTransitionFrameIR {
|
|
const timeline = parseSequencerTimeline(value);
|
|
const effect = timeline.strips.find((strip) => strip.id === effectStripId);
|
|
if (!effect || effect.type !== "EFFECT" ||
|
|
(effect.effectType !== "CROSS" && effect.effectType !== "GAMMA_CROSS") ||
|
|
effect.inputStripIds?.length !== 2) {
|
|
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is not a supported two-input cross transition`);
|
|
}
|
|
if (!Number.isFinite(timelineFrame) || timelineFrame < effect.frameStart || timelineFrame >= effect.frameEnd) {
|
|
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `${effectStripId} is inactive at frame ${timelineFrame}`);
|
|
}
|
|
const [fromId, toId] = effect.inputStripIds;
|
|
const from = timeline.strips.find((strip) => strip.id === fromId);
|
|
const to = timeline.strips.find((strip) => strip.id === toId);
|
|
if (!from || !to || from.muted || to.muted || timelineFrame < from.frameStart || timelineFrame >= from.frameEnd ||
|
|
timelineFrame < to.frameStart || timelineFrame >= to.frameEnd) {
|
|
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${effectStripId} has an inactive transition input`);
|
|
}
|
|
const factor = (timelineFrame - effect.frameStart) / (effect.frameEnd - effect.frameStart);
|
|
return {
|
|
effectStripId,
|
|
effectType: effect.effectType,
|
|
factor,
|
|
from: { stripId: from.id, sourceFrame: sequencerSourceFrame(from, timelineFrame) },
|
|
to: { stripId: to.id, sourceFrame: sequencerSourceFrame(to, timelineFrame) },
|
|
};
|
|
}
|
|
|
|
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`)]);
|
|
}
|