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; actionChannelPaths?: ReadonlyMap>; targetChannelPaths?: ReadonlySet; ownerId?: string; actionNlaReferences?: ReadonlyMap>; } 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 { 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; 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(); 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(); 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)]); } }