431 lines
27 KiB
TypeScript
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)));
|
|
}
|