161 lines
9.9 KiB
TypeScript
161 lines
9.9 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
|
|
|
export const NLA_PROTOCOL_SCHEMA = 1 as const;
|
|
export type NlaBlendMode = "REPLACE" | "ADD" | "MULTIPLY" | "COMBINE";
|
|
export type NlaExtrapolation = "NOTHING" | "HOLD" | "HOLD_FORWARD";
|
|
|
|
export interface NlaStripIR {
|
|
id: string;
|
|
actionId: string;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
actionFrameStart: number;
|
|
actionFrameEnd: number;
|
|
scale: number;
|
|
repeat: number;
|
|
blendIn: number;
|
|
blendOut: number;
|
|
influence: number;
|
|
blendMode: NlaBlendMode;
|
|
extrapolation: NlaExtrapolation;
|
|
muted: boolean;
|
|
selected: boolean;
|
|
reverse?: boolean;
|
|
useTimeWarp?: boolean;
|
|
stripType?: "CLIP" | "TRANSITION" | "META" | "SOUND" | "UNKNOWN";
|
|
unsupportedReason?: string;
|
|
}
|
|
|
|
export interface NlaTrackIR {
|
|
schemaVersion: typeof NLA_PROTOCOL_SCHEMA;
|
|
id: string;
|
|
ownerId: string;
|
|
name: string;
|
|
strips: NlaStripIR[];
|
|
muted: boolean;
|
|
solo: boolean;
|
|
selected: boolean;
|
|
}
|
|
|
|
export interface NlaValidationContext {
|
|
actionIds: ReadonlySet<string>;
|
|
actionChannelPaths?: ReadonlyMap<string, ReadonlySet<string>>;
|
|
targetChannelPaths?: ReadonlySet<string>;
|
|
ownerId?: string;
|
|
actionNlaReferences?: ReadonlyMap<string, ReadonlySet<string>>;
|
|
}
|
|
|
|
export interface NlaValidationResult {
|
|
status: "SUPPORTED" | "BLOCKED";
|
|
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
|
|
}
|
|
|
|
export class NlaValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
readonly path?: string;
|
|
|
|
constructor(code: ErrorCode, message: string, path?: string) {
|
|
super(message);
|
|
this.name = "NlaValidationError";
|
|
this.code = code;
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
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);
|
|
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);
|
|
const strip = value as Record<string, unknown>;
|
|
const frameStart = finite(strip.frameStart, `${path}.frameStart`);
|
|
const frameEnd = finite(strip.frameEnd, `${path}.frameEnd`);
|
|
const actionFrameStart = finite(strip.actionFrameStart, `${path}.actionFrameStart`);
|
|
const actionFrameEnd = finite(strip.actionFrameEnd, `${path}.actionFrameEnd`);
|
|
const scale = finite(strip.scale, `${path}.scale`);
|
|
const repeat = finite(strip.repeat, `${path}.repeat`);
|
|
const blendIn = finite(strip.blendIn, `${path}.blendIn`);
|
|
const blendOut = finite(strip.blendOut, `${path}.blendOut`);
|
|
const influence = finite(strip.influence, `${path}.influence`);
|
|
if (frameEnd <= frameStart || actionFrameEnd <= actionFrameStart || scale <= 0 || repeat <= 0 || blendIn < 0 || blendOut < 0 || influence < 0 || influence > 1) throw new NlaValidationError("NLA_INVALID_STACK", `${path} has an invalid range or influence`, path);
|
|
if (!["REPLACE", "ADD", "MULTIPLY", "COMBINE"].includes(strip.blendMode as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.blendMode is invalid`, `${path}.blendMode`);
|
|
if (!["NOTHING", "HOLD", "HOLD_FORWARD"].includes(strip.extrapolation as string)) throw new NlaValidationError("NLA_INVALID_STACK", `${path}.extrapolation is invalid`, `${path}.extrapolation`);
|
|
if (typeof strip.muted !== "boolean" || typeof strip.selected !== "boolean") throw new NlaValidationError("NLA_INVALID_STACK", `${path}.muted/selected must be boolean`, path);
|
|
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`);
|
|
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");
|
|
const tracks: NlaTrackIR[] = [];
|
|
const ids = new Set<string>();
|
|
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 (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);
|
|
}
|
|
return tracks;
|
|
}
|
|
|
|
export function validateNlaTracks(tracks: readonly NlaTrackIR[], context: NlaValidationContext): NlaValidationResult {
|
|
const issues: NlaValidationResult["issues"] = [];
|
|
const stripIds = new Set<string>();
|
|
for (const [trackIndex, track] of tracks.entries()) {
|
|
if (context.ownerId !== undefined && track.ownerId !== context.ownerId) issues.push({ code: "NLA_PATH_INCOMPATIBLE", message: `NLA track owner does not match target: ${track.ownerId}`, path: `nlaTracks[${trackIndex}].ownerId` });
|
|
for (let index = 1; index < track.strips.length; index++) {
|
|
const previous = track.strips[index - 1];
|
|
const current = track.strips[index];
|
|
if (previous.frameStart > current.frameStart || (previous.frameStart === current.frameStart && previous.id.localeCompare(current.id) > 0)) {
|
|
issues.push({ code: "NLA_INVALID_STACK", message: "NLA strips must be deterministically sorted by frameStart and id", path: `nlaTracks[${trackIndex}].strips` });
|
|
break;
|
|
}
|
|
if (previous.frameEnd > current.frameStart) issues.push({ code: "NLA_INVALID_STACK", message: "NLA strips on one track cannot overlap", path: `nlaTracks[${trackIndex}].strips` });
|
|
}
|
|
for (const [stripIndex, strip] of track.strips.entries()) {
|
|
const path = `nlaTracks[${trackIndex}].strips[${stripIndex}]`;
|
|
if (stripIds.has(strip.id)) issues.push({ code: "NLA_INVALID_STACK", message: `duplicate strip ID: ${strip.id}`, path });
|
|
stripIds.add(strip.id);
|
|
if (!context.actionIds.has(strip.actionId)) issues.push({ code: "NLA_ACTION_MISSING", message: `NLA strip references missing Action: ${strip.actionId}`, path });
|
|
if (strip.useTimeWarp) issues.push({ code: "NLA_TIME_WARP_UNSUPPORTED", message: "time warp is outside the finite NLA subset", path });
|
|
if (strip.stripType !== undefined && strip.stripType !== "CLIP") issues.push({ code: "NLA_STRIP_UNSUPPORTED", message: `${strip.stripType} strips are outside the finite Action Clip subset`, path });
|
|
if (strip.unsupportedReason) issues.push({ code: "NLA_STRIP_UNSUPPORTED", message: strip.unsupportedReason, path });
|
|
const expectedDuration = (strip.actionFrameEnd - strip.actionFrameStart) * strip.scale * strip.repeat;
|
|
if (Math.abs(expectedDuration - (strip.frameEnd - strip.frameStart)) > 1e-4) issues.push({ code: "NLA_INVALID_STACK", message: "NLA strip time mapping is inconsistent with Action range, scale and repeat", path });
|
|
if (strip.blendIn + strip.blendOut > strip.frameEnd - strip.frameStart) issues.push({ code: "NLA_INVALID_STACK", message: "NLA strip blend ranges exceed its duration", path });
|
|
const paths = context.actionChannelPaths?.get(strip.actionId);
|
|
if (paths && paths.size === 0) issues.push({ code: "NLA_PATH_INCOMPATIBLE", message: `Action has no compatible FCurve paths: ${strip.actionId}`, path });
|
|
if (paths && context.targetChannelPaths && ![...paths].some((channelPath) => context.targetChannelPaths?.has(channelPath))) issues.push({ code: "NLA_PATH_INCOMPATIBLE", message: `Action paths do not target the NLA owner: ${strip.actionId}`, path });
|
|
const references = context.actionNlaReferences?.get(strip.actionId);
|
|
if (references?.has(strip.actionId)) issues.push({ code: "NLA_INVALID_STACK", message: `Action references itself through NLA: ${strip.actionId}`, path });
|
|
}
|
|
}
|
|
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues };
|
|
}
|
|
|
|
export function gateNlaTracks(value: unknown, context: NlaValidationContext): CapabilityGateResult {
|
|
try {
|
|
const tracks = parseNlaTracks(value);
|
|
const result = validateNlaTracks(tracks, context);
|
|
if (result.status === "SUPPORTED") return readyGate("N-014", "NLA_STRIP_STACK");
|
|
return blockedGate("N-014", "NLA_STRIP_STACK", result.issues.map((issue) => capabilityIssue(issue.code, issue.message, issue.path)));
|
|
}
|
|
catch (error) {
|
|
const issue = error as NlaValidationError;
|
|
return blockedGate("N-014", "NLA_STRIP_STACK", [capabilityIssue(issue.code ?? "NLA_INVALID_STACK", issue.message, issue.path)]);
|
|
}
|
|
}
|