import { normalizeProjectAssetPath } from "./asset-path"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const TRACKING_MASK_SCHEMA = 1 as const; export const TRACKING_MASK_BUDGET = { maxClips: 256, maxTracks: 100_000, maxMarkers: 1_000_000, maxPlaneTracks: 10_000, maxMaskLayers: 1_024, maxSplines: 100_000, maxMaskPoints: 1_000_000, maxBindings: 4_096, maxFrame: 1_000_000, } as const; type Vec2 = [number, number]; type Quad2 = [Vec2, Vec2, Vec2, Vec2]; export interface TrackingMarkerIR { frame: number; position: Vec2; patternMin: Vec2; patternMax: Vec2; searchMin: Vec2; searchMax: Vec2; keyframe: boolean; muted: boolean; selected: boolean; } export interface TrackingTrackIR { id: string; name: string; selected: boolean; locked: boolean; markers: TrackingMarkerIR[]; } export interface PlaneTrackKeyframeIR { frame: number; corners: Quad2 } export interface PlaneTrackIR { id: string; name: string; selected: boolean; pointTrackIds: string[]; keyframes: PlaneTrackKeyframeIR[]; } export interface CameraSolveIR { status: "NONE" | "SOLVED" | "FAILED"; sourceSha256?: string; settingsHash?: string; reprojectionError?: number; focalLength?: number; principalPoint?: Vec2; solvedFrames?: number[]; } export interface MovieClipIR { id: string; name: string; sourcePath: string; sourceSha256: string; width: number; height: number; frameStart: number; frameEnd: number; fpsNumerator: number; fpsDenominator: number; tracks: TrackingTrackIR[]; planeTracks: PlaneTrackIR[]; cameraSolve?: CameraSolveIR; } export interface MaskPointIR { id: string; co: Vec2; handleLeft: Vec2; handleRight: Vec2; handleType: "AUTO" | "VECTOR" | "ALIGNED" | "FREE"; feather: number; selected: boolean; } export interface MaskSplineIR { id: string; cyclic: boolean; fill: boolean; points: MaskPointIR[]; } export interface MaskLayerIR { id: string; name: string; visible: boolean; locked: boolean; opacity: number; splines: MaskSplineIR[]; } export interface MaskIR { id: string; name: string; layers: MaskLayerIR[] } export interface TrackingMaskBindingIR { id: string; target: "COMPOSITOR" | "SCENE"; ownerId: string; clipId?: string; maskId?: string; } export interface TrackingMaskProjectIR { schemaVersion: typeof TRACKING_MASK_SCHEMA; revision: number; clips: MovieClipIR[]; masks: MaskIR[]; bindings: TrackingMaskBindingIR[]; } export type TrackingMaskEditIR = | { type: "SET_MARKER"; revision: number; clipId: string; trackId: string; marker: TrackingMarkerIR } | { type: "DELETE_MARKER"; revision: number; clipId: string; trackId: string; frame: number } | { type: "SET_TRACK_SELECTION"; revision: number; clipId: string; trackId: string; selected: boolean } | { type: "SET_MASK_POINT"; revision: number; maskId: string; layerId: string; splineId: string; point: MaskPointIR } | { type: "SET_SPLINE_CYCLIC"; revision: number; maskId: string; layerId: string; splineId: string; cyclic: boolean }; export class TrackingMaskValidationError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "TrackingMaskValidationError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const HANDLE_TYPES = new Set(["AUTO", "VECTOR", "ALIGNED", "FREE"]); function record(value: unknown): value is Record { 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 TrackingMaskValidationError("TRACKING_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 TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is outside the bounded range`); return value; } function finite(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is outside the bounded range`); return value; } function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; } function vec2(value: unknown, name: string, minimum = -16, maximum = 16): Vec2 { if (!Array.isArray(value) || value.length !== 2) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} must be vec2`); return [finite(value[0], `${name}[0]`, minimum, maximum), finite(value[1], `${name}[1]`, minimum, maximum)]; } function marker(value: unknown, name: string): TrackingMarkerIR { if (!record(value)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`); const patternMin = vec2(value.patternMin, `${name}.patternMin`); const patternMax = vec2(value.patternMax, `${name}.patternMax`); const searchMin = vec2(value.searchMin, `${name}.searchMin`); const searchMax = vec2(value.searchMax, `${name}.searchMax`); if (patternMin[0] >= patternMax[0] || patternMin[1] >= patternMax[1] || searchMin[0] >= searchMax[0] || searchMin[1] >= searchMax[1]) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} bounds are invalid`); return { frame: integer(value.frame, `${name}.frame`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame), position: vec2(value.position, `${name}.position`, 0, 1), patternMin, patternMax, searchMin, searchMax, keyframe: value.keyframe === true, muted: value.muted === true, selected: value.selected === true, }; } function sortedFrames(items: T[], name: string): T[] { items.sort((a, b) => a.frame - b.frame); if (items.some((item, index) => index > 0 && item.frame === items[index - 1].frame)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} has duplicate frames`); return items; } function maskPoint(value: unknown, name: string): MaskPointIR { if (!record(value) || !HANDLE_TYPES.has(value.handleType as MaskPointIR["handleType"])) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${name} is invalid`); return { id: text(value.id, `${name}.id`), co: vec2(value.co, `${name}.co`, -4, 4), handleLeft: vec2(value.handleLeft, `${name}.handleLeft`, -4, 4), handleRight: vec2(value.handleRight, `${name}.handleRight`, -4, 4), handleType: value.handleType as MaskPointIR["handleType"], feather: finite(value.feather, `${name}.feather`, 0, 100), selected: value.selected === true, }; } function cameraSolve(value: unknown, sourceSha256: string, name: string): CameraSolveIR | undefined { if (value === undefined) return undefined; if (!record(value) || !["NONE", "SOLVED", "FAILED"].includes(value.status as string)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`); const solve: CameraSolveIR = { status: value.status as CameraSolveIR["status"] }; if (solve.status === "SOLVED") { solve.sourceSha256 = digest(value.sourceSha256, `${name}.sourceSha256`); if (solve.sourceSha256 !== sourceSha256) throw new TrackingMaskValidationError("TRACKING_SOURCE_HASH_MISMATCH", `${name} source hash is stale`); solve.settingsHash = digest(value.settingsHash, `${name}.settingsHash`); solve.reprojectionError = finite(value.reprojectionError, `${name}.reprojectionError`, 0, 1_000_000); solve.focalLength = finite(value.focalLength, `${name}.focalLength`, 0.001, 100_000); solve.principalPoint = vec2(value.principalPoint, `${name}.principalPoint`, -4, 4); if (!Array.isArray(value.solvedFrames) || value.solvedFrames.length === 0 || value.solvedFrames.length > TRACKING_MASK_BUDGET.maxMarkers) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", `${name}.solvedFrames exceeds the budget`); solve.solvedFrames = sortedFrames(value.solvedFrames.map((item, index) => ({ frame: integer(item, `${name}.solvedFrames[${index}]`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame) })), `${name}.solvedFrames`).map((item) => item.frame); } return solve; } export function parseTrackingMaskProject(value: unknown): TrackingMaskProjectIR { if (!record(value) || value.schemaVersion !== TRACKING_MASK_SCHEMA || !Array.isArray(value.clips) || !Array.isArray(value.masks) || !Array.isArray(value.bindings)) throw new TrackingMaskValidationError("PROTOCOL_MISMATCH", "Unsupported TrackingMask project schema"); if (value.clips.length > TRACKING_MASK_BUDGET.maxClips || value.bindings.length > TRACKING_MASK_BUDGET.maxBindings) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Tracking project exceeds the clip or binding budget"); let trackCount = 0; let markerCount = 0; let planeTrackCount = 0; let layerCount = 0; let splineCount = 0; let pointCount = 0; const clipIds = new Set(); const clips = value.clips.map((clipValue, clipIndex): MovieClipIR => { const name = `clips[${clipIndex}]`; if (!record(clipValue) || !Array.isArray(clipValue.tracks) || !Array.isArray(clipValue.planeTracks)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`); const id = text(clipValue.id, `${name}.id`); if (clipIds.has(id)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate clip ${id}`); clipIds.add(id); const frameStart = integer(clipValue.frameStart, `${name}.frameStart`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame); const frameEnd = integer(clipValue.frameEnd, `${name}.frameEnd`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame); if (frameEnd < frameStart) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} frame range is invalid`); trackCount += clipValue.tracks.length; planeTrackCount += clipValue.planeTracks.length; if (trackCount > TRACKING_MASK_BUDGET.maxTracks || planeTrackCount > TRACKING_MASK_BUDGET.maxPlaneTracks) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Tracking tracks exceed the budget"); const trackIds = new Set(); const tracks = clipValue.tracks.map((trackValue, trackIndex): TrackingTrackIR => { const trackName = `${name}.tracks[${trackIndex}]`; if (!record(trackValue) || !Array.isArray(trackValue.markers)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${trackName} is invalid`); const trackId = text(trackValue.id, `${trackName}.id`); if (trackIds.has(trackId)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate track ${trackId}`); trackIds.add(trackId); markerCount += trackValue.markers.length; if (markerCount > TRACKING_MASK_BUDGET.maxMarkers) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Tracking markers exceed the budget"); return { id: trackId, name: text(trackValue.name, `${trackName}.name`), selected: trackValue.selected === true, locked: trackValue.locked === true, markers: sortedFrames(trackValue.markers.map((item, markerIndex) => marker(item, `${trackName}.markers[${markerIndex}]`)), `${trackName}.markers`) }; }); const planeIds = new Set(); const planeTracks = clipValue.planeTracks.map((planeValue, planeIndex): PlaneTrackIR => { const planeName = `${name}.planeTracks[${planeIndex}]`; if (!record(planeValue) || !Array.isArray(planeValue.pointTrackIds) || planeValue.pointTrackIds.length < 4 || planeValue.pointTrackIds.length > 64 || !Array.isArray(planeValue.keyframes)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${planeName} is invalid`); const planeId = text(planeValue.id, `${planeName}.id`); if (planeIds.has(planeId)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate plane track ${planeId}`); planeIds.add(planeId); const pointTrackIds = planeValue.pointTrackIds.map((item, index) => text(item, `${planeName}.pointTrackIds[${index}]`)); if (new Set(pointTrackIds).size !== pointTrackIds.length || pointTrackIds.some((trackId) => !trackIds.has(trackId))) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `${planeName} references missing point tracks`); const keyframes = sortedFrames(planeValue.keyframes.map((item, keyIndex): PlaneTrackKeyframeIR => { if (!record(item) || !Array.isArray(item.corners) || item.corners.length !== 4) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${planeName}.keyframes[${keyIndex}] is invalid`); return { frame: integer(item.frame, `${planeName}.keyframes[${keyIndex}].frame`, -TRACKING_MASK_BUDGET.maxFrame, TRACKING_MASK_BUDGET.maxFrame), corners: item.corners.map((corner, cornerIndex) => vec2(corner, `${planeName}.keyframes[${keyIndex}].corners[${cornerIndex}]`, -4, 4)) as Quad2 }; }), `${planeName}.keyframes`); return { id: planeId, name: text(planeValue.name, `${planeName}.name`), selected: planeValue.selected === true, pointTrackIds, keyframes }; }); let sourcePath: string; try { sourcePath = normalizeProjectAssetPath(text(clipValue.sourcePath, `${name}.sourcePath`, 2048)); } catch { throw new TrackingMaskValidationError("TRACKING_RESOURCE_OUTSIDE_PROJECT", `${name}.sourcePath is outside the project`); } const sourceSha256 = digest(clipValue.sourceSha256, `${name}.sourceSha256`); return { id, name: text(clipValue.name, `${name}.name`), sourcePath, sourceSha256, width: integer(clipValue.width, `${name}.width`, 1, 32768), height: integer(clipValue.height, `${name}.height`, 1, 32768), frameStart, frameEnd, fpsNumerator: integer(clipValue.fpsNumerator, `${name}.fpsNumerator`, 1, 1_000_000), fpsDenominator: integer(clipValue.fpsDenominator, `${name}.fpsDenominator`, 1, 1_000_000), tracks, planeTracks, cameraSolve: cameraSolve(clipValue.cameraSolve, sourceSha256, `${name}.cameraSolve`) }; }); const maskIds = new Set(); const masks = value.masks.map((maskValue, maskIndex): MaskIR => { const name = `masks[${maskIndex}]`; if (!record(maskValue) || !Array.isArray(maskValue.layers)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${name} is invalid`); const id = text(maskValue.id, `${name}.id`); if (maskIds.has(id)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask ${id}`); maskIds.add(id); layerCount += maskValue.layers.length; if (layerCount > TRACKING_MASK_BUDGET.maxMaskLayers) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask layers exceed the budget"); const layerIds = new Set(); const layers = maskValue.layers.map((layerValue, layerIndex): MaskLayerIR => { const layerName = `${name}.layers[${layerIndex}]`; if (!record(layerValue) || !Array.isArray(layerValue.splines)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${layerName} is invalid`); const layerId = text(layerValue.id, `${layerName}.id`); if (layerIds.has(layerId)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask layer ${layerId}`); layerIds.add(layerId); splineCount += layerValue.splines.length; if (splineCount > TRACKING_MASK_BUDGET.maxSplines) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask splines exceed the budget"); const splineIds = new Set(); const splines = layerValue.splines.map((splineValue, splineIndex): MaskSplineIR => { const splineName = `${layerName}.splines[${splineIndex}]`; if (!record(splineValue) || !Array.isArray(splineValue.points)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${splineName} is invalid`); const splineId = text(splineValue.id, `${splineName}.id`); if (splineIds.has(splineId)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask spline ${splineId}`); splineIds.add(splineId); pointCount += splineValue.points.length; if (pointCount > TRACKING_MASK_BUDGET.maxMaskPoints) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask points exceed the budget"); const pointIds = new Set(); const points = splineValue.points.map((item, pointIndex) => { const point = maskPoint(item, `${splineName}.points[${pointIndex}]`); if (pointIds.has(point.id)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Duplicate mask point ${point.id}`); pointIds.add(point.id); return point; }); if (points.length < 2) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${splineName} needs at least two points`); return { id: splineId, cyclic: splineValue.cyclic === true, fill: splineValue.fill !== false, points }; }); return { id: layerId, name: text(layerValue.name, `${layerName}.name`), visible: layerValue.visible !== false, locked: layerValue.locked === true, opacity: finite(layerValue.opacity, `${layerName}.opacity`, 0, 1), splines }; }); return { id, name: text(maskValue.name, `${name}.name`), layers }; }); const bindingIds = new Set(); const bindings = value.bindings.map((bindingValue, index): TrackingMaskBindingIR => { const name = `bindings[${index}]`; if (!record(bindingValue) || !["COMPOSITOR", "SCENE"].includes(bindingValue.target as string)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${name} is invalid`); const id = text(bindingValue.id, `${name}.id`); if (bindingIds.has(id)) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Duplicate binding ${id}`); bindingIds.add(id); const binding: TrackingMaskBindingIR = { id, target: bindingValue.target as TrackingMaskBindingIR["target"], ownerId: text(bindingValue.ownerId, `${name}.ownerId`) }; if (bindingValue.clipId !== undefined) binding.clipId = text(bindingValue.clipId, `${name}.clipId`); if (bindingValue.maskId !== undefined) binding.maskId = text(bindingValue.maskId, `${name}.maskId`); if ((!binding.clipId && !binding.maskId) || (binding.clipId && !clipIds.has(binding.clipId)) || (binding.maskId && !maskIds.has(binding.maskId))) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `${name} references missing resources`); return binding; }); return { schemaVersion: TRACKING_MASK_SCHEMA, revision: integer(value.revision, "revision", 0, Number.MAX_SAFE_INTEGER), clips, masks, bindings }; } export function applyTrackingMaskEdit(value: unknown, edit: TrackingMaskEditIR): TrackingMaskProjectIR { const project = parseTrackingMaskProject(value); if (edit.revision !== project.revision) throw new TrackingMaskValidationError("REVISION_CONFLICT", "Tracking/Mask edit revision is stale"); const clone = structuredClone(project); if (edit.type === "SET_MARKER" || edit.type === "DELETE_MARKER" || edit.type === "SET_TRACK_SELECTION") { const clip = clone.clips.find((item) => item.id === edit.clipId); const track = clip?.tracks.find((item) => item.id === edit.trackId); if (!track) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `Unknown track ${edit.trackId}`); if (track.locked) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `${track.id} is locked`); if (edit.type === "SET_TRACK_SELECTION") track.selected = edit.selected; else if (edit.type === "DELETE_MARKER") { const index = track.markers.findIndex((item) => item.frame === edit.frame); if (index < 0) throw new TrackingMaskValidationError("TRACKING_SCHEMA_INVALID", `Marker frame ${edit.frame} is missing`); track.markers.splice(index, 1); } else { const next = marker(edit.marker, "edit.marker"); const index = track.markers.findIndex((item) => item.frame === next.frame); if (index < 0) track.markers.push(next); else track.markers[index] = next; } } else { const maskValue = clone.masks.find((item) => item.id === edit.maskId); const layer = maskValue?.layers.find((item) => item.id === edit.layerId); const spline = layer?.splines.find((item) => item.id === edit.splineId); if (!spline || !layer) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `Unknown mask spline ${edit.splineId}`); if (layer.locked) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `${layer.id} is locked`); if (edit.type === "SET_SPLINE_CYCLIC") spline.cyclic = edit.cyclic; else { const next = maskPoint(edit.point, "edit.point"); const index = spline.points.findIndex((item) => item.id === next.id); if (index < 0) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `Unknown mask point ${next.id}`); spline.points[index] = next; } } clone.revision += 1; return parseTrackingMaskProject(clone); } export function gateTrackingOperation(operation: "MARKER_EDIT" | "MASK_EDIT" | "BROWSER_TRACKING" | "CAMERA_SOLVE", browserProbe: "VERIFIED" | "UNAVAILABLE" | "UNVERIFIED" = "UNVERIFIED"): CapabilityGateResult { if (operation === "MARKER_EDIT" || operation === "MASK_EDIT") return readyGate("N-022", operation); if (operation === "BROWSER_TRACKING" && browserProbe === "VERIFIED") return readyGate("N-022", operation); return blockedGate("N-022", operation, [capabilityIssue("TRACKING_SOLVE_UNAVAILABLE", operation === "CAMERA_SOLVE" ? "Camera solve requires a verified server Blender implementation" : "Browser tracking requires an explicit feature probe")]); }