Advance M8-M11 parity workflows
This commit is contained in:
@@ -2,6 +2,14 @@ import type { ErrorCode } from "./error";
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
|
||||
export const NLA_PROTOCOL_SCHEMA = 1 as const;
|
||||
export const NLA_STACK_BUDGET = Object.freeze({
|
||||
maxTracks: 4_096,
|
||||
maxStripsPerTrack: 16_384,
|
||||
maxTotalStrips: 65_536,
|
||||
maxIdentifierBytes: 256,
|
||||
maxNameBytes: 1_024,
|
||||
maxUnsupportedReasonBytes: 4_096,
|
||||
});
|
||||
export type NlaBlendMode = "REPLACE" | "ADD" | "MULTIPLY" | "COMBINE";
|
||||
export type NlaExtrapolation = "NOTHING" | "HOLD" | "HOLD_FORWARD";
|
||||
|
||||
@@ -51,6 +59,15 @@ export interface NlaValidationResult {
|
||||
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
|
||||
}
|
||||
|
||||
export interface NlaMoveStripCommand {
|
||||
type: "moveNLAStrip";
|
||||
objectId: string;
|
||||
trackId: string;
|
||||
stripId: string;
|
||||
frameStart: number;
|
||||
baseRevision: number;
|
||||
}
|
||||
|
||||
export class NlaValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly path?: string;
|
||||
@@ -67,14 +84,36 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite`, path);
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `${path} contains undeclared fields`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, path: string, maximumBytes: number): string {
|
||||
if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", `${path} is outside its text budget`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string, maximumMagnitude = 1_000_000): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > maximumMagnitude) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be finite and bounded`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseStrip(value: unknown, path: string): NlaStripIR {
|
||||
if (!record(value)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} must be an object`, path);
|
||||
if (typeof value.id !== "string" || value.id.length === 0 || typeof value.actionId !== "string" || value.actionId.length === 0) throw new NlaValidationError("NLA_INVALID_STACK", `${path} requires id and actionId`, path);
|
||||
exactKeys(value, [
|
||||
"id", "actionId", "frameStart", "frameEnd", "actionFrameStart", "actionFrameEnd",
|
||||
"scale", "repeat", "blendIn", "blendOut", "influence", "blendMode", "extrapolation",
|
||||
"muted", "selected", "reverse", "useTimeWarp", "stripType", "unsupportedReason",
|
||||
], path);
|
||||
boundedText(value.id, `${path}.id`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
boundedText(value.actionId, `${path}.actionId`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
const strip = value as Record<string, unknown>;
|
||||
const frameStart = finite(strip.frameStart, `${path}.frameStart`);
|
||||
const frameEnd = finite(strip.frameEnd, `${path}.frameEnd`);
|
||||
@@ -92,19 +131,34 @@ function parseStrip(value: unknown, path: string): NlaStripIR {
|
||||
if (strip.reverse !== undefined && typeof strip.reverse !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.reverse must be boolean`, `${path}.reverse`);
|
||||
if (strip.useTimeWarp !== undefined && typeof strip.useTimeWarp !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.useTimeWarp must be boolean`, `${path}.useTimeWarp`);
|
||||
if (strip.stripType !== undefined && !["CLIP", "TRANSITION", "META", "SOUND", "UNKNOWN"].includes(strip.stripType as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.stripType is invalid`, `${path}.stripType`);
|
||||
if (strip.unsupportedReason !== undefined && (typeof strip.unsupportedReason !== "string" || strip.unsupportedReason.length === 0)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.unsupportedReason is invalid`, `${path}.unsupportedReason`);
|
||||
if (strip.unsupportedReason !== undefined) boundedText(strip.unsupportedReason, `${path}.unsupportedReason`, NLA_STACK_BUDGET.maxUnsupportedReasonBytes);
|
||||
return value as unknown as NlaStripIR;
|
||||
}
|
||||
|
||||
export function parseNlaTracks(value: unknown): NlaTrackIR[] {
|
||||
if (!Array.isArray(value)) throw new NlaValidationError("NLA_INVALID_STACK", "nlaTracks must be an array", "nlaTracks");
|
||||
if (value.length > NLA_STACK_BUDGET.maxTracks) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", "NLA track count exceeds the bounded stack budget", "nlaTracks");
|
||||
}
|
||||
const tracks: NlaTrackIR[] = [];
|
||||
const ids = new Set<string>();
|
||||
let totalStrips = 0;
|
||||
for (const [index, item] of value.entries()) {
|
||||
const path = `nlaTracks[${index}]`;
|
||||
if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || typeof item.id !== "string" || item.id.length === 0 || typeof item.ownerId !== "string" || item.ownerId.length === 0 || typeof item.name !== "string" || item.name.length === 0 || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path);
|
||||
if (ids.has(item.id)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${item.id}`, path);
|
||||
ids.add(item.id);
|
||||
if (!record(item) || item.schemaVersion !== NLA_PROTOCOL_SCHEMA || !Array.isArray(item.strips)) throw new NlaValidationError("NLA_INVALID_STACK", `${path} is invalid`, path);
|
||||
exactKeys(item, ["schemaVersion", "id", "ownerId", "name", "strips", "muted", "solo", "selected"], path);
|
||||
const trackId = boundedText(item.id, `${path}.id`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
boundedText(item.ownerId, `${path}.ownerId`, NLA_STACK_BUDGET.maxIdentifierBytes);
|
||||
boundedText(item.name, `${path}.name`, NLA_STACK_BUDGET.maxNameBytes);
|
||||
if (item.strips.length > NLA_STACK_BUDGET.maxStripsPerTrack) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", `${path}.strips exceeds the per-track budget`, `${path}.strips`);
|
||||
}
|
||||
totalStrips += item.strips.length;
|
||||
if (!Number.isSafeInteger(totalStrips) || totalStrips > NLA_STACK_BUDGET.maxTotalStrips) {
|
||||
throw new NlaValidationError("NLA_BUDGET_EXCEEDED", "NLA strip count exceeds the total stack budget", "nlaTracks");
|
||||
}
|
||||
if (ids.has(trackId)) throw new NlaValidationError("NLA_INVALID_STACK", `duplicate NLA track ID: ${trackId}`, path);
|
||||
ids.add(trackId);
|
||||
if (typeof item.muted !== "boolean" || typeof item.solo !== "boolean" || typeof item.selected !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path} track flags are invalid`, path);
|
||||
tracks.push({ ...item, strips: item.strips.map((strip, stripIndex) => parseStrip(strip, `${path}.strips[${stripIndex}]`)) } as NlaTrackIR);
|
||||
}
|
||||
@@ -158,3 +212,39 @@ export function gateNlaTracks(value: unknown, context: NlaValidationContext): Ca
|
||||
return blockedGate("N-014", "NLA_STRIP_STACK", [capabilityIssue(issue.code ?? "NLA_INVALID_STACK", issue.message, issue.path)]);
|
||||
}
|
||||
}
|
||||
|
||||
export function moveNlaStrip(
|
||||
value: unknown,
|
||||
command: NlaMoveStripCommand,
|
||||
context: NlaValidationContext,
|
||||
): NlaTrackIR[] {
|
||||
if (!Number.isSafeInteger(command.baseRevision) || command.baseRevision < 0 ||
|
||||
typeof command.objectId !== "string" || command.objectId.length === 0 ||
|
||||
typeof command.trackId !== "string" || command.trackId.length === 0 ||
|
||||
typeof command.stripId !== "string" || command.stripId.length === 0 ||
|
||||
!Number.isFinite(command.frameStart) || Math.abs(command.frameStart) > 1_000_000) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", "moveNLAStrip command is invalid");
|
||||
}
|
||||
if (context.ownerId !== undefined && command.objectId !== context.ownerId) {
|
||||
throw new NlaValidationError("NLA_PATH_INCOMPATIBLE", "moveNLAStrip owner does not match the current object", "objectId");
|
||||
}
|
||||
const tracks = structuredClone(parseNlaTracks(value));
|
||||
const track = tracks.find((candidate) => candidate.id === command.trackId);
|
||||
if (!track || track.ownerId !== command.objectId) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `NLA track was not found: ${command.trackId}`, "trackId");
|
||||
}
|
||||
const strip = track.strips.find((candidate) => candidate.id === command.stripId);
|
||||
if (!strip) {
|
||||
throw new NlaValidationError("NLA_INVALID_STACK", `NLA strip was not found: ${command.stripId}`, "stripId");
|
||||
}
|
||||
const duration = strip.frameEnd - strip.frameStart;
|
||||
strip.frameStart = command.frameStart;
|
||||
strip.frameEnd = command.frameStart + duration;
|
||||
track.strips.sort((left, right) => left.frameStart - right.frameStart || left.id.localeCompare(right.id));
|
||||
const validation = validateNlaTracks(tracks, context);
|
||||
if (validation.status === "BLOCKED") {
|
||||
const issue = validation.issues[0];
|
||||
throw new NlaValidationError(issue.code, issue.message, issue.path);
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user