Files
workinf_Blender_Wasm/web/protocol/tracking-mask.ts
2026-08-14 18:08:29 -04:00

431 lines
27 KiB
TypeScript

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 interface MaskRaycastHitIR {
maskId: string;
layerId: string;
splineId: string;
kind: "POINT" | "SEGMENT";
pointId: string;
nextPointId?: string;
distance: number;
parameter?: number;
}
export interface MaskPointSelectionIR {
maskId: string;
layerId: string;
splineId: string;
pointId: string;
}
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<MaskPointIR["handleType"]>(["AUTO", "VECTOR", "ALIGNED", "FREE"]);
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 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<T extends { frame: number }>(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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>(); 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<string>();
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")]);
}
function bezierPoint(a: Vec2, b: Vec2, c: Vec2, d: Vec2, t: number): Vec2 {
const inverse = 1 - t;
return [inverse ** 3 * a[0] + 3 * inverse ** 2 * t * b[0] + 3 * inverse * t ** 2 * c[0] + t ** 3 * d[0], inverse ** 3 * a[1] + 3 * inverse ** 2 * t * b[1] + 3 * inverse * t ** 2 * c[1] + t ** 3 * d[1]];
}
export function raycastMaskProject(value: unknown, positionValue: unknown, thresholdValue = 0.02, segmentSamples = 24): MaskRaycastHitIR | null {
const project = parseTrackingMaskProject(value);
const position = vec2(positionValue, "position", -4, 4);
const threshold = finite(thresholdValue, "threshold", 0.000001, 1);
const samples = integer(segmentSamples, "segmentSamples", 2, 128);
let best: MaskRaycastHitIR | null = null;
const consider = (hit: MaskRaycastHitIR): void => { if (hit.distance <= threshold && (!best || hit.distance < best.distance || (hit.distance === best.distance && hit.kind === "POINT" && best.kind === "SEGMENT"))) best = hit; };
for (const mask of project.masks) for (const layer of mask.layers) {
if (!layer.visible || layer.locked || layer.opacity <= 0) continue;
for (const spline of layer.splines) {
for (const point of spline.points) consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "POINT", pointId: point.id, distance: Math.hypot(point.co[0] - position[0], point.co[1] - position[1]) });
const segmentCount = spline.cyclic ? spline.points.length : spline.points.length - 1;
for (let segment = 0; segment < segmentCount; segment++) {
const first = spline.points[segment]; const next = spline.points[(segment + 1) % spline.points.length];
for (let sample = 0; sample <= samples; sample++) {
const parameter = sample / samples;
const point = bezierPoint(first.co, first.handleRight, next.handleLeft, next.co, parameter);
consider({ maskId: mask.id, layerId: layer.id, splineId: spline.id, kind: "SEGMENT", pointId: first.id, nextPointId: next.id, distance: Math.hypot(point[0] - position[0], point[1] - position[1]), parameter });
}
}
}
}
return best;
}
function maskSelectionKey(selection: MaskPointSelectionIR): string {
return `${selection.maskId}\0${selection.layerId}\0${selection.splineId}\0${selection.pointId}`;
}
/** Applies deterministic replace/add/toggle marquee selection to editable Mask control points. */
export function selectMaskPointsInBounds(
value: unknown,
minimumValue: unknown,
maximumValue: unknown,
currentValue: unknown = [],
mode: "REPLACE" | "ADD" | "TOGGLE" = "REPLACE",
): MaskPointSelectionIR[] {
const project = parseTrackingMaskProject(value);
if (!(["REPLACE", "ADD", "TOGGLE"] as const).includes(mode)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection mode is invalid");
const minimum = vec2(minimumValue, "minimum", -4, 4);
const maximum = vec2(maximumValue, "maximum", -4, 4);
if (minimum[0] > maximum[0] || minimum[1] > maximum[1]) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", "Mask selection bounds are inverted");
if (!Array.isArray(currentValue) || currentValue.length > TRACKING_MASK_BUDGET.maxMaskPoints) throw new TrackingMaskValidationError("TRACKING_BUDGET_EXCEEDED", "Mask selection exceeds the point budget");
const all: MaskPointSelectionIR[] = [];
const editable = new Set<string>();
for (const mask of project.masks) for (const layer of mask.layers) for (const spline of layer.splines) for (const point of spline.points) {
const selection = { maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id };
all.push(selection);
if (layer.visible && !layer.locked && layer.opacity > 0) editable.add(maskSelectionKey(selection));
}
const allKeys = new Set(all.map(maskSelectionKey));
const current = new Set<string>();
currentValue.forEach((item, index) => {
if (!record(item)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is invalid`);
const selection = { maskId: text(item.maskId, `current[${index}].maskId`), layerId: text(item.layerId, `current[${index}].layerId`), splineId: text(item.splineId, `current[${index}].splineId`), pointId: text(item.pointId, `current[${index}].pointId`) };
const key = maskSelectionKey(selection);
if (!allKeys.has(key)) throw new TrackingMaskValidationError("TRACKING_BINDING_MISSING", `current[${index}] references a missing Mask point`);
if (current.has(key)) throw new TrackingMaskValidationError("MASK_SCHEMA_INVALID", `current[${index}] is duplicated`);
current.add(key);
});
const hits = new Set<string>();
for (const mask of project.masks) for (const layer of mask.layers) {
if (!layer.visible || layer.locked || layer.opacity <= 0) continue;
for (const spline of layer.splines) for (const point of spline.points) {
if (point.co[0] >= minimum[0] && point.co[0] <= maximum[0] && point.co[1] >= minimum[1] && point.co[1] <= maximum[1]) {
hits.add(maskSelectionKey({ maskId: mask.id, layerId: layer.id, splineId: spline.id, pointId: point.id }));
}
}
}
const selected = mode === "REPLACE" ? new Set<string>() : new Set(current);
for (const key of hits) {
if (!editable.has(key)) continue;
if (mode === "TOGGLE" && selected.has(key)) selected.delete(key);
else selected.add(key);
}
return all.filter((selection) => selected.has(maskSelectionKey(selection)));
}