Add Chromium-only Blender WebEngine parity work
This commit is contained in:
251
web/protocol/sequencer.ts
Normal file
251
web/protocol/sequencer.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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`)]);
|
||||
}
|
||||
Reference in New Issue
Block a user