fablecut 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -84,8 +84,7 @@ const TRANSITIONS = ["none", "fade", "slide-left", "slide-right", "slide-up", "s
84
84
  const TEXT_ANIMS = ["none", "typewriter", "word-pop", "word-slide", "karaoke",
85
85
  "letter-pop", "wave", "bounce", "shake",
86
86
  "clip-reveal", "zoom-in", "font-cut", "rise-mask"];
87
- const BLEND_MODES = ["normal", "multiply", "screen", "overlay", "lighter", "soft-light",
88
- "hard-light", "color-dodge", "darken", "lighten", "difference"];
87
+ const BLEND_MODES = self.FableCutMask.BLENDS; // the canvas composite set, shared with the agent (mask.js)
89
88
  /* Named looks. % props multiply against the clip's own value, additive props add. */
90
89
  const FILTER_PRESETS = {
91
90
  none: {},
@@ -779,7 +778,7 @@ const els = {
779
778
  importUrlOverlay: $("importUrlOverlay"), importUrlInput: $("importUrlInput"),
780
779
  importUrlStatus: $("importUrlStatus"), importUrlProgress: $("importUrlProgress"),
781
780
  };
782
- const ctx2d = els.preview.getContext("2d");
781
+ let ctx2d = els.preview.getContext("2d"); // drawMaskedClip points it at an offscreen layer while a masked clip draws
783
782
 
784
783
  /* ── Utils ─────────────────────────────────────────────────────────────── */
785
784
  const uid = () => Math.random().toString(36).slice(2, 9);
@@ -3273,6 +3272,9 @@ function rebuildClips() {
3273
3272
  els.tracksContent.style.width = w + "px";
3274
3273
  els.ruler.style.width = els.timelineScroll.clientWidth + "px";
3275
3274
  for (const row of els.tracks.children) row.innerHTML = "";
3275
+ const matted = project.clips.map((x) => [x, clipMatte(x)]).filter(([, m]) => m);
3276
+ const isMatte = (c) => matted.some(([x, m]) => m.track === c.track && clipRenders(x) &&
3277
+ x.start < c.start + c.duration && c.start < x.start + x.duration); // hidden while it cuts that clip
3276
3278
  for (const c of project.clips) {
3277
3279
  const tr = trackOf(c); if (!tr) continue;
3278
3280
  const row = els.tracks.querySelector(`[data-track="${c.track}"]`);
@@ -3280,7 +3282,7 @@ function rebuildClips() {
3280
3282
  div.className = `clip c-${c.kind}` +
3281
3283
  (state.selIds.has(c.id) ? " selected" : "") + (c.id === state.selId ? " primary" : "") +
3282
3284
  (isClipLocked(c) ? " locked" : "") + (c.disabled === true ? " disabled" : "") +
3283
- (c.unlinked === true ? " unlinked" : "");
3285
+ (c.unlinked === true ? " unlinked" : "") + (c.kind !== "audio" && isMatte(c) ? " matte-src" : "");
3284
3286
  div.dataset.id = c.id;
3285
3287
  div.style.left = c.start * state.pps + "px";
3286
3288
  div.style.width = Math.max(8, c.duration * state.pps) + "px";
@@ -3292,7 +3294,8 @@ function rebuildClips() {
3292
3294
  const hasWave = c.kind === "audio" && !!wavePeaksFor(c);
3293
3295
  if (hasWave) body += `<canvas class="wave"></canvas>`;
3294
3296
  const badge = (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") +
3295
- (c.transitionIn || c.transitionOut ? "⇄ " : "");
3297
+ (c.transitionIn || c.transitionOut ? "⇄ " : "") +
3298
+ (clipMatte(c) ? "◐ " : "");
3296
3299
  const chTag = (() => {
3297
3300
  const s = audioChannelShort(c.props?.audioChannel);
3298
3301
  return s ? s + " · " : "";
@@ -3904,6 +3907,8 @@ function startTrimToolGesture(e, c, mode) {
3904
3907
  const base = new Map(project.clips.map((x) => [x.id, {
3905
3908
  start: x.start, in: x.in, duration: x.duration, keyframes: x.keyframes,
3906
3909
  transitionIn: cloneTr(x.transitionIn), transitionOut: cloneTr(x.transitionOut),
3910
+ masks: x.props && x.props.masks, // head trims re-base mask keys: each step starts from the originals
3911
+ tracks: x.props && x.props.tracks,
3907
3912
  }]));
3908
3913
  const restore = () => {
3909
3914
  for (const x of project.clips) {
@@ -3911,6 +3916,10 @@ function startTrimToolGesture(e, c, mode) {
3911
3916
  if (!b) continue;
3912
3917
  x.start = b.start; x.in = b.in; x.duration = b.duration; x.keyframes = b.keyframes;
3913
3918
  x.transitionIn = cloneTr(b.transitionIn); x.transitionOut = cloneTr(b.transitionOut);
3919
+ if (x.props) {
3920
+ if (b.masks) x.props.masks = b.masks; else delete x.props.masks;
3921
+ if (b.tracks) x.props.tracks = b.tracks; else delete x.props.tracks;
3922
+ }
3914
3923
  }
3915
3924
  };
3916
3925
  // Snap targets ignore everything this edit moves.
@@ -4092,6 +4101,8 @@ function startClipGesture(e, c, mode, collapseOnClick) {
4092
4101
  const orig = {
4093
4102
  start: c.start, in: c.in, duration: c.duration, track: c.track,
4094
4103
  keyframes: c.keyframes ? JSON.parse(JSON.stringify(c.keyframes)) : undefined,
4104
+ masks: c.props.masks,
4105
+ tracks: c.props.tracks,
4095
4106
  };
4096
4107
  // moving a clip that belongs to a multi-selection drags the whole group;
4097
4108
  // AV-linked partners (video+audio from one file) always move together
@@ -4146,6 +4157,8 @@ function startClipGesture(e, c, mode, collapseOnClick) {
4146
4157
  c.in = (c.kind === "video" || c.kind === "audio") ? orig.in + d * sp : 0;
4147
4158
  c.duration = orig.duration - d;
4148
4159
  c.keyframes = shiftKF(orig.keyframes, d, c.duration);
4160
+ if (orig.masks) c.props.masks = Mask.shiftKeys(orig.masks, d);
4161
+ if (orig.tracks) { const tr = Tracker.shiftTracks(orig.tracks, d); if (tr.length) c.props.tracks = tr; else delete c.props.tracks; }
4149
4162
  syncLinkedTiming(c);
4150
4163
  } else { // trim-r
4151
4164
  let ne = snapTime(orig.start + orig.duration + dt, groupIds);
@@ -4856,9 +4869,18 @@ function renderInspector(lite) {
4856
4869
  const sel = (label, k, opts, cur) => row(label,
4857
4870
  `<select data-k="${k}">${opts.map((o) => `<option value="${o}" ${String(o) === String(cur) ? "selected" : ""}>${o}</option>`).join("")}</select>`, k);
4858
4871
  const check = (label, k, on) => row(label, `<input type="checkbox" data-k="${k}" ${on ? "checked" : ""}>`, k);
4872
+ // track matte: the mode, and the track it comes from (auto = the video track above)
4873
+ const matteRows = () => {
4874
+ const others = videoTrackIds().filter((id) => id !== c.track), auto = Mask.matteTrackFor({ ...c, props: { matte: "alpha" } }, videoTrackIds());
4875
+ const opts = (cur) => [`<option value="" ${!cur ? "selected" : ""}>Above (${auto || "none"})</option>`, ...others.map((id) => `<option value="${id}" ${cur === id ? "selected" : ""}>${id}</option>`)].join("");
4876
+ return row("Track matte", `<select data-k="matte">${["none", ...Mask.MATTES].map((o) => `<option value="${o}" ${(p.matte || "none") === o ? "selected" : ""}>${o.replace("-", " ")}</option>`).join("")}</select>`, "matte")
4877
+ + (p.matte ? row("Matte track", `<select data-k="matteTrack">${opts(p.matteTrack)}</select>`, "matteTrack")
4878
+ + (clipMatte(c) ? "" : `<div class="insp-note">No video track above to take the matte from — pick one.</div>`) : "");
4879
+ };
4859
4880
  if (c.kind === "adjust") {
4860
4881
  html += `<div class="insp-section"><h3>Adjustment layer</h3>
4861
4882
  ${slider("opacity", 0, 1, 0.01, p.opacity)}
4883
+ ${matteRows()}
4862
4884
  </div>`;
4863
4885
  } else if (c.kind !== "audio") {
4864
4886
  html += `<div class="insp-section"><h3>Transform</h3>
@@ -4868,6 +4890,7 @@ function renderInspector(lite) {
4868
4890
  ${slider("rotation", -180, 180, 1, p.rotation, "°")}
4869
4891
  ${slider("opacity", 0, 1, 0.01, p.opacity)}
4870
4892
  ${sel("Blend", "blend", BLEND_MODES, p.blend)}
4893
+ ${matteRows()}
4871
4894
  </div>`;
4872
4895
  }
4873
4896
  if (c.kind === "video" || c.kind === "image" || c.kind === "svg") {
@@ -4958,6 +4981,8 @@ function renderInspector(lite) {
4958
4981
  const curveSel = (label, k, tr) => tr?.type === "fade" && c.kind === "audio"
4959
4982
  ? row(label, `<select data-k="${k}" title="Fade shape (audio)">${[["", "Smooth (eased)"], ...Object.entries(AUDIO_FADE_CURVES)].map(([v, l]) =>
4960
4983
  `<option value="${v}" ${(tr.curve || "") === v ? "selected" : ""}>${l}</option>`).join("")}</select>`) : "";
4984
+ if (c.kind !== "audio") html += maskSectionHtml(c);
4985
+ if (c.kind === "video") html += trackSectionHtml(c);
4961
4986
  html += `<div class="insp-section"><h3>Transition</h3>
4962
4987
  ${tsel("In", "transIn", c.transitionIn)}
4963
4988
  ${curveSel("In curve", "curveIn", c.transitionIn)}
@@ -5075,6 +5100,12 @@ function renderInspector(lite) {
5075
5100
  if (!setAnimProp(c, k, v))
5076
5101
  toast("Move the playhead over the clip to edit its keyframes");
5077
5102
  }
5103
+ else if (k === "matte" || k === "matteTrack") { // "none" / "" (= the track above) clear the prop
5104
+ if (v === "none" || v === "") delete c.props[k]; else c.props[k] = String(v);
5105
+ if (!c.props.matte) delete c.props.matteTrack;
5106
+ state.dirtyTimeline = true; inspPropGen++;
5107
+ if (k === "matte") renderInspector();
5108
+ }
5078
5109
  else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
5079
5110
  const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
5080
5111
  if (valEl) valEl.textContent = input.value + (valEl.dataset.unit || "");
@@ -5208,6 +5239,8 @@ function renderInspector(lite) {
5208
5239
  toggleKfGraph(lab.dataset.kfgraph);
5209
5240
  });
5210
5241
  });
5242
+ bindMaskControls(c);
5243
+ bindTrackControls(c);
5211
5244
  syncInspectorOffClip(c);
5212
5245
  renderKfGraphsPanel();
5213
5246
  }
@@ -5294,6 +5327,7 @@ function syncInspectorPlayhead() {
5294
5327
  const label = "◆" + (n || "");
5295
5328
  if (btn.textContent !== label) btn.textContent = label;
5296
5329
  }
5330
+ syncMaskInspector(c);
5297
5331
  syncInspectorOffClip(c); // after the class pass, so ◆ titles read the fresh "on" state
5298
5332
  }
5299
5333
 
@@ -8517,6 +8551,7 @@ function drawAdjust(c, W, H, t) {
8517
8551
  }
8518
8552
  if (p.grain > 0) drawGrain(p.grain, 0, 0, W, H, t);
8519
8553
  ctx2d.restore();
8554
+ return p;
8520
8555
  }
8521
8556
  function needsPixelPass(p, c) {
8522
8557
  return !!(p.chromaKey || p.temperature || p.tint || p.rgbSplit > 0 ||
@@ -8817,7 +8852,10 @@ function drawFrame(t = state.time) {
8817
8852
  ctx2d.filter = "none"; ctx2d.globalAlpha = 1;
8818
8853
  ctx2d.fillStyle = project.background || "#000"; ctx2d.fillRect(0, 0, W, H);
8819
8854
  // render video tracks bottom-up (V1 under V2)
8820
- for (const c of visibleClipsAt(t)) drawClip(c, W, H, t);
8855
+ if (runtime.maskView) runtime.maskView.ready = false;
8856
+ const clips = visibleClipsAt(t), mattes = matteTracksAt(clips);
8857
+ for (const c of clips) if (!mattes.has(c.track)) drawClip(c, W, H, t); // a matte track only cuts
8858
+ if (!state.exporting && !runtime.sampling) drawMaskView(W, H);
8821
8859
  if (!runtime.sampling) updateScopes(); // before the handles, so they never reach the scopes
8822
8860
  // on-canvas selection handles (never during export or playback)
8823
8861
  if (!state.exporting && !state.playing && !runtime.sampling) drawSelectionOverlay(W, H, t);
@@ -8861,7 +8899,10 @@ function overlayHandles(b, W, H) {
8861
8899
  }
8862
8900
  function drawSelectionOverlay(W, H, t) {
8863
8901
  const c = getClip(state.selId);
8902
+ if (drawingLayerMask(c)) { drawLayerStroke(c, W); if (!maskEditing(c)) return; }
8864
8903
  if (maskEditing(c) && drawMaskOverlay(c, W)) return; // a layer's mask replaces the box
8904
+ if (maskEditActive(c) && drawClipMaskOverlay(c, W)) return; // so does a clip mask being edited
8905
+ drawTrackOverlay(c, W);
8865
8906
  if (!isVisualClip(c) || !activeAt(c, t) || !clipRenders(c)) return;
8866
8907
  const b = clipBounds(c, evalProps(c, t), W, H);
8867
8908
  const lw = Math.max(2, W / 640);
@@ -8896,7 +8937,8 @@ function toLocal(pt, b) {
8896
8937
  return { x: dx * cs - dy * sn, y: dx * sn + dy * cs };
8897
8938
  }
8898
8939
  function pickClipAt(pt, W, H) {
8899
- const seq = visibleClipsAt(state.time).filter(isVisualClip);
8940
+ const all = visibleClipsAt(state.time), mattes = matteTracksAt(all);
8941
+ const seq = all.filter((c) => isVisualClip(c) && !mattes.has(c.track));
8900
8942
  for (let i = seq.length - 1; i >= 0; i--) {
8901
8943
  const c = seq[i], b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
8902
8944
  if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) return c;
@@ -9093,9 +9135,21 @@ function endCanvasDrag(e) {
9093
9135
  els.preview.addEventListener("pointerup", endCanvasDrag);
9094
9136
  els.preview.addEventListener("pointercancel", endCanvasDrag);
9095
9137
 
9096
- function drawClip(c, W, H, t) {
9097
- if (c.kind === "adjust") { drawAdjust(c, W, H, t); return; }
9138
+ function drawClip(c, W, H, t, asMatte = false) {
9139
+ const masks = clipMasksAt(c, t), matte = asMatte ? null : clipMatte(c);
9140
+ if (masks || matte) drawMaskedClip(c, W, H, t, masks, matte);
9141
+ else drawClipPicture(c, W, H, t, false);
9142
+ }
9143
+ /** Draw one clip. `layered`: into a mask layer — opacity and blend wait for
9144
+ * the composite. Returns { p, geom } — geom = where the clip's picture landed
9145
+ * (canvas transform + its box), the space its masks live in. */
9146
+ function drawClipPicture(c, W, H, t, layered) {
9147
+ if (c.kind === "adjust") {
9148
+ const p = drawAdjust(c, W, H, t);
9149
+ return { p, geom: noteMaskGeom(c, new DOMMatrix(), 0, 0, W, H) };
9150
+ }
9098
9151
  const p = evalProps(c, t);
9152
+ let geom = null;
9099
9153
  ctx2d.save();
9100
9154
  if (p._wipe) {
9101
9155
  ctx2d.beginPath();
@@ -9111,9 +9165,10 @@ function drawClip(c, W, H, t) {
9111
9165
  ctx2d.arc(W / 2, H / 2, Math.max(0.01, (1 - p._iris)) * Math.hypot(W, H) * 0.55, 0, Math.PI * 2);
9112
9166
  ctx2d.clip();
9113
9167
  }
9114
- ctx2d.globalAlpha = clamp(p.opacity, 0, 1);
9115
- if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend))
9116
- ctx2d.globalCompositeOperation = p.blend === "normal" ? "source-over" : p.blend;
9168
+ if (!layered) {
9169
+ ctx2d.globalAlpha = clamp(p.opacity, 0, 1);
9170
+ if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) ctx2d.globalCompositeOperation = p.blend;
9171
+ }
9117
9172
  ctx2d.translate(W / 2 + (+p.x || 0), H / 2 + (+p.y || 0));
9118
9173
  ctx2d.rotate((p.rotation || 0) * Math.PI / 180);
9119
9174
  if (p.shake > 0) { // deterministic multi-sine handheld/impact shake
@@ -9124,9 +9179,11 @@ function drawClip(c, W, H, t) {
9124
9179
  ctx2d.rotate(Math.sin(tt * 0.9) * a * 0.0022);
9125
9180
  }
9126
9181
  if (c.kind === "text") {
9182
+ const b = clipBounds(c, p, W, H);
9183
+ geom = noteMaskGeom(c, ctx2d.getTransform(), -b.hw, -b.hh, b.hw * 2, b.hh * 2);
9127
9184
  drawText(c, p, t - c.start);
9128
9185
  ctx2d.restore();
9129
- return;
9186
+ return { p, geom };
9130
9187
  }
9131
9188
  let src = null, sw = 0, sh = 0;
9132
9189
  if (c.kind === "image") {
@@ -9164,6 +9221,7 @@ function drawClip(c, W, H, t) {
9164
9221
  const grade = activeGrade(c, p, t), matte = gradeMatte(c);
9165
9222
  let graded = null;
9166
9223
  noteGradeGeom(c, ctx2d.getTransform(), dw, dh, -dw / 2, -dh / 2);
9224
+ geom = noteMaskGeom(c, ctx2d.getTransform(), -dw / 2, -dh / 2, dw, dh);
9167
9225
  if (needsPixelPass(p, c)) { // key / cut-out first, so the grade never shifts the key colour
9168
9226
  let processed = pixelPass(c, p, src, sx, sy, cw, ch, dw, dh);
9169
9227
  if (runtime.gradeCapture) { // pixelPass reuses its canvas — keep a copy
@@ -9189,8 +9247,1047 @@ function drawClip(c, W, H, t) {
9189
9247
  if (p.grain > 0) drawGrain(p.grain, -dw / 2, -dh / 2, dw, dh, t);
9190
9248
  }
9191
9249
  ctx2d.restore();
9250
+ return { p, geom };
9251
+ }
9252
+
9253
+ /* ═══════════ MASKS (props.masks — math in mask.js) ═══════════
9254
+ A masked clip draws into an offscreen layer instead of the program canvas;
9255
+ the layer is cut by the masks' matte (destination-in), then composited with
9256
+ the clip's opacity and blend. Preview and export share this path. */
9257
+ const Mask = self.FableCutMask;
9258
+ const maskCv = {};
9259
+ function maskCtx(key, W, H) {
9260
+ let cv = maskCv[key];
9261
+ if (!cv) cv = maskCv[key] = document.createElement("canvas");
9262
+ if (cv.width !== W) cv.width = W;
9263
+ if (cv.height !== H) cv.height = H;
9264
+ return cv.getContext("2d", { willReadFrequently: false });
9265
+ }
9266
+ const maskNorm = new WeakMap(); // stored list → validated list (hand-written JSON gets defaults once)
9267
+ function clipMasks(c) {
9268
+ const list = c.props && c.props.masks;
9269
+ if (!Array.isArray(list) || !list.length) return null;
9270
+ let n = maskNorm.get(list);
9271
+ if (n === undefined) { n = Mask.normalizeMasks(list); maskNorm.set(list, n); }
9272
+ return n;
9273
+ }
9274
+ /** The masks cutting clip c at timeline time t (keys resolved), or null. */
9275
+ function clipMasksAt(c, t) {
9276
+ if (c.kind === "audio") return null;
9277
+ const list = clipMasks(c);
9278
+ return list ? Mask.masksAt(list, t - c.start) : null;
9279
+ }
9280
+ /** Remember where the clip's picture landed (the monitor's mask editor maps through it). */
9281
+ function noteMaskGeom(c, m, x0, y0, w, h) {
9282
+ const g = { m, x0, y0, w, h };
9283
+ if (!state.exporting && !runtime.sampling) (runtime.maskGeom ||= new Map()).set(c.id, g);
9284
+ return g;
9285
+ }
9286
+ /* ── Track mattes ──
9287
+ A clip with props.matte is cut by the picture on its matte track (the video
9288
+ track above unless props.matteTrack names one): by that picture's alpha, or
9289
+ by its luma (white = shows), each optionally inverted. The matte track does
9290
+ not draw into the frame while a clip on screen uses it. */
9291
+ const videoTrackIds = () => TRACKS.filter((tr) => tr.kind === "video").map((tr) => tr.id);
9292
+ /** { mode, track } when the clip has a track matte with a track to take it from. */
9293
+ function clipMatte(c) {
9294
+ if (c.kind === "audio" || !c.props || !c.props.matte) return null;
9295
+ const track = Mask.matteTrackFor(c, videoTrackIds());
9296
+ return track ? { mode: c.props.matte, track } : null;
9297
+ }
9298
+ /** The tracks that are mattes for clips in `clips` (the frame's clips): they only cut. */
9299
+ function matteTracksAt(clips) {
9300
+ const out = new Set();
9301
+ for (const c of clips) { const m = clipMatte(c); if (m) out.add(m.track); }
9302
+ return out;
9303
+ }
9304
+ /** The matte track's picture at t as an alpha matte: { canvas, empty }. A luma matte
9305
+ * turns brightness into alpha (over black, so transparent = black). */
9306
+ function trackMatteAt(matte, W, H, t) {
9307
+ const src = maskCtx("tmSrc", W, H), luma = matte.mode.startsWith("luma");
9308
+ src.setTransform(1, 0, 0, 1, 0, 0);
9309
+ src.globalAlpha = 1; src.globalCompositeOperation = "source-over"; src.filter = "none";
9310
+ src.clearRect(0, 0, W, H);
9311
+ const clips = project.clips.filter((x) => x.track === matte.track && x.disabled !== true && x.kind !== "audio" &&
9312
+ x.kind !== "adjust" && activeAt(x, t)).sort((a, b) => a.start - b.start);
9313
+ if (!clips.length) return { canvas: src.canvas, empty: true };
9314
+ if (luma) { src.fillStyle = "#000"; src.fillRect(0, 0, W, H); }
9315
+ const main = ctx2d;
9316
+ ctx2d = src;
9317
+ try { for (const x of clips) drawClip(x, W, H, t, true); } finally { ctx2d = main; }
9318
+ if (!luma) return { canvas: src.canvas, empty: false };
9319
+ return { canvas: lumaToAlpha(src.canvas, maskCtx("tmOut", W, H), W, H), empty: false };
9320
+ }
9321
+ /** Brightness → alpha (Rec. 709 weights): an SVG luminanceToAlpha filter on the
9322
+ * canvas, or a pixel loop where canvas filters are missing. */
9323
+ let lumaFilter = null;
9324
+ function lumaToAlpha(srcCanvas, out, W, H) {
9325
+ out.setTransform(1, 0, 0, 1, 0, 0);
9326
+ out.globalAlpha = 1; out.globalCompositeOperation = "source-over";
9327
+ out.clearRect(0, 0, W, H);
9328
+ if (lumaFilter == null) lumaFilter = makeLumaFilter();
9329
+ if (lumaFilter) {
9330
+ out.filter = lumaFilter; out.drawImage(srcCanvas, 0, 0); out.filter = "none";
9331
+ return out.canvas;
9332
+ }
9333
+ out.drawImage(srcCanvas, 0, 0);
9334
+ const img = out.getImageData(0, 0, W, H), d = img.data;
9335
+ for (let i = 0; i < d.length; i += 4) {
9336
+ d[i + 3] = Math.round(0.2125 * d[i] + 0.7154 * d[i + 1] + 0.0721 * d[i + 2]);
9337
+ d[i] = d[i + 1] = d[i + 2] = 0;
9338
+ }
9339
+ out.putImageData(img, 0, 0);
9340
+ return out.canvas;
9341
+ }
9342
+ /** "url(#…)" for the luma filter, or "" when the canvas can't apply it (checked once on a pixel). */
9343
+ function makeLumaFilter() {
9344
+ try {
9345
+ const ns = "http://www.w3.org/2000/svg", svg = document.createElementNS(ns, "svg");
9346
+ svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); svg.setAttribute("aria-hidden", "true");
9347
+ svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden";
9348
+ svg.innerHTML = '<filter id="fcLumaAlpha" color-interpolation-filters="sRGB"><feColorMatrix type="luminanceToAlpha"/></filter>';
9349
+ document.body.appendChild(svg);
9350
+ const a = document.createElement("canvas"), b = document.createElement("canvas");
9351
+ a.width = b.width = a.height = b.height = 1;
9352
+ const x = a.getContext("2d"); x.fillStyle = "#fff"; x.fillRect(0, 0, 1, 1);
9353
+ const y = b.getContext("2d", { willReadFrequently: true });
9354
+ y.filter = "url(#fcLumaAlpha)"; y.drawImage(a, 0, 0);
9355
+ const px = y.getImageData(0, 0, 1, 1).data;
9356
+ return px[3] > 250 && px[0] < 5 ? "url(#fcLumaAlpha)" : "";
9357
+ } catch { return ""; }
9358
+ }
9359
+ /** A clip that is cut before it composites: by its masks, by a track matte, or both. */
9360
+ function drawMaskedClip(c, W, H, t, masks, matte = null) {
9361
+ const tm = matte ? trackMatteAt(matte, W, H, t) : null; // first: the matte's clips use the layer buffers too
9362
+ const layer = maskCtx("layer", W, H);
9363
+ layer.setTransform(1, 0, 0, 1, 0, 0);
9364
+ layer.globalAlpha = 1; layer.globalCompositeOperation = "source-over"; layer.filter = "none";
9365
+ layer.clearRect(0, 0, W, H);
9366
+ const main = ctx2d;
9367
+ let r;
9368
+ ctx2d = layer;
9369
+ try { r = drawClipPicture(c, W, H, t, true); } finally { ctx2d = main; }
9370
+ if (!r || !r.geom) return;
9371
+ layer.setTransform(1, 0, 0, 1, 0, 0);
9372
+ if (tm) {
9373
+ layer.globalCompositeOperation = matte.mode.endsWith("-inverted") ? "destination-out" : "destination-in";
9374
+ if (tm.empty) { if (!matte.mode.endsWith("-inverted")) layer.clearRect(0, 0, W, H); } // nothing on the matte track: alpha hides all
9375
+ else layer.drawImage(tm.canvas, 0, 0);
9376
+ layer.globalCompositeOperation = "source-over";
9377
+ }
9378
+ if (masks) cutByMasks(c, layer, r.geom, masks, W, H);
9379
+ main.save();
9380
+ main.setTransform(1, 0, 0, 1, 0, 0);
9381
+ const p = r.p;
9382
+ if (c.kind !== "adjust") { // an adjustment layer already drew at its own opacity
9383
+ main.globalAlpha = clamp(p.opacity, 0, 1);
9384
+ if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) main.globalCompositeOperation = p.blend;
9385
+ }
9386
+ main.drawImage(layer.canvas, 0, 0);
9387
+ main.restore();
9388
+ }
9389
+ function cutByMasks(c, layer, g, masks, W, H) {
9390
+ const matte = maskCtx("matte", W, H);
9391
+ Mask.rasterize(matte, maskCtx("tmp", W, H), maskCtx("tmp2", W, H), masks,
9392
+ g.m.translate(g.x0, g.y0), g.w, g.h, W / Math.max(1, project.width));
9393
+ layer.setTransform(1, 0, 0, 1, 0, 0);
9394
+ layer.globalCompositeOperation = "destination-in";
9395
+ layer.drawImage(matte.canvas, 0, 0);
9396
+ layer.globalCompositeOperation = "source-over";
9397
+ const cap = runtime.maskCapture;
9398
+ if (cap && cap.id === c.id && cap.coverage == null) measureMaskCoverage(cap, masks, g, layer.canvas, W, H);
9399
+ const view = runtime.maskView;
9400
+ if (view && view.id === c.id && state.selId === c.id && !state.exporting) { // the monitor shows the selected clip's matte
9401
+ const v = maskCtx("view", W, H);
9402
+ v.setTransform(1, 0, 0, 1, 0, 0);
9403
+ v.globalCompositeOperation = "source-over"; v.clearRect(0, 0, W, H);
9404
+ v.fillStyle = "#fff"; v.fillRect(0, 0, W, H);
9405
+ v.globalCompositeOperation = "destination-in"; v.drawImage(matte.canvas, 0, 0);
9406
+ v.globalCompositeOperation = "source-over";
9407
+ view.ready = true;
9408
+ }
9409
+ }
9410
+ /** fablecut_scopes mask: how much of the clip's own picture its masks keep
9411
+ * (rasterized alone, box-aligned) and how much of the frame the cut clip covers. */
9412
+ function measureMaskCoverage(cap, masks, g, layerCanvas, W, H) {
9413
+ const mean = (cv, w, h) => {
9414
+ const s = document.createElement("canvas"); s.width = w; s.height = h;
9415
+ const x = s.getContext("2d", { willReadFrequently: true });
9416
+ x.drawImage(cv, 0, 0, w, h);
9417
+ const d = x.getImageData(0, 0, w, h).data;
9418
+ let sum = 0;
9419
+ for (let i = 3; i < d.length; i += 4) sum += d[i];
9420
+ return sum / 255 / (w * h);
9421
+ };
9422
+ const bw = Math.max(1, g.w), bh = Math.max(1, g.h), k = Math.min(1, 480 / Math.max(bw, bh));
9423
+ const mw = Math.max(2, Math.round(bw * k)), mh = Math.max(2, Math.round(bh * k));
9424
+ const out = maskCtx("cap", mw, mh);
9425
+ Mask.rasterize(out, maskCtx("cap1", mw, mh), maskCtx("cap2", mw, mh), masks, new DOMMatrix([mw / bw, 0, 0, mh / bh, 0, 0]), bw, bh,
9426
+ W / Math.max(1, project.width) * mw / bw);
9427
+ cap.coverage = +(100 * mean(out.canvas, mw, mh)).toFixed(2);
9428
+ const fw = Math.max(2, Math.min(480, W)), fh = Math.max(2, Math.round(fw * H / W));
9429
+ cap.frameCoverage = +(100 * mean(layerCanvas, fw, fh)).toFixed(2);
9430
+ }
9431
+ /** Matte view: the selected clip's mask matte, white on black, over the frame. */
9432
+ function drawMaskView(W, H) {
9433
+ const view = runtime.maskView;
9434
+ if (!view || !view.ready || state.selId !== view.id) return; // off once the clip is deselected
9435
+ ctx2d.save();
9436
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
9437
+ ctx2d.globalAlpha = 1; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
9438
+ ctx2d.fillStyle = "#000"; ctx2d.fillRect(0, 0, W, H);
9439
+ ctx2d.drawImage(maskCv.view, 0, 0);
9440
+ ctx2d.restore();
9192
9441
  }
9193
9442
 
9443
+ /* ── Masks in the Inspector and on the monitor ──
9444
+ The Masks section lists the clip's masks; picking one shows its outline and
9445
+ handles on the monitor. Pen: click to place points (drag pulls handles),
9446
+ click the first point or press Enter to close. Draw: drag a free-hand
9447
+ outline, fitted into an editable bezier. */
9448
+ const maskUi = { clip: null, sel: -1, pt: -1, tool: null, pen: null, stroke: null, drag: null };
9449
+ const MASK_ROWS_UI = {
9450
+ opacity: ["Opacity", 0, 1, 0.01], feather: ["Feather", 0, 300, 1], expand: ["Expand", -200, 200, 1],
9451
+ x: ["X", -0.5, 1.5, 0.001], y: ["Y", -0.5, 1.5, 0.001], w: ["Width", 0.01, 2, 0.001], h: ["Height", 0.01, 2, 0.001],
9452
+ scale: ["Scale", 0.05, 4, 0.01], rotation: ["Rotation", -180, 180, 0.5],
9453
+ };
9454
+ const MASK_MODE_LABEL = { add: "Add", subtract: "Subtract", intersect: "Intersect", difference: "Difference" };
9455
+ function maskList(c) { return clipMasks(c) || []; }
9456
+ function maskUiFor(c) {
9457
+ if (!c || maskUi.clip !== c.id) { Object.assign(maskUi, { clip: c ? c.id : null, sel: -1, pt: -1, tool: null, pen: null, stroke: null, drag: null }); if (runtime.maskView) runtime.maskView = null; }
9458
+ const n = maskList(c).length;
9459
+ if (maskUi.sel >= n) maskUi.sel = n - 1;
9460
+ return maskUi;
9461
+ }
9462
+ function writeMasks(c, list) {
9463
+ const n = list.length ? Mask.normalizeMasks(list) : null;
9464
+ if (n) c.props.masks = n; else delete c.props.masks;
9465
+ state.dirtyTimeline = true;
9466
+ }
9467
+ const maskLocalT = (c) => state.time - c.start;
9468
+ /** Mask i as it is at the playhead. */
9469
+ function maskNowAt(c, i) { const m = maskList(c)[i]; return m ? Mask.maskAt(m, maskLocalT(c)) : null; }
9470
+ function maskKeyIndex(m, t) {
9471
+ const tol = 0.5 / (projectFps() || 30);
9472
+ return m && m.keys ? m.keys.findIndex((k) => Math.abs(k.t - t) < tol) : -1;
9473
+ }
9474
+ function maskSnapshotKey(now, t) {
9475
+ const k = { t: +Math.max(0, t).toFixed(4) };
9476
+ for (const p of Mask.KEYED) if (now[p] != null) k[p] = p === "points" ? now.points.map((q) => q.slice()) : now[p];
9477
+ return k;
9478
+ }
9479
+ /** Change mask i; once it has keys, keyed params land in a key at the playhead. */
9480
+ function writeMaskSet(c, i, set) {
9481
+ const list = maskList(c).map((m) => m), m = list[i];
9482
+ if (!m) return;
9483
+ if (!m.keys || !m.keys.length || !Mask.KEYED.some((p) => p in set)) { list[i] = Mask.mergeMask(m, set) || m; writeMasks(c, list); return; }
9484
+ const t = maskLocalT(c), keys = m.keys.map((k) => ({ ...k })), now = Mask.maskAt(m, t), statics = {};
9485
+ for (const [p, v] of Object.entries(set)) if (!Mask.KEYED.includes(p)) statics[p] = v;
9486
+ let j = maskKeyIndex(m, t);
9487
+ if (j < 0) { const k = maskSnapshotKey(now, t); keys.push(k); keys.sort((a, b) => a.t - b.t); j = keys.indexOf(k); }
9488
+ for (const p of Mask.KEYED) if (p in set) keys[j][p] = set[p];
9489
+ list[i] = Mask.mergeMask(m, { ...statics, keys }) || m;
9490
+ writeMasks(c, list);
9491
+ }
9492
+ /* ── Tracking ──
9493
+ tracker.js follows a region of the clip's own video frame by frame. The
9494
+ frames come from a private <video> (the monitor never moves), drawn small
9495
+ enough that the region is ~56 px across. A track is stored on its clip as
9496
+ picture fractions (props.tracks); masks and other clips follow it by having
9497
+ it baked into their keys, so rendering never depends on it. */
9498
+ const Tracker = self.FableCutTracker;
9499
+ const trackUi = { clip: null, sel: -1, tool: null, size: 0.08, scale: false, rotation: false, run: null };
9500
+ const trackNorm = new WeakMap();
9501
+ function clipTracks(c) {
9502
+ const list = c && c.props && c.props.tracks;
9503
+ if (!Array.isArray(list) || !list.length) return [];
9504
+ let n = trackNorm.get(list);
9505
+ if (n === undefined) { n = Tracker.normalizeTracks(list) || []; trackNorm.set(list, n); }
9506
+ return n;
9507
+ }
9508
+ function writeTracks(c, list) {
9509
+ const n = list.length ? Tracker.normalizeTracks(list) : null;
9510
+ if (n) c.props.tracks = n; else delete c.props.tracks;
9511
+ state.dirtyTimeline = true;
9512
+ }
9513
+ /** Crop as fractions; picture (u, v) ↔ media (x, y), both 0…1. */
9514
+ function cropFrac(p) {
9515
+ const k = (v) => clamp(+v || 0, 0, 95) / 100;
9516
+ return { l: k(p.cropL), t: k(p.cropT), r: k(p.cropR), b: k(p.cropB) };
9517
+ }
9518
+ function picToMedia(p, u, v) { const k = cropFrac(p); return { x: k.l + u * (1 - k.l - k.r), y: k.t + v * (1 - k.t - k.b) }; }
9519
+ function mediaToPic(p, x, y) { const k = cropFrac(p); return { u: (x - k.l) / Math.max(1e-6, 1 - k.l - k.r), v: (y - k.t) / Math.max(1e-6, 1 - k.t - k.b) }; }
9520
+ function seekFrame(el, t) {
9521
+ return new Promise((res, rej) => {
9522
+ const done = () => { el.removeEventListener("seeked", done); clearTimeout(tm); res(); };
9523
+ const tm = setTimeout(() => { el.removeEventListener("seeked", done); rej(new Error(`the video did not seek to ${t.toFixed(2)} s`)); }, 4000);
9524
+ el.addEventListener("seeked", done);
9525
+ el.currentTime = t;
9526
+ });
9527
+ }
9528
+ /**
9529
+ * Follow `box` (picture fractions {x, y, w, h}) through clip c from clip-local
9530
+ * `from` towards `to` (dir ±1), one sample per project frame. Returns
9531
+ * { samples: [[t, x, y, s, r, q], …] in time order, lostAt, cancelled }.
9532
+ */
9533
+ async function runTrack(c, { box, from, to, dir, scale = false, rotation = false, onProgress, run = {} }) {
9534
+ const m = getMedia(c.mediaId);
9535
+ if (c.kind !== "video" || !m) throw new Error("tracking needs a video clip");
9536
+ const el = document.createElement("video");
9537
+ el.muted = true; el.preload = "auto"; el.playsInline = true; el.src = m.src;
9538
+ try {
9539
+ await new Promise((res, rej) => {
9540
+ const tm = setTimeout(() => rej(new Error("the video did not load")), 15000);
9541
+ el.addEventListener("loadeddata", () => { clearTimeout(tm); res(); }, { once: true });
9542
+ el.addEventListener("error", () => { clearTimeout(tm); rej(new Error("the video could not be read")); }, { once: true });
9543
+ });
9544
+ const vw = el.videoWidth, vh = el.videoHeight, p0 = evalProps(c, c.start + from), k = cropFrac(p0);
9545
+ const bw = Math.max(4, box.w * (1 - k.l - k.r) * vw), bh = Math.max(4, box.h * (1 - k.t - k.b) * vh);
9546
+ const sc = clamp(56 / Math.max(bw, bh), 160 / vw, Math.min(1, 1280 / vw));
9547
+ const aw = Math.max(16, Math.round(vw * sc)), ah = Math.max(16, Math.round(vh * sc));
9548
+ const cv = document.createElement("canvas"); cv.width = aw; cv.height = ah;
9549
+ const x = cv.getContext("2d", { willReadFrequently: true });
9550
+ const grab = async (local) => {
9551
+ await seekFrame(el, Math.max(0, mediaTimeAt(c, c.start + local)) + 0.001);
9552
+ x.drawImage(el, 0, 0, aw, ah);
9553
+ return Tracker.toGray(x.getImageData(0, 0, aw, ah).data, aw, ah);
9554
+ };
9555
+ const mc = picToMedia(p0, box.x, box.y);
9556
+ const tr = Tracker.createTracker(await grab(from), aw, ah, { cx: mc.x * aw, cy: mc.y * ah, w: bw * sc, h: bh * sc }, { scale, rotation });
9557
+ if (tr.flat) throw new Error("that region has no detail to follow — pick a spot with texture or edges");
9558
+ const dt = 1 / (projectFps() || 30), end = clamp(to, 0, c.duration);
9559
+ const n = Math.floor(Math.abs(end - from) / dt + 1e-6);
9560
+ const samples = [[+from.toFixed(4), box.x, box.y, 1, 0, 1]];
9561
+ let lostAt = null;
9562
+ for (let i = 1; i <= n; i++) {
9563
+ if (run.cancel) break;
9564
+ const t = from + dir * i * dt, r = tr.step(await grab(t));
9565
+ if (r.lost) { lostAt = t; break; }
9566
+ const pic = mediaToPic(p0, r.cx / aw, r.cy / ah);
9567
+ samples.push([+t.toFixed(4), pic.u, pic.v, r.s, r.r, r.q]);
9568
+ if (onProgress) onProgress(i / n, t);
9569
+ }
9570
+ if (dir < 0) samples.reverse();
9571
+ return { samples, lostAt, cancelled: !!run.cancel };
9572
+ } finally { el.removeAttribute("src"); el.load(); }
9573
+ }
9574
+ /** What a tracking result was measured against: the clip object and its timing. */
9575
+ function trackStamp(c) {
9576
+ if (!c) return "";
9577
+ const p = c.props || {}, k = c.keyframes || {};
9578
+ return JSON.stringify([c.mediaId, c.start, c.in, c.duration, clipSpeed(c), k.speed || null,
9579
+ p.cropL, p.cropR, p.cropT, p.cropB, k.cropL || null, k.cropR || null, k.cropT || null, k.cropB || null]);
9580
+ }
9581
+ /** Merge new samples into track `name` (replacing the old ones in their time range). */
9582
+ function storeTrack(c, name, kind, w, h, samples) {
9583
+ const list = clipTracks(c).map((x) => ({ ...x })), t0 = samples[0][0], t1 = samples[samples.length - 1][0];
9584
+ const i = list.findIndex((x) => x.name === name);
9585
+ const keep = i >= 0 ? list[i].samples.filter((s) => s[0] < t0 - 1e-4 || s[0] > t1 + 1e-4) : [];
9586
+ const fresh = kind === "box" ? Tracker.smooth(samples) : samples; // scale / rotation steadied once, for every use
9587
+ const tr = { name, kind, w: i >= 0 ? list[i].w : w, h: i >= 0 ? list[i].h : h, samples: [...keep, ...fresh].sort((a, b) => a[0] - b[0]) };
9588
+ if (i >= 0) list[i] = tr; else list.push(tr);
9589
+ writeTracks(c, list.slice(-Tracker.MAX_TRACKS));
9590
+ return clipTracks(c).find((x) => x.name === name);
9591
+ }
9592
+ /** Bake samples into mask i's keys: x / y (and scale / rotation) move by what the samples moved. */
9593
+ function bakeTrackIntoMask(c, i, samples, { scale, rotation }, from = samples[0][0]) {
9594
+ const list = maskList(c).slice(), raw = list[i];
9595
+ if (!raw || samples.length < 2) return 0;
9596
+ const g = pictureRect(c, evalProps(c, c.start + samples[0][0]));
9597
+ // the mask is where it was drawn at `from` (where tracking started): motion is measured from there, on the
9598
+ // smoothed path, and a key stays at `from` — so a backward or two-way track keeps it exactly at the playhead
9599
+ const sm3 = Tracker.smooth(samples, 3, 1);
9600
+ const anchor = sm3.find((sm) => Math.abs(sm[0] - from) < 1e-4) || (() => { const q = Tracker.trackAt({ samples: sm3 }, from); return [from, q.x, q.y, q.s, q.r, q.q]; })();
9601
+ const S = Tracker.simplify(sm3, 1.5 / Math.max(1, g ? g.dw : 1000)); // keys within ~1.5 px
9602
+ if (from >= samples[0][0] && from <= samples[samples.length - 1][0] && !S.some((sm) => Math.abs(sm[0] - from) < 1e-4)) {
9603
+ S.push(anchor); S.sort((a, b) => a[0] - b[0]);
9604
+ }
9605
+ const first = samples[0], base = Mask.maskAt(raw, from);
9606
+ const motion = ["x", "y", ...(scale ? ["scale"] : []), ...(rotation ? ["rotation"] : [])];
9607
+ const t0 = first[0] - 1e-4, t1 = samples[samples.length - 1][0] + 1e-4;
9608
+ let keys = (raw.keys || []).map((k) => ({ ...k }));
9609
+ for (const k of keys) if (k.t >= t0 && k.t <= t1) for (const p of motion) delete k[p];
9610
+ keys = keys.filter((k) => Object.keys(k).some((p) => p !== "t" && p !== "ease"));
9611
+ for (const sm of S) {
9612
+ const v = { x: +(base.x + sm[1] - anchor[1]).toFixed(4), y: +(base.y + sm[2] - anchor[2]).toFixed(4) };
9613
+ if (scale) v.scale = +(base.scale * sm[3] / (anchor[3] || 1)).toFixed(4);
9614
+ if (rotation) v.rotation = +(base.rotation + sm[4] - anchor[4]).toFixed(3);
9615
+ const at = keys.find((k) => Math.abs(k.t - sm[0]) < 1e-4);
9616
+ if (at) Object.assign(at, v, { ease: "linear" }); else keys.push({ t: sm[0], ease: "linear", ...v });
9617
+ }
9618
+ keys.sort((a, b) => a.t - b.t);
9619
+ list[i] = Mask.mergeMask(raw, { keys }) || raw;
9620
+ writeMasks(c, list);
9621
+ return S.length;
9622
+ }
9623
+ /** Where the clip's video is drawn at props p: centre, size, rotation, flips (project px). */
9624
+ function pictureRect(c, p) {
9625
+ const el = c.kind === "video" ? getClipEl(c) : null, W = project.width, H = project.height;
9626
+ const sw = el && el.videoWidth, sh = el && el.videoHeight;
9627
+ if (!sw || !sh) return null;
9628
+ const k = cropFrac(p), cw = Math.max(1, sw * (1 - k.l - k.r)), ch = Math.max(1, sh * (1 - k.t - k.b)), sc = p.scale || 1;
9629
+ let dw, dh;
9630
+ if (p.fit === "cover") { const f = Math.max(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
9631
+ else if (p.fit === "stretch") { dw = W * sc; dh = H * sc; }
9632
+ else if (p.fit === "none") { dw = cw * sc; dh = ch * sc; }
9633
+ else { const f = Math.min(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
9634
+ return { cx: W / 2 + (+p.x || 0), cy: H / 2 + (+p.y || 0), dw, dh, rot: (p.rotation || 0) * Math.PI / 180, fx: p.flipH ? -1 : 1, fy: p.flipV ? -1 : 1 };
9635
+ }
9636
+ /** A track's point on the frame at timeline time T (project px), with its scale and rotation there. */
9637
+ function trackOnFrame(c, tr, T) {
9638
+ const s = Tracker.trackAt(tr, T - c.start), p = evalProps(c, T), g = pictureRect(c, p);
9639
+ if (!g) return null;
9640
+ const lx = (s.x - 0.5) * g.dw * g.fx, ly = (s.y - 0.5) * g.dh * g.fy, cs = Math.cos(g.rot), sn = Math.sin(g.rot);
9641
+ return { x: g.cx + lx * cs - ly * sn, y: g.cy + lx * sn + ly * cs, s: s.s * (p.scale || 1), r: (g.fx * g.fy < 0 ? -s.r : s.r) + (p.rotation || 0) };
9642
+ }
9643
+ /** Bake clip c's track into clip b's x / y (and scale / rotation) keyframes so b rides on it. */
9644
+ function attachToTrack(c, track, b, { scale, rotation, at = state.time }) {
9645
+ const tr = { ...track, samples: Tracker.smooth(track.samples, 3, 1) }, S = tr.samples, a = Math.max(c.start + S[0][0], b.start), z = Math.min(c.start + S[S.length - 1][0], b.start + b.duration);
9646
+ if (z - a < 1e-3) return 0;
9647
+ const tRef = clamp(at, a, z), ref = trackOnFrame(c, tr, tRef);
9648
+ if (!ref) return 0;
9649
+ const pb = evalProps(b, tRef), W = project.width, H = project.height;
9650
+ const off = { x: (+pb.x || 0) - (ref.x - W / 2), y: (+pb.y || 0) - (ref.y - H / 2) };
9651
+ const g = pictureRect(c, evalProps(c, tRef));
9652
+ // the samples inside the overlap, plus the track at its edges and at the reference (between samples, too)
9653
+ const edge = (T) => { const q = Tracker.trackAt(tr, T - c.start); return [+(T - c.start).toFixed(4), q.x, q.y, q.s, q.r, q.q]; };
9654
+ const inRange = S.filter((sm) => c.start + sm[0] > a + 1e-4 && c.start + sm[0] < z - 1e-4);
9655
+ for (const T of [a, z, tRef]) if (!inRange.some((sm) => Math.abs(c.start + sm[0] - T) < 1e-4)) inRange.push(edge(T));
9656
+ inRange.sort((p, q) => p[0] - q[0]);
9657
+ const pts = Tracker.simplify(inRange, 1.5 / Math.max(1, g ? g.dw : 1000));
9658
+ const props = ["x", "y", ...(scale ? ["scale"] : []), ...(rotation ? ["rotation"] : [])];
9659
+ b.keyframes = b.keyframes || {};
9660
+ const l0 = a - b.start - 1e-4, l1 = z - b.start + 1e-4;
9661
+ for (const k of props) b.keyframes[k] = (b.keyframes[k] || []).filter((kf) => kf.t < l0 || kf.t > l1);
9662
+ for (const sm of pts) {
9663
+ const T = c.start + sm[0], v = trackOnFrame(c, tr, T), t = +(T - b.start).toFixed(4);
9664
+ if (!v) continue;
9665
+ const vals = { x: v.x - W / 2 + off.x, y: v.y - H / 2 + off.y, scale: (+pb.scale || 1) * v.s / ref.s, rotation: (+pb.rotation || 0) + v.r - ref.r };
9666
+ for (const k of props) b.keyframes[k].push({ t, v: +vals[k].toFixed(k === "scale" ? 4 : 2), ease: "linear" });
9667
+ }
9668
+ for (const k of props) { b.keyframes[k].sort((p, q) => p.t - q.t); if (!b.keyframes[k].length) delete b.keyframes[k]; }
9669
+ if (!Object.keys(b.keyframes).length) delete b.keyframes;
9670
+ state.dirtyTimeline = true; inspPropGen++;
9671
+ return pts.length;
9672
+ }
9673
+ /** The mask's outline bounds at clip-local t, as picture fractions (clamped into the picture). */
9674
+ function maskBoundsAt(c, raw, t) {
9675
+ const g = pictureRect(c, evalProps(c, c.start + t)), m = Mask.maskAt(raw, t);
9676
+ if (!g) return null;
9677
+ const B = { bw: g.dw, bh: g.dh };
9678
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
9679
+ for (const a of Mask.anchors(m, B.bw, B.bh)) { x0 = Math.min(x0, a.x); y0 = Math.min(y0, a.y); x1 = Math.max(x1, a.x); y1 = Math.max(y1, a.y); }
9680
+ x0 = clamp(x0 / B.bw, 0, 1); x1 = clamp(x1 / B.bw, 0, 1); y0 = clamp(y0 / B.bh, 0, 1); y1 = clamp(y1 / B.bh, 0, 1);
9681
+ if (x1 - x0 < 0.01 || y1 - y0 < 0.01) return null;
9682
+ return { x: (x0 + x1) / 2, y: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
9683
+ }
9684
+ /** Run a track from the playhead with progress in the Inspector; Esc stops it. */
9685
+ async function trackWithUi(c, label, job, after) {
9686
+ if (trackUi.run) { toast("Already tracking — wait, or press Esc to stop"); return null; }
9687
+ if (state.playing) pause();
9688
+ const run = trackUi.run = { cancel: false, clip: c.id, label, pct: 0 };
9689
+ const stamp = trackStamp(c), maskStamp = job.mask != null ? JSON.stringify(maskList(c)[job.mask] ?? null) : null;
9690
+ renderInspector();
9691
+ try {
9692
+ const r = await runTrack(c, { ...job, run, onProgress: (f) => {
9693
+ run.pct = f;
9694
+ for (const el of els.inspector.querySelectorAll("[data-track-progress]")) el.textContent = `${label}: ${Math.round(f * 100)}% — Esc stops`;
9695
+ } });
9696
+ if (r.samples.length < 2) { toast(r.lostAt != null ? "Lost the region on the next frame — try a spot with more detail, or a bigger region" : "Nothing to track there"); return r; }
9697
+ if (getClip(c.id) !== c || trackStamp(c) !== stamp || (maskStamp != null && JSON.stringify(maskList(c)[job.mask] ?? null) !== maskStamp)) {
9698
+ toast("The clip changed while tracking — nothing was applied; track it again");
9699
+ return null;
9700
+ }
9701
+ pushUndo();
9702
+ after(r);
9703
+ scheduleSave();
9704
+ const span = Math.abs(r.samples[r.samples.length - 1][0] - r.samples[0][0]);
9705
+ toast(r.cancelled ? `Stopped — kept ${span.toFixed(2)} s` : r.lostAt != null ? `Lost the region at ${fmt(c.start + r.lostAt)} — kept ${span.toFixed(2)} s up to there` : `Tracked ${span.toFixed(2)} s (${r.samples.length} frames)`);
9706
+ return r;
9707
+ } catch (err) {
9708
+ toast("Tracking failed: " + (err && err.message || err));
9709
+ return null;
9710
+ } finally {
9711
+ trackUi.run = null;
9712
+ state.dirtyTimeline = true;
9713
+ renderInspector(); drawFrame();
9714
+ }
9715
+ }
9716
+ function trackMaskFromPlayhead(c, i, dir) {
9717
+ const raw = maskList(c)[i], t = maskLocalT(c);
9718
+ if (!raw || !activeAt(c, state.time)) { toast("Move the playhead over the clip first"); return; }
9719
+ const box = maskBoundsAt(c, raw, t);
9720
+ if (!box) { toast("The mask is outside the picture here"); return; }
9721
+ const name = raw.name || `Mask ${i + 1}`, opts = { scale: trackUi.scale, rotation: trackUi.rotation };
9722
+ trackWithUi(c, `Tracking ${name}`, { box, from: t, to: dir > 0 ? c.duration : 0, dir, mask: i, ...opts }, (r) => {
9723
+ storeTrack(c, name, "box", box.w, box.h, r.samples);
9724
+ bakeTrackIntoMask(c, i, r.samples, opts, t);
9725
+ });
9726
+ }
9727
+ function trackPointFromPlayhead(c, name, box, dir) {
9728
+ const t = clamp(state.time - c.start, 0, c.duration);
9729
+ trackWithUi(c, `Tracking ${name}`, { box, from: t, to: dir > 0 ? c.duration : 0, dir, scale: trackUi.scale, rotation: trackUi.rotation }, (r) => {
9730
+ storeTrack(c, name, trackUi.scale || trackUi.rotation ? "box" : "point", box.w, box.h, r.samples);
9731
+ trackUi.sel = clipTracks(c).findIndex((x) => x.name === name);
9732
+ });
9733
+ }
9734
+ /** The region a track continues from at the playhead: its position there, its size. */
9735
+ function trackBoxAt(c, tr) {
9736
+ const s = Tracker.trackAt(tr, state.time - c.start);
9737
+ return { x: s.x, y: s.y, w: tr.w * s.s, h: tr.h * s.s };
9738
+ }
9739
+ function trackSectionHtml(c) {
9740
+ if (trackUi.clip !== c.id) Object.assign(trackUi, { clip: c.id, sel: -1, tool: null });
9741
+ const list = clipTracks(c), tr = list[trackUi.sel], run = trackUi.run && trackUi.run.clip === c.id ? trackUi.run : null;
9742
+ const others = project.clips.filter((x) => x.id !== c.id && isVisualClip(x) && x.start < c.start + c.duration && c.start < x.start + x.duration);
9743
+ const chips = list.map((x, j) => `<button type="button" class="btn tiny cl-chip${j === trackUi.sel ? " toggle on" : ""}" data-tsel="${j}" title="${escapeHtml(Tracker.describe([x]))}">${escapeHtml(x.name)}</button>`).join("");
9744
+ let html = `<div class="insp-section" data-track-section><h3>Tracking</h3>
9745
+ <div class="cl-row"><button type="button" class="btn tiny${trackUi.tool === "point" ? " toggle on" : ""}" data-tact="point" ${run ? "disabled" : ""} title="Click a spot on the monitor to follow it from the playhead to the clip's end">+ Track point</button>
9746
+ <button type="button" class="btn tiny${trackUi.scale ? " toggle on" : ""}" data-tact="scale" title="Also follow the region's size (tracking, and attaching)">Scale</button>
9747
+ <button type="button" class="btn tiny${trackUi.rotation ? " toggle on" : ""}" data-tact="rotation" title="Also follow the region's rotation (tracking, and attaching)">Rotate</button></div>
9748
+ <div class="insp-row cp-row"><label class="cp-label" title="The area matched around a picked point, % of the picture's width">Region %</label>
9749
+ <input type="number" class="cp-num" data-tsize min="2" max="40" step="1" value="${Math.round(trackUi.size * 100)}"></div>
9750
+ ${run ? `<p class="cl-note" data-track-progress>${escapeHtml(run.label)}: ${Math.round(run.pct * 100)}% — Esc stops</p>` : ""}
9751
+ ${trackUi.tool === "point" ? `<p class="cl-note">Click the spot to follow on the monitor — something with detail: a corner, a logo, an eye. Esc cancels.</p>` : ""}
9752
+ ${list.length ? `<div class="cl-row">${chips}</div>` : trackUi.tool || run ? "" : `<p class="cl-note">Follow a spot in the video, then pin a title, sticker or another clip to it. A mask tracks from the Masks section.</p>`}`;
9753
+ if (tr) {
9754
+ const S = tr.samples;
9755
+ html += `<div class="cl-row"><button type="button" class="btn tiny" data-tact="back" ${run ? "disabled" : ""} title="Track from the playhead back to the clip's start">◀ Track back</button>
9756
+ <button type="button" class="btn tiny" data-tact="fwd" ${run ? "disabled" : ""} title="Track from the playhead to the clip's end">Track on ▶</button>
9757
+ <button type="button" class="btn tiny" data-tact="del" title="Delete this track (what follows it keeps its keys)">Delete</button></div>
9758
+ <div class="insp-row"><label title="A clip to pin to this track">Attach</label><select data-tattach title="A clip to pin to this track"><option value="">Pick a clip…</option>${others.map((x) => `<option value="${x.id}">${escapeHtml(x.track + " · " + (x.kind === "text" ? (x.props.text || "").split("\n")[0] : x.name || x.kind))}</option>`).join("")}</select></div>
9759
+ <div class="cl-row"><button type="button" class="btn tiny" data-tact="attach" ${others.length ? "" : "disabled"} title="Key the clip's position (and scale / rotation when those are on) so it rides on the track, keeping where it is at the playhead">Attach to the track</button></div>
9760
+ <p class="cl-note">${S.length} samples, ${S[0][0].toFixed(2)}–${S[S.length - 1][0].toFixed(2)} s of the clip. Attaching writes keyframes — edit them like any others.</p>`;
9761
+ }
9762
+ return html + `</div>`;
9763
+ }
9764
+ function bindTrackControls(c) {
9765
+ const root = els.inspector.querySelector("[data-track-section]");
9766
+ if (!root) return;
9767
+ const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
9768
+ const size = root.querySelector("[data-tsize]");
9769
+ if (size) size.addEventListener("change", () => { trackUi.size = clamp((+size.value || 8) / 100, 0.02, 0.4); size.blur(); });
9770
+ for (const b of root.querySelectorAll("[data-tsel]")) b.addEventListener("click", () => {
9771
+ const j = +b.dataset.tsel;
9772
+ trackUi.sel = trackUi.sel === j ? -1 : j; trackUi.tool = null;
9773
+ renderInspector(); drawFrame();
9774
+ });
9775
+ for (const b of root.querySelectorAll("[data-tact]")) b.addEventListener("click", () => {
9776
+ const a = b.dataset.tact, tr = clipTracks(c)[trackUi.sel];
9777
+ if (a === "scale" || a === "rotation") { trackUi[a] = !trackUi[a]; renderInspector(); return; }
9778
+ if (!guard()) return;
9779
+ if (a === "point") {
9780
+ trackUi.tool = trackUi.tool === "point" ? null : "point";
9781
+ if (trackUi.tool && !activeAt(c, state.time)) toast("Move the playhead over the clip, then click the spot to follow");
9782
+ renderInspector(); return;
9783
+ }
9784
+ if (!tr) return;
9785
+ if (a === "del") { pushUndo(); writeTracks(c, clipTracks(c).filter((x) => x !== tr)); trackUi.sel = -1; scheduleSave(); renderInspector(); drawFrame(); return; }
9786
+ if (a === "back" || a === "fwd") {
9787
+ if (!activeAt(c, state.time)) { toast("Move the playhead over the clip first"); return; }
9788
+ trackPointFromPlayhead(c, tr.name, trackBoxAt(c, tr), a === "fwd" ? 1 : -1);
9789
+ return;
9790
+ }
9791
+ if (a === "attach") {
9792
+ const other = getClip(root.querySelector("[data-tattach]").value);
9793
+ if (!other) { toast("Pick a clip to attach first"); return; }
9794
+ if (isGroupLocked(other)) { toastLocked(); return; }
9795
+ pushUndo();
9796
+ const n = attachToTrack(c, tr, other, { scale: trackUi.scale, rotation: trackUi.rotation });
9797
+ scheduleSave(); renderInspector(); drawFrame();
9798
+ toast(n ? `${other.name || other.kind} follows ${tr.name} (${n} keys)` : "That clip and the track don't overlap in time");
9799
+ }
9800
+ });
9801
+ }
9802
+ /** Monitor: the selected track's path, and its region at the playhead. */
9803
+ function drawTrackOverlay(c, W) {
9804
+ if (!c || trackUi.clip !== c.id || !els.inspector.querySelector("[data-track-section]")) return;
9805
+ const tr = clipTracks(c)[trackUi.sel], B = maskBox(c);
9806
+ if (!tr || !B || !activeAt(c, state.time)) return;
9807
+ const lw = Math.max(1.5, W / 900), S = tr.samples, step = Math.max(1, Math.floor(S.length / 400));
9808
+ ctx2d.save(); ctx2d.setTransform(1, 0, 0, 1, 0, 0);
9809
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(255, 196, 64, .8)";
9810
+ ctx2d.beginPath();
9811
+ for (let i = 0; i < S.length; i += step) { const p = B.toCanvas(S[i][1] * B.bw, S[i][2] * B.bh); if (i) ctx2d.lineTo(p.x, p.y); else ctx2d.moveTo(p.x, p.y); }
9812
+ ctx2d.stroke();
9813
+ const s = Tracker.trackAt(tr, state.time - c.start), p = B.toCanvas(s.x * B.bw, s.y * B.bh);
9814
+ const q = B.toCanvas((s.x + tr.w * s.s / 2) * B.bw, s.y * B.bh), r = Math.max(6, Math.hypot(q.x - p.x, q.y - p.y));
9815
+ ctx2d.strokeStyle = s.inside ? "#ffc440" : "rgba(255, 196, 64, .45)";
9816
+ ctx2d.strokeRect(p.x - r, p.y - r, r * 2, r * 2);
9817
+ ctx2d.beginPath(); ctx2d.moveTo(p.x - r * 0.5, p.y); ctx2d.lineTo(p.x + r * 0.5, p.y); ctx2d.moveTo(p.x, p.y - r * 0.5); ctx2d.lineTo(p.x, p.y + r * 0.5); ctx2d.stroke();
9818
+ ctx2d.restore();
9819
+ }
9820
+ els.preview.addEventListener("pointerdown", (e) => {
9821
+ if (e.button !== 0 || trackUi.tool !== "point" || isSourceMode()) return;
9822
+ const c = getClip(state.selId);
9823
+ if (!c || c.id !== trackUi.clip || !activeAt(c, state.time)) return;
9824
+ const B = maskBox(c);
9825
+ if (!B) return;
9826
+ e.preventDefault(); e.stopImmediatePropagation();
9827
+ const bp = B.toBox(canvasPt(e)), u = bp.x / B.bw, v = bp.y / B.bh;
9828
+ if (u < 0 || u > 1 || v < 0 || v > 1) { toast("Click inside the clip's picture"); return; }
9829
+ const used = new Set(clipTracks(c).map((x) => x.name));
9830
+ let n = 1;
9831
+ while (used.has(`Track ${n}`)) n++;
9832
+ trackUi.tool = null;
9833
+ trackPointFromPlayhead(c, `Track ${n}`, { x: u, y: v, w: trackUi.size, h: trackUi.size * B.bw / B.bh }, 1);
9834
+ }, true);
9835
+ window.addEventListener("keydown", (e) => {
9836
+ if (e.key !== "Escape" || (!trackUi.run && trackUi.tool !== "point")) return;
9837
+ e.preventDefault(); e.stopImmediatePropagation();
9838
+ if (trackUi.run) trackUi.run.cancel = true;
9839
+ else { trackUi.tool = null; renderInspector(); }
9840
+ }, true);
9841
+ function maskSectionHtml(c) {
9842
+ const ui = maskUiFor(c), list = maskList(c), i = ui.sel, m = i >= 0 ? maskNowAt(c, i) : null, raw = list[i];
9843
+ const tool = (k, label, title) => `<button type="button" class="btn tiny${ui.tool === k ? " toggle on" : ""}" data-mtool="${k}" title="${title}" ${list.length >= Mask.MAX_MASKS ? "disabled" : ""}>${label}</button>`;
9844
+ const chips = list.map((mk, j) => `<button type="button" class="btn tiny cl-chip${j === i ? " toggle on" : ""}${mk.on === false ? " off" : ""}" data-msel="${j}"
9845
+ title="${escapeHtml(mk.shape + (mk.mode !== "add" ? " · " + mk.mode : "") + (mk.invert ? " · inverted" : ""))} — click to edit on the monitor">${escapeHtml(mk.name || `Mask ${j + 1}`)}</button>`).join("");
9846
+ let html = `<div class="insp-section" data-mask-section><h3>Masks</h3>
9847
+ <div class="cl-row">${tool("rect", "+ Rect", "Add a rectangle mask")}${tool("ellipse", "+ Ellipse", "Add an ellipse mask")}${tool("pen", "Pen", "Click points on the monitor (drag to pull curve handles); click the first point or press Enter to close")}${tool("draw", "Draw", "Drag a free-hand outline on the monitor — it becomes an editable bezier")}
9848
+ ${list.length ? `<button type="button" class="btn tiny${runtime.maskView ? " toggle on" : ""}" data-mact="view" title="Show the matte in the monitor (white = visible)">Matte</button>` : ""}</div>
9849
+ ${ui.tool === "pen" ? `<p class="cl-note">Pen: click to add points, drag to curve. Click the first point or press Enter to close. Ctrl+Z / Backspace removes the last point, Ctrl+Shift+Z puts it back, Esc cancels.</p>` : ""}
9850
+ ${ui.tool === "draw" ? `<p class="cl-note">Draw: drag around what to keep. Esc cancels.</p>` : ""}
9851
+ ${list.length ? `<div class="cl-row">${chips}</div>` : ui.tool ? "" : `<p class="cl-note">No masks: the whole clip shows. Masks keep (add), cut (subtract) or intersect parts of the picture and move with the clip.</p>`}`;
9852
+ if (m) {
9853
+ const keyed = raw.keys ? raw.keys.length : 0, onKey = maskKeyIndex(raw, maskLocalT(c)) >= 0;
9854
+ const mrow = (p) => {
9855
+ const [lab, min, max, step] = MASK_ROWS_UI[p];
9856
+ return `<div class="insp-row cp-row"><label class="cp-label">${lab}</label>
9857
+ <input type="range" data-mk="${p}" min="${min}" max="${max}" step="${step}" value="${m[p]}">
9858
+ <input type="number" class="cp-num" data-mknum="${p}" step="${step}" value="${fmtG(m[p], step)}"></div>`;
9859
+ };
9860
+ html += `<div class="cl-row"><input type="text" class="cl-name" data-mname value="${escapeHtml(raw.name || "")}" placeholder="Mask ${i + 1}" maxlength="40" title="Mask name">
9861
+ <button type="button" class="btn tiny${raw.on === false ? "" : " toggle on"}" data-mact="on" title="Mask on / off">${raw.on === false ? "Off" : "On"}</button>
9862
+ <button type="button" class="btn tiny" data-mact="up" ${i === 0 ? "disabled" : ""} title="Move down the stack (combined earlier)">↑</button>
9863
+ <button type="button" class="btn tiny" data-mact="down" ${i >= list.length - 1 ? "disabled" : ""} title="Move up the stack (combined later)">↓</button>
9864
+ <button type="button" class="btn tiny" data-mact="dup" ${list.length >= Mask.MAX_MASKS ? "disabled" : ""} title="Duplicate">Copy</button>
9865
+ <button type="button" class="btn tiny" data-mact="del" title="Delete this mask">Delete</button></div>
9866
+ <div class="cl-row"><select data-mmode title="How this mask combines with the masks before it">${Mask.MODES.map((md) => `<option value="${md}" ${raw.mode === md ? "selected" : ""}>${MASK_MODE_LABEL[md]}</option>`).join("")}</select>
9867
+ <button type="button" class="btn tiny${raw.invert ? " toggle on" : ""}" data-mact="invert" title="Use everything outside the shape">Invert</button>
9868
+ <button type="button" class="btn tiny${onKey ? " toggle on" : ""}" data-mact="key" title="${onKey ? "Remove the key at the playhead" : "Key the mask at the playhead — with keys, every change keys"}">◆ Key</button>
9869
+ <span class="cl-note">${keyed ? `${keyed} key${keyed > 1 ? "s" : ""}` : ""}</span>
9870
+ ${keyed ? `<button type="button" class="btn tiny" data-mact="clearkeys" title="Remove every key (keeps the mask as it is at the playhead)">Clear keys</button>` : ""}</div>
9871
+ ${c.kind === "video" ? `<div class="cl-row"><span class="cl-note">Track</span>
9872
+ <button type="button" class="btn tiny" data-mtrack="-1" ${trackUi.run ? "disabled" : ""} title="Follow what the mask covers from the playhead back to the clip's start — keys its position">◀ Back</button>
9873
+ <button type="button" class="btn tiny" data-mtrack="1" ${trackUi.run ? "disabled" : ""} title="Follow what the mask covers from the playhead to the clip's end — keys its position">On ▶</button>
9874
+ <button type="button" class="btn tiny${trackUi.scale ? " toggle on" : ""}" data-mtrackopt="scale" title="Also key its scale">Scale</button>
9875
+ <button type="button" class="btn tiny${trackUi.rotation ? " toggle on" : ""}" data-mtrackopt="rotation" title="Also key its rotation">Rotate</button></div>
9876
+ ${trackUi.run && trackUi.run.clip === c.id ? `<p class="cl-note" data-track-progress>${escapeHtml(trackUi.run.label)}: ${Math.round(trackUi.run.pct * 100)}% — Esc stops</p>` : ""}` : ""}
9877
+ ${mrow("opacity")}${mrow("feather")}${mrow("expand")}${mrow("x")}${mrow("y")}${m.shape !== "bezier" ? mrow("w") + mrow("h") : ""}${mrow("scale")}${mrow("rotation")}
9878
+ <p class="cl-note">On the monitor: drag inside to move, the knob rotates (Shift: 15° steps)${m.shape === "bezier"
9879
+ ? "; drag a point to move it, its handles to curve it (Alt: break the pair), Alt-drag a point to pull new handles, Ctrl-click the outline to add a point, double-click a point to remove it"
9880
+ : ", the edge handles resize"}. Esc deselects. Every change undoes with Ctrl+Z (redo: Ctrl+Shift+Z or Ctrl+Y).</p>`;
9881
+ }
9882
+ return html + `</div>`;
9883
+ }
9884
+ function bindMaskControls(c) {
9885
+ const root = els.inspector.querySelector("[data-mask-section]");
9886
+ if (!root) return;
9887
+ const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
9888
+ const rerender = () => { state.dirtyTimeline = true; renderInspector(); };
9889
+ for (const b of root.querySelectorAll("[data-mtool]")) b.addEventListener("click", () => {
9890
+ const k = b.dataset.mtool;
9891
+ if (!guard()) return;
9892
+ if (k === "rect" || k === "ellipse") {
9893
+ pushUndo();
9894
+ const list = maskList(c).slice();
9895
+ list.push({ shape: k, x: 0.5, y: 0.5, w: k === "rect" ? 0.5 : 0.45, h: k === "rect" ? 0.5 : 0.6 });
9896
+ writeMasks(c, list); maskUi.sel = list.length - 1; maskUi.tool = null; maskUi.pt = -1;
9897
+ scheduleSave(); rerender();
9898
+ if (!activeAt(c, state.time)) toast("Move the playhead over the clip to see the mask on the monitor");
9899
+ return;
9900
+ }
9901
+ maskUi.tool = maskUi.tool === k ? null : k; maskUi.pen = null; maskUi.stroke = null;
9902
+ if (maskUi.tool) { maskUi.sel = -1; if (state.playing) pause(); if (!activeAt(c, state.time)) toast("Move the playhead over the clip, then draw on the monitor"); }
9903
+ rerender();
9904
+ });
9905
+ for (const b of root.querySelectorAll("[data-mtrackopt]")) b.addEventListener("click", () => { trackUi[b.dataset.mtrackopt] = !trackUi[b.dataset.mtrackopt]; rerender(); });
9906
+ for (const b of root.querySelectorAll("[data-mtrack]")) b.addEventListener("click", () => {
9907
+ if (!guard() || maskUi.sel < 0) return;
9908
+ trackMaskFromPlayhead(c, maskUi.sel, +b.dataset.mtrack);
9909
+ });
9910
+ for (const b of root.querySelectorAll("[data-msel]")) b.addEventListener("click", () => {
9911
+ const j = +b.dataset.msel;
9912
+ maskUi.sel = maskUi.sel === j ? -1 : j; maskUi.pt = -1; maskUi.tool = null;
9913
+ rerender();
9914
+ });
9915
+ for (const b of root.querySelectorAll("[data-mact]")) b.addEventListener("click", () => {
9916
+ const a = b.dataset.mact, i = maskUi.sel;
9917
+ if (a === "view") { runtime.maskView = runtime.maskView ? null : { id: c.id }; rerender(); return; }
9918
+ const list = maskList(c).slice(), m = list[i];
9919
+ if (!m || !guard()) return;
9920
+ pushUndo();
9921
+ if (a === "on") list[i] = Mask.mergeMask(m, { on: m.on === false ? null : false });
9922
+ else if (a === "invert") list[i] = Mask.mergeMask(m, { invert: m.invert ? null : true });
9923
+ else if (a === "del") { list.splice(i, 1); maskUi.sel = Math.min(i, list.length - 1); maskUi.pt = -1; }
9924
+ else if (a === "dup") { list.splice(i + 1, 0, JSON.parse(JSON.stringify({ ...m, name: m.name ? `${m.name} copy`.slice(0, 40) : undefined }))); maskUi.sel = i + 1; }
9925
+ else if (a === "up" || a === "down") { const j = a === "up" ? i - 1 : i + 1; [list[i], list[j]] = [list[j], list[i]]; maskUi.sel = j; }
9926
+ else if (a === "clearkeys") { const now = Mask.maskAt(m, maskLocalT(c)); list[i] = Mask.mergeMask(m, { ...maskSnapshotKey(now, 0), t: null, keys: null }); }
9927
+ else if (a === "key") {
9928
+ const t = maskLocalT(c), j = maskKeyIndex(m, t), now = Mask.maskAt(m, t);
9929
+ const keys = (m.keys || []).map((k) => ({ ...k }));
9930
+ if (j >= 0) keys.splice(j, 1); else keys.push(maskSnapshotKey(now, t));
9931
+ const set = { keys: keys.length ? keys : null };
9932
+ if (!keys.length) Object.assign(set, maskSnapshotKey(now, 0), { t: null });
9933
+ list[i] = Mask.mergeMask(m, set);
9934
+ }
9935
+ writeMasks(c, list); scheduleSave(); rerender();
9936
+ });
9937
+ const mode = root.querySelector("[data-mmode]");
9938
+ if (mode) mode.addEventListener("change", () => {
9939
+ if (!guard()) return;
9940
+ pushUndo(); writeMaskSet(c, maskUi.sel, { mode: mode.value }); scheduleSave(); rerender();
9941
+ document.activeElement?.blur?.(); // hand Ctrl+Z back to the editor
9942
+ });
9943
+ const name = root.querySelector("[data-mname]");
9944
+ if (name) name.addEventListener("change", () => {
9945
+ if (!guard()) return;
9946
+ pushUndo(); writeMaskSet(c, maskUi.sel, { name: name.value.trim() || null }); scheduleSave(); rerender();
9947
+ });
9948
+ for (const r of root.querySelectorAll("[data-mk]")) {
9949
+ const p = r.dataset.mk;
9950
+ r.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
9951
+ r.addEventListener("input", () => {
9952
+ if (isGroupLocked(c)) return;
9953
+ writeMaskSet(c, maskUi.sel, { [p]: +r.value }); scheduleSave();
9954
+ const n = root.querySelector(`[data-mknum="${p}"]`); if (n) n.value = fmtG(+r.value, MASK_ROWS_UI[p][3]);
9955
+ });
9956
+ }
9957
+ for (const n of root.querySelectorAll("[data-mknum]")) {
9958
+ const p = n.dataset.mknum;
9959
+ n.addEventListener("change", () => {
9960
+ const x = parseFloat(n.value);
9961
+ if (!Number.isFinite(x) || !guard()) return;
9962
+ pushUndo(); writeMaskSet(c, maskUi.sel, { [p]: x }); scheduleSave(); rerender();
9963
+ document.activeElement?.blur?.();
9964
+ });
9965
+ }
9966
+ }
9967
+ /** Keyed masks: the sliders and ◆ follow the playhead (called from the inspector sync). */
9968
+ function syncMaskInspector(c) {
9969
+ const root = els.inspector.querySelector("[data-mask-section]");
9970
+ if (!root || maskUi.clip !== c.id || maskUi.sel < 0 || maskUi.drag) return;
9971
+ const raw = maskList(c)[maskUi.sel], m = maskNowAt(c, maskUi.sel);
9972
+ if (!m) return;
9973
+ const active = document.activeElement;
9974
+ for (const p of Object.keys(MASK_ROWS_UI)) {
9975
+ const r = root.querySelector(`[data-mk="${p}"]`), n = root.querySelector(`[data-mknum="${p}"]`);
9976
+ if (r && r !== active) r.value = m[p];
9977
+ if (n && n !== active) n.value = fmtG(m[p], MASK_ROWS_UI[p][3]);
9978
+ }
9979
+ const k = root.querySelector("[data-mact=key]"), on = maskKeyIndex(raw, maskLocalT(c)) >= 0;
9980
+ if (k) { k.classList.toggle("on", on); k.classList.toggle("toggle", on); }
9981
+ }
9982
+
9983
+ /* Monitor geometry: the picture box (bw × bh px, origin top-left) ↔ canvas px. */
9984
+ function maskBox(c) {
9985
+ const g = runtime.maskGeom && runtime.maskGeom.get(c.id);
9986
+ if (!g) return null;
9987
+ const M = g.m.translate(g.x0, g.y0), inv = M.inverse();
9988
+ return {
9989
+ bw: g.w, bh: g.h,
9990
+ toCanvas: (x, y) => { const p = M.transformPoint(new DOMPoint(x, y)); return { x: p.x, y: p.y }; },
9991
+ toBox: (pt) => { const p = inv.transformPoint(new DOMPoint(pt.x, pt.y)); return { x: p.x, y: p.y }; },
9992
+ };
9993
+ }
9994
+ /** Box px ↔ a mask's own frame (fractions of the picture, before its scale / rotation). */
9995
+ function maskBoxToLocal(m, B, bx, by, vector = false) {
9996
+ const th = (m.rotation || 0) * Math.PI / 180, s = m.scale || 1, cs = Math.cos(th), sn = Math.sin(th);
9997
+ const dx = bx - (vector ? 0 : m.x * B.bw), dy = by - (vector ? 0 : m.y * B.bh);
9998
+ return { x: (dx * cs + dy * sn) / s / B.bw, y: (-dx * sn + dy * cs) / s / B.bh };
9999
+ }
10000
+ function maskLocalToBox(m, B, lx, ly) {
10001
+ const th = (m.rotation || 0) * Math.PI / 180, s = m.scale || 1, cs = Math.cos(th), sn = Math.sin(th);
10002
+ const px = lx * B.bw * s, py = ly * B.bh * s;
10003
+ return { x: m.x * B.bw + px * cs - py * sn, y: m.y * B.bh + px * sn + py * cs };
10004
+ }
10005
+ function maskEditActive(c) {
10006
+ return !!c && maskUi.clip === c.id && state.selId === c.id && (maskUi.sel >= 0 || !!maskUi.tool) &&
10007
+ activeAt(c, state.time) && !!maskBox(c) && !state.playing;
10008
+ }
10009
+ /** Handles for the selected mask, in canvas px. */
10010
+ function clipMaskHandles(c) {
10011
+ const m = maskNowAt(c, maskUi.sel), B = m && maskBox(c);
10012
+ if (!B) return null;
10013
+ const at = (lx, ly) => { const q = maskLocalToBox(m, B, lx, ly); return B.toCanvas(q.x, q.y); };
10014
+ const A = Mask.anchors(m, B.bw, B.bh).map((a) => ({ a: B.toCanvas(a.x, a.y), i: B.toCanvas(a.ix, a.iy), o: B.toCanvas(a.ox, a.oy) }));
10015
+ const top = m.shape === "bezier" ? Math.min(...m.points.map((p) => p[1])) : -m.h / 2;
10016
+ const base = at(0, top), up = at(0, top - 0.01), d = Math.hypot(up.x - base.x, up.y - base.y) || 1;
10017
+ const W = els.preview.width, H = els.preview.height, gap = Math.max(24, W / 34), inset = Math.max(8, W / 110);
10018
+ const knob = { x: clamp(base.x + (up.x - base.x) / d * gap, inset, W - inset), y: clamp(base.y + (up.y - base.y) / d * gap, inset, H - inset) };
10019
+ const edges = m.shape === "bezier" ? [] : [["w", m.w / 2, 0], ["w", -m.w / 2, 0], ["h", 0, m.h / 2], ["h", 0, -m.h / 2]].map(([k, x, y]) => ({ k, ...at(x, y) }));
10020
+ return { m, B, A, centre: at(0, 0), knob, knobBase: base, edges };
10021
+ }
10022
+ function strokeOutline(ctx, pts, closed) {
10023
+ ctx.beginPath();
10024
+ pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])));
10025
+ if (closed) ctx.closePath();
10026
+ ctx.stroke();
10027
+ }
10028
+ /** Drawn instead of the selection box while a mask is picked or a mask tool is on. */
10029
+ function drawClipMaskOverlay(c, W) {
10030
+ const B = maskBox(c);
10031
+ if (!B) return false;
10032
+ const lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
10033
+ ctx2d.save();
10034
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
10035
+ ctx2d.lineJoin = "round";
10036
+ const outline = (m) => Mask.flatten(Mask.anchors(m, B.bw, B.bh), 24).map(([x, y]) => { const q = B.toCanvas(x, y); return [q.x, q.y]; });
10037
+ maskList(c).forEach((raw, j) => { // the other masks, faint
10038
+ if (j === maskUi.sel) return;
10039
+ ctx2d.setLineDash([lw * 3, lw * 3]); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(255,210,90,0.45)";
10040
+ strokeOutline(ctx2d, outline(Mask.maskAt(raw, maskLocalT(c))), true);
10041
+ });
10042
+ ctx2d.setLineDash([]);
10043
+ const dot = (p, r, fill, square) => {
10044
+ ctx2d.beginPath();
10045
+ if (square) ctx2d.rect(p.x - r, p.y - r, r * 2, r * 2); else ctx2d.arc(p.x, p.y, r, 0, Math.PI * 2);
10046
+ ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.7)"; ctx2d.stroke();
10047
+ };
10048
+ const h = maskUi.sel >= 0 ? clipMaskHandles(c) : null;
10049
+ if (h) {
10050
+ const pts = outline(h.m);
10051
+ ctx2d.lineWidth = lw * 2.2; ctx2d.strokeStyle = "rgba(0,0,0,0.55)"; strokeOutline(ctx2d, pts, true);
10052
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = h.m.invert ? "#ff9f5a" : "#ffd25a"; strokeOutline(ctx2d, pts, true);
10053
+ ctx2d.beginPath(); ctx2d.moveTo(h.knobBase.x, h.knobBase.y); ctx2d.lineTo(h.knob.x, h.knob.y); ctx2d.stroke();
10054
+ for (const e of h.edges) dot(e, hs * 0.8, "#fff");
10055
+ if (h.m.shape === "bezier") {
10056
+ const sp = h.A[maskUi.pt];
10057
+ if (sp) {
10058
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#9fd0ff";
10059
+ for (const q of [sp.i, sp.o]) { ctx2d.beginPath(); ctx2d.moveTo(sp.a.x, sp.a.y); ctx2d.lineTo(q.x, q.y); ctx2d.stroke(); }
10060
+ dot(sp.i, hs * 0.6, "#9fd0ff"); dot(sp.o, hs * 0.6, "#9fd0ff");
10061
+ }
10062
+ h.A.forEach((p, j) => dot(p.a, hs * 0.62, j === maskUi.pt ? "#ffd25a" : "#fff", true));
10063
+ }
10064
+ dot(h.centre, hs * 0.5, "#ffd25a");
10065
+ dot(h.knob, hs * 0.9, "#ffce5c");
10066
+ }
10067
+ if (maskUi.tool === "pen" && maskUi.pen && maskUi.pen.pts.length) {
10068
+ const P = maskUi.pen.pts, A = P.map((p) => ({ x: p[0], y: p[1], ix: p[0] + p[2], iy: p[1] + p[3], ox: p[0] + p[4], oy: p[1] + p[5] }));
10069
+ const open = Mask.flatten(A, 24).slice(0, (A.length - 1) * 24 + 1).map(([x, y]) => { const q = B.toCanvas(x, y); return [q.x, q.y]; });
10070
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#ffd25a";
10071
+ if (A.length > 1) strokeOutline(ctx2d, open, false);
10072
+ A.forEach((a, j) => dot(B.toCanvas(a.x, a.y), hs * (j === 0 && A.length >= 3 ? 0.9 : 0.62), j === 0 ? "#ffd25a" : "#fff", true));
10073
+ }
10074
+ if (maskUi.tool === "draw" && maskUi.stroke && maskUi.stroke.length > 1) {
10075
+ ctx2d.lineWidth = lw * 1.5; ctx2d.strokeStyle = "#ffd25a";
10076
+ strokeOutline(ctx2d, maskUi.stroke.map(([u, v]) => { const q = B.toCanvas(u * B.bw, v * B.bh); return [q.x, q.y]; }), false);
10077
+ }
10078
+ ctx2d.restore();
10079
+ return true;
10080
+ }
10081
+ function addMaskFrom(c, mask) {
10082
+ const list = maskList(c).slice();
10083
+ if (list.length >= Mask.MAX_MASKS) { toast(`A clip has at most ${Mask.MAX_MASKS} masks`); return; }
10084
+ const m = Mask.normalizeMask(mask);
10085
+ if (!m) return;
10086
+ pushUndo();
10087
+ list.push(m); writeMasks(c, list);
10088
+ maskUi.sel = list.length - 1; maskUi.pt = -1; maskUi.tool = null; maskUi.pen = null; maskUi.stroke = null;
10089
+ scheduleSave(); renderInspector();
10090
+ }
10091
+ function finishPen(c) {
10092
+ const B = maskBox(c), P = maskUi.pen && maskUi.pen.pts;
10093
+ if (!B || !P || P.length < 3) { toast("A pen mask needs at least 3 points"); return; }
10094
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
10095
+ for (const [x, y] of P) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
10096
+ const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
10097
+ addMaskFrom(c, {
10098
+ shape: "bezier", x: cx / B.bw, y: cy / B.bh,
10099
+ points: P.slice(0, Mask.MAX_POINTS).map(([x, y, ix, iy, ox, oy]) => [(x - cx) / B.bw, (y - cy) / B.bh, ix / B.bw, iy / B.bh, ox / B.bw, oy / B.bh]),
10100
+ });
10101
+ }
10102
+ /** The segment and parameter of the selected bezier's outline nearest `bp` (box px). */
10103
+ function nearestMaskSegment(m, B, bp) {
10104
+ const A = Mask.anchors(m, B.bw, B.bh);
10105
+ let best = null;
10106
+ for (let i = 0; i < A.length; i++) {
10107
+ const a = A[i], b = A[(i + 1) % A.length];
10108
+ for (let k = 1; k < 24; k++) {
10109
+ const t = k / 24, u = 1 - t;
10110
+ const x = u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x;
10111
+ const y = u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y;
10112
+ const d = Math.hypot(x - bp.x, y - bp.y);
10113
+ if (!best || d < best.d) best = { d, i, t };
10114
+ }
10115
+ }
10116
+ return best;
10117
+ }
10118
+ /** Add or remove bezier points on mask i: the base outline and every keyed outline change together. */
10119
+ function writeMaskTopology(c, i, edit) {
10120
+ const list = maskList(c).slice();
10121
+ if (!list[i]) return;
10122
+ list[i] = Mask.mergeMask(list[i], Mask.editTopology(list[i], edit)) || list[i];
10123
+ writeMasks(c, list);
10124
+ }
10125
+ els.preview.addEventListener("pointerdown", (e) => {
10126
+ if (e.button !== 0 || isSourceMode()) return;
10127
+ const c = getClip(state.selId);
10128
+ if (!maskEditActive(c)) return;
10129
+ const B = maskBox(c), pt = canvasPt(e), bp = B.toBox(pt), W = els.preview.width, grab = Math.max(8, W / 110);
10130
+ const near = (p) => Math.hypot(p.x - pt.x, p.y - pt.y) <= grab;
10131
+ if (maskUi.tool) {
10132
+ e.preventDefault(); e.stopImmediatePropagation();
10133
+ if (isGroupLocked(c)) { toastLocked(); return; }
10134
+ if (maskUi.tool === "draw") maskUi.stroke = [[bp.x / B.bw, bp.y / B.bh]];
10135
+ else if (maskUi.tool === "pen") {
10136
+ const P = (maskUi.pen ||= { pts: [] }).pts;
10137
+ if (P.length >= 3 && near(B.toCanvas(P[0][0], P[0][1]))) { finishPen(c); return; }
10138
+ if (P.length >= Mask.MAX_POINTS) { toast(`A mask has at most ${Mask.MAX_POINTS} points`); return; }
10139
+ P.push([bp.x, bp.y, 0, 0, 0, 0]);
10140
+ maskUi.pen.redo = [];
10141
+ maskUi.pen.dragging = true;
10142
+ } else return;
10143
+ maskUi.drag = { tool: true };
10144
+ els.preview.setPointerCapture(e.pointerId);
10145
+ state.dirtyTimeline = true;
10146
+ return;
10147
+ }
10148
+ const h = clipMaskHandles(c);
10149
+ if (!h) return;
10150
+ const { m } = h;
10151
+ let drag = null;
10152
+ const sp = m.shape === "bezier" ? h.A[maskUi.pt] : null;
10153
+ if (near(h.knob)) drag = { mode: "rotate" };
10154
+ else if (sp && near(sp.o)) drag = { mode: "handle", side: "o" };
10155
+ else if (sp && near(sp.i)) drag = { mode: "handle", side: "i" };
10156
+ else if (m.shape === "bezier" && h.A.some((p) => near(p.a))) {
10157
+ maskUi.pt = h.A.findIndex((p) => near(p.a));
10158
+ drag = { mode: e.altKey ? "pull" : "point" };
10159
+ } else if (h.edges.some(near)) drag = { mode: h.edges.find(near).k };
10160
+ else if (m.shape === "bezier" && (e.ctrlKey || e.metaKey)) {
10161
+ if (m.points.length >= Mask.MAX_POINTS) { toast(`A mask has at most ${Mask.MAX_POINTS} points`); e.stopImmediatePropagation(); return; }
10162
+ if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
10163
+ const r = nearestMaskSegment(m, h.B, bp);
10164
+ if (!r) return;
10165
+ pushUndo(); writeMaskTopology(c, maskUi.sel, (pts) => Mask.splitPoints(pts, r.i, r.t)); scheduleSave();
10166
+ maskUi.pt = r.i + 1;
10167
+ drag = { mode: "point", pushed: true };
10168
+ } else if (Mask.matteAt([{ ...m, mode: "add", invert: false, feather: 0, opacity: 1 }], bp.x, bp.y, h.B.bw, h.B.bh, 0) > 0.5) drag = { mode: "move" };
10169
+ if (!drag) return; // outside the mask: the monitor's usual clicks
10170
+ e.preventDefault(); e.stopImmediatePropagation();
10171
+ if (isGroupLocked(c)) { toastLocked(); return; }
10172
+ if (!drag.pushed) pushUndo();
10173
+ maskUi.drag = { ...drag, id: c.id, start: bp, m0: JSON.parse(JSON.stringify(maskNowAt(c, maskUi.sel))) };
10174
+ els.preview.setPointerCapture(e.pointerId);
10175
+ state.dirtyTimeline = true;
10176
+ }, true);
10177
+ els.preview.addEventListener("pointermove", (e) => {
10178
+ const d = maskUi.drag;
10179
+ if (!d) return;
10180
+ e.stopImmediatePropagation();
10181
+ const c = getClip(state.selId), B = c && maskBox(c);
10182
+ if (!B) return;
10183
+ const bp = B.toBox(canvasPt(e));
10184
+ if (d.tool) {
10185
+ if (maskUi.tool === "draw" && maskUi.stroke) {
10186
+ const last = maskUi.stroke[maskUi.stroke.length - 1], u = bp.x / B.bw, v = bp.y / B.bh;
10187
+ if (Math.hypot((u - last[0]) * B.bw, (v - last[1]) * B.bh) > 2) maskUi.stroke.push([u, v]);
10188
+ } else if (maskUi.tool === "pen" && maskUi.pen && maskUi.pen.dragging) {
10189
+ const p = maskUi.pen.pts[maskUi.pen.pts.length - 1], ox = bp.x - p[0], oy = bp.y - p[1];
10190
+ if (Math.hypot(ox, oy) > 3) { p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
10191
+ }
10192
+ state.dirtyTimeline = true;
10193
+ return;
10194
+ }
10195
+ const m0 = d.m0;
10196
+ let set;
10197
+ if (d.mode === "move") set = { x: +(m0.x + (bp.x - d.start.x) / B.bw).toFixed(4), y: +(m0.y + (bp.y - d.start.y) / B.bh).toFixed(4) };
10198
+ else if (d.mode === "w" || d.mode === "h") {
10199
+ const l = maskBoxToLocal(m0, B, bp.x, bp.y);
10200
+ set = d.mode === "w" ? { w: +clamp(Math.abs(l.x) * 2, 0.01, 8).toFixed(4) } : { h: +clamp(Math.abs(l.y) * 2, 0.01, 8).toFixed(4) };
10201
+ } else if (d.mode === "rotate") {
10202
+ let r = Math.atan2(bp.x - m0.x * B.bw, -(bp.y - m0.y * B.bh)) * 180 / Math.PI;
10203
+ if (e.shiftKey) r = Math.round(r / 15) * 15;
10204
+ set = { rotation: +r.toFixed(1) };
10205
+ } else if (m0.shape === "bezier") {
10206
+ const pts = m0.points.map((p) => [p[0], p[1], p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0]), p = pts[maskUi.pt];
10207
+ if (!p) return;
10208
+ const l = maskBoxToLocal(m0, B, bp.x, bp.y);
10209
+ if (d.mode === "point") { p[0] = l.x; p[1] = l.y; }
10210
+ else if (d.mode === "pull") { const ox = l.x - p[0], oy = l.y - p[1]; p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
10211
+ else if (d.mode === "handle") {
10212
+ const hx = l.x - p[0], hy = l.y - p[1], o = d.side === "o" ? 4 : 2, q = d.side === "o" ? 2 : 4;
10213
+ p[o] = hx; p[o + 1] = hy;
10214
+ if (!e.altKey) { // keep the pair smooth: the other handle mirrors the direction, keeps its length
10215
+ const len = Math.hypot(p[q], p[q + 1]) || Math.hypot(hx, hy), k = Math.hypot(hx, hy) || 1;
10216
+ p[q] = -hx / k * len; p[q + 1] = -hy / k * len;
10217
+ }
10218
+ }
10219
+ set = { points: pts.map((r) => r.map((v) => +v.toFixed(4))) };
10220
+ }
10221
+ if (set) { writeMaskSet(c, maskUi.sel, set); syncMaskInspector(c); }
10222
+ }, true);
10223
+ const endClipMaskDrag = (e) => {
10224
+ const d = maskUi.drag;
10225
+ if (!d) return;
10226
+ e.stopImmediatePropagation();
10227
+ maskUi.drag = null;
10228
+ const c = getClip(state.selId);
10229
+ if (d.tool) {
10230
+ if (maskUi.tool === "draw" && maskUi.stroke) {
10231
+ const B = c && maskBox(c), st = maskUi.stroke;
10232
+ maskUi.stroke = null;
10233
+ if (c && B && st.length >= 6) addMaskFrom(c, { shape: "freehand", stroke: st, aspect: B.bw / B.bh });
10234
+ else toast("Drag around what the mask should keep");
10235
+ } else if (maskUi.pen) maskUi.pen.dragging = false;
10236
+ state.dirtyTimeline = true;
10237
+ return;
10238
+ }
10239
+ scheduleSave(); renderInspector();
10240
+ };
10241
+ els.preview.addEventListener("pointerup", endClipMaskDrag, true);
10242
+ els.preview.addEventListener("pointercancel", endClipMaskDrag, true);
10243
+ els.preview.addEventListener("dblclick", (e) => {
10244
+ const c = getClip(state.selId);
10245
+ if (!maskEditActive(c) || maskUi.tool) return;
10246
+ const h = clipMaskHandles(c), pt = canvasPt(e), grab = Math.max(8, els.preview.width / 110);
10247
+ if (!h || h.m.shape !== "bezier") return;
10248
+ const i = h.A.findIndex((p) => Math.hypot(p.a.x - pt.x, p.a.y - pt.y) <= grab);
10249
+ if (i < 0) return;
10250
+ e.preventDefault(); e.stopImmediatePropagation();
10251
+ if (h.m.points.length <= 3) { toast("A mask needs at least 3 points"); return; }
10252
+ if (isGroupLocked(c)) { toastLocked(); return; }
10253
+ pushUndo();
10254
+ writeMaskTopology(c, maskUi.sel, (pts) => pts.filter((_, j) => j !== i));
10255
+ maskUi.pt = -1; scheduleSave(); renderInspector();
10256
+ }, true);
10257
+ window.addEventListener("keydown", (e) => {
10258
+ if (e.key === "Escape" && colorState.maskDraw) { // the Color tab's Draw
10259
+ e.preventDefault(); e.stopImmediatePropagation();
10260
+ colorState.maskDraw = false; colorState.maskStroke = null; state.dirtyTimeline = true; renderColorPage(true);
10261
+ return;
10262
+ }
10263
+ if (!maskUi.clip || (maskUi.sel < 0 && !maskUi.tool)) return;
10264
+ const tg = e.target;
10265
+ if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
10266
+ const c = getClip(state.selId);
10267
+ if (!c || c.id !== maskUi.clip) return;
10268
+ if (e.key === "Escape") {
10269
+ e.preventDefault(); e.stopImmediatePropagation();
10270
+ if (maskUi.tool) { maskUi.tool = null; maskUi.pen = null; maskUi.stroke = null; }
10271
+ else if (maskUi.pt >= 0) maskUi.pt = -1;
10272
+ else maskUi.sel = -1;
10273
+ state.dirtyTimeline = true; renderInspector();
10274
+ } else if (e.key === "Enter" && maskUi.tool === "pen") {
10275
+ e.preventDefault(); e.stopImmediatePropagation();
10276
+ finishPen(c);
10277
+ } else if (maskUi.tool === "pen" && maskUi.pen && !maskUi.drag) {
10278
+ // while placing points the outline is not in the project yet: undo / redo step through the points
10279
+ const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
10280
+ const back = (mod && k === "z" && !e.shiftKey) || ((e.key === "Backspace" || e.key === "Delete") && !mod);
10281
+ const fwd = mod && ((k === "z" && e.shiftKey) || k === "y");
10282
+ if (!back && !fwd) return;
10283
+ e.preventDefault(); e.stopImmediatePropagation();
10284
+ const P = maskUi.pen.pts, R = (maskUi.pen.redo ||= []);
10285
+ if (back && P.length) R.push(P.pop());
10286
+ else if (fwd && R.length) P.push(R.pop());
10287
+ state.dirtyTimeline = true;
10288
+ }
10289
+ }, true);
10290
+
9194
10291
  /* ── Text direction (LTR / RTL) — per-line when direction is "auto".
9195
10292
  Auto-detect uses a DOM probe + getComputedStyle; cache by line text so
9196
10293
  preview/export frames don't re-resolve style every tick. ── */
@@ -10970,6 +12067,7 @@ async function runExportJob(ticket) {
10970
12067
  await new Promise((r) => setTimeout(r, 100));
10971
12068
  if (ticket.revision != null && (project.revision || 0) < ticket.revision) await syncFromServer();
10972
12069
  if (ticket.kind === "scopes") return runScopesJob(ticket, fail);
12070
+ if (ticket.kind === "track") return runTrackJob(ticket, fail);
10973
12071
  if (!state.ffmpeg) {
10974
12072
  const j = await fetch("/api/export/ffmpeg").then((r) => r.json()).catch(() => ({}));
10975
12073
  state.ffmpeg = !!j.available;
@@ -10989,6 +12087,110 @@ async function runExportJob(ticket) {
10989
12087
  exportRangeForced = null;
10990
12088
  }
10991
12089
  }
12090
+ /* fablecut_track: follow a region of a clip in this tab, store the track,
12091
+ optionally bake it into a mask or another clip, save, and report. */
12092
+ async function runTrackJob(ticket, fail) {
12093
+ const spec = ticket.track || {}, c = getClip(spec.clip);
12094
+ if (!c) return fail(`no clip ${spec.clip}`);
12095
+ if (c.kind !== "video") return fail(`${c.id} is a ${c.kind} clip — tracking follows a video clip's picture`);
12096
+ if (isGroupLocked(c) && spec.force !== true) return fail(`${c.id} is locked — pass force:true only if the user asked to change it`);
12097
+ const el = getClipEl(c);
12098
+ if (el && !el.videoWidth) await new Promise((r) => { el.addEventListener("loadedmetadata", r, { once: true }); setTimeout(r, 15000); });
12099
+ if (!pictureRect(c, evalProps(c, c.start))) return fail("the clip's video did not load");
12100
+ const list = maskList(c), tracks = clipTracks(c), ap = spec.apply || null;
12101
+ let maskIdx = -1, other = null;
12102
+ if (ap && ap.mask != null) {
12103
+ maskIdx = typeof ap.mask === "string" ? list.findIndex((m) => m.name === ap.mask) : ap.mask;
12104
+ if (!list[maskIdx]) return fail(`${c.id} has no mask ${JSON.stringify(ap.mask)}${list.length ? ` (it has ${Mask.describe(list)})` : " — add one with setMask first"}`);
12105
+ } else if (ap && ap.clip != null) {
12106
+ other = getClip(ap.clip);
12107
+ if (!other || !isVisualClip(other) || other.id === c.id) return fail(`apply.clip must be another visual clip (${ap.clip} is not)`);
12108
+ if (isGroupLocked(other) && spec.force !== true) return fail(`${other.id} is locked`);
12109
+ }
12110
+ const at = clamp(Number.isFinite(spec.at) ? spec.at : c.start, c.start, c.start + c.duration), from = at - c.start;
12111
+ let name = typeof spec.name === "string" && spec.name.trim() ? spec.name.trim().slice(0, 60) : null;
12112
+ if (!name && maskIdx >= 0) name = list[maskIdx].name || `Mask ${maskIdx + 1}`;
12113
+ if (!name) { let n = 1; while (tracks.some((x) => x.name === `Track ${n}`)) n++; name = `Track ${n}`; }
12114
+ const g = pictureRect(c, evalProps(c, at)) || pictureRect(c, evalProps(c, c.start));
12115
+ let box = null;
12116
+ if (spec.region) box = { x: +spec.region.x, y: +spec.region.y, w: +spec.region.w, h: +spec.region.h };
12117
+ else if (spec.point) { const sz = clamp(+spec.size || 0.08, 0.02, 0.4); box = { x: +spec.point.x, y: +spec.point.y, w: sz, h: sz * g.dw / g.dh }; }
12118
+ else if (maskIdx >= 0 && !tracks.some((x) => x.name === name)) box = maskBoundsAt(c, list[maskIdx], from);
12119
+ const existing = tracks.find((x) => x.name === name);
12120
+ if (!box && !existing) return fail("say what to follow: region {x, y, w, h} or point {x, y} (fractions of the clip's picture), or a mask to apply it to");
12121
+ if (box && !(box.x >= 0 && box.x <= 1 && box.y >= 0 && box.y <= 1 && box.w > 0 && box.h > 0)) return fail("region / point must lie inside the clip's picture (x, y in 0…1)");
12122
+ const opts = { scale: spec.scale === true, rotation: spec.rotation === true };
12123
+ const dirs = spec.direction === "backward" ? [-1] : spec.direction === "both" ? [-1, 1] : [1];
12124
+ const to = Number.isFinite(spec.to) ? clamp(spec.to - c.start, 0, c.duration) : null;
12125
+ if (state.playing) pause();
12126
+ renderCancelled = false;
12127
+ const stamp = trackStamp(c), otherStamp = other ? trackStamp(other) : "", maskStamp = maskIdx >= 0 ? JSON.stringify(list[maskIdx]) : null;
12128
+ let samples = [], cancelled = false;
12129
+ const lostAt = [];
12130
+ try {
12131
+ if (box) {
12132
+ for (const [k, dir] of dirs.entries()) {
12133
+ const run = { cancel: false };
12134
+ const end = to != null && Math.sign(to - from) === dir ? to : dir > 0 ? c.duration : 0;
12135
+ const r = await runTrack(c, { box, from, to: end, dir, ...opts, run, onProgress: (f) => {
12136
+ run.cancel = renderCancelled;
12137
+ reportExportJob(ticket.id, { progress: (k + f) / dirs.length });
12138
+ } });
12139
+ if (r.lostAt != null) lostAt.push(+(c.start + r.lostAt).toFixed(3));
12140
+ cancelled = cancelled || r.cancelled;
12141
+ samples = dir < 0 ? [...r.samples, ...samples] : [...samples, ...r.samples];
12142
+ if (cancelled) break;
12143
+ }
12144
+ samples = samples.filter((sm, i) => i === 0 || sm[0] > samples[i - 1][0] + 1e-6);
12145
+ if (samples.length < 2) return fail(lostAt.length ? "lost the region on the very next frame — pick a spot with more detail, or a bigger region" : "nothing to track in that range");
12146
+ }
12147
+ // the project may have been reloaded or edited while tracking: apply only onto what was measured
12148
+ if (getClip(c.id) !== c || trackStamp(c) !== stamp || (other && (getClip(other.id) !== other || trackStamp(other) !== otherStamp)) ||
12149
+ (maskStamp != null && JSON.stringify(maskList(c)[maskIdx] ?? null) !== maskStamp))
12150
+ return fail("the clip changed while tracking (an edit or a reload) — nothing was applied; run it again");
12151
+ const beforeJob = undoSnapshot();
12152
+ pushUndo(beforeJob);
12153
+ const tr = box ? storeTrack(c, name, opts.scale || opts.rotation ? "box" : "point", box.w, box.h, samples) : existing;
12154
+ const used = box ? samples : tr.samples;
12155
+ let applied = null;
12156
+ if (maskIdx >= 0) applied = `mask ${JSON.stringify(list[maskIdx].name || maskIdx)}: ${bakeTrackIntoMask(c, maskIdx, used, opts, from)} keys`;
12157
+ else if (other) {
12158
+ const o = { scale: ap.scale === true, rotation: ap.rotation === true, at: Number.isFinite(ap.at) ? ap.at : at };
12159
+ const n = attachToTrack(c, tr, other, o);
12160
+ applied = n ? `clip ${other.id}: ${n} keys on ${["x", "y", ...(o.scale ? ["scale"] : []), ...(o.rotation ? ["rotation"] : [])].join(", ")}` : `clip ${other.id}: it does not overlap the track in time — nothing keyed`;
12161
+ }
12162
+ state.dirtyTimeline = true;
12163
+ if (state.connected) {
12164
+ clearTimeout(runtime.saveTimer); runtime.saveTimer = null;
12165
+ project.revision++;
12166
+ const res = await fetch("/api/project", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(projectJSON(), null, 2) });
12167
+ if (!res.ok) {
12168
+ project.revision--;
12169
+ // put back only the clips this job changed (edits elsewhere since stay), and drop its undo step
12170
+ const was = JSON.parse(beforeJob), wasClips = Array.isArray(was) ? was : was.clips;
12171
+ for (const id of [c.id, other && other.id].filter(Boolean)) {
12172
+ const i = project.clips.findIndex((x) => x.id === id), old = wasClips.find((x) => x.id === id);
12173
+ if (i >= 0 && old) project.clips[i] = old;
12174
+ }
12175
+ const u = runtime.undo.lastIndexOf(beforeJob);
12176
+ if (u >= 0) runtime.undo.splice(u, 1);
12177
+ pruneSelection(); renderInspector(); drawFrame();
12178
+ return fail(res.status === 409 ? "the project changed while tracking — nothing was saved; run it again" : `could not save the project (${res.status}) — nothing was kept`);
12179
+ }
12180
+ }
12181
+ renderInspector(); drawFrame();
12182
+ const qs = used.map((sm) => sm[5]);
12183
+ await reportExportJob(ticket.id, { status: "done", result: {
12184
+ clip: c.id, track: tr.name, kind: tr.kind, describe: Tracker.describe([tr]),
12185
+ from: +(c.start + used[0][0]).toFixed(3), to: +(c.start + used[used.length - 1][0]).toFixed(3),
12186
+ samples: used.length, tracked: !!box, lostAt, cancelled,
12187
+ quality: { min: +Math.min(...qs).toFixed(3), mean: +(qs.reduce((a, b) => a + b, 0) / qs.length).toFixed(3) },
12188
+ applied, revision: project.revision,
12189
+ } }, true);
12190
+ } catch (err) {
12191
+ fail("tracking failed: " + (err && err.message || err));
12192
+ }
12193
+ }
10992
12194
  /* fablecut_scopes: render one still exactly as export would (graded, all
10993
12195
  tracks), then measure it with color.js scopeStats. The user's playhead
10994
12196
  stays where it is. */
@@ -11023,7 +12225,12 @@ async function runScopesJob(ticket, fail) {
11023
12225
  runtime.matte = null; // measure the graded picture, not the matte the user may be looking at
11024
12226
  const mClip = ticket.matte && getClip(ticket.matte.clip);
11025
12227
  if (mClip) runtime.gradeCapture = { id: mClip.id, args: null };
11026
- try { await renderStillAt(t); } finally { runtime.matte = shown; }
12228
+ const kClip = ticket.mask && getClip(ticket.mask.clip), view = runtime.maskView;
12229
+ runtime.maskCapture = kClip ? { id: kClip.id } : null;
12230
+ runtime.maskView = null;
12231
+ try { await renderStillAt(t); } finally { runtime.matte = shown; runtime.maskView = view; }
12232
+ const kcap = runtime.maskCapture;
12233
+ runtime.maskCapture = null;
11027
12234
  const cap = runtime.gradeCapture;
11028
12235
  runtime.gradeCapture = null;
11029
12236
  const W = els.preview.width, H = els.preview.height;
@@ -11067,15 +12274,32 @@ async function runScopesJob(ticket, fail) {
11067
12274
  }
11068
12275
  }
11069
12276
  }
11070
- const clips = visibleClipsAt(t).map((c) => ({
11071
- id: c.id, name: c.name, kind: c.kind, track: c.track,
11072
- grade: Color.summarizeGrade(c.props.grade),
11073
- }));
11074
- await reportExportJob(ticket.id, { status: "done", result: { time: +t.toFixed(3), frame: { w: Math.round(r.w), h: Math.round(r.h) }, stats, clips, matte } }, true);
12277
+ let mask = null;
12278
+ if (ticket.mask) {
12279
+ mask = { clip: ticket.mask.clip };
12280
+ if (!kClip) mask.error = "no such clip";
12281
+ else if (!clipMasks(kClip)) mask.error = "the clip has no masks";
12282
+ else if (!kcap || kcap.coverage == null) mask.error = "the clip is not on screen at that time (or its masks are all off)";
12283
+ else Object.assign(mask, { masks: Mask.describe(clipMasks(kClip)), coverage: kcap.coverage, frameCoverage: kcap.frameCoverage });
12284
+ }
12285
+ const onScreen = visibleClipsAt(t), mattes = matteTracksAt(onScreen);
12286
+ const clips = onScreen.map((c) => {
12287
+ const tm = clipMatte(c);
12288
+ return {
12289
+ id: c.id, name: c.name, kind: c.kind, track: c.track,
12290
+ grade: Color.summarizeGrade(c.props.grade),
12291
+ ...(clipMasks(c) ? { masks: Mask.describe(clipMasks(c)) } : {}),
12292
+ ...(tm ? { matte: `${tm.mode} from ${tm.track}` } : {}),
12293
+ ...(mattes.has(c.track) ? { isMatte: true } : {}), // cuts a clip below; not drawn itself
12294
+ ...(c.props.blend && c.props.blend !== "normal" ? { blend: c.props.blend } : {}),
12295
+ };
12296
+ });
12297
+ await reportExportJob(ticket.id, { status: "done", result: { time: +t.toFixed(3), frame: { w: Math.round(r.w), h: Math.round(r.h) }, stats, clips, matte, mask } }, true);
11075
12298
  } catch (err) {
11076
12299
  fail("could not measure the frame: " + (err && err.message || err));
11077
12300
  } finally {
11078
12301
  runtime.gradeCapture = null;
12302
+ runtime.maskCapture = null;
11079
12303
  state.rendering = false;
11080
12304
  seekMediaWhilePaused(); // the videos sat on the job's frame — back to the playhead
11081
12305
  if (wasPlaying) play();
@@ -12845,12 +14069,13 @@ function layerSectionsHtml(c) {
12845
14069
  </div>
12846
14070
  <div class="insp-section cp-mask"><h3>Mask</h3>
12847
14071
  <div class="cl-row">${["none", "ellipse", "rect", "poly"].map((s) => `<button type="button" class="btn tiny${(m ? m.shape : "none") === s ? " toggle on" : ""}" data-mshape="${s}">${s === "none" ? "None" : s === "rect" ? "Rect" : s[0].toUpperCase() + s.slice(1)}</button>`).join("")}
14072
+ <button type="button" class="btn tiny${colorState.maskDraw || (m && m.shape === "bezier") ? " toggle on" : ""}" data-mdraw title="Drag a free-hand outline on the monitor — it becomes an editable bezier">Draw</button>
12848
14073
  ${m ? `<button type="button" class="btn tiny${m.invert ? " toggle on" : ""}" data-mact="invert" title="Grade outside the shape">Invert</button>` : ""}</div>
12849
14074
  ${m ? `<div class="cl-row"><button type="button" class="btn tiny${onKey ? " toggle on" : ""}" data-mact="key" title="${onKey ? "Remove the key at the playhead" : "Key the shape at the playhead — with keys, every change keys"}">◆ Key</button>
12850
14075
  <span class="cl-note">${keyed ? `${keyed} key${keyed > 1 ? "s" : ""}` : "not animated"}</span>
12851
14076
  ${keyed ? `<button type="button" class="btn tiny" data-mact="clearkeys" title="Remove every key (keeps the shape at the playhead)">Clear keys</button>` : ""}</div>
12852
14077
  ${mrow("x")}${mrow("y")}${m.shape !== "poly" ? mrow("w") + mrow("h") : ""}${mrow("rotation")}${mrow("feather")}
12853
- <p class="cl-note">Drag it on the monitor: inside moves, handles resize, the knob rotates${m.shape === "poly" ? "; Ctrl-click adds a point, double-click one removes it" : ""}.</p>` : ""}
14078
+ <p class="cl-note">Drag it on the monitor: inside moves, handles resize, the knob rotates${m.points ? "; Ctrl-click adds a point, double-click one removes it" : ""}.</p>` : colorState.maskDraw ? `<p class="cl-note">Drag around what this layer should grade. Esc cancels.</p>` : ""}
12854
14079
  </div>`;
12855
14080
  }
12856
14081
  function bindLayerControls(c, guard) {
@@ -12919,6 +14144,13 @@ function bindLayerControls(c, guard) {
12919
14144
  scheduleSave(); rerender();
12920
14145
  });
12921
14146
  // mask
14147
+ const draw = root.querySelector("[data-mdraw]");
14148
+ if (draw) draw.addEventListener("click", () => {
14149
+ if (!guard()) return;
14150
+ colorState.maskDraw = !colorState.maskDraw; colorState.maskStroke = null;
14151
+ if (colorState.maskDraw) { if (state.playing) pause(); toast("Drag around what this layer should grade"); }
14152
+ rerender();
14153
+ });
12922
14154
  for (const b of root.querySelectorAll("[data-mshape]")) b.addEventListener("click", () => {
12923
14155
  if (!guard()) return;
12924
14156
  const s = b.dataset.mshape, cur = maskNow(c);
@@ -13044,11 +14276,11 @@ function maskHandles(c) {
13044
14276
  if (!sp) return null;
13045
14277
  const a = sp.aspect, at = (px, py) => { const q = maskLocalToUv(m, a, px, py); return sp.toCanvas(q.u, q.v); };
13046
14278
  const rx = (m.w / 2) * a, ry = m.h / 2;
13047
- const top = m.shape === "poly" ? Math.min(...m.points.map(([, y]) => y)) : -ry;
14279
+ const top = m.points ? Math.min(...m.points.map((p) => p[1])) : -ry;
13048
14280
  const W = els.preview.width, H = els.preview.height, inset = Math.max(8, W / 110), knob = at(0, top - 0.08);
13049
14281
  knob.x = clamp(knob.x, inset, W - inset); knob.y = clamp(knob.y, inset, H - inset); // stays grabbable above the frame
13050
14282
  const h = { m, sp, centre: at(0, 0), knob, knobBase: at(0, top), edges: [], points: [] };
13051
- if (m.shape === "poly") h.points = m.points.map(([x, y]) => at(x * a, y));
14283
+ if (m.points) h.points = m.points.map(([x, y]) => at(x * a, y));
13052
14284
  else h.edges = [["w", rx, 0], ["w", -rx, 0], ["h", 0, ry], ["h", 0, -ry]].map(([k, x, y]) => ({ k, ...at(x, y) }));
13053
14285
  return h;
13054
14286
  }
@@ -13058,6 +14290,9 @@ function maskOutline(c, h) {
13058
14290
  for (let i = 0; i < 64; i++) { const t = i / 64 * Math.PI * 2; const q = maskLocalToUv(m, a, Math.cos(t) * m.w / 2 * a, Math.sin(t) * m.h / 2); pts.push(sp.toCanvas(q.u, q.v)); }
13059
14291
  } else if (m.shape === "rect") {
13060
14292
  for (const [x, y] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { const q = maskLocalToUv(m, a, x * m.w / 2 * a, y * m.h / 2); pts.push(sp.toCanvas(q.u, q.v)); }
14293
+ } else if (m.shape === "bezier") {
14294
+ const A = Mask.anchors({ shape: "bezier", x: 0, y: 0, scale: 1, rotation: 0, points: m.points }, 1, 1);
14295
+ for (const [x, y] of Mask.flatten(A, 16)) { const q = maskLocalToUv(m, a, x * a, y); pts.push(sp.toCanvas(q.u, q.v)); }
13061
14296
  } else pts.push(...h.points);
13062
14297
  return pts;
13063
14298
  }
@@ -13085,10 +14320,34 @@ function drawMaskOverlay(c, W) {
13085
14320
  ctx2d.restore();
13086
14321
  return true;
13087
14322
  }
14323
+ /** Color tab Draw: a free-hand outline for the selected layer's mask. */
14324
+ function drawingLayerMask(c) {
14325
+ return colorState.maskDraw && colorState.ws === "color" && colorState.layer >= 0 && c && state.selId === c.id && activeAt(c, state.time) && !!maskSpace(c);
14326
+ }
14327
+ function drawLayerStroke(c, W) {
14328
+ const sp = maskSpace(c), st = colorState.maskStroke;
14329
+ if (!sp || !st || st.length < 2) return;
14330
+ ctx2d.save();
14331
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
14332
+ ctx2d.lineWidth = Math.max(2, W / 600); ctx2d.strokeStyle = "#ffd25a";
14333
+ ctx2d.beginPath();
14334
+ st.forEach(([u, v], i) => { const p = sp.toCanvas(u, v); if (i) ctx2d.lineTo(p.x, p.y); else ctx2d.moveTo(p.x, p.y); });
14335
+ ctx2d.stroke();
14336
+ ctx2d.restore();
14337
+ }
13088
14338
  let maskDrag = null;
13089
14339
  els.preview.addEventListener("pointerdown", (e) => {
13090
14340
  if (e.button !== 0 || e.altKey || isSourceMode() || colorState.pick || colorState.curvePick || colorState.qualPick) return;
13091
14341
  const c = getClip(state.selId);
14342
+ if (drawingLayerMask(c)) {
14343
+ e.preventDefault(); e.stopImmediatePropagation();
14344
+ if (isGroupLocked(c)) { toastLocked(); return; }
14345
+ const uv = maskSpace(c).toUv(canvasPt(e));
14346
+ colorState.maskStroke = [[uv.u, uv.v]];
14347
+ maskDrag = { mode: "draw", id: c.id };
14348
+ els.preview.setPointerCapture(e.pointerId);
14349
+ return;
14350
+ }
13092
14351
  if (!maskEditing(c)) return;
13093
14352
  const h = maskHandles(c);
13094
14353
  if (!h) return;
@@ -13099,7 +14358,7 @@ els.preview.addEventListener("pointerdown", (e) => {
13099
14358
  if (near(h.knob)) drag = { mode: "rotate" };
13100
14359
  else if (h.points.some(near)) drag = { mode: "point", i: h.points.findIndex(near) };
13101
14360
  else if (h.edges.some(near)) drag = { mode: h.edges.find(near).k };
13102
- else if (m.shape === "poly" && (e.ctrlKey || e.metaKey)) { // add a point on the nearest edge
14361
+ else if (m.points && (e.ctrlKey || e.metaKey)) { // add a point on the nearest edge
13103
14362
  const lp = uvToMaskLocal(m, a, uv.u, uv.v), P = m.points.map(([x, y]) => [x * a, y]);
13104
14363
  let best = 0, bd = Infinity;
13105
14364
  for (let i = 0; i < P.length; i++) {
@@ -13108,7 +14367,8 @@ els.preview.addEventListener("pointerdown", (e) => {
13108
14367
  const d = Math.hypot(lp.x - x1 - ex * k, lp.y - y1 - ey * k);
13109
14368
  if (d < bd) { bd = d; best = i; }
13110
14369
  }
13111
- if (m.points.length >= Color.MAX_POLY) { toast(`A mask has at most ${Color.MAX_POLY} points`); e.stopImmediatePropagation(); return; }
14370
+ const most = m.shape === "bezier" ? Color.MAX_BEZIER : Color.MAX_POLY;
14371
+ if (m.points.length >= most) { toast(`A mask has at most ${most} points`); e.stopImmediatePropagation(); return; }
13112
14372
  if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
13113
14373
  const pts = m.points.map((p) => p.slice());
13114
14374
  pts.splice(best + 1, 0, [lp.x / a, lp.y]);
@@ -13128,6 +14388,11 @@ els.preview.addEventListener("pointermove", (e) => {
13128
14388
  e.stopImmediatePropagation();
13129
14389
  const c = getClip(maskDrag.id), sp = c && maskSpace(c);
13130
14390
  if (!sp) return;
14391
+ if (maskDrag.mode === "draw") {
14392
+ const uv = sp.toUv(canvasPt(e)), st = colorState.maskStroke, last = st && st[st.length - 1];
14393
+ if (last && Math.hypot((uv.u - last[0]) * sp.aspect, uv.v - last[1]) > 0.003) { st.push([uv.u, uv.v]); state.dirtyTimeline = true; }
14394
+ return;
14395
+ }
13131
14396
  const uv = sp.toUv(canvasPt(e)), m0 = maskDrag.m0, a = sp.aspect, d = maskDrag;
13132
14397
  const lp = uvToMaskLocal(m0, a, uv.u, uv.v);
13133
14398
  let set;
@@ -13141,7 +14406,7 @@ els.preview.addEventListener("pointermove", (e) => {
13141
14406
  set = { rotation: +r.toFixed(1) };
13142
14407
  } else if (d.mode === "point") {
13143
14408
  const pts = (curLayer(c)?.mask?.points || m0.points).map((p) => p.slice());
13144
- pts[d.i] = [+(lp.x / a).toFixed(4), +lp.y.toFixed(4)];
14409
+ pts[d.i] = [+(lp.x / a).toFixed(4), +lp.y.toFixed(4), ...pts[d.i].slice(2)]; // a bezier anchor keeps its handles
13145
14410
  set = { points: pts };
13146
14411
  }
13147
14412
  if (set) { writeMask(c, set); syncMaskInputs(c); state.dirtyTimeline = true; }
@@ -13149,6 +14414,19 @@ els.preview.addEventListener("pointermove", (e) => {
13149
14414
  const endMaskDrag = (e) => {
13150
14415
  if (!maskDrag) return;
13151
14416
  e.stopImmediatePropagation();
14417
+ if (maskDrag.mode === "draw") {
14418
+ const c = getClip(maskDrag.id), sp = c && maskSpace(c), st = colorState.maskStroke;
14419
+ maskDrag = null; colorState.maskStroke = null; state.dirtyTimeline = true;
14420
+ if (!c || !sp || !st || st.length < 6) { toast("Drag around what this layer should grade"); return; }
14421
+ const cur = maskNow(c), m = { shape: "freehand", stroke: st, aspect: sp.aspect, feather: cur ? cur.feather : 0.03 };
14422
+ if (cur && cur.invert) m.invert = true;
14423
+ pushUndo();
14424
+ writeLayer(c, colorState.layer, { mask: null });
14425
+ writeLayer(c, colorState.layer, { mask: m });
14426
+ colorState.maskDraw = false;
14427
+ scheduleSave(); renderColorPage(true);
14428
+ return;
14429
+ }
13152
14430
  maskDrag = null; colorState.mdrag = false;
13153
14431
  scheduleSave(); renderColorPage(true);
13154
14432
  };