fablecut 1.12.1 → 1.14.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
@@ -46,6 +46,11 @@ const TIMELINE_FIT_FILL = 0.95; // ⇧Z / Fit — clip content fills this fracti
46
46
 
47
47
  const DEFAULT_PROPS = {
48
48
  x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0,
49
+ anchorX: 0, anchorY: 0, // anchor point, px from the picture's centre (motion.js)
50
+ trimStart: 0, trimEnd: 100, trimOffset: 0, // shape layers: the part of every path that draws (%)
51
+ autoOrient: false, // turn along the motion path
52
+ pathOffset: 0, pathAlign: "start", // text on a path: where along it (%), which end sits there
53
+ motionBlur: 0, motionBlurSamples: 8, // shutter angle (°, 0 = off) and sub-frame samples
49
54
  gain: 0, // clip gain, dB — applied before volume
50
55
  channelMode: "stereo", // stereo | mono | left | right | swap
51
56
  duck: 0, // ducking, dB (≤ 0) — keyframed by Auto-duck, multiplies volume
@@ -77,15 +82,14 @@ const DEFAULT_PROPS = {
77
82
  const ANIMATABLE = ["x", "y", "scale", "rotation", "opacity", "volume", "pan", "duck", "speed",
78
83
  "brightness", "contrast", "saturation", "hue", "blur", "grayscale", "sepia", "invert",
79
84
  "temperature", "tint", "vignette", "cornerRadius", "shake", "rgbSplit", "grain",
80
- "fontSize", "letterSpacing", "glow"];
85
+ "fontSize", "letterSpacing", "glow", "anchorX", "anchorY", "trimStart", "trimEnd", "trimOffset", "pathOffset"];
81
86
  const TRANSITIONS = ["none", "fade", "slide-left", "slide-right", "slide-up", "slide-down",
82
87
  "zoom", "wipe", "wipe-right", "wipe-up", "wipe-down", "iris", "spin", "blur", "whip",
83
88
  "glitch", "pop"];
84
89
  const TEXT_ANIMS = ["none", "typewriter", "word-pop", "word-slide", "karaoke",
85
90
  "letter-pop", "wave", "bounce", "shake",
86
91
  "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"];
92
+ const BLEND_MODES = self.FableCutMask.BLENDS; // the canvas composite set, shared with the agent (mask.js)
89
93
  /* Named looks. % props multiply against the clip's own value, additive props add. */
90
94
  const FILTER_PRESETS = {
91
95
  none: {},
@@ -430,6 +434,10 @@ function showClipCtxMenu(clientX, clientY, c) {
430
434
  item("Crossfade with neighbours", "Shift+D", () => crossfadeSelected(), locked ? "Locked — unlock to change" : null);
431
435
  item("Auto-duck under other tracks", "", () => autoDuckClips(selectedClips()), locked ? "Locked — unlock to change" : null);
432
436
  }
437
+ if (selectedClips().some((x) => isVisualClip(x) && x.kind !== "null"))
438
+ item("Parent to a new null", "", parentSelectionToNull, locked ? "Locked — unlock to change" : null);
439
+ item("Precompose", "", precomposeSelection, locked ? "Locked — unlock to change" : null);
440
+ if (c.kind === "comp") item("Open composition", "", () => enterComp(c.compId, c));
433
441
  const linked = selectedClips().some((x) => x.linkGroup || x.linkedId);
434
442
  if (linked) item("Unlink audio / video", "Ctrl+L", toggleLinkSelected, locked ? "Locked — unlock to change" : null);
435
443
  else if (c.kind === "video" || c.kind === "audio") {
@@ -557,7 +565,7 @@ const state = {
557
565
  webCodecs: false, // VideoEncoder + Annex-B H.264 supported
558
566
  dirtyTimeline: true, gesture: false,
559
567
  workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT
560
- binTab: "project", // project | elements | sfx | svg
568
+ binTab: "project", // project | elements | sfx | svg | shapes
561
569
  disabledTracks: new Set(), // mirror of project.disabledTracks for fast lookup
562
570
  lockedTracks: new Set(), // mirror of project.lockedTracks
563
571
  untargetedTracks: new Set(), // mirror of project.untargetedTracks
@@ -610,7 +618,11 @@ const {
610
618
  listAlignedGaps, adjacentClip, rippleTrim, rollEdit, slipClip, slideClip, crossfadeCuts, crossfadeCut,
611
619
  } = EDIT;
612
620
  /** Does this clip reach the picture / the mix? Track output and clip enable. */
613
- function clipRenders(c) { return isTrackEnabled(c.track) && c.disabled !== true; }
621
+ function clipRenders(c) {
622
+ // comp audio plays through the main timeline's track of the same id, so that track's switch applies too
623
+ if (c._comp) return c._on !== false && (c.kind !== "audio" || isTrackEnabled(c.track));
624
+ return isTrackEnabled(c.track) && c.disabled !== true;
625
+ }
614
626
  function toastLocked(partial = false) {
615
627
  toast(partial ? "Locked clips were left untouched — unlock them to edit"
616
628
  : "Locked — unlock the clip or its track to edit");
@@ -779,7 +791,7 @@ const els = {
779
791
  importUrlOverlay: $("importUrlOverlay"), importUrlInput: $("importUrlInput"),
780
792
  importUrlStatus: $("importUrlStatus"), importUrlProgress: $("importUrlProgress"),
781
793
  };
782
- const ctx2d = els.preview.getContext("2d");
794
+ let ctx2d = els.preview.getContext("2d"); // drawMaskedClip points it at an offscreen layer while a masked clip draws
783
795
 
784
796
  /* ── Utils ─────────────────────────────────────────────────────────────── */
785
797
  const uid = () => Math.random().toString(36).slice(2, 9);
@@ -874,7 +886,7 @@ function projDur() {
874
886
  }
875
887
  return mx;
876
888
  }
877
- const clipSpeed = (c) => clamp(+(c.props?.speed) || 1, 0.1, 8);
889
+ const clipSpeed = FableCutEdit.clipSpeed; // comp clips run at 1×
878
890
 
879
891
  /* ── Speed ramps (time remapping) ──
880
892
  `speed` is keyframable: media time = in + ∫ speed(t) dt over the clip.
@@ -922,8 +934,17 @@ function propsAtPlayhead(c) {
922
934
  const v = kfChannel(c, k, local, +(p[k] ?? DEFAULT_PROPS[k] ?? 0));
923
935
  if (typeof v === "number" && !isNaN(v)) p[k] = v;
924
936
  }
937
+ applyMotionPath(c, p, local, false);
925
938
  return p;
926
939
  }
940
+ /* Motion path (motion.js): position keys with spatial handles move the clip along a
941
+ curve; auto-orient turns it with its direction of travel. */
942
+ function applyMotionPath(c, p, local, orient) {
943
+ if (!c.keyframes || !c.keyframes.x || !c.keyframes.y) return;
944
+ const q = self.FableCutMotion.pathPositionAt(c, local);
945
+ if (q) { p.x = q.x; p.y = q.y; }
946
+ if (orient && p.autoOrient) { const a = self.FableCutMotion.orientAt(c, local); if (a != null) p.rotation = (+p.rotation || 0) + a; }
947
+ }
927
948
  function fmtInspNum(v, step) {
928
949
  const n = +v;
929
950
  if (!Number.isFinite(n)) return "0";
@@ -981,6 +1002,11 @@ function setAnimProp(c, k, v) {
981
1002
  const eps = kfTimeEps();
982
1003
  const dup = arr.find((kf) => Math.abs(kf.t - lt) < eps);
983
1004
  if (dup) { dup.v = v; return true; }
1005
+ const other = k === "x" ? "y" : k === "y" ? "x" : null, oarr = other && c.keyframes[other];
1006
+ if (other && Array.isArray(oarr) && oarr.length && self.FableCutMotion.hasPathHandles(c) && !oarr.some((kf) => Math.abs(kf.t - lt) < eps)) {
1007
+ oarr.push({ t: lt, v: +self.FableCutMotion.positionAt(c, lt)[other].toFixed(3) });
1008
+ oarr.sort((a, b) => a.t - b.t);
1009
+ }
984
1010
  arr.push({ t: lt, v });
985
1011
  arr.sort((a, b) => a.t - b.t);
986
1012
  state.dirtyTimeline = true;
@@ -1074,7 +1100,7 @@ function toggleKfAtPlayhead(c, k) {
1074
1100
  return true;
1075
1101
  }
1076
1102
  function hasSpeedRamp(c) {
1077
- return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0;
1103
+ return c.kind !== "comp" && Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0; // comps run at 1×
1078
1104
  }
1079
1105
  function mediaTimeAt(c, t) {
1080
1106
  const base = clipSpeed(c);
@@ -1391,7 +1417,19 @@ function syncExportFrameSel() {
1391
1417
  html += `<option value="custom" selected>Custom · ${ef.w}×${ef.h}</option>`;
1392
1418
  els.exportFrameSel.innerHTML = html;
1393
1419
  }
1394
- function applyProject(data) {
1420
+ function applyProject(data, opts = {}) {
1421
+ if (state.compCtx && !opts.ctx) { // the root changed on disk (an agent, another tab): keep the open comp open
1422
+ const x = state.compCtx;
1423
+ x.root = data;
1424
+ // every undo step here and in the levels above predates this document; replaying one would save over it
1425
+ for (const f of x.frames) { f.undo = []; f.redo = []; }
1426
+ runtime.undo.length = 0; runtime.redo.length = 0;
1427
+ const path = x.path.filter((id) => CompLib.getComp(data, id));
1428
+ if (path.length !== x.path.length) { state.compCtx = null; applyProject(data, { ctx: true }); syncCompCrumb(); toast("The open composition was removed"); return; }
1429
+ applyProject(compSyntheticDoc(data, path), { ctx: true });
1430
+ syncCompCrumb();
1431
+ return;
1432
+ }
1395
1433
  const wa = normalizeWorkArea(data.inPoint, data.outPoint);
1396
1434
  Object.assign(project, {
1397
1435
  name: data.name || "Untitled Project",
@@ -1409,7 +1447,13 @@ function applyProject(data) {
1409
1447
  encodeProfile: data.encodeProfile || null,
1410
1448
  master: normalizeMaster(data.master),
1411
1449
  buses: normalizeBuses(data.buses),
1450
+ comps: (Array.isArray(data.comps) ? data.comps : []).filter((cp) => cp && cp.id).map((cp) => ({
1451
+ ...cp, clips: (cp.clips || []).map((x) => {
1452
+ if (x.keyframes) for (const arr of Object.values(x.keyframes)) if (Array.isArray(arr)) arr.sort((a, b) => a.t - b.t);
1453
+ return { ...x, props: { ...DEFAULT_PROPS, ...(x.kind === "comp" ? { fit: "none" } : {}), ...(x.props || {}) } };
1454
+ }) })),
1412
1455
  });
1456
+ runtime.compGen = (runtime.compGen || 0) + 1;
1413
1457
  applyTracksFromProject(data.tracks);
1414
1458
  ensureTracksCoverClips();
1415
1459
  project.tracks = serializeTracks();
@@ -1434,7 +1478,7 @@ function applyProject(data) {
1434
1478
  c.fx = normFx(c.fx);
1435
1479
  if (!c.fx) delete c.fx;
1436
1480
  const raw = c.props || {};
1437
- c.props = { ...DEFAULT_PROPS, ...raw };
1481
+ c.props = { ...DEFAULT_PROPS, ...(c.kind === "comp" ? { fit: "none" } : {}), ...raw };
1438
1482
  if (c.props.grade !== undefined) {
1439
1483
  const g = self.FableCutColor.normalizeGrade(c.props.grade);
1440
1484
  if (g) c.props.grade = g; else delete c.props.grade;
@@ -1495,7 +1539,28 @@ function scheduleSave() {
1495
1539
  } catch { }
1496
1540
  }, 400);
1497
1541
  }
1542
+ /** One clip as saved: only the fields the editor knows (unknown runtime fields stay out). */
1543
+ function serializeClip({ id, mediaId, kind, compId, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup, locked, disabled, unlinked, fx, parent }) {
1544
+ const clipOut = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut };
1545
+ if (compId) clipOut.compId = compId;
1546
+ if (fx?.length) clipOut.fx = fx;
1547
+ if (linkGroup) clipOut.linkGroup = linkGroup;
1548
+ if (linkedId) clipOut.linkedId = linkedId;
1549
+ // Written only when set, so untouched projects stay byte-identical.
1550
+ if (locked === true) clipOut.locked = true;
1551
+ if (disabled === true) clipOut.disabled = true;
1552
+ if (unlinked === true) clipOut.unlinked = true;
1553
+ if (parent) clipOut.parent = parent;
1554
+ return clipOut;
1555
+ }
1556
+ /** The document to save: the root project, with the open comp (if any) written into it. */
1498
1557
  function projectJSON() {
1558
+ if (!state.compCtx) return projectJSONHere();
1559
+ const root = commitCompCtx();
1560
+ return { ...root, revision: project.revision };
1561
+ }
1562
+ /** The project as it is loaded right now (an open comp's stand-in project, or the root). */
1563
+ function projectJSONHere() {
1499
1564
  const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, encodeProfile } = project;
1500
1565
  const out = {
1501
1566
  name, width, height, fps, background, revision,
@@ -1508,17 +1573,7 @@ function projectJSON() {
1508
1573
  if (derivedFrom) { mo.derivedFrom = derivedFrom; if (denoise) mo.denoise = denoise; }
1509
1574
  return mo;
1510
1575
  }),
1511
- clips: clips.map(({ id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup, locked, disabled, unlinked, fx }) => {
1512
- const clipOut = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut };
1513
- if (fx?.length) clipOut.fx = fx;
1514
- if (linkGroup) clipOut.linkGroup = linkGroup;
1515
- if (linkedId) clipOut.linkedId = linkedId;
1516
- // Written only when set, so untouched projects stay byte-identical.
1517
- if (locked === true) clipOut.locked = true;
1518
- if (disabled === true) clipOut.disabled = true;
1519
- if (unlinked === true) clipOut.unlinked = true;
1520
- return clipOut;
1521
- }),
1576
+ clips: clips.map(serializeClip),
1522
1577
  markers: normalizeMarkers(markers),
1523
1578
  inPoint: inPoint == null ? null : inPoint,
1524
1579
  outPoint: outPoint == null ? null : outPoint,
@@ -1535,6 +1590,7 @@ function projectJSON() {
1535
1590
  if (master) out.master = master;
1536
1591
  const buses = normalizeBuses(project.buses);
1537
1592
  if (buses.length) out.buses = buses;
1593
+ if (project.comps && project.comps.length) out.comps = project.comps.map((cp) => ({ ...cp, clips: (cp.clips || []).map(serializeClip) }));
1538
1594
  return out;
1539
1595
  }
1540
1596
  function listenSSE() {
@@ -1560,7 +1616,9 @@ function listenSSE() {
1560
1616
  async function syncFromServer(force) {
1561
1617
  if (state.gesture || state.exporting) { runtime.pendingSync = true; return; }
1562
1618
  runtime.pendingSync = false;
1563
- if (state.binTab !== "project") fetchLibrary(state.binTab).then(renderLibrary);
1619
+ if (state.binTab === "templates") fetchSaved("templates").then(() => { if (state.binTab === "templates") renderTemplatesTab(); });
1620
+ else if (state.binTab !== "project" && state.binTab !== "shapes") fetchLibrary(state.binTab).then(renderLibrary);
1621
+ fetchSaved("presets");
1564
1622
  loadLibraryFonts();
1565
1623
  fetchEncodeProfiles();
1566
1624
  try {
@@ -1571,6 +1629,8 @@ async function syncFromServer(force) {
1571
1629
  if (!force && (data.revision || 0) === (project.revision || 0)) return; // our own save
1572
1630
  if (runtime.saveTimer) { // unsaved local edit vs. external write: external wins, tell the user
1573
1631
  clearTimeout(runtime.saveTimer); runtime.saveTimer = null;
1632
+ // the undo steps describe the discarded edit; replaying one would re-save over the external write
1633
+ runtime.undo.length = 0; runtime.redo.length = 0;
1574
1634
  toast("Project was updated externally — your last change may need redoing.");
1575
1635
  }
1576
1636
  applyProject(data);
@@ -1834,10 +1894,12 @@ async function importFromUrl(url) {
1834
1894
  }
1835
1895
 
1836
1896
  function renderBin() {
1837
- els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root").forEach((n) => n.remove());
1838
- const empty = !project.media.length && !project.folders.length;
1897
+ els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root, .comp-item").forEach((n) => n.remove());
1898
+ const comps = project.comps || [];
1899
+ const empty = !project.media.length && !project.folders.length && !comps.length;
1839
1900
  els.binEmpty.style.display = empty ? "" : "none";
1840
1901
  if (empty) return;
1902
+ for (const comp of comps) els.binList.appendChild(makeCompItem(comp));
1841
1903
 
1842
1904
  const clearBinDropHints = () => {
1843
1905
  els.binList.querySelectorAll(".bin-drop-over").forEach((n) => n.classList.remove("bin-drop-over"));
@@ -1945,9 +2007,13 @@ function renderBin() {
1945
2007
  });
1946
2008
  item.querySelector(".bin-del").addEventListener("click", (e) => {
1947
2009
  e.stopPropagation();
1948
- pushUndo();
2010
+ if (state.compCtx && (state.compCtx.root.clips || []).some((c) => c.mediaId === m.id)) { toast("It's used on the main timeline — go back to Main to delete it"); return; }
2011
+ pushUndo(JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media })); // undo brings the media back too
1949
2012
  project.media = project.media.filter((x) => x.id !== m.id);
1950
2013
  project.clips = project.clips.filter((c) => c.mediaId !== m.id);
2014
+ // and its clips inside comps (and on the main timeline while a comp is open)
2015
+ project.comps = (project.comps || []).map((cp) => ({ ...cp, clips: (cp.clips || []).filter((c) => c.mediaId !== m.id) }));
2016
+ runtime.compGen = (runtime.compGen || 0) + 1;
1951
2017
  if (state.source.mediaId === m.id) clearSource();
1952
2018
  renderBin(); scheduleSave(); renderInspector();
1953
2019
  });
@@ -2162,14 +2228,16 @@ function setBinTab(tab) {
2162
2228
  const isProj = tab === "project";
2163
2229
  els.binList.classList.toggle("hidden", !isProj);
2164
2230
  els.libList.classList.toggle("hidden", isProj);
2165
- if (!isProj) fetchLibrary(tab).then(renderLibrary);
2231
+ if (tab === "shapes") renderShapesTab();
2232
+ else if (tab === "templates") { renderTemplatesTab(); fetchSaved("templates").then(() => { if (state.binTab === "templates") renderTemplatesTab(); }); }
2233
+ else if (!isProj) fetchLibrary(tab).then(renderLibrary);
2166
2234
  }
2167
2235
 
2168
2236
  /* ═══════════════════════════ EDIT OPERATIONS ═══════════════════════════ */
2169
2237
  /* Undo entries are {clips, markers} snapshots. Drag gestures still push a bare
2170
2238
  clips array (taken at pointerdown) — restoring one leaves markers alone. */
2171
2239
  function undoSnapshot() {
2172
- return JSON.stringify({ clips: project.clips, markers: project.markers || [] });
2240
+ return JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [] });
2173
2241
  }
2174
2242
  /** Record an undo step — `snap` lets an edit snapshot first and record only
2175
2243
  * once it knows it changed something. */
@@ -2181,7 +2249,8 @@ function pushUndo(snap = undoSnapshot()) {
2181
2249
  function restoreSnapshot(json) {
2182
2250
  const snap = JSON.parse(json);
2183
2251
  if (Array.isArray(snap)) project.clips = snap;
2184
- else { project.clips = snap.clips; project.markers = snap.markers; }
2252
+ else { project.clips = snap.clips; project.markers = snap.markers; if (snap.comps) project.comps = snap.comps; if (snap.media) { project.media = snap.media; renderBin(); } }
2253
+ runtime.compGen = (runtime.compGen || 0) + 1;
2185
2254
  }
2186
2255
  function undo() {
2187
2256
  if (!runtime.undo.length) return;
@@ -3273,6 +3342,9 @@ function rebuildClips() {
3273
3342
  els.tracksContent.style.width = w + "px";
3274
3343
  els.ruler.style.width = els.timelineScroll.clientWidth + "px";
3275
3344
  for (const row of els.tracks.children) row.innerHTML = "";
3345
+ const matted = project.clips.map((x) => [x, clipMatte(x)]).filter(([, m]) => m);
3346
+ const isMatte = (c) => matted.some(([x, m]) => m.track === c.track && clipRenders(x) &&
3347
+ x.start < c.start + c.duration && c.start < x.start + x.duration); // hidden while it cuts that clip
3276
3348
  for (const c of project.clips) {
3277
3349
  const tr = trackOf(c); if (!tr) continue;
3278
3350
  const row = els.tracks.querySelector(`[data-track="${c.track}"]`);
@@ -3280,7 +3352,7 @@ function rebuildClips() {
3280
3352
  div.className = `clip c-${c.kind}` +
3281
3353
  (state.selIds.has(c.id) ? " selected" : "") + (c.id === state.selId ? " primary" : "") +
3282
3354
  (isClipLocked(c) ? " locked" : "") + (c.disabled === true ? " disabled" : "") +
3283
- (c.unlinked === true ? " unlinked" : "");
3355
+ (c.unlinked === true ? " unlinked" : "") + (c.kind !== "audio" && isMatte(c) ? " matte-src" : "");
3284
3356
  div.dataset.id = c.id;
3285
3357
  div.style.left = c.start * state.pps + "px";
3286
3358
  div.style.width = Math.max(8, c.duration * state.pps) + "px";
@@ -3292,17 +3364,27 @@ function rebuildClips() {
3292
3364
  const hasWave = c.kind === "audio" && !!wavePeaksFor(c);
3293
3365
  if (hasWave) body += `<canvas class="wave"></canvas>`;
3294
3366
  const badge = (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") +
3295
- (c.transitionIn || c.transitionOut ? "⇄ " : "");
3367
+ (c.transitionIn || c.transitionOut ? "⇄ " : "") +
3368
+ (clipMatte(c) ? "◐ " : "");
3296
3369
  const chTag = (() => {
3297
3370
  const s = audioChannelShort(c.props?.audioChannel);
3298
3371
  return s ? s + " · " : "";
3299
3372
  })();
3300
3373
  const label = c.kind === "text" ? "T · " + (c.props.text || "").split("\n")[0]
3301
3374
  : c.kind === "adjust" ? "FX · " + (c.name || "")
3375
+ : c.kind === "shape" ? "◆ · " + (c.name || "")
3376
+ : c.kind === "null" ? "✥ · " + (c.name || "")
3377
+ : c.kind === "comp" ? "▣ · " + ((getComp(c.compId) || {}).name || c.name || "missing comp")
3302
3378
  : c.kind === "audio" ? chTag + (c.name || "")
3303
3379
  : (c.name || "");
3304
3380
  body += `<div class="fade"></div>
3305
3381
  <div class="clip-label">${badge}${escapeHtml(label)}</div>`;
3382
+ if (c.kind === "comp") {
3383
+ // say on hover how to get inside (wide enough clips show it in the clip, every one in its tooltip)
3384
+ const comp = getComp(c.compId);
3385
+ div.title = comp ? `${comp.template ? "Template" : "Composition"} "${comp.name}" — double-click to open and edit${comp.template ? " · its fields are in the Inspector" : ""}` : "Its composition is missing";
3386
+ if (comp && c.duration * state.pps >= label.length * 7 + 190) body += `<div class="comp-open-hint">Double-click to open</div>`;
3387
+ }
3306
3388
  body += clipKeyframesHtml(c);
3307
3389
  let inner = `<div class="clip-body">${body}</div>`;
3308
3390
  inner += transitionMarksHtml(c, tr.h);
@@ -3904,6 +3986,9 @@ function startTrimToolGesture(e, c, mode) {
3904
3986
  const base = new Map(project.clips.map((x) => [x.id, {
3905
3987
  start: x.start, in: x.in, duration: x.duration, keyframes: x.keyframes,
3906
3988
  transitionIn: cloneTr(x.transitionIn), transitionOut: cloneTr(x.transitionOut),
3989
+ masks: x.props && x.props.masks, // head trims re-base mask keys: each step starts from the originals
3990
+ tracks: x.props && x.props.tracks,
3991
+ shapes: x.props && x.props.shapes,
3907
3992
  }]));
3908
3993
  const restore = () => {
3909
3994
  for (const x of project.clips) {
@@ -3911,6 +3996,11 @@ function startTrimToolGesture(e, c, mode) {
3911
3996
  if (!b) continue;
3912
3997
  x.start = b.start; x.in = b.in; x.duration = b.duration; x.keyframes = b.keyframes;
3913
3998
  x.transitionIn = cloneTr(b.transitionIn); x.transitionOut = cloneTr(b.transitionOut);
3999
+ if (x.props) {
4000
+ if (b.masks) x.props.masks = b.masks; else delete x.props.masks;
4001
+ if (b.tracks) x.props.tracks = b.tracks; else delete x.props.tracks;
4002
+ if (b.shapes) x.props.shapes = b.shapes; else delete x.props.shapes;
4003
+ }
3914
4004
  }
3915
4005
  };
3916
4006
  // Snap targets ignore everything this edit moves.
@@ -4041,6 +4131,7 @@ els.tracksContent.addEventListener("dblclick", (e) => {
4041
4131
  }
4042
4132
  if (e.target.closest(".handle, .trans-mark, .trans-dur-handle, .clip-kf, .fade-grip, .vol-hit")) return;
4043
4133
  e.preventDefault();
4134
+ if (c.kind === "comp") { enterComp(c.compId, c); return; } // a comp opens on the timeline
4044
4135
  loadSourceFromClip(c, { timelineTime: timeAtEvent(e) });
4045
4136
  });
4046
4137
 
@@ -4092,6 +4183,9 @@ function startClipGesture(e, c, mode, collapseOnClick) {
4092
4183
  const orig = {
4093
4184
  start: c.start, in: c.in, duration: c.duration, track: c.track,
4094
4185
  keyframes: c.keyframes ? JSON.parse(JSON.stringify(c.keyframes)) : undefined,
4186
+ masks: c.props.masks,
4187
+ tracks: c.props.tracks,
4188
+ shapes: c.props.shapes,
4095
4189
  };
4096
4190
  // moving a clip that belongs to a multi-selection drags the whole group;
4097
4191
  // AV-linked partners (video+audio from one file) always move together
@@ -4139,19 +4233,23 @@ function startClipGesture(e, c, mode, collapseOnClick) {
4139
4233
  } else if (mode === "trim-l") {
4140
4234
  let ns = snapTime(orig.start + dt, groupIds);
4141
4235
  const sp = clipSpeed(c);
4142
- const maxShiftLeft = (c.kind === "video" || c.kind === "audio") ? orig.in / sp : 1e6;
4236
+ const maxShiftLeft = FableCutEdit.hasSourceTime(c) ? orig.in / sp : 1e6;
4143
4237
  ns = clamp(ns, Math.max(0, orig.start - maxShiftLeft), orig.start + orig.duration - MIN_DUR);
4144
4238
  const d = ns - orig.start;
4145
4239
  c.start = ns;
4146
- c.in = (c.kind === "video" || c.kind === "audio") ? orig.in + d * sp : 0;
4240
+ c.in = FableCutEdit.hasSourceTime(c) ? orig.in + d * sp : 0;
4147
4241
  c.duration = orig.duration - d;
4148
4242
  c.keyframes = shiftKF(orig.keyframes, d, c.duration);
4243
+ if (orig.masks) c.props.masks = Mask.shiftKeys(orig.masks, d);
4244
+ if (orig.shapes) c.props.shapes = Motion.shiftKeys(orig.shapes, d);
4245
+ if (orig.tracks) { const tr = Tracker.shiftTracks(orig.tracks, d); if (tr.length) c.props.tracks = tr; else delete c.props.tracks; }
4149
4246
  syncLinkedTiming(c);
4150
4247
  } else { // trim-r
4151
4248
  let ne = snapTime(orig.start + orig.duration + dt, groupIds);
4152
4249
  let maxDur = 1e6;
4153
- if ((c.kind === "video" || c.kind === "audio") && media?.duration)
4154
- maxDur = (media.duration - orig.in) / clipSpeed(c);
4250
+ const srcLen = c.kind === "comp" ? (getComp(c.compId) || {}).duration : media?.duration;
4251
+ if (FableCutEdit.hasSourceTime(c) && srcLen)
4252
+ maxDur = Math.max(MIN_DUR, (srcLen - orig.in) / clipSpeed(c));
4155
4253
  c.duration = clamp(ne - orig.start, MIN_DUR, maxDur);
4156
4254
  c.keyframes = shiftKF(orig.keyframes, 0, c.duration);
4157
4255
  syncLinkedTiming(c);
@@ -4610,7 +4708,7 @@ function updateWorkArea() {
4610
4708
 
4611
4709
  /* ── Drag & drop: bin → timeline, files → window ── */
4612
4710
  els.timelineScroll.addEventListener("dragover", (e) => {
4613
- if (e.dataTransfer.types.includes("text/fablecut-media")) {
4711
+ if (["text/fablecut-media", "text/fablecut-comp", "text/fablecut-template"].some((t) => e.dataTransfer.types.includes(t))) {
4614
4712
  e.preventDefault();
4615
4713
  e.dataTransfer.dropEffect = "copy";
4616
4714
  for (const row of els.tracks.children)
@@ -4622,6 +4720,10 @@ els.timelineScroll.addEventListener("dragleave", () => {
4622
4720
  });
4623
4721
  els.timelineScroll.addEventListener("drop", (e) => {
4624
4722
  for (const row of els.tracks.children) row.classList.remove("drop-hint");
4723
+ const cid = e.dataTransfer.getData("text/fablecut-comp");
4724
+ if (cid) { e.preventDefault(); const comp = getComp(cid); if (comp) addCompClip(comp, trackAtEvent(e), snapTime(timeAtEvent(e), null)); return; }
4725
+ const tid = e.dataTransfer.getData("text/fablecut-template");
4726
+ if (tid) { e.preventDefault(); const t = findTemplate(tid); if (t) addTemplateClip(t, trackAtEvent(e), snapTime(timeAtEvent(e), null)); return; }
4625
4727
  const mid = e.dataTransfer.getData("text/fablecut-media");
4626
4728
  if (!mid) return;
4627
4729
  e.preventDefault();
@@ -4848,7 +4950,7 @@ function renderInspector(lite) {
4848
4950
  let html = (state.selIds.size > 1
4849
4951
  ? `<div class="insp-multi">${state.selIds.size} clips selected — drag moves them together, Del deletes all. Fields below edit the primary (white-outlined) clip.</div>`
4850
4952
  : "") + clipStatusBar(c) + `<div class="insp-section"><h3>Clip — ${c.kind}</h3>
4851
- ${row("Name", `<input type="text" data-k="name" value="${c.name.replace(/"/g, "&quot;")}">`)}
4953
+ ${row("Name", `<input type="text" data-k="name" value="${escapeHtml(c.name || "")}">`)}
4852
4954
  ${c.mediaId ? row("Source", `<button type="button" class="btn tiny style-picker-btn" data-media-open title="Replace this clip's media — keeps position, trim, keyframes and effects">${escapeHtml((getMedia(c.mediaId) || {}).name || "Missing media")} ▾</button>`) : ""}
4853
4955
  ${row("Start (s)", `<input type="number" data-k="start" step="0.01" value="${c.start.toFixed(2)}">`)}
4854
4956
  ${row("Length (s)", `<input type="number" data-k="duration" step="0.01" value="${c.duration.toFixed(2)}">`)}
@@ -4856,9 +4958,28 @@ function renderInspector(lite) {
4856
4958
  const sel = (label, k, opts, cur) => row(label,
4857
4959
  `<select data-k="${k}">${opts.map((o) => `<option value="${o}" ${String(o) === String(cur) ? "selected" : ""}>${o}</option>`).join("")}</select>`, k);
4858
4960
  const check = (label, k, on) => row(label, `<input type="checkbox" data-k="${k}" ${on ? "checked" : ""}>`, k);
4961
+ // track matte: the mode, and the track it comes from (auto = the video track above)
4962
+ const matteRows = () => {
4963
+ const others = videoTrackIds().filter((id) => id !== c.track), auto = Mask.matteTrackFor({ ...c, props: { matte: "alpha" } }, videoTrackIds());
4964
+ const opts = (cur) => [`<option value="" ${!cur ? "selected" : ""}>Above (${auto || "none"})</option>`, ...others.map((id) => `<option value="${id}" ${cur === id ? "selected" : ""}>${id}</option>`)].join("");
4965
+ 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")
4966
+ + (p.matte ? row("Matte track", `<select data-k="matteTrack">${opts(p.matteTrack)}</select>`, "matteTrack")
4967
+ + (clipMatte(c) ? "" : `<div class="insp-note">No video track above to take the matte from — pick one.</div>`) : "");
4968
+ };
4859
4969
  if (c.kind === "adjust") {
4860
4970
  html += `<div class="insp-section"><h3>Adjustment layer</h3>
4861
4971
  ${slider("opacity", 0, 1, 0.01, p.opacity)}
4972
+ ${matteRows()}
4973
+ </div>`;
4974
+ } else if (c.kind === "null") {
4975
+ html += `<div class="insp-section"><h3>Null object</h3>
4976
+ <p class="cl-note">A null never shows in the picture. Parent clips to it (their Parent field), then move, turn or scale the null to move them together.</p>
4977
+ ${row("Position X", `<input type="number" data-k="x" value="${fmtInspNum(p.x)}">`, "x")}
4978
+ ${row("Position Y", `<input type="number" data-k="y" value="${fmtInspNum(p.y)}">`, "y")}
4979
+ ${slider("scale", 0.1, 4, 0.01, p.scale)}
4980
+ ${slider("rotation", -180, 180, 1, p.rotation, "°")}
4981
+ ${anchorRowsHtml(c, p, row)}
4982
+ ${parentRowHtml(c)}
4862
4983
  </div>`;
4863
4984
  } else if (c.kind !== "audio") {
4864
4985
  html += `<div class="insp-section"><h3>Transform</h3>
@@ -4867,10 +4988,15 @@ function renderInspector(lite) {
4867
4988
  ${slider("scale", 0.1, 4, 0.01, p.scale)}
4868
4989
  ${slider("rotation", -180, 180, 1, p.rotation, "°")}
4869
4990
  ${slider("opacity", 0, 1, 0.01, p.opacity)}
4991
+ ${anchorRowsHtml(c, p, row)}
4992
+ ${parentRowHtml(c)}
4870
4993
  ${sel("Blend", "blend", BLEND_MODES, p.blend)}
4994
+ ${matteRows()}
4871
4995
  </div>`;
4872
4996
  }
4873
- if (c.kind === "video" || c.kind === "image" || c.kind === "svg") {
4997
+ if (c.kind === "shape") html += shapeSectionHtml(c, p, row) + `<div class="insp-section"><h3>Look</h3>${slider("blur", 0, 40, 0.5, p.blur, "px")}${slider("shake", 0, 40, 0.5, p.shake, "px")}${slider("shakeSpeed", 1, 30, 0.5, p.shakeSpeed)}</div>`;
4998
+ if (c.kind === "comp") html += templateSectionHtml(c, row) + compSectionHtml(c, row);
4999
+ if (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "comp") {
4874
5000
  html += `<div class="insp-section"><h3>Layout</h3>
4875
5001
  ${sel("Fit", "fit", ["contain", "cover", "stretch", "none"], p.fit)}
4876
5002
  ${row("Crop L/R %", `<input type="number" data-k="cropL" min="0" max="95" value="${p.cropL}" style="max-width:58px">
@@ -4882,7 +5008,7 @@ function renderInspector(lite) {
4882
5008
  ${check("Flip V", "flipV", p.flipV)}
4883
5009
  </div>`;
4884
5010
  }
4885
- if (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust") {
5011
+ if (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust" || c.kind === "comp") {
4886
5012
  html += `<div class="insp-section"><h3>Filter / Color</h3>
4887
5013
  ${sel("Preset", "filterPreset", Object.keys(FILTER_PRESETS), p.filterPreset)}
4888
5014
  ${slider("brightness", 0, 200, 1, p.brightness, "%")}
@@ -4958,6 +5084,10 @@ function renderInspector(lite) {
4958
5084
  const curveSel = (label, k, tr) => tr?.type === "fade" && c.kind === "audio"
4959
5085
  ? row(label, `<select data-k="${k}" title="Fade shape (audio)">${[["", "Smooth (eased)"], ...Object.entries(AUDIO_FADE_CURVES)].map(([v, l]) =>
4960
5086
  `<option value="${v}" ${(tr.curve || "") === v ? "selected" : ""}>${l}</option>`).join("")}</select>`) : "";
5087
+ if (c.kind === "text") html += textPathSectionHtml(c, p, row);
5088
+ if (isVisualClip(c)) html += motionSectionHtml(c, p, row);
5089
+ if (c.kind !== "audio" && c.kind !== "null") html += maskSectionHtml(c);
5090
+ if (c.kind === "video") html += trackSectionHtml(c);
4961
5091
  html += `<div class="insp-section"><h3>Transition</h3>
4962
5092
  ${tsel("In", "transIn", c.transitionIn)}
4963
5093
  ${curveSel("In curve", "curveIn", c.transitionIn)}
@@ -5075,6 +5205,12 @@ function renderInspector(lite) {
5075
5205
  if (!setAnimProp(c, k, v))
5076
5206
  toast("Move the playhead over the clip to edit its keyframes");
5077
5207
  }
5208
+ else if (k === "matte" || k === "matteTrack") { // "none" / "" (= the track above) clear the prop
5209
+ if (v === "none" || v === "") delete c.props[k]; else c.props[k] = String(v);
5210
+ if (!c.props.matte) delete c.props.matteTrack;
5211
+ state.dirtyTimeline = true; inspPropGen++;
5212
+ if (k === "matte") renderInspector();
5213
+ }
5078
5214
  else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
5079
5215
  const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
5080
5216
  if (valEl) valEl.textContent = input.value + (valEl.dataset.unit || "");
@@ -5208,6 +5344,15 @@ function renderInspector(lite) {
5208
5344
  toggleKfGraph(lab.dataset.kfgraph);
5209
5345
  });
5210
5346
  });
5347
+ bindMaskControls(c);
5348
+ bindTrackControls(c);
5349
+ bindParentAnchorControls(c);
5350
+ bindShapeControls(c);
5351
+ bindMotionControls(c);
5352
+ bindTextPathControls(c);
5353
+ bindCompControls(c);
5354
+ bindTemplateControls(c);
5355
+ bindPresetControls(c);
5211
5356
  syncInspectorOffClip(c);
5212
5357
  renderKfGraphsPanel();
5213
5358
  }
@@ -5294,6 +5439,8 @@ function syncInspectorPlayhead() {
5294
5439
  const label = "◆" + (n || "");
5295
5440
  if (btn.textContent !== label) btn.textContent = label;
5296
5441
  }
5442
+ syncMaskInspector(c);
5443
+ syncShapeInspector(c);
5297
5444
  syncInspectorOffClip(c); // after the class pass, so ◆ titles read the fresh "on" state
5298
5445
  }
5299
5446
 
@@ -5305,6 +5452,7 @@ const KF_GRAPH_LABEL = {
5305
5452
  invert: "Invert", temperature: "Temp", tint: "Tint", vignette: "Vignette",
5306
5453
  cornerRadius: "Radius", shake: "Shake", rgbSplit: "RGB", grain: "Grain",
5307
5454
  fontSize: "Size", letterSpacing: "Track", glow: "Glow",
5455
+ anchorX: "Anchor X", anchorY: "Anchor Y", trimStart: "Trim start", trimEnd: "Trim end", trimOffset: "Trim offset", pathOffset: "Path offset",
5308
5456
  };
5309
5457
  function toggleKfGraph(key) {
5310
5458
  if (!ANIMATABLE.includes(key)) return;
@@ -5859,8 +6007,8 @@ function loadSourceFromMedia(m, opts = {}) {
5859
6007
  }
5860
6008
  function loadSourceFromClip(c, opts = {}) {
5861
6009
  if (!c || !c.mediaId) {
5862
- if (c && (c.kind === "text" || c.kind === "adjust"))
5863
- toast("Titles and adjustment layers have no source media");
6010
+ if (c && (c.kind === "text" || c.kind === "adjust" || c.kind === "shape" || c.kind === "null" || c.kind === "comp"))
6011
+ toast("Titles, shapes, nulls and adjustment layers have no source media");
5864
6012
  return;
5865
6013
  }
5866
6014
  const m = getMedia(c.mediaId);
@@ -8014,7 +8162,7 @@ function refreshAudioHold() {
8014
8162
  for (const el of runtime.clipEls.values()) { if (!el.paused) el.pause(); }
8015
8163
  for (const chain of runtime.clipGain.values()) muteClipChain(chain);
8016
8164
 
8017
- for (const c of project.clips) {
8165
+ for (const c of mediaClips()) {
8018
8166
  if (c.kind !== "audio" && c.kind !== "video") continue;
8019
8167
  if (!clipRenders(c) || !activeAt(c, t)) continue;
8020
8168
  const m = getMedia(c.mediaId);
@@ -8107,7 +8255,7 @@ function syncMedia() {
8107
8255
  const reversing = state.playing && rate < 0;
8108
8256
  // Timeline lookahead grows with preview rate so wall-clock budget stays ≈VIDEO_PREFETCH_SEC.
8109
8257
  const prefetchTl = VIDEO_PREFETCH_SEC * Math.max(rate, 1);
8110
- for (const c of project.clips) {
8258
+ for (const c of mediaClips()) {
8111
8259
  if (c.kind === "text" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust") continue;
8112
8260
  const el = getClipEl(c); if (!el) continue;
8113
8261
  const enabled = clipRenders(c);
@@ -8150,7 +8298,7 @@ function syncMedia() {
8150
8298
  function seekMediaWhilePaused() {
8151
8299
  if (state.playing) return;
8152
8300
  const t = state.time;
8153
- for (const c of project.clips) {
8301
+ for (const c of mediaClips()) {
8154
8302
  if (c.kind !== "video") continue;
8155
8303
  if (!clipRenders(c)) continue;
8156
8304
  if (!activeAt(c, t)) continue;
@@ -8192,6 +8340,7 @@ function evalProps(c, t) {
8192
8340
  }
8193
8341
  if (typeof v === "number" && !isNaN(v)) p[k] = v;
8194
8342
  }
8343
+ applyMotionPath(c, p, local, true);
8195
8344
  }
8196
8345
  applyFilterPreset(p);
8197
8346
  const W = els.preview.width, H = els.preview.height;
@@ -8488,7 +8637,7 @@ function drawAdjust(c, W, H, t) {
8488
8637
  if (adjScratch.height !== H) adjScratch.height = H;
8489
8638
  const a = adjScratch.getContext("2d");
8490
8639
  a.clearRect(0, 0, W, H);
8491
- a.drawImage(els.preview, 0, 0);
8640
+ a.drawImage(runtime.adjSource || els.preview, 0, 0); // inside a comp: the comp's own layers below
8492
8641
  ctx2d.save();
8493
8642
  ctx2d.setTransform(1, 0, 0, 1, 0, 0);
8494
8643
  if (p.shake > 0) { // whole-frame impact shake
@@ -8517,6 +8666,7 @@ function drawAdjust(c, W, H, t) {
8517
8666
  }
8518
8667
  if (p.grain > 0) drawGrain(p.grain, 0, 0, W, H, t);
8519
8668
  ctx2d.restore();
8669
+ return p;
8520
8670
  }
8521
8671
  function needsPixelPass(p, c) {
8522
8672
  return !!(p.chromaKey || p.temperature || p.tint || p.rgbSplit > 0 ||
@@ -8817,31 +8967,547 @@ function drawFrame(t = state.time) {
8817
8967
  ctx2d.filter = "none"; ctx2d.globalAlpha = 1;
8818
8968
  ctx2d.fillStyle = project.background || "#000"; ctx2d.fillRect(0, 0, W, H);
8819
8969
  // render video tracks bottom-up (V1 under V2)
8820
- for (const c of visibleClipsAt(t)) drawClip(c, W, H, t);
8970
+ if (runtime.maskView) runtime.maskView.ready = false;
8971
+ const clips = visibleClipsAt(t), mattes = matteTracksAt(clips);
8972
+ for (const c of clips) if (!mattes.has(c.track)) drawClip(c, W, H, t); // a matte track only cuts
8973
+ if (!state.exporting && !runtime.sampling) drawMaskView(W, H);
8821
8974
  if (!runtime.sampling) updateScopes(); // before the handles, so they never reach the scopes
8822
8975
  // on-canvas selection handles (never during export or playback)
8823
- if (!state.exporting && !state.playing && !runtime.sampling) drawSelectionOverlay(W, H, t);
8976
+ if (!state.exporting && !state.playing && !runtime.sampling) { drawNullGuides(clips, W, H, t); drawSelectionOverlay(W, H, t); }
8977
+ }
8978
+ /** Nulls never render; the monitor shows where they are (never in export or the scopes). */
8979
+ function drawNullGuides(clips, W, H, t) {
8980
+ for (const c of clips) {
8981
+ if (c.kind !== "null" || c.id === state.selId) continue;
8982
+ drawNullMark(clipBounds(c, evalProps(c, t), W, H), W, 0.45);
8983
+ }
8984
+ }
8985
+ function drawNullMark(b, W, alpha) {
8986
+ const lw = Math.max(1.5, W / 800);
8987
+ ctx2d.save();
8988
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
8989
+ ctx2d.globalAlpha = alpha; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
8990
+ ctx2d.translate(b.cx, b.cy); ctx2d.rotate(b.rot);
8991
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#ff6b6b";
8992
+ ctx2d.strokeRect(-b.hw, -b.hh, b.hw * 2, b.hh * 2);
8993
+ ctx2d.beginPath(); ctx2d.moveTo(-b.hw * 0.4, 0); ctx2d.lineTo(b.hw * 0.4, 0); ctx2d.moveTo(0, -b.hh * 0.4); ctx2d.lineTo(0, b.hh * 0.4); ctx2d.stroke();
8994
+ ctx2d.restore();
8995
+ }
8996
+ /** Where the clip's anchor point is on screen (its x / y in its parents' space). */
8997
+ function anchorScreenPt(c, p, W, H, t = state.time) {
8998
+ const q = parentScreenMatrix(c, W, H, t).transformPoint(new DOMPoint(+p.x || 0, +p.y || 0));
8999
+ return { x: q.x, y: q.y };
8824
9000
  }
8825
9001
 
8826
9002
  /* ═══════════ Direct manipulation on the program monitor ═══════════
8827
9003
  Drag a clip to move it, corner handles to resize (scale), the top
8828
9004
  handle to rotate. Maps gestures straight onto props.x/y/scale/rotation. */
8829
- function clipBounds(c, p, W, H) {
8830
- const cx = W / 2 + (+p.x || 0), cy = H / 2 + (+p.y || 0);
8831
- const rot = (p.rotation || 0) * Math.PI / 180, sc = +p.scale || 1;
8832
- let hw, hh;
9005
+ function clipBounds(c, p, W, H, t = state.time) {
9006
+ const box = layerBox(c, p, W, H, t), m = layerScreenMatrix(c, p, W, H, t);
9007
+ const o = m.transformPoint(new DOMPoint(box.ox, box.oy)), k = Math.hypot(m.a, m.b) || 1;
9008
+ return { cx: o.x, cy: o.y, hw: box.hw * k, hh: box.hh * k, rot: Math.atan2(m.b, m.a) };
9009
+ }
9010
+ /** The picture's box in its drawn space (content scale applied): centre offset and half size. */
9011
+ const NULL_HALF = 40;
9012
+ function layerBox(c, p, W, H, t) {
9013
+ const sc = +p.scale || 1;
8833
9014
  if (c.kind === "text") {
8834
- if (hasTextBox(p)) {
8835
- hw = +p.boxW / 2; hh = +p.boxH / 2;
8836
- } else {
8837
- const half = measureTextHalfSize(p);
8838
- hw = half.hw; hh = half.hh;
9015
+ const tb = p.textPath ? textPathBox(c, p, t) : null;
9016
+ if (tb) return tb;
9017
+ if (hasTextBox(p)) return { ox: 0, oy: 0, hw: +p.boxW / 2, hh: +p.boxH / 2 };
9018
+ const half = measureTextHalfSize(p);
9019
+ return { ox: 0, oy: 0, hw: half.hw, hh: half.hh };
9020
+ }
9021
+ if (c.kind === "null") return { ox: 0, oy: 0, hw: NULL_HALF * sc, hh: NULL_HALF * sc };
9022
+ if (c.kind === "shape") {
9023
+ const b = Motion.bounds(shapeItemsAt(c, t));
9024
+ if (!b) return { ox: 0, oy: 0, hw: NULL_HALF * sc, hh: NULL_HALF * sc };
9025
+ return { ox: (b.x0 + b.x1) / 2 * sc, oy: (b.y0 + b.y1) / 2 * sc, hw: Math.max(4, (b.x1 - b.x0) / 2) * sc, hh: Math.max(4, (b.y1 - b.y0) / 2) * sc };
9026
+ }
9027
+ if (c.kind === "comp") { // a comp clip's box is the comp's own size (fit none) or fitted like a picture
9028
+ const comp = getComp(c.compId);
9029
+ if (comp) {
9030
+ const cw = comp.width, ch = comp.height;
9031
+ const f = p.fit === "none" ? 1 : p.fit === "cover" ? Math.max(W / cw, H / ch) : p.fit === "stretch" ? null : Math.min(W / cw, H / ch);
9032
+ return f == null ? { ox: 0, oy: 0, hw: (W / 2) * sc, hh: (H / 2) * sc } : { ox: 0, oy: 0, hw: cw * f / 2 * sc, hh: ch * f / 2 * sc };
9033
+ }
9034
+ }
9035
+ return { ox: 0, oy: 0, hw: (W / 2) * sc, hh: (H / 2) * sc }; // media/svg: canvas-sized base box, scaled
9036
+ }
9037
+ /* ── Layer transforms (motion.js): the anchor point and parenting ──
9038
+ A clip draws at canvas centre · its parents · translate(x, y) · rotate ·
9039
+ (its picture at its scale, shifted so the anchor point sits at x / y). */
9040
+ const Motion = self.FableCutMotion;
9041
+ function parentM(c, t) { return c.parent ? Motion.parentMatrix(c, anyClip, t) : null; }
9042
+ /** Canvas centre and the parents: the space a clip's x / y live in. */
9043
+ function parentScreenMatrix(c, W, H, t) {
9044
+ const m = new DOMMatrix().translate(W / 2, H / 2), pm = parentM(c, t);
9045
+ return pm ? m.multiply(new DOMMatrix(pm)) : m;
9046
+ }
9047
+ /** Canvas px of the clip's drawn space, before its content scale (shake left out). */
9048
+ function layerScreenMatrix(c, p, W, H, t) {
9049
+ const as = Motion.contentScale(c, p);
9050
+ return parentScreenMatrix(c, W, H, t).translate(+p.x || 0, +p.y || 0).rotate(+p.rotation || 0)
9051
+ .translate(-(+p.anchorX || 0) * as, -(+p.anchorY || 0) * as);
9052
+ }
9053
+ /** How much the clip's parents scale it (1 without a parent). */
9054
+ function parentScaleAt(c, t = state.time) { const m = parentScreenMatrix(c, 0, 0, t); return Math.hypot(m.a, m.b) || 1; }
9055
+ /** The x / y that put the centre of the clip's (unshifted) picture at screen (sx, sy). */
9056
+ function posForScreenCentre(c, p, sx, sy, W, H, t = state.time) {
9057
+ const q = parentScreenMatrix(c, W, H, t).inverse().transformPoint(new DOMPoint(sx, sy));
9058
+ const th = (+p.rotation || 0) * Math.PI / 180, as = Motion.contentScale(c, p), ax = (+p.anchorX || 0) * as, ay = (+p.anchorY || 0) * as;
9059
+ return { x: q.x + ax * Math.cos(th) - ay * Math.sin(th), y: q.y + ax * Math.sin(th) + ay * Math.cos(th) };
9060
+ }
9061
+ /** A screen-space drag delta in the clip's x / y space (its parents undone). */
9062
+ function screenDeltaToParent(c, dx, dy, t = state.time) {
9063
+ const m = parentScreenMatrix(c, els.preview.width, els.preview.height, t), det = m.a * m.d - m.b * m.c || 1;
9064
+ return { x: (m.d * dx - m.c * dy) / det, y: (-m.b * dx + m.a * dy) / det };
9065
+ }
9066
+ /* ═══════════ COMPOSITIONS (comp.js) ═══════════
9067
+ project.comps holds nested sequences; a "comp" clip places one. The media
9068
+ inside comps is flattened into virtual clips (mediaClips) so playback,
9069
+ export seeking and the audio mix treat it like timeline media; the comp
9070
+ clip draws its comp's frame like a picture (renderComp). Opening a comp
9071
+ loads it as a stand-in project, so the whole timeline edits it; saves write
9072
+ it back into the root document. */
9073
+ const CompLib = self.FableCutComp;
9074
+ function compCtxId() { const x = state.compCtx; return x ? x.path[x.path.length - 1] : null; }
9075
+ function getComp(id) { return CompLib.getComp(project, id); }
9076
+ let flatMemo = null;
9077
+ /** Timeline clips plus the clips every comp clip brings in (virtual, at timeline times). */
9078
+ function mediaClips() {
9079
+ const now = performance.now();
9080
+ if (flatMemo && flatMemo.clips === project.clips && flatMemo.comps === project.comps && flatMemo.gen === runtime.compGen && now - flatMemo.at < 4) return flatMemo.list;
9081
+ const virt = project.comps && project.comps.length && project.clips.some((c) => c.kind === "comp")
9082
+ ? CompLib.flatten(project, project.clips, { inComp: compCtxId(), renders: (c) => clipRenders(c) }) : [];
9083
+ const list = virt.length ? project.clips.concat(virt) : project.clips;
9084
+ flatMemo = { at: now, clips: project.clips, comps: project.comps, gen: runtime.compGen, list, virt, byId: new Map(virt.map((v) => [v.id, v])) };
9085
+ return list;
9086
+ }
9087
+ function virtualClip(id) { mediaClips(); return (flatMemo && flatMemo.byId.get(id)) || null; }
9088
+ /** The clips a comp clip (or virtual comp clip) shows directly. */
9089
+ function compChildren(c) { mediaClips(); return flatMemo ? flatMemo.virt.filter((v) => v._comp === c.id) : []; }
9090
+ /** The comp's frame at timeline time t, as a canvas of the comp's size (null when it has none). */
9091
+ function renderComp(c, t) {
9092
+ const comp = getComp(c.compId);
9093
+ const depth = (runtime.compDepth || 0) + 1;
9094
+ if (!comp || depth > CompLib.MAX_DEPTH) return null;
9095
+ const W2 = comp.width, H2 = comp.height;
9096
+ const prev = { ctx: ctx2d, scope: runtime.scope, depth: runtime.compDepth, adj: runtime.adjSource };
9097
+ runtime.compDepth = depth; // every scratch buffer below gets this depth's own copy
9098
+ const cv = maskCtx("comp", W2, H2);
9099
+ cv.setTransform(1, 0, 0, 1, 0, 0); cv.globalAlpha = 1; cv.globalCompositeOperation = "source-over"; cv.filter = "none";
9100
+ cv.clearRect(0, 0, W2, H2);
9101
+ if (comp.background) { cv.fillStyle = comp.background; cv.fillRect(0, 0, W2, H2); }
9102
+ const kids = compChildren(c), order = CompLib.drawOrder(comp).video;
9103
+ const on = kids.filter((v) => v._on && v.kind !== "audio" && activeAt(v, t))
9104
+ .sort((a, b) => (order.indexOf(a.track) - order.indexOf(b.track)) || (a.start - b.start));
9105
+ runtime.scope = { clips: kids, videoTracks: order };
9106
+ ctx2d = cv; runtime.adjSource = cv.canvas;
9107
+ try {
9108
+ const mattes = matteTracksAt(on);
9109
+ for (const v of on) if (!mattes.has(v.track)) drawClip(v, W2, H2, t);
9110
+ } finally { ctx2d = prev.ctx; runtime.scope = prev.scope; runtime.compDepth = prev.depth; runtime.adjSource = prev.adj; }
9111
+ return cv.canvas;
9112
+ }
9113
+ const anyClip = (id) => getClip(id) || virtualClip(id);
9114
+
9115
+ /* ── Opening a comp: it becomes the timeline until you go back ── */
9116
+ function compSyntheticDoc(root, path) {
9117
+ const comp = CompLib.getComp(root, path[path.length - 1]);
9118
+ if (!comp) return null;
9119
+ const names = path.map((id) => (CompLib.getComp(root, id) || { name: id }).name);
9120
+ return {
9121
+ ...root, name: `${root.name} › ${names.join(" › ")}`,
9122
+ width: comp.width, height: comp.height, background: comp.background || "#000000",
9123
+ clips: comp.clips, tracks: CompLib.compTracks(comp), markers: comp.markers || [], inPoint: null, outPoint: null,
9124
+ disabledTracks: comp.disabledTracks || [], lockedTracks: [], untargetedTracks: [], exportFrame: null,
9125
+ };
9126
+ }
9127
+ /** Write the open comp's clips and tracks back into the root document (and return it). */
9128
+ function commitCompCtx() {
9129
+ const x = state.compCtx;
9130
+ if (!x) return null;
9131
+ const cur = projectJSONHere();
9132
+ const id = compCtxId();
9133
+ const comps = (cur.comps || []).map((c) => (c.id === id
9134
+ ? { ...c, clips: cur.clips, tracks: cur.tracks, disabledTracks: cur.disabledTracks && cur.disabledTracks.length ? cur.disabledTracks : undefined, markers: cur.markers && cur.markers.length ? cur.markers : undefined, background: c.background }
9135
+ : c));
9136
+ x.root = { ...x.root, media: cur.media, folders: cur.folders, fps: cur.fps, master: cur.master, buses: cur.buses, revision: cur.revision, comps };
9137
+ return x.root;
9138
+ }
9139
+ function enterComp(compId, viaClip) {
9140
+ if (state.exporting) return;
9141
+ if (state.playing) pause();
9142
+ const comp = getComp(compId);
9143
+ if (!comp) { toast("That composition is missing"); return; }
9144
+ const here = compCtxId();
9145
+ if (here && CompLib.wouldCycle(state.compCtx.root, here, compId) && here === compId) return;
9146
+ const root = state.compCtx ? commitCompCtx() : projectJSONHere();
9147
+ const t = viaClip && activeAt(viaClip, state.time) ? state.time - viaClip.start + (viaClip.in || 0) : 0;
9148
+ const frame = { undo: runtime.undo.slice(), redo: runtime.redo.slice(), time: state.time, sel: state.selId };
9149
+ state.compCtx = { root, path: [...(state.compCtx ? state.compCtx.path : []), compId], frames: [...(state.compCtx ? state.compCtx.frames : []), frame] };
9150
+ runtime.undo.length = 0; runtime.redo.length = 0;
9151
+ applyProject(compSyntheticDoc(root, state.compCtx.path), { ctx: true });
9152
+ setSelection([]);
9153
+ setTime(clamp(t, 0, comp.duration));
9154
+ syncCompCrumb();
9155
+ }
9156
+ function exitComp(levels = 1) {
9157
+ const x = state.compCtx;
9158
+ if (!x) return;
9159
+ const root = commitCompCtx();
9160
+ let frame = null;
9161
+ for (let i = 0; i < levels && x.path.length; i++) { x.path.pop(); frame = x.frames.pop(); }
9162
+ if (!x.path.length) { state.compCtx = null; applyProject(root, { ctx: true }); }
9163
+ else applyProject(compSyntheticDoc(root, x.path), { ctx: true });
9164
+ if (frame) {
9165
+ runtime.undo.splice(0, runtime.undo.length, ...frame.undo); runtime.redo.splice(0, runtime.redo.length, ...frame.redo);
9166
+ setSelection(frame.sel && getClip(frame.sel) ? [frame.sel] : []);
9167
+ setTime(frame.time);
9168
+ }
9169
+ syncCompCrumb();
9170
+ scheduleSave();
9171
+ }
9172
+ function syncCompCrumb() {
9173
+ if (els.projectName) els.projectName.textContent = project.name + (state.connected ? " · 🟢 connected" : " · ⚪ local session");
9174
+ const el = document.getElementById("compCrumb");
9175
+ if (!el) return;
9176
+ const x = state.compCtx;
9177
+ el.classList.toggle("hidden", !x);
9178
+ if (!x) { el.innerHTML = ""; return; }
9179
+ const names = x.path.map((id) => (CompLib.getComp(x.root, id) || { name: id }).name);
9180
+ el.innerHTML = `<button type="button" class="btn tiny" data-crumb="${x.path.length}" title="Back to the main timeline">◀ Main</button>` +
9181
+ names.map((n, i) => `<span class="crumb-sep">›</span>` + (i < names.length - 1
9182
+ ? `<button type="button" class="btn tiny" data-crumb="${x.path.length - 1 - i}">${escapeHtml(n)}</button>`
9183
+ : `<b title="You are editing this composition — Esc or ◀ Main goes back">${escapeHtml(n)}</b>`)).join("");
9184
+ for (const b of el.querySelectorAll("[data-crumb]")) b.addEventListener("click", () => exitComp(+b.dataset.crumb));
9185
+ }
9186
+
9187
+ /* ── Making comps ── */
9188
+ function newCompId() { let id; do id = "comp_" + uid(); while (getComp(id)); return id; }
9189
+ /** Precompose the selection: the clips move into a new comp, a comp clip takes their place. */
9190
+ function precomposeSelection() {
9191
+ const sel = withLinked(selectedClips());
9192
+ if (!sel.length) { toast("Select the clips to put in a composition"); return; }
9193
+ if (sel.some(isClipLocked)) { toastLocked(); return; }
9194
+ pushUndo();
9195
+ const doc = { clips: project.clips, comps: project.comps || [], tracks: project.tracks, width: project.width, height: project.height, disabledTracks: project.disabledTracks || [] };
9196
+ const { comp, clip } = CompLib.precompose(doc, sel.map((c) => c.id), { id: newCompId(), name: "Comp " + ((project.comps || []).length + 1) });
9197
+ project.clips = doc.clips; project.comps = doc.comps;
9198
+ clip.id = "c_" + uid(); clip.props = { ...DEFAULT_PROPS, fit: "none" };
9199
+ for (const c of comp.clips) c.props = { ...DEFAULT_PROPS, ...(c.props || {}) };
9200
+ runtime.compGen = (runtime.compGen || 0) + 1;
9201
+ setSelection([clip.id]);
9202
+ state.dirtyTimeline = true;
9203
+ scheduleSave(); renderBin(); renderInspector();
9204
+ toast(`${sel.length} clip${sel.length > 1 ? "s" : ""} now live in "${comp.name}" — double-click it to open`);
9205
+ }
9206
+ function addNewComp() {
9207
+ project.comps = project.comps || [];
9208
+ pushUndo();
9209
+ const comp = { id: newCompId(), name: "Comp " + (project.comps.length + 1), width: project.width, height: project.height, duration: 5, clips: [], tracks: CompLib.DEFAULT_TRACKS.map((t) => ({ ...t })) };
9210
+ project.comps.push(comp);
9211
+ runtime.compGen = (runtime.compGen || 0) + 1;
9212
+ scheduleSave(); renderBin();
9213
+ return comp;
9214
+ }
9215
+ function addCompClip(comp, trackId, at) {
9216
+ const track = trackId && TRACKS.some((t) => t.id === trackId && t.kind === "video") ? trackId : "V1";
9217
+ if (isTrackLocked(track)) { toast(`${track} is locked`); return; }
9218
+ const here = compCtxId();
9219
+ if (here && CompLib.wouldCycle(state.compCtx ? state.compCtx.root : project, here, comp.id)) { toast("A composition can't contain itself"); return; }
9220
+ pushUndo();
9221
+ const c = { id: "c_" + uid(), mediaId: null, kind: "comp", compId: comp.id, track, start: Math.max(0, at), in: 0, duration: comp.duration, name: comp.name, props: { ...DEFAULT_PROPS, fit: "none" } };
9222
+ project.clips.push(c);
9223
+ runtime.compGen = (runtime.compGen || 0) + 1;
9224
+ selectClip(c.id); scheduleSave(); renderInspector();
9225
+ }
9226
+ /** The Project tab's compositions: drag one onto the timeline, double-click to open it. */
9227
+ function makeCompItem(comp) {
9228
+ const item = document.createElement("div");
9229
+ item.className = "bin-item comp-item";
9230
+ item.draggable = true;
9231
+ const used = CompLib.usages(state.compCtx ? state.compCtx.root : project, comp.id).length;
9232
+ item.innerHTML = `<div class="bin-thumb comp-thumb">▣</div>
9233
+ <div class="bin-meta"><div class="bin-name"></div><div class="bin-sub">Composition · ${comp.width}×${comp.height} · ${comp.duration}s${used ? ` · used ${used}×` : ""}</div></div>
9234
+ <button class="btn tiny accent lib-add" title="Add at the playhead">+</button>`;
9235
+ item.querySelector(".bin-name").textContent = comp.name;
9236
+ item.title = "Drag onto the timeline · double-click to open and edit";
9237
+ item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/fablecut-comp", comp.id); e.dataTransfer.effectAllowed = "copy"; });
9238
+ item.addEventListener("dblclick", () => enterComp(comp.id));
9239
+ item.querySelector(".lib-add").addEventListener("click", (e) => { e.stopPropagation(); addCompClip(comp, "V1", state.time); });
9240
+ item.addEventListener("contextmenu", (e) => { e.preventDefault(); showCompMenu(e.clientX, e.clientY, comp); });
9241
+ return item;
9242
+ }
9243
+ function showCompMenu(x, y, comp) {
9244
+ hideTrackCtxMenu();
9245
+ const menu = document.createElement("div");
9246
+ menu.className = "ctx-menu"; menu.id = "trackCtxMenu";
9247
+ const item = (label, run, why) => {
9248
+ const b = document.createElement("button");
9249
+ b.type = "button"; b.className = "ctx-item"; b.innerHTML = `<span>${escapeHtml(label)}</span>`;
9250
+ if (why) { b.disabled = true; b.title = why; } else b.addEventListener("click", () => { hideTrackCtxMenu(); run(); });
9251
+ menu.appendChild(b);
9252
+ };
9253
+ item("Open", () => enterComp(comp.id));
9254
+ item("Duplicate", () => {
9255
+ pushUndo();
9256
+ const copy = JSON.parse(JSON.stringify(comp)); copy.id = newCompId(); copy.name = `${comp.name} copy`.slice(0, 80);
9257
+ project.comps.push(copy); runtime.compGen = (runtime.compGen || 0) + 1; scheduleSave(); renderBin();
9258
+ });
9259
+ // an open comp's live clips are on the timeline, not yet in its entry
9260
+ item("Save as template", () => saveCompAsTemplate(comp.id === compCtxId() ? { ...comp, clips: project.clips, tracks: project.tracks } : comp));
9261
+ const used = CompLib.usages(state.compCtx ? state.compCtx.root : project, comp.id).length;
9262
+ item("Delete", () => {
9263
+ pushUndo();
9264
+ project.comps = project.comps.filter((c) => c.id !== comp.id);
9265
+ runtime.compGen = (runtime.compGen || 0) + 1; scheduleSave(); renderBin();
9266
+ }, used ? `Used ${used}× — remove its clips first` : state.compCtx && state.compCtx.path.includes(comp.id) ? "It is open" : null);
9267
+ document.body.appendChild(menu);
9268
+ trackCtxMenu = menu;
9269
+ menu.style.left = Math.max(6, Math.min(x, innerWidth - menu.offsetWidth - 6)) + "px";
9270
+ menu.style.top = Math.max(6, Math.min(y, innerHeight - menu.offsetHeight - 6)) + "px";
9271
+ }
9272
+ function compSectionHtml(c, row) {
9273
+ const comp = getComp(c.compId);
9274
+ if (!comp) return `<div class="insp-section"><h3>Composition</h3><p class="cl-note">Its composition (${escapeHtml(c.compId || "?")}) is missing.</p></div>`;
9275
+ return `<div class="insp-section" data-comp-section><h3>Composition</h3>
9276
+ ${row("Comp", `<span class="insp-ctrls"><input type="text" data-comp-name value="${escapeHtml(comp.name)}" maxlength="80" title="The composition's name (every clip using it shows it)"> <button type="button" class="btn tiny" data-comp-open title="Open it on the timeline (or double-click the clip)">Open</button></span>`)}
9277
+ ${row("Size", `<span class="insp-ctrls"><input type="number" data-comp-w min="16" max="8192" value="${comp.width}" style="max-width:72px"> × <input type="number" data-comp-h min="16" max="8192" value="${comp.height}" style="max-width:72px"></span>`)}
9278
+ ${row("Length (s)", `<input type="number" data-comp-dur min="0.05" step="0.01" value="${comp.duration}">`)}
9279
+ ${row("Background", `<span class="insp-ctrls"><input type="color" data-comp-bg value="${comp.background || "#000000"}"><button type="button" class="btn tiny${comp.background ? "" : " toggle on"}" data-comp-bgoff title="Transparent: what is under the comp clip shows through">None</button></span>`)}
9280
+ <p class="cl-note">The clip shows the comp from its In point (${(+c.in || 0).toFixed(2)} s). Every clip using this comp changes when you edit it.</p>
9281
+ </div>`;
9282
+ }
9283
+ function bindCompControls(c) {
9284
+ const root = els.inspector.querySelector("[data-comp-section]");
9285
+ const comp = root && getComp(c.compId);
9286
+ if (!comp) return;
9287
+ const edit = (fn) => { pushUndo(); fn(); runtime.compGen = (runtime.compGen || 0) + 1; state.dirtyTimeline = true; scheduleSave(); renderBin(); renderInspector(); };
9288
+ root.querySelector("[data-comp-open]")?.addEventListener("click", () => enterComp(comp.id, c));
9289
+ root.querySelector("[data-comp-name]")?.addEventListener("change", (e) => { const n = e.target.value.trim(); if (n) edit(() => { comp.name = n.slice(0, 80); }); });
9290
+ root.querySelector("[data-comp-w]")?.addEventListener("change", (e) => { const v = Math.round(+e.target.value); if (v >= 16 && v <= 8192) edit(() => { comp.width = v; }); });
9291
+ root.querySelector("[data-comp-h]")?.addEventListener("change", (e) => { const v = Math.round(+e.target.value); if (v >= 16 && v <= 8192) edit(() => { comp.height = v; }); });
9292
+ root.querySelector("[data-comp-dur]")?.addEventListener("change", (e) => { const v = +e.target.value; if (v > 0.05) edit(() => { comp.duration = +v.toFixed(3); }); });
9293
+ root.querySelector("[data-comp-bg]")?.addEventListener("change", (e) => edit(() => { comp.background = e.target.value; }));
9294
+ root.querySelector("[data-comp-bgoff]")?.addEventListener("click", () => edit(() => { delete comp.background; }));
9295
+ }
9296
+
9297
+ /* ── Animation presets and templates (the data and the maths live in templates.js) ── */
9298
+ const Tpl = self.FableCutTemplates;
9299
+ const savedLib = { presets: [], templates: [] }; // the user's own, from library/presets and library/templates
9300
+ async function fetchSaved(kind) {
9301
+ try { const r = await fetch("/api/" + kind, { cache: "no-store" }); savedLib[kind] = r.ok ? await r.json() : []; }
9302
+ catch { savedLib[kind] = []; }
9303
+ return savedLib[kind];
9304
+ }
9305
+ const findTemplate = (id) => Tpl.findTemplate(id, savedLib.templates);
9306
+ const TEMPLATE_ICONS = {
9307
+ "Lower thirds": '<rect x="2" y="13" width="14" height="5" rx="1" fill="#ffd166"/><rect x="2" y="13" width="2" height="5" fill="#fff"/><rect x="6" y="15" width="7" height="1.4" rx=".7" fill="#111"/>',
9308
+ "Logo animations": '<circle cx="12" cy="11" r="7" fill="none" stroke="#7b6cff" stroke-width="2.2"/><rect x="9" y="8" width="6" height="6" rx="1.5" fill="#ff7ac8"/>',
9309
+ Social: '<rect x="7" y="2.5" width="10" height="19" rx="2" fill="none" stroke="#22d3ee" stroke-width="2"/><rect x="9" y="8" width="6" height="2" rx="1" fill="#fff"/><rect x="9" y="12" width="4" height="1.5" rx=".7" fill="#fff"/>',
9310
+ Saved: '<path d="M6 3h12v18l-6-4-6 4z" fill="#ffd166"/>',
9311
+ };
9312
+ /* The bin's Templates tab: lower thirds, logo animations and social layouts, then the user's own. */
9313
+ function renderTemplatesTab() {
9314
+ els.libList.innerHTML = "";
9315
+ let group = null;
9316
+ for (const t of [...Tpl.TEMPLATES, ...savedLib.templates]) {
9317
+ if (t.group !== group) {
9318
+ group = t.group;
9319
+ const h = document.createElement("div");
9320
+ h.className = "lib-group";
9321
+ h.textContent = group;
9322
+ els.libList.appendChild(h);
8839
9323
  }
8840
- } else { // media/svg: canvas-sized base box, scaled
8841
- hw = (W / 2) * sc; hh = (H / 2) * sc;
9324
+ const item = document.createElement("div");
9325
+ item.className = "bin-item lib-item";
9326
+ item.draggable = true;
9327
+ item.title = "Drag onto the timeline or click + — then edit its fields in the Inspector";
9328
+ item.innerHTML = `<div class="bin-thumb shape-thumb"><svg viewBox="0 0 24 24" aria-hidden="true">${TEMPLATE_ICONS[t.group] || TEMPLATE_ICONS.Saved}</svg></div>
9329
+ <div class="bin-meta"><div class="bin-name"></div><div class="bin-sub"></div></div>
9330
+ <button class="btn tiny accent lib-add" title="Add at the playhead">+</button>`;
9331
+ item.querySelector(".bin-name").textContent = t.name;
9332
+ item.querySelector(".bin-sub").textContent = `${t.width}×${t.height} · ${t.duration}s · ${t.fields.map((f) => f.label).join(", ") || "no fields"}`;
9333
+ item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/fablecut-template", t.id); e.dataTransfer.effectAllowed = "copy"; });
9334
+ item.addEventListener("dblclick", () => addTemplateClip(t, null, state.time));
9335
+ item.querySelector(".lib-add").addEventListener("click", (e) => { e.stopPropagation(); addTemplateClip(t, null, state.time); });
9336
+ els.libList.appendChild(item);
8842
9337
  }
8843
- return { cx, cy, hw, hh, rot };
9338
+ if (!savedLib.templates.length) {
9339
+ const p = document.createElement("p");
9340
+ p.className = "lib-hint";
9341
+ p.textContent = "Right-click a composition in the Project tab → Save as template to keep your own here.";
9342
+ els.libList.appendChild(p);
9343
+ }
9344
+ }
9345
+ /** Place a template: its own comp (so its fields are this copy's alone) and a comp clip at `at`. */
9346
+ function addTemplateClip(t, trackId, at) {
9347
+ const track = trackId && TRACKS.some((x) => x.id === trackId && x.kind === "video") ? trackId : shapeTrack();
9348
+ if (!track || isTrackLocked(track)) { toast(track ? `${track} is locked` : "V1–V3 are locked — unlock a video track to add a template"); return; }
9349
+ const snap = JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media });
9350
+ const had = new Set(project.media.map((m) => m.id));
9351
+ project.comps = project.comps || [];
9352
+ let r;
9353
+ try { r = Tpl.instantiate(project, t, { compId: newCompId(), start: Math.max(0, at), track }); }
9354
+ catch (err) { restoreSnapshot(snap); toast(err.message); return; } // put back any media it added; history untouched
9355
+ pushUndo(snap);
9356
+ const { comp, clip } = r;
9357
+ for (const x of comp.clips) {
9358
+ x.props = { ...DEFAULT_PROPS, ...(x.kind === "comp" ? { fit: "none" } : {}), ...(x.props || {}) };
9359
+ if (x.kind === "text") ensureFont(x.props.font);
9360
+ }
9361
+ clip.id = "c_" + uid();
9362
+ clip.props = { ...DEFAULT_PROPS, ...clip.props };
9363
+ project.clips.push(clip);
9364
+ for (const m of project.media) if (!had.has(m.id)) loadMediaMetadata(m).then(() => { state.dirtyTimeline = true; }).catch(() => { });
9365
+ runtime.compGen = (runtime.compGen || 0) + 1;
9366
+ selectClip(clip.id); scheduleSave(); renderBin(); renderInspector();
9367
+ toast(`${comp.name} placed — edit its fields in the Inspector`);
9368
+ }
9369
+ /** Set the canvas size (the aspect menu and a template's "Use … canvas" button). */
9370
+ function setCanvasSize(w, h) {
9371
+ project.width = w; project.height = h;
9372
+ els.preview.width = w; els.preview.height = h;
9373
+ if (project.exportFrame) project.exportFrame = normalizeExportFrame(project.exportFrame, w, h);
9374
+ updateMonitorRes();
9375
+ syncAspectSel();
9376
+ syncExportFrameSel();
9377
+ updateExportFrameOverlay();
9378
+ seekMediaWhilePaused();
9379
+ scheduleSave();
9380
+ }
9381
+ /** The Inspector's Template section on a placed template: its fields, its length, and the canvas it was made for. */
9382
+ function templateSectionHtml(c, row) {
9383
+ const comp = getComp(c.compId), tpl = comp && comp.template;
9384
+ if (!tpl) return "";
9385
+ const pics = project.media.filter((m) => m.kind === "image" || m.kind === "svg" || m.kind === "video");
9386
+ const fields = (tpl.fields || []).map((f) => {
9387
+ const v = Tpl.fieldValue(comp, f), label = escapeHtml(f.label);
9388
+ if (f.type === "text") return row(label, `<textarea data-tf="${escapeHtml(f.key)}" rows="${String(v || "").length > 28 ? 2 : 1}" maxlength="2000">${escapeHtml(v || "")}</textarea>`);
9389
+ if (f.type === "color") return row(label, `<input type="color" data-tf="${escapeHtml(f.key)}" value="${/^#[0-9a-f]{6}/i.test(v || "") ? v.slice(0, 7) : "#ffffff"}">`);
9390
+ return row(label, `<select data-tf="${escapeHtml(f.key)}" title="Import your picture (or drop it in the bin), then pick it here">${pics.map((m) => `<option value="${m.id}" ${m.id === v ? "selected" : ""}>${escapeHtml(m.name)}</option>`).join("")}</select>`);
9391
+ }).join("");
9392
+ const sameShape = Math.abs(comp.width / comp.height - project.width / project.height) < 0.01;
9393
+ return `<div class="insp-section" data-tpl-section><h3>Template</h3>
9394
+ ${fields}
9395
+ ${row("Length (s)", `<input type="number" data-tf-dur min="0.5" step="0.1" value="${comp.duration}" title="Longer holds longer — the exit keeps its timing at the end">`)}
9396
+ ${sameShape || state.compCtx ? "" : row("Canvas", `<button type="button" class="btn tiny" data-tf-canvas title="Make the project canvas ${comp.width}×${comp.height}, the size this template was made for">Use ${comp.width}×${comp.height}</button>`)}
9397
+ <p class="cl-note">These fields belong to this copy (its comp "${escapeHtml(comp.name)}"). Open it to change anything else.</p>
9398
+ </div>`;
9399
+ }
9400
+ function bindTemplateControls(c) {
9401
+ const root = els.inspector.querySelector("[data-tpl-section]");
9402
+ const comp = root && getComp(c.compId);
9403
+ if (!comp || !comp.template) return;
9404
+ let undoTaken = false;
9405
+ const refresh = () => { runtime.compGen = (runtime.compGen || 0) + 1; state.dirtyTimeline = true; scheduleSave(); };
9406
+ const set = (key, value) => {
9407
+ if (!undoTaken) { pushUndo(); undoTaken = true; }
9408
+ try { Tpl.setField(comp, key, value, project); } catch (err) { toast(err.message); return; }
9409
+ refresh();
9410
+ };
9411
+ for (const el of root.querySelectorAll("[data-tf]")) {
9412
+ const key = el.dataset.tf;
9413
+ if (el.tagName === "SELECT") el.addEventListener("change", () => {
9414
+ const m = getMedia(el.value);
9415
+ set(key, el.value);
9416
+ if (m && !runtime.mediaAux.get(m.id)?.svgText && !runtime.mediaAux.get(m.id)?.img) loadMediaMetadata(m).then(refresh).catch(() => { });
9417
+ });
9418
+ else el.addEventListener("input", () => set(key, el.value));
9419
+ el.addEventListener("change", () => { undoTaken = false; renderBin(); });
9420
+ }
9421
+ root.querySelector("[data-tf-dur]")?.addEventListener("change", (e) => {
9422
+ const v = +e.target.value, old = comp.duration;
9423
+ const snap = undoSnapshot(); // recorded only once the new length is accepted
9424
+ try { Tpl.retime(comp, +v.toFixed(3)); } catch (err) { toast(err.message); renderInspector(); return; }
9425
+ pushUndo(snap);
9426
+ // clips that showed the comp to its end keep doing so
9427
+ for (const x of project.clips) if (x.kind === "comp" && x.compId === comp.id && Math.abs((+x.in || 0) + x.duration - old) < 1e-3) x.duration = +(comp.duration - (+x.in || 0)).toFixed(4);
9428
+ refresh(); renderBin(); renderInspector();
9429
+ });
9430
+ root.querySelector("[data-tf-canvas]")?.addEventListener("click", () => { setCanvasSize(comp.width, comp.height); renderInspector(); toast(`Canvas is now ${comp.width}×${comp.height}`); });
9431
+ }
9432
+ /** Save comp `comp` to library/templates as a template (its titles' text and its pictures become fields). */
9433
+ async function saveCompAsTemplate(comp) {
9434
+ let template;
9435
+ try { template = Tpl.templateFromComp(comp, { media: project.media }); } catch (err) { toast(err.message); return; }
9436
+ try {
9437
+ const r = await fetch("/api/templates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template }) });
9438
+ const j = await r.json();
9439
+ if (!r.ok) throw new Error(j.error || r.statusText);
9440
+ toast(`Saved "${j.name}" to ${j.file}`);
9441
+ } catch (err) { toast("Couldn't save the template: " + err.message); return; }
9442
+ await fetchSaved("templates");
9443
+ if (state.binTab === "templates") renderTemplatesTab();
9444
+ }
9445
+
9446
+ /** Inspector rows (in Motion): put a preset on the selected clips, or save this clip's animation as one. */
9447
+ function presetRowsHtml(c, row) {
9448
+ const opts = (anchor, first) => {
9449
+ const own = savedLib.presets.filter((p) => p.anchor === anchor);
9450
+ const list = (l) => l.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("");
9451
+ return `<select data-preset="${anchor}"><option value="">${first}</option>${list(Tpl.PRESETS.filter((p) => p.anchor === anchor))}${own.length ? `<optgroup label="Saved">${list(own)}</optgroup>` : ""}</select>`;
9452
+ };
9453
+ return row("Presets", `<span class="insp-ctrls">${opts("in", "In…")}${opts("out", "Out…")}</span>`)
9454
+ + row("", `<span class="insp-ctrls">${opts("at", "At playhead…")}${opts("whole", "Loop…")}</span>`)
9455
+ + row("Save as preset", `<span class="insp-ctrls"><input type="text" data-preset-name maxlength="60" placeholder="Name" style="max-width:110px">
9456
+ <select data-preset-anchor title="In: from the clip's start · Out: up to its end · Loop: the whole clip"><option value="in">In</option><option value="out">Out</option><option value="whole">Loop</option></select>
9457
+ <button type="button" class="btn tiny" data-preset-save title="Save this clip's keyframes to library/presets — every project can use it">Save</button></span>`);
9458
+ }
9459
+ function bindPresetControls(c) {
9460
+ for (const s of els.inspector.querySelectorAll("[data-preset]")) s.addEventListener("change", () => {
9461
+ const p = Tpl.findPreset(s.value, savedLib.presets);
9462
+ s.value = "";
9463
+ if (!p) return;
9464
+ const clips = selectedClips().filter(isVisualClip);
9465
+ const targets = clips.length ? clips : [c];
9466
+ if (targets.some(isGroupLocked)) { toastLocked(); return; }
9467
+ pushUndo();
9468
+ for (const x of targets) Tpl.applyPreset(x, p, { at: state.time - x.start });
9469
+ inspPropGen++; state.dirtyTimeline = true;
9470
+ scheduleSave(); renderInspector();
9471
+ toast(`${p.name} on ${targets.length} clip${targets.length > 1 ? "s" : ""}`);
9472
+ });
9473
+ els.inspector.querySelector("[data-preset-save]")?.addEventListener("click", async () => {
9474
+ const name = els.inspector.querySelector("[data-preset-name]").value.trim();
9475
+ const anchor = els.inspector.querySelector("[data-preset-anchor]").value;
9476
+ if (!name) { toast("Name the preset first"); return; }
9477
+ const times = Object.values(c.keyframes || {}).flat().map((x) => x.t);
9478
+ if (!times.length) { toast("This clip has no keyframes to save"); return; }
9479
+ const from = anchor === "out" ? Math.min(...times) : 0, to = anchor === "in" ? Math.max(...times) : c.duration;
9480
+ let preset;
9481
+ try { preset = Tpl.presetFromClip(c, { name, anchor, from, to }); } catch (err) { toast(err.message); return; }
9482
+ try {
9483
+ const r = await fetch("/api/presets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset }) });
9484
+ const j = await r.json();
9485
+ if (!r.ok) throw new Error(j.error || r.statusText);
9486
+ toast(`Saved "${j.name}" to ${j.file}`);
9487
+ } catch (err) { toast("Couldn't save the preset: " + err.message); return; }
9488
+ await fetchSaved("presets");
9489
+ renderInspector();
9490
+ });
8844
9491
  }
9492
+ window.addEventListener("keydown", (e) => {
9493
+ if (e.key !== "Escape" || !state.compCtx) return;
9494
+ const tg = e.target;
9495
+ if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
9496
+ if (state.selIds.size || shapeUi.sel || shapeUi.tool || maskUi.sel >= 0 || maskUi.tool || runtime.anchorTool) return; // Esc steps back through those first
9497
+ e.preventDefault(); exitComp();
9498
+ });
9499
+
9500
+ /* ── Shape layers (props.shapes — math in motion.js) ── */
9501
+ const shapeNorm = new WeakMap(); // stored list → validated list
9502
+ function clipShapes(c) {
9503
+ const list = c && c.props && c.props.shapes;
9504
+ if (!Array.isArray(list) || !list.length) return null;
9505
+ let n = shapeNorm.get(list);
9506
+ if (n === undefined) { n = Motion.normalizeShapes(list); shapeNorm.set(list, n); }
9507
+ return n;
9508
+ }
9509
+ /** The shape layer's items at timeline time t (keys resolved). */
9510
+ function shapeItemsAt(c, t) { const l = clipShapes(c); return l ? Motion.shapesAt(l, t - c.start) : null; }
8845
9511
  function isVisualClip(c) { return c && c.kind !== "adjust" && c.kind !== "audio"; }
8846
9512
  /* Screen-space handle positions for the selection overlay. Each handle is
8847
9513
  clamped into the visible canvas (inset by its own size) so it stays visible
@@ -8861,9 +9527,15 @@ function overlayHandles(b, W, H) {
8861
9527
  }
8862
9528
  function drawSelectionOverlay(W, H, t) {
8863
9529
  const c = getClip(state.selId);
9530
+ if (drawingLayerMask(c)) { drawLayerStroke(c, W); if (!maskEditing(c)) return; }
8864
9531
  if (maskEditing(c) && drawMaskOverlay(c, W)) return; // a layer's mask replaces the box
9532
+ if (maskEditActive(c) && drawClipMaskOverlay(c, W)) return; // so does a clip mask being edited
9533
+ drawTrackOverlay(c, W);
8865
9534
  if (!isVisualClip(c) || !activeAt(c, t) || !clipRenders(c)) return;
8866
- const b = clipBounds(c, evalProps(c, t), W, H);
9535
+ if (shapeEditActive(c) && drawShapeOverlay(c, W, H, t)) return; // a picked shape item replaces the box
9536
+ drawMotionPathOverlay(c, W, H, t);
9537
+ const ep = evalProps(c, t), b = clipBounds(c, ep, W, H, t);
9538
+ if (c.kind === "null") drawNullMark(b, W, 1);
8867
9539
  const lw = Math.max(2, W / 640);
8868
9540
  const hd = overlayHandles(b, W, H), hs = hd.hs;
8869
9541
  ctx2d.setTransform(1, 0, 0, 1, 0, 0);
@@ -8882,6 +9554,12 @@ function drawSelectionOverlay(W, H, t) {
8882
9554
  }
8883
9555
  ctx2d.beginPath(); ctx2d.arc(hd.rotate.x, hd.rotate.y, hs * 1.1, 0, Math.PI * 2);
8884
9556
  ctx2d.fillStyle = "#ffce5c"; ctx2d.fill(); ctx2d.stroke();
9557
+ // the anchor point: what x / y place and what rotation and scale turn around
9558
+ const ap = anchorScreenPt(c, ep, W, H, t), ar = hs * (runtime.anchorTool ? 1.3 : 0.9);
9559
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = runtime.anchorTool ? "#ffce5c" : "#ffffff";
9560
+ ctx2d.beginPath(); ctx2d.arc(ap.x, ap.y, ar, 0, Math.PI * 2);
9561
+ ctx2d.moveTo(ap.x - ar * 1.7, ap.y); ctx2d.lineTo(ap.x + ar * 1.7, ap.y);
9562
+ ctx2d.moveTo(ap.x, ap.y - ar * 1.7); ctx2d.lineTo(ap.x, ap.y + ar * 1.7); ctx2d.stroke();
8885
9563
  ctx2d.restore();
8886
9564
  }
8887
9565
  function canvasPt(e) {
@@ -8896,10 +9574,15 @@ function toLocal(pt, b) {
8896
9574
  return { x: dx * cs - dy * sn, y: dx * sn + dy * cs };
8897
9575
  }
8898
9576
  function pickClipAt(pt, W, H) {
8899
- const seq = visibleClipsAt(state.time).filter(isVisualClip);
8900
- for (let i = seq.length - 1; i >= 0; i--) {
8901
- const c = seq[i], b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
8902
- if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) return c;
9577
+ const all = visibleClipsAt(state.time), mattes = matteTracksAt(all);
9578
+ const seq = all.filter((c) => isVisualClip(c) && !mattes.has(c.track));
9579
+ for (const nulls of [false, true]) { // a null (invisible) is picked only where nothing else is
9580
+ for (let i = seq.length - 1; i >= 0; i--) {
9581
+ const c = seq[i];
9582
+ if ((c.kind === "null") !== nulls) continue;
9583
+ const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
9584
+ if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) return c;
9585
+ }
8903
9586
  }
8904
9587
  return null;
8905
9588
  }
@@ -8933,11 +9616,14 @@ els.preview.addEventListener("pointerdown", (e) => {
8933
9616
  if (isVisualClip(cur) && activeAt(cur, state.time)) {
8934
9617
  const b = clipBounds(cur, evalProps(cur, state.time), W, H), lp = toLocal(pt, b);
8935
9618
  const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
8936
- if (Math.hypot(pt.x - hd.rotate.x, pt.y - hd.rotate.y) <= grab) {
9619
+ if (runtime.anchorTool) { // Anchor tool: the anchor point follows the pointer, the picture stays put
9620
+ const p0 = propsAtPlayhead(cur);
9621
+ canvasDrag = { mode: "anchor", id: cur.id, p0, m: layerScreenMatrix(cur, p0, W, H, state.time).scale(Motion.contentScale(cur, p0)).inverse() };
9622
+ } else if (Math.hypot(pt.x - hd.rotate.x, pt.y - hd.rotate.y) <= grab) {
8937
9623
  const ep = propsAtPlayhead(cur);
8938
9624
  canvasDrag = { mode: "rotate", id: cur.id, startRot: +ep.rotation || 0, startAng: Math.atan2(pt.y - b.cy, pt.x - b.cx), cx: b.cx, cy: b.cy };
8939
9625
  } else if (hd.corners.some((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab)) {
8940
- if (cur.kind === "text") {
9626
+ if (cur.kind === "text" && !cur.props.textPath) { // a title on a path scales; its box is the path's
8941
9627
  canvasDrag = {
8942
9628
  ...beginTextBoxDrag(cur, pt, W, H),
8943
9629
  seedBox: !hasTextBox(cur.props),
@@ -8987,7 +9673,8 @@ function updateCanvasCursor(e) {
8987
9673
  const W = els.preview.width, H = els.preview.height, pt = canvasPt(e);
8988
9674
  const cur = getClip(state.selId);
8989
9675
  let cursor = "default";
8990
- if (isVisualClip(cur) && activeAt(cur, state.time) && !state.playing && !state.exporting) {
9676
+ if (runtime.anchorTool && isVisualClip(cur) && activeAt(cur, state.time) && !state.playing && !state.exporting) cursor = "crosshair";
9677
+ else if (isVisualClip(cur) && activeAt(cur, state.time) && !state.playing && !state.exporting) {
8991
9678
  const b = clipBounds(cur, evalProps(cur, state.time), W, H);
8992
9679
  const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
8993
9680
  const corner = hd.corners.find((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab);
@@ -9018,8 +9705,9 @@ els.preview.addEventListener("pointermove", (e) => {
9018
9705
  }
9019
9706
  }
9020
9707
  if (canvasDrag.mode === "move") {
9021
- setAnimProp(c, "x", Math.round(canvasDrag.startX + (pt.x - canvasDrag.startPt.x)));
9022
- setAnimProp(c, "y", Math.round(canvasDrag.startY + (pt.y - canvasDrag.startPt.y)));
9708
+ const d = screenDeltaToParent(c, pt.x - canvasDrag.startPt.x, pt.y - canvasDrag.startPt.y);
9709
+ setAnimProp(c, "x", Math.round(canvasDrag.startX + d.x));
9710
+ setAnimProp(c, "y", Math.round(canvasDrag.startY + d.y));
9023
9711
  } else if (canvasDrag.mode === "box") {
9024
9712
  const aspect = canvasDrag.aspect || 1;
9025
9713
  const lockAR = e.shiftKey;
@@ -9030,8 +9718,8 @@ els.preview.addEventListener("pointermove", (e) => {
9030
9718
  const env = transOffsetAt(c, state.time);
9031
9719
  if (e.ctrlKey || e.metaKey) {
9032
9720
  // Ctrl/Cmd: resize from center (all corners move).
9033
- const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
9034
- let bw = Math.abs(lp.x) * 2, bh = Math.abs(lp.y) * 2;
9721
+ const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b), pk = parentScaleAt(c);
9722
+ let bw = Math.abs(lp.x) * 2 / pk, bh = Math.abs(lp.y) * 2 / pk;
9035
9723
  if (lockAR) {
9036
9724
  if (bw / aspect >= bh) bh = bw / aspect;
9037
9725
  else bw = bh * aspect;
@@ -9068,11 +9756,22 @@ els.preview.addEventListener("pointermove", (e) => {
9068
9756
  const c2 = Math.cos(rot), s2 = Math.sin(rot);
9069
9757
  const freeX = fix.x + ldx * c2 - ldy * s2;
9070
9758
  const freeY = fix.y + ldx * s2 + ldy * c2;
9071
- setAnimProp(c, "x", Math.round((fix.x + freeX) / 2 - W / 2 - env.x));
9072
- setAnimProp(c, "y", Math.round((fix.y + freeY) / 2 - H / 2 - env.y));
9073
- c.props.boxW = +Math.abs(ldx).toFixed(1);
9074
- c.props.boxH = +Math.abs(ldy).toFixed(1);
9759
+ // the box's centre on screen → the clip's x / y (its parents and anchor undone)
9760
+ const pos = posForScreenCentre(c, propsAtPlayhead(c), (fix.x + freeX) / 2, (fix.y + freeY) / 2, W, H);
9761
+ setAnimProp(c, "x", Math.round(pos.x - env.x));
9762
+ setAnimProp(c, "y", Math.round(pos.y - env.y));
9763
+ const pk = parentScaleAt(c);
9764
+ c.props.boxW = +(Math.abs(ldx) / pk).toFixed(1);
9765
+ c.props.boxH = +(Math.abs(ldy) / pk).toFixed(1);
9075
9766
  }
9767
+ } else if (canvasDrag.mode === "anchor") {
9768
+ let q = canvasDrag.m.transformPoint(new DOMPoint(pt.x, pt.y));
9769
+ const box = layerBox(c, canvasDrag.p0, W, H, state.time), as = Motion.contentScale(c, canvasDrag.p0);
9770
+ const snap = Math.max(6, W / 120) * Math.hypot(canvasDrag.m.a, canvasDrag.m.b); // screen px → layer px
9771
+ const cx = box.ox / as, cy = box.oy / as;
9772
+ if (e.shiftKey || Math.hypot(q.x - cx, q.y - cy) < snap) q = { x: cx, y: cy }; // snaps to the picture's centre
9773
+ const v = Motion.anchorMoveValues(c, canvasDrag.p0, Math.round(q.x), Math.round(q.y));
9774
+ for (const k of ["anchorX", "anchorY", "x", "y"]) setAnimProp(c, k, v[k]);
9076
9775
  } else if (canvasDrag.mode === "scale") {
9077
9776
  const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
9078
9777
  setAnimProp(c, "scale", clamp(+(canvasDrag.startScale * (Math.hypot(lp.x, lp.y) / canvasDrag.startDist)).toFixed(3), 0.05, 12));
@@ -9093,9 +9792,89 @@ function endCanvasDrag(e) {
9093
9792
  els.preview.addEventListener("pointerup", endCanvasDrag);
9094
9793
  els.preview.addEventListener("pointercancel", endCanvasDrag);
9095
9794
 
9096
- function drawClip(c, W, H, t) {
9097
- if (c.kind === "adjust") { drawAdjust(c, W, H, t); return; }
9795
+ function drawClip(c, W, H, t, asMatte = false) {
9796
+ if (c.kind === "null") return; // a null only carries a transform for its children
9797
+ if (+c.props.motionBlur > 0 && c.kind !== "adjust" && drawMotionBlurred(c, W, H, t, asMatte)) return;
9798
+ drawClipCut(c, W, H, t, asMatte, false);
9799
+ }
9800
+ /** The clip cut by its masks / matte. raw: no opacity or blend (a motion-blur sample). */
9801
+ function drawClipCut(c, W, H, t, asMatte, raw) {
9802
+ const masks = clipMasksAt(c, t), matte = asMatte ? null : clipMatte(c);
9803
+ if (masks || matte) drawMaskedClip(c, W, H, t, masks, matte, raw);
9804
+ else drawClipPicture(c, W, H, t, raw);
9805
+ }
9806
+ /* ── Motion blur: the clip drawn at sub-frame times across the shutter and
9807
+ averaged (premultiplied, "lighter" at 1/n each), then composited once with its
9808
+ opacity and blend. Preview and export share it; preview playback uses fewer samples. */
9809
+ function drawMotionBlurred(c, W, H, t, asMatte) {
9810
+ const fps = projectFps() || 30, span = clamp(+c.props.motionBlur, 0, 720) / 360 / fps;
9811
+ let n = clamp(Math.round(+c.props.motionBlurSamples || 8), 2, 32);
9812
+ // playback: as many samples as fit in ~12 ms (all of them on a fast machine); a paused frame and export use them all
9813
+ const live = state.playing && !state.exporting, cost = live && (runtime.mbCost ||= new Map()).get(c.id);
9814
+ if (cost) n = clamp(Math.floor(12 / cost), 2, n);
9815
+ const began = live ? performance.now() : 0;
9816
+ const lo = c.start, hi = clipEnd(c) - 1e-4;
9817
+ const t0 = clamp(t - span / 2, lo, hi), t1 = clamp(t + span / 2, lo, hi);
9818
+ if (t1 - t0 < 1e-6 || !clipMovesBetween(c, t0, t1, W, H)) return false;
9819
+ // a blurred clip on a matte track blurs inside this one's samples: each depth has its own buffers
9820
+ const depth = runtime.mbDepth = (runtime.mbDepth || 0) + 1;
9821
+ const acc = maskCtx("mbAcc" + depth, W, H), smp = maskCtx("mbSmp" + depth, W, H);
9822
+ const main = ctx2d;
9823
+ try {
9824
+ acc.setTransform(1, 0, 0, 1, 0, 0); acc.globalAlpha = 1; acc.globalCompositeOperation = "source-over"; acc.filter = "none";
9825
+ acc.clearRect(0, 0, W, H);
9826
+ acc.fillStyle = "#000";
9827
+ for (let i = 0; i < n; i++) {
9828
+ const ts = t0 + (t1 - t0) * i / (n - 1);
9829
+ smp.setTransform(1, 0, 0, 1, 0, 0); smp.globalAlpha = 1; smp.globalCompositeOperation = "source-over"; smp.filter = "none";
9830
+ smp.clearRect(0, 0, W, H);
9831
+ ctx2d = smp;
9832
+ try { drawClipCut(c, W, H, ts, asMatte, true); } finally { ctx2d = main; }
9833
+ // running average, exact in transparent areas too: acc = acc·(1−a) + sample·a with a = 1/(i+1).
9834
+ // (Adding every sample at 1/n instead rounds each 8-bit contribution and bands the result.)
9835
+ const a = 1 / (i + 1);
9836
+ if (i > 0) { acc.globalCompositeOperation = "destination-in"; acc.globalAlpha = 1 - a; acc.fillRect(0, 0, W, H); }
9837
+ acc.globalCompositeOperation = "lighter"; acc.globalAlpha = a;
9838
+ acc.drawImage(smp.canvas, 0, 0);
9839
+ }
9840
+ } finally { runtime.mbDepth = depth - 1; ctx2d = main; }
9841
+ acc.globalCompositeOperation = "source-over"; acc.globalAlpha = 1;
9842
+ if (live) { const per = (performance.now() - began) / n, prev = runtime.mbCost.get(c.id); runtime.mbCost.set(c.id, prev ? prev * 0.7 + per * 0.3 : per); }
9843
+ const p = evalProps(c, t);
9844
+ main.save();
9845
+ main.setTransform(1, 0, 0, 1, 0, 0);
9846
+ main.globalAlpha = clamp(p.opacity, 0, 1);
9847
+ main.globalCompositeOperation = p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend) ? p.blend : "source-over";
9848
+ main.filter = "none";
9849
+ main.drawImage(acc.canvas, 0, 0);
9850
+ main.restore();
9851
+ return true;
9852
+ }
9853
+ /** Does the clip move (or its shapes change) between t0 and t1? Still clips draw once. */
9854
+ function clipMovesBetween(c, t0, t1, W, H) {
9855
+ if (c.kind === "shape" && (shapeKeyed(c) || c.keyframes?.trimStart || c.keyframes?.trimEnd || c.keyframes?.trimOffset)) return true;
9856
+ const a = clipBounds(c, evalProps(c, t0), W, H, t0), b = clipBounds(c, evalProps(c, t1), W, H, t1);
9857
+ const corners = (q) => [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sy]) => {
9858
+ const cs = Math.cos(q.rot), sn = Math.sin(q.rot), x = sx * q.hw, y = sy * q.hh;
9859
+ return [q.cx + x * cs - y * sn, q.cy + x * sn + y * cs];
9860
+ });
9861
+ const A = corners(a), B = corners(b);
9862
+ return A.some((p, i) => Math.hypot(p[0] - B[i][0], p[1] - B[i][1]) > 0.5);
9863
+ }
9864
+ function shapeKeyed(c) {
9865
+ const any = (list) => (list || []).some((it) => (it.keys && it.keys.length) || (it.type === "group" && any(it.items)));
9866
+ return any(c.props.shapes);
9867
+ }
9868
+ /** Draw one clip. `layered`: into a mask layer — opacity and blend wait for
9869
+ * the composite. Returns { p, geom } — geom = where the clip's picture landed
9870
+ * (canvas transform + its box), the space its masks live in. */
9871
+ function drawClipPicture(c, W, H, t, layered) {
9872
+ if (c.kind === "adjust") {
9873
+ const p = drawAdjust(c, W, H, t);
9874
+ return { p, geom: noteMaskGeom(c, new DOMMatrix(), 0, 0, W, H) };
9875
+ }
9098
9876
  const p = evalProps(c, t);
9877
+ let geom = null;
9099
9878
  ctx2d.save();
9100
9879
  if (p._wipe) {
9101
9880
  ctx2d.beginPath();
@@ -9111,10 +9890,14 @@ function drawClip(c, W, H, t) {
9111
9890
  ctx2d.arc(W / 2, H / 2, Math.max(0.01, (1 - p._iris)) * Math.hypot(W, H) * 0.55, 0, Math.PI * 2);
9112
9891
  ctx2d.clip();
9113
9892
  }
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;
9117
- ctx2d.translate(W / 2 + (+p.x || 0), H / 2 + (+p.y || 0));
9893
+ if (!layered) {
9894
+ ctx2d.globalAlpha = clamp(p.opacity, 0, 1);
9895
+ if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) ctx2d.globalCompositeOperation = p.blend;
9896
+ }
9897
+ ctx2d.translate(W / 2, H / 2);
9898
+ const pm = parentM(c, t);
9899
+ if (pm) ctx2d.transform(pm[0], pm[1], pm[2], pm[3], pm[4], pm[5]);
9900
+ ctx2d.translate(+p.x || 0, +p.y || 0);
9118
9901
  ctx2d.rotate((p.rotation || 0) * Math.PI / 180);
9119
9902
  if (p.shake > 0) { // deterministic multi-sine handheld/impact shake
9120
9903
  const a = +p.shake, tt = t * clamp(+p.shakeSpeed || 8, 0.5, 40) * Math.PI * 2;
@@ -9123,10 +9906,26 @@ function drawClip(c, W, H, t) {
9123
9906
  Math.cos(tt * 1.7) * a * 0.5 + Math.sin(tt * 3.13) * a * 0.35);
9124
9907
  ctx2d.rotate(Math.sin(tt * 0.9) * a * 0.0022);
9125
9908
  }
9909
+ if (+p.anchorX || +p.anchorY) { // the anchor point sits at x / y; rotation and scale turn around it
9910
+ const as = Motion.contentScale(c, p);
9911
+ ctx2d.translate(-(+p.anchorX || 0) * as, -(+p.anchorY || 0) * as);
9912
+ }
9913
+ if (c.kind === "shape") {
9914
+ const s = +p.scale || 1;
9915
+ ctx2d.scale(s, s);
9916
+ geom = noteMaskGeom(c, ctx2d.getTransform(), -W / 2, -H / 2, W, H); // masks live in the canvas-sized layer box
9917
+ ctx2d.filter = buildFilter(p);
9918
+ Motion.draw(ctx2d, shapeItemsAt(c, t), [+p.trimStart || 0, p.trimEnd == null ? 100 : +p.trimEnd, +p.trimOffset || 0]);
9919
+ ctx2d.filter = "none";
9920
+ ctx2d.restore();
9921
+ return { p, geom };
9922
+ }
9126
9923
  if (c.kind === "text") {
9924
+ const b = layerBox(c, p, W, H, t); // a path's box is usually off the origin (ox / oy)
9925
+ geom = noteMaskGeom(c, ctx2d.getTransform(), (b.ox || 0) - b.hw, (b.oy || 0) - b.hh, b.hw * 2, b.hh * 2);
9127
9926
  drawText(c, p, t - c.start);
9128
9927
  ctx2d.restore();
9129
- return;
9928
+ return { p, geom };
9130
9929
  }
9131
9930
  let src = null, sw = 0, sh = 0;
9132
9931
  if (c.kind === "image") {
@@ -9138,6 +9937,9 @@ function drawClip(c, W, H, t) {
9138
9937
  } else if (c.kind === "video") {
9139
9938
  src = getClipEl(c);
9140
9939
  if (src) { sw = src.videoWidth; sh = src.videoHeight; }
9940
+ } else if (c.kind === "comp") {
9941
+ src = renderComp(c, t);
9942
+ if (src) { sw = src.width; sh = src.height; }
9141
9943
  }
9142
9944
  if (src && sw && sh) {
9143
9945
  // source crop (percent per edge)
@@ -9164,6 +9966,7 @@ function drawClip(c, W, H, t) {
9164
9966
  const grade = activeGrade(c, p, t), matte = gradeMatte(c);
9165
9967
  let graded = null;
9166
9968
  noteGradeGeom(c, ctx2d.getTransform(), dw, dh, -dw / 2, -dh / 2);
9969
+ geom = noteMaskGeom(c, ctx2d.getTransform(), -dw / 2, -dh / 2, dw, dh);
9167
9970
  if (needsPixelPass(p, c)) { // key / cut-out first, so the grade never shifts the key colour
9168
9971
  let processed = pixelPass(c, p, src, sx, sy, cw, ch, dw, dh);
9169
9972
  if (runtime.gradeCapture) { // pixelPass reuses its canvas — keep a copy
@@ -9189,111 +9992,2210 @@ function drawClip(c, W, H, t) {
9189
9992
  if (p.grain > 0) drawGrain(p.grain, -dw / 2, -dh / 2, dw, dh, t);
9190
9993
  }
9191
9994
  ctx2d.restore();
9995
+ return { p, geom };
9192
9996
  }
9193
9997
 
9194
- /* ── Text direction (LTR / RTL) — per-line when direction is "auto".
9195
- Auto-detect uses a DOM probe + getComputedStyle; cache by line text so
9196
- preview/export frames don't re-resolve style every tick. ── */
9197
- let _textDirProbe;
9198
- const _textDirCache = new Map(); // line text → "ltr" | "rtl"
9199
- const TEXT_DIR_CACHE_MAX = 256;
9200
- function detectTextDirection(text) {
9201
- const key = (text && String(text).trim()) ? String(text) : " ";
9202
- const hit = _textDirCache.get(key);
9203
- if (hit) return hit;
9204
- if (!_textDirProbe) {
9205
- _textDirProbe = document.createElement("p");
9206
- _textDirProbe.style.cssText = "position:fixed;left:-9999px;visibility:hidden;white-space:nowrap";
9207
- document.body.appendChild(_textDirProbe);
9208
- }
9209
- _textDirProbe.dir = "auto";
9210
- _textDirProbe.textContent = key;
9211
- const dir = getComputedStyle(_textDirProbe).direction === "rtl" ? "rtl" : "ltr";
9212
- if (_textDirCache.size >= TEXT_DIR_CACHE_MAX) _textDirCache.clear();
9213
- _textDirCache.set(key, dir);
9214
- return dir;
9998
+ /* ═══════════ MASKS (props.masks — math in mask.js) ═══════════
9999
+ A masked clip draws into an offscreen layer instead of the program canvas;
10000
+ the layer is cut by the masks' matte (destination-in), then composited with
10001
+ the clip's opacity and blend. Preview and export share this path. */
10002
+ const Mask = self.FableCutMask;
10003
+ const maskCv = {};
10004
+ function maskCtx(key, W, H) {
10005
+ if (runtime.compDepth) key += ":" + runtime.compDepth;
10006
+ let cv = maskCv[key];
10007
+ if (!cv) cv = maskCv[key] = document.createElement("canvas");
10008
+ if (cv.width !== W) cv.width = W;
10009
+ if (cv.height !== H) cv.height = H;
10010
+ return cv.getContext("2d", { willReadFrequently: false });
10011
+ }
10012
+ const maskNorm = new WeakMap(); // stored list → validated list (hand-written JSON gets defaults once)
10013
+ function clipMasks(c) {
10014
+ const list = c.props && c.props.masks;
10015
+ if (!Array.isArray(list) || !list.length) return null;
10016
+ let n = maskNorm.get(list);
10017
+ if (n === undefined) { n = Mask.normalizeMasks(list); maskNorm.set(list, n); }
10018
+ return n;
10019
+ }
10020
+ /** The masks cutting clip c at timeline time t (keys resolved), or null. */
10021
+ function clipMasksAt(c, t) {
10022
+ if (c.kind === "audio") return null;
10023
+ const list = clipMasks(c);
10024
+ return list ? Mask.masksAt(list, t - c.start) : null;
10025
+ }
10026
+ /** Remember where the clip's picture landed (the monitor's mask editor maps through it). */
10027
+ function noteMaskGeom(c, m, x0, y0, w, h) {
10028
+ const g = { m, x0, y0, w, h };
10029
+ if (!state.exporting && !runtime.sampling) (runtime.maskGeom ||= new Map()).set(c.id, g);
10030
+ return g;
10031
+ }
10032
+ /* ── Track mattes ──
10033
+ A clip with props.matte is cut by the picture on its matte track (the video
10034
+ track above unless props.matteTrack names one): by that picture's alpha, or
10035
+ by its luma (white = shows), each optionally inverted. The matte track does
10036
+ not draw into the frame while a clip on screen uses it. */
10037
+ const videoTrackIds = () => TRACKS.filter((tr) => tr.kind === "video").map((tr) => tr.id);
10038
+ /** { mode, track } when the clip has a track matte with a track to take it from. */
10039
+ function clipMatte(c) {
10040
+ if (c.kind === "audio" || !c.props || !c.props.matte) return null;
10041
+ const track = Mask.matteTrackFor(c, runtime.scope ? runtime.scope.videoTracks : videoTrackIds());
10042
+ return track ? { mode: c.props.matte, track } : null;
10043
+ }
10044
+ /** The tracks that are mattes for clips in `clips` (the frame's clips): they only cut. */
10045
+ function matteTracksAt(clips) {
10046
+ const out = new Set();
10047
+ for (const c of clips) { const m = clipMatte(c); if (m) out.add(m.track); }
10048
+ return out;
9215
10049
  }
9216
- function lineDirections(p, lines) {
9217
- if (p.direction === "rtl" || p.direction === "ltr") {
9218
- const forced = p.direction;
9219
- return lines.map(() => forced);
9220
- }
9221
- return lines.map((ln) => detectTextDirection(ln));
10050
+ /** The matte track's picture at t as an alpha matte: { canvas, empty }. A luma matte
10051
+ * turns brightness into alpha (over black, so transparent = black). */
10052
+ function trackMatteAt(matte, W, H, t) {
10053
+ const src = maskCtx("tmSrc", W, H), luma = matte.mode.startsWith("luma");
10054
+ src.setTransform(1, 0, 0, 1, 0, 0);
10055
+ src.globalAlpha = 1; src.globalCompositeOperation = "source-over"; src.filter = "none";
10056
+ src.clearRect(0, 0, W, H);
10057
+ const clips = (runtime.scope ? runtime.scope.clips : project.clips).filter((x) => x.track === matte.track && clipRenders(x) && x.kind !== "audio" &&
10058
+ x.kind !== "adjust" && activeAt(x, t)).sort((a, b) => a.start - b.start);
10059
+ if (!clips.length) return { canvas: src.canvas, empty: true };
10060
+ if (luma) { src.fillStyle = "#000"; src.fillRect(0, 0, W, H); }
10061
+ const main = ctx2d;
10062
+ ctx2d = src;
10063
+ try { for (const x of clips) drawClip(x, W, H, t, true); } finally { ctx2d = main; }
10064
+ if (!luma) return { canvas: src.canvas, empty: false };
10065
+ return { canvas: lumaToAlpha(src.canvas, maskCtx("tmOut", W, H), W, H), empty: false };
10066
+ }
10067
+ /** Brightness → alpha (Rec. 709 weights): an SVG luminanceToAlpha filter on the
10068
+ * canvas, or a pixel loop where canvas filters are missing. */
10069
+ let lumaFilter = null;
10070
+ function lumaToAlpha(srcCanvas, out, W, H) {
10071
+ out.setTransform(1, 0, 0, 1, 0, 0);
10072
+ out.globalAlpha = 1; out.globalCompositeOperation = "source-over";
10073
+ out.clearRect(0, 0, W, H);
10074
+ if (lumaFilter == null) lumaFilter = makeLumaFilter();
10075
+ if (lumaFilter) {
10076
+ out.filter = lumaFilter; out.drawImage(srcCanvas, 0, 0); out.filter = "none";
10077
+ return out.canvas;
10078
+ }
10079
+ out.drawImage(srcCanvas, 0, 0);
10080
+ const img = out.getImageData(0, 0, W, H), d = img.data;
10081
+ for (let i = 0; i < d.length; i += 4) {
10082
+ d[i + 3] = Math.round(0.2125 * d[i] + 0.7154 * d[i + 1] + 0.0721 * d[i + 2]);
10083
+ d[i] = d[i + 1] = d[i + 2] = 0;
10084
+ }
10085
+ out.putImageData(img, 0, 0);
10086
+ return out.canvas;
10087
+ }
10088
+ /** "url(#…)" for the luma filter, or "" when the canvas can't apply it (checked once on a pixel). */
10089
+ function makeLumaFilter() {
10090
+ try {
10091
+ const ns = "http://www.w3.org/2000/svg", svg = document.createElementNS(ns, "svg");
10092
+ svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); svg.setAttribute("aria-hidden", "true");
10093
+ svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden";
10094
+ svg.innerHTML = '<filter id="fcLumaAlpha" color-interpolation-filters="sRGB"><feColorMatrix type="luminanceToAlpha"/></filter>';
10095
+ document.body.appendChild(svg);
10096
+ const a = document.createElement("canvas"), b = document.createElement("canvas");
10097
+ a.width = b.width = a.height = b.height = 1;
10098
+ const x = a.getContext("2d"); x.fillStyle = "#fff"; x.fillRect(0, 0, 1, 1);
10099
+ const y = b.getContext("2d", { willReadFrequently: true });
10100
+ y.filter = "url(#fcLumaAlpha)"; y.drawImage(a, 0, 0);
10101
+ const px = y.getImageData(0, 0, 1, 1).data;
10102
+ return px[3] > 250 && px[0] < 5 ? "url(#fcLumaAlpha)" : "";
10103
+ } catch { return ""; }
10104
+ }
10105
+ /** A clip that is cut before it composites: by its masks, by a track matte, or both. */
10106
+ function drawMaskedClip(c, W, H, t, masks, matte = null, raw = false) {
10107
+ const tm = matte ? trackMatteAt(matte, W, H, t) : null; // first: the matte's clips use the layer buffers too
10108
+ const layer = maskCtx("layer", W, H);
10109
+ layer.setTransform(1, 0, 0, 1, 0, 0);
10110
+ layer.globalAlpha = 1; layer.globalCompositeOperation = "source-over"; layer.filter = "none";
10111
+ layer.clearRect(0, 0, W, H);
10112
+ const main = ctx2d;
10113
+ let r;
10114
+ ctx2d = layer;
10115
+ try { r = drawClipPicture(c, W, H, t, true); } finally { ctx2d = main; }
10116
+ if (!r || !r.geom) return;
10117
+ layer.setTransform(1, 0, 0, 1, 0, 0);
10118
+ if (tm) {
10119
+ layer.globalCompositeOperation = matte.mode.endsWith("-inverted") ? "destination-out" : "destination-in";
10120
+ if (tm.empty) { if (!matte.mode.endsWith("-inverted")) layer.clearRect(0, 0, W, H); } // nothing on the matte track: alpha hides all
10121
+ else layer.drawImage(tm.canvas, 0, 0);
10122
+ layer.globalCompositeOperation = "source-over";
10123
+ }
10124
+ if (masks) cutByMasks(c, layer, r.geom, masks, W, H);
10125
+ main.save();
10126
+ main.setTransform(1, 0, 0, 1, 0, 0);
10127
+ const p = r.p;
10128
+ if (c.kind !== "adjust" && !raw) { // an adjustment layer already drew at its own opacity; a raw sample composites later
10129
+ main.globalAlpha = clamp(p.opacity, 0, 1);
10130
+ if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) main.globalCompositeOperation = p.blend;
10131
+ }
10132
+ main.drawImage(layer.canvas, 0, 0);
10133
+ main.restore();
10134
+ }
10135
+ function cutByMasks(c, layer, g, masks, W, H) {
10136
+ const matte = maskCtx("matte", W, H);
10137
+ Mask.rasterize(matte, maskCtx("tmp", W, H), maskCtx("tmp2", W, H), masks,
10138
+ g.m.translate(g.x0, g.y0), g.w, g.h, W / Math.max(1, project.width));
10139
+ layer.setTransform(1, 0, 0, 1, 0, 0);
10140
+ layer.globalCompositeOperation = "destination-in";
10141
+ layer.drawImage(matte.canvas, 0, 0);
10142
+ layer.globalCompositeOperation = "source-over";
10143
+ const cap = runtime.maskCapture;
10144
+ if (cap && cap.id === c.id && cap.coverage == null) measureMaskCoverage(cap, masks, g, layer.canvas, W, H);
10145
+ const view = runtime.maskView;
10146
+ if (view && view.id === c.id && state.selId === c.id && !state.exporting) { // the monitor shows the selected clip's matte
10147
+ const v = maskCtx("view", W, H);
10148
+ v.setTransform(1, 0, 0, 1, 0, 0);
10149
+ v.globalCompositeOperation = "source-over"; v.clearRect(0, 0, W, H);
10150
+ v.fillStyle = "#fff"; v.fillRect(0, 0, W, H);
10151
+ v.globalCompositeOperation = "destination-in"; v.drawImage(matte.canvas, 0, 0);
10152
+ v.globalCompositeOperation = "source-over";
10153
+ view.ready = true;
10154
+ }
10155
+ }
10156
+ /** fablecut_scopes mask: how much of the clip's own picture its masks keep
10157
+ * (rasterized alone, box-aligned) and how much of the frame the cut clip covers. */
10158
+ function measureMaskCoverage(cap, masks, g, layerCanvas, W, H) {
10159
+ const mean = (cv, w, h) => {
10160
+ const s = document.createElement("canvas"); s.width = w; s.height = h;
10161
+ const x = s.getContext("2d", { willReadFrequently: true });
10162
+ x.drawImage(cv, 0, 0, w, h);
10163
+ const d = x.getImageData(0, 0, w, h).data;
10164
+ let sum = 0;
10165
+ for (let i = 3; i < d.length; i += 4) sum += d[i];
10166
+ return sum / 255 / (w * h);
10167
+ };
10168
+ const bw = Math.max(1, g.w), bh = Math.max(1, g.h), k = Math.min(1, 480 / Math.max(bw, bh));
10169
+ const mw = Math.max(2, Math.round(bw * k)), mh = Math.max(2, Math.round(bh * k));
10170
+ const out = maskCtx("cap", mw, mh);
10171
+ Mask.rasterize(out, maskCtx("cap1", mw, mh), maskCtx("cap2", mw, mh), masks, new DOMMatrix([mw / bw, 0, 0, mh / bh, 0, 0]), bw, bh,
10172
+ W / Math.max(1, project.width) * mw / bw);
10173
+ cap.coverage = +(100 * mean(out.canvas, mw, mh)).toFixed(2);
10174
+ const fw = Math.max(2, Math.min(480, W)), fh = Math.max(2, Math.round(fw * H / W));
10175
+ cap.frameCoverage = +(100 * mean(layerCanvas, fw, fh)).toFixed(2);
10176
+ }
10177
+ /** Matte view: the selected clip's mask matte, white on black, over the frame. */
10178
+ function drawMaskView(W, H) {
10179
+ const view = runtime.maskView;
10180
+ if (!view || !view.ready || state.selId !== view.id) return; // off once the clip is deselected
10181
+ ctx2d.save();
10182
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
10183
+ ctx2d.globalAlpha = 1; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
10184
+ ctx2d.fillStyle = "#000"; ctx2d.fillRect(0, 0, W, H);
10185
+ ctx2d.drawImage(maskCv.view, 0, 0);
10186
+ ctx2d.restore();
9222
10187
  }
9223
- function revealWipeRect(cx, w, lh, y, e, rtl, pad = 6) {
9224
- const clipW = (w + pad * 2) * e;
9225
- const x0 = rtl ? cx + w / 2 + pad - clipW : cx - w / 2 - pad;
9226
- ctx2d.rect(x0, y - lh / 2, clipW, lh);
10188
+
10189
+ /* ── Masks in the Inspector and on the monitor ──
10190
+ The Masks section lists the clip's masks; picking one shows its outline and
10191
+ handles on the monitor. Pen: click to place points (drag pulls handles),
10192
+ click the first point or press Enter to close. Draw: drag a free-hand
10193
+ outline, fitted into an editable bezier. */
10194
+ const maskUi = { clip: null, sel: -1, pt: -1, tool: null, pen: null, stroke: null, drag: null };
10195
+ const MASK_ROWS_UI = {
10196
+ opacity: ["Opacity", 0, 1, 0.01], feather: ["Feather", 0, 300, 1], expand: ["Expand", -200, 200, 1],
10197
+ 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],
10198
+ scale: ["Scale", 0.05, 4, 0.01], rotation: ["Rotation", -180, 180, 0.5],
10199
+ };
10200
+ const MASK_MODE_LABEL = { add: "Add", subtract: "Subtract", intersect: "Intersect", difference: "Difference" };
10201
+ function maskList(c) { return clipMasks(c) || []; }
10202
+ function maskUiFor(c) {
10203
+ 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; }
10204
+ const n = maskList(c).length;
10205
+ if (maskUi.sel >= n) maskUi.sel = n - 1;
10206
+ return maskUi;
10207
+ }
10208
+ function writeMasks(c, list) {
10209
+ const n = list.length ? Mask.normalizeMasks(list) : null;
10210
+ if (n) c.props.masks = n; else delete c.props.masks;
10211
+ state.dirtyTimeline = true;
9227
10212
  }
9228
- function typewriterX(cx, fullW, shownW, rtl) {
9229
- const dx = (fullW - shownW) / 2;
9230
- return rtl ? cx + dx : cx - dx;
10213
+ const maskLocalT = (c) => state.time - c.start;
10214
+ /** Mask i as it is at the playhead. */
10215
+ function maskNowAt(c, i) { const m = maskList(c)[i]; return m ? Mask.maskAt(m, maskLocalT(c)) : null; }
10216
+ function maskKeyIndex(m, t) {
10217
+ const tol = 0.5 / (projectFps() || 30);
10218
+ return m && m.keys ? m.keys.findIndex((k) => Math.abs(k.t - t) < tol) : -1;
9231
10219
  }
9232
- function runOrigin(cx, totalW, rtl) {
9233
- return rtl ? cx + totalW / 2 : cx - totalW / 2;
10220
+ function maskSnapshotKey(now, t) {
10221
+ const k = { t: +Math.max(0, t).toFixed(4) };
10222
+ for (const p of Mask.KEYED) if (now[p] != null) k[p] = p === "points" ? now.points.map((q) => q.slice()) : now[p];
10223
+ return k;
9234
10224
  }
9235
- // Arabic/Indic letters change shape when drawn in isolation — letter-pop/wave must
9236
- // paint shaped clusters (whole words), not individual code points.
9237
- const SHAPING_SCRIPT_RE = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F]/;
9238
- function needsContextualShaping(text) {
9239
- return SHAPING_SCRIPT_RE.test(text);
10225
+ /** Change mask i; once it has keys, keyed params land in a key at the playhead. */
10226
+ function writeMaskSet(c, i, set) {
10227
+ const list = maskList(c).map((m) => m), m = list[i];
10228
+ if (!m) return;
10229
+ if (!m.keys || !m.keys.length || !Mask.KEYED.some((p) => p in set)) { list[i] = Mask.mergeMask(m, set) || m; writeMasks(c, list); return; }
10230
+ const t = maskLocalT(c), keys = m.keys.map((k) => ({ ...k })), now = Mask.maskAt(m, t), statics = {};
10231
+ for (const [p, v] of Object.entries(set)) if (!Mask.KEYED.includes(p)) statics[p] = v;
10232
+ let j = maskKeyIndex(m, t);
10233
+ if (j < 0) { const k = maskSnapshotKey(now, t); keys.push(k); keys.sort((a, b) => a.t - b.t); j = keys.indexOf(k); }
10234
+ for (const p of Mask.KEYED) if (p in set) keys[j][p] = set[p];
10235
+ list[i] = Mask.mergeMask(m, { ...statics, keys }) || m;
10236
+ writeMasks(c, list);
10237
+ }
10238
+ /* ── Tracking ──
10239
+ tracker.js follows a region of the clip's own video frame by frame. The
10240
+ frames come from a private <video> (the monitor never moves), drawn small
10241
+ enough that the region is ~56 px across. A track is stored on its clip as
10242
+ picture fractions (props.tracks); masks and other clips follow it by having
10243
+ it baked into their keys, so rendering never depends on it. */
10244
+ const Tracker = self.FableCutTracker;
10245
+ const trackUi = { clip: null, sel: -1, tool: null, size: 0.08, scale: false, rotation: false, run: null };
10246
+ const trackNorm = new WeakMap();
10247
+ function clipTracks(c) {
10248
+ const list = c && c.props && c.props.tracks;
10249
+ if (!Array.isArray(list) || !list.length) return [];
10250
+ let n = trackNorm.get(list);
10251
+ if (n === undefined) { n = Tracker.normalizeTracks(list) || []; trackNorm.set(list, n); }
10252
+ return n;
10253
+ }
10254
+ function writeTracks(c, list) {
10255
+ const n = list.length ? Tracker.normalizeTracks(list) : null;
10256
+ if (n) c.props.tracks = n; else delete c.props.tracks;
10257
+ state.dirtyTimeline = true;
9240
10258
  }
9241
- let _graphemeSeg;
9242
- const _graphemeCache = new Map(); // line text → string[]
9243
- const _letterAnimCache = new Map(); // line text → {text, animate}[]
9244
- const TEXT_SEG_CACHE_MAX = 256;
9245
- function graphemeSegments(text) {
9246
- const key = String(text ?? "");
9247
- const hit = _graphemeCache.get(key);
9248
- if (hit) return hit;
9249
- let segs;
9250
- if (typeof Intl !== "undefined" && Intl.Segmenter) {
9251
- if (!_graphemeSeg) _graphemeSeg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
9252
- segs = [..._graphemeSeg.segment(key)].map((s) => s.segment);
9253
- } else {
9254
- segs = [...key];
9255
- }
9256
- if (_graphemeCache.size >= TEXT_SEG_CACHE_MAX) _graphemeCache.clear();
9257
- _graphemeCache.set(key, segs);
9258
- return segs;
10259
+ /** Crop as fractions; picture (u, v) ↔ media (x, y), both 0…1. */
10260
+ function cropFrac(p) {
10261
+ const k = (v) => clamp(+v || 0, 0, 95) / 100;
10262
+ return { l: k(p.cropL), t: k(p.cropT), r: k(p.cropR), b: k(p.cropB) };
9259
10263
  }
9260
- function letterAnimSegments(line) {
9261
- const key = String(line ?? "");
9262
- const hit = _letterAnimCache.get(key);
9263
- if (hit) return hit;
9264
- let segs;
9265
- if (needsContextualShaping(key)) {
9266
- segs = [];
9267
- const re = /(\s+|[^\s]+)/g;
9268
- let m;
9269
- while ((m = re.exec(key))) segs.push({ text: m[0], animate: !!m[0].trim() });
9270
- } else {
9271
- segs = graphemeSegments(key).map((text) => ({ text, animate: !!text.trim() }));
9272
- }
9273
- if (_letterAnimCache.size >= TEXT_SEG_CACHE_MAX) _letterAnimCache.clear();
9274
- _letterAnimCache.set(key, segs);
9275
- return segs;
10264
+ 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) }; }
10265
+ 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) }; }
10266
+ function seekFrame(el, t) {
10267
+ return new Promise((res, rej) => {
10268
+ const done = () => { el.removeEventListener("seeked", done); clearTimeout(tm); res(); };
10269
+ const tm = setTimeout(() => { el.removeEventListener("seeked", done); rej(new Error(`the video did not seek to ${t.toFixed(2)} s`)); }, 4000);
10270
+ el.addEventListener("seeked", done);
10271
+ el.currentTime = t;
10272
+ });
9276
10273
  }
9277
-
9278
- /* ── Text rendering: styling (stroke / background pill) + kinetic animations.
9279
- `local` is clip-local time in seconds. Word timing: word i enters at
9280
- i * wordRate; each entrance animation lasts ~0.25 s. ── */
9281
- const backOut = (u) => { const c1 = 1.70158, c3 = c1 + 1; const v = u - 1; return 1 + c3 * v * v * v + c1 * v * v; };
9282
- function hexToRgba(hex, a) {
9283
- const n = parseInt(String(hex).replace("#", ""), 16) || 0;
9284
- return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
10274
+ /**
10275
+ * Follow `box` (picture fractions {x, y, w, h}) through clip c from clip-local
10276
+ * `from` towards `to` (dir ±1), one sample per project frame. Returns
10277
+ * { samples: [[t, x, y, s, r, q], …] in time order, lostAt, cancelled }.
10278
+ */
10279
+ async function runTrack(c, { box, from, to, dir, scale = false, rotation = false, onProgress, run = {} }) {
10280
+ const m = getMedia(c.mediaId);
10281
+ if (c.kind !== "video" || !m) throw new Error("tracking needs a video clip");
10282
+ const el = document.createElement("video");
10283
+ el.muted = true; el.preload = "auto"; el.playsInline = true; el.src = m.src;
10284
+ try {
10285
+ await new Promise((res, rej) => {
10286
+ const tm = setTimeout(() => rej(new Error("the video did not load")), 15000);
10287
+ el.addEventListener("loadeddata", () => { clearTimeout(tm); res(); }, { once: true });
10288
+ el.addEventListener("error", () => { clearTimeout(tm); rej(new Error("the video could not be read")); }, { once: true });
10289
+ });
10290
+ const vw = el.videoWidth, vh = el.videoHeight, p0 = evalProps(c, c.start + from), k = cropFrac(p0);
10291
+ 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);
10292
+ const sc = clamp(56 / Math.max(bw, bh), 160 / vw, Math.min(1, 1280 / vw));
10293
+ const aw = Math.max(16, Math.round(vw * sc)), ah = Math.max(16, Math.round(vh * sc));
10294
+ const cv = document.createElement("canvas"); cv.width = aw; cv.height = ah;
10295
+ const x = cv.getContext("2d", { willReadFrequently: true });
10296
+ const grab = async (local) => {
10297
+ await seekFrame(el, Math.max(0, mediaTimeAt(c, c.start + local)) + 0.001);
10298
+ x.drawImage(el, 0, 0, aw, ah);
10299
+ return Tracker.toGray(x.getImageData(0, 0, aw, ah).data, aw, ah);
10300
+ };
10301
+ const mc = picToMedia(p0, box.x, box.y);
10302
+ 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 });
10303
+ if (tr.flat) throw new Error("that region has no detail to follow — pick a spot with texture or edges");
10304
+ const dt = 1 / (projectFps() || 30), end = clamp(to, 0, c.duration);
10305
+ const n = Math.floor(Math.abs(end - from) / dt + 1e-6);
10306
+ const samples = [[+from.toFixed(4), box.x, box.y, 1, 0, 1]];
10307
+ let lostAt = null;
10308
+ for (let i = 1; i <= n; i++) {
10309
+ if (run.cancel) break;
10310
+ const t = from + dir * i * dt, r = tr.step(await grab(t));
10311
+ if (r.lost) { lostAt = t; break; }
10312
+ const pic = mediaToPic(p0, r.cx / aw, r.cy / ah);
10313
+ samples.push([+t.toFixed(4), pic.u, pic.v, r.s, r.r, r.q]);
10314
+ if (onProgress) onProgress(i / n, t);
10315
+ }
10316
+ if (dir < 0) samples.reverse();
10317
+ return { samples, lostAt, cancelled: !!run.cancel };
10318
+ } finally { el.removeAttribute("src"); el.load(); }
10319
+ }
10320
+ /** What a tracking result was measured against: the clip object and its timing. */
10321
+ function trackStamp(c) {
10322
+ if (!c) return "";
10323
+ const p = c.props || {}, k = c.keyframes || {};
10324
+ return JSON.stringify([c.mediaId, c.start, c.in, c.duration, clipSpeed(c), k.speed || null,
10325
+ p.cropL, p.cropR, p.cropT, p.cropB, k.cropL || null, k.cropR || null, k.cropT || null, k.cropB || null]);
10326
+ }
10327
+ /** Merge new samples into track `name` (replacing the old ones in their time range). */
10328
+ function storeTrack(c, name, kind, w, h, samples) {
10329
+ const list = clipTracks(c).map((x) => ({ ...x })), t0 = samples[0][0], t1 = samples[samples.length - 1][0];
10330
+ const i = list.findIndex((x) => x.name === name);
10331
+ const keep = i >= 0 ? list[i].samples.filter((s) => s[0] < t0 - 1e-4 || s[0] > t1 + 1e-4) : [];
10332
+ const fresh = kind === "box" ? Tracker.smooth(samples) : samples; // scale / rotation steadied once, for every use
10333
+ 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]) };
10334
+ if (i >= 0) list[i] = tr; else list.push(tr);
10335
+ writeTracks(c, list.slice(-Tracker.MAX_TRACKS));
10336
+ return clipTracks(c).find((x) => x.name === name);
10337
+ }
10338
+ /** Bake samples into mask i's keys: x / y (and scale / rotation) move by what the samples moved. */
10339
+ function bakeTrackIntoMask(c, i, samples, { scale, rotation }, from = samples[0][0]) {
10340
+ const list = maskList(c).slice(), raw = list[i];
10341
+ if (!raw || samples.length < 2) return 0;
10342
+ const g = pictureRect(c, evalProps(c, c.start + samples[0][0]));
10343
+ // the mask is where it was drawn at `from` (where tracking started): motion is measured from there, on the
10344
+ // smoothed path, and a key stays at `from` — so a backward or two-way track keeps it exactly at the playhead
10345
+ const sm3 = Tracker.smooth(samples, 3, 1);
10346
+ 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]; })();
10347
+ const S = Tracker.simplify(sm3, 1.5 / Math.max(1, g ? g.dw : 1000)); // keys within ~1.5 px
10348
+ if (from >= samples[0][0] && from <= samples[samples.length - 1][0] && !S.some((sm) => Math.abs(sm[0] - from) < 1e-4)) {
10349
+ S.push(anchor); S.sort((a, b) => a[0] - b[0]);
10350
+ }
10351
+ const first = samples[0], base = Mask.maskAt(raw, from);
10352
+ const motion = ["x", "y", ...(scale ? ["scale"] : []), ...(rotation ? ["rotation"] : [])];
10353
+ const t0 = first[0] - 1e-4, t1 = samples[samples.length - 1][0] + 1e-4;
10354
+ let keys = (raw.keys || []).map((k) => ({ ...k }));
10355
+ for (const k of keys) if (k.t >= t0 && k.t <= t1) for (const p of motion) delete k[p];
10356
+ keys = keys.filter((k) => Object.keys(k).some((p) => p !== "t" && p !== "ease"));
10357
+ for (const sm of S) {
10358
+ const v = { x: +(base.x + sm[1] - anchor[1]).toFixed(4), y: +(base.y + sm[2] - anchor[2]).toFixed(4) };
10359
+ if (scale) v.scale = +(base.scale * sm[3] / (anchor[3] || 1)).toFixed(4);
10360
+ if (rotation) v.rotation = +(base.rotation + sm[4] - anchor[4]).toFixed(3);
10361
+ const at = keys.find((k) => Math.abs(k.t - sm[0]) < 1e-4);
10362
+ if (at) Object.assign(at, v, { ease: "linear" }); else keys.push({ t: sm[0], ease: "linear", ...v });
10363
+ }
10364
+ keys.sort((a, b) => a.t - b.t);
10365
+ list[i] = Mask.mergeMask(raw, { keys }) || raw;
10366
+ writeMasks(c, list);
10367
+ return S.length;
10368
+ }
10369
+ /** Where the clip's video is drawn at props p: centre, size, rotation, flips (project px). */
10370
+ function pictureRect(c, p) {
10371
+ const el = c.kind === "video" ? getClipEl(c) : null, W = project.width, H = project.height;
10372
+ const sw = el && el.videoWidth, sh = el && el.videoHeight;
10373
+ if (!sw || !sh) return null;
10374
+ 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;
10375
+ let dw, dh;
10376
+ if (p.fit === "cover") { const f = Math.max(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
10377
+ else if (p.fit === "stretch") { dw = W * sc; dh = H * sc; }
10378
+ else if (p.fit === "none") { dw = cw * sc; dh = ch * sc; }
10379
+ else { const f = Math.min(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
10380
+ 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 };
10381
+ }
10382
+ /** A track's point on the frame at timeline time T (project px), with its scale and rotation there. */
10383
+ function trackOnFrame(c, tr, T) {
10384
+ const s = Tracker.trackAt(tr, T - c.start), p = evalProps(c, T), g = pictureRect(c, p);
10385
+ if (!g) return null;
10386
+ 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);
10387
+ 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) };
10388
+ }
10389
+ /** Bake clip c's track into clip b's x / y (and scale / rotation) keyframes so b rides on it. */
10390
+ function attachToTrack(c, track, b, { scale, rotation, at = state.time }) {
10391
+ 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);
10392
+ if (z - a < 1e-3) return 0;
10393
+ const tRef = clamp(at, a, z), ref = trackOnFrame(c, tr, tRef);
10394
+ if (!ref) return 0;
10395
+ const pb = evalProps(b, tRef), W = project.width, H = project.height;
10396
+ const off = { x: (+pb.x || 0) - (ref.x - W / 2), y: (+pb.y || 0) - (ref.y - H / 2) };
10397
+ const g = pictureRect(c, evalProps(c, tRef));
10398
+ // the samples inside the overlap, plus the track at its edges and at the reference (between samples, too)
10399
+ 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]; };
10400
+ const inRange = S.filter((sm) => c.start + sm[0] > a + 1e-4 && c.start + sm[0] < z - 1e-4);
10401
+ for (const T of [a, z, tRef]) if (!inRange.some((sm) => Math.abs(c.start + sm[0] - T) < 1e-4)) inRange.push(edge(T));
10402
+ inRange.sort((p, q) => p[0] - q[0]);
10403
+ const pts = Tracker.simplify(inRange, 1.5 / Math.max(1, g ? g.dw : 1000));
10404
+ const props = ["x", "y", ...(scale ? ["scale"] : []), ...(rotation ? ["rotation"] : [])];
10405
+ b.keyframes = b.keyframes || {};
10406
+ const l0 = a - b.start - 1e-4, l1 = z - b.start + 1e-4;
10407
+ for (const k of props) b.keyframes[k] = (b.keyframes[k] || []).filter((kf) => kf.t < l0 || kf.t > l1);
10408
+ for (const sm of pts) {
10409
+ const T = c.start + sm[0], v = trackOnFrame(c, tr, T), t = +(T - b.start).toFixed(4);
10410
+ if (!v) continue;
10411
+ 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 };
10412
+ for (const k of props) b.keyframes[k].push({ t, v: +vals[k].toFixed(k === "scale" ? 4 : 2), ease: "linear" });
10413
+ }
10414
+ for (const k of props) { b.keyframes[k].sort((p, q) => p.t - q.t); if (!b.keyframes[k].length) delete b.keyframes[k]; }
10415
+ if (!Object.keys(b.keyframes).length) delete b.keyframes;
10416
+ state.dirtyTimeline = true; inspPropGen++;
10417
+ return pts.length;
10418
+ }
10419
+ /** The mask's outline bounds at clip-local t, as picture fractions (clamped into the picture). */
10420
+ function maskBoundsAt(c, raw, t) {
10421
+ const g = pictureRect(c, evalProps(c, c.start + t)), m = Mask.maskAt(raw, t);
10422
+ if (!g) return null;
10423
+ const B = { bw: g.dw, bh: g.dh };
10424
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
10425
+ 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); }
10426
+ 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);
10427
+ if (x1 - x0 < 0.01 || y1 - y0 < 0.01) return null;
10428
+ return { x: (x0 + x1) / 2, y: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
10429
+ }
10430
+ /** Run a track from the playhead with progress in the Inspector; Esc stops it. */
10431
+ async function trackWithUi(c, label, job, after) {
10432
+ if (trackUi.run) { toast("Already tracking — wait, or press Esc to stop"); return null; }
10433
+ if (state.playing) pause();
10434
+ const run = trackUi.run = { cancel: false, clip: c.id, label, pct: 0 };
10435
+ const stamp = trackStamp(c), maskStamp = job.mask != null ? JSON.stringify(maskList(c)[job.mask] ?? null) : null;
10436
+ renderInspector();
10437
+ try {
10438
+ const r = await runTrack(c, { ...job, run, onProgress: (f) => {
10439
+ run.pct = f;
10440
+ for (const el of els.inspector.querySelectorAll("[data-track-progress]")) el.textContent = `${label}: ${Math.round(f * 100)}% — Esc stops`;
10441
+ } });
10442
+ 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; }
10443
+ if (getClip(c.id) !== c || trackStamp(c) !== stamp || (maskStamp != null && JSON.stringify(maskList(c)[job.mask] ?? null) !== maskStamp)) {
10444
+ toast("The clip changed while tracking — nothing was applied; track it again");
10445
+ return null;
10446
+ }
10447
+ pushUndo();
10448
+ after(r);
10449
+ scheduleSave();
10450
+ const span = Math.abs(r.samples[r.samples.length - 1][0] - r.samples[0][0]);
10451
+ 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)`);
10452
+ return r;
10453
+ } catch (err) {
10454
+ toast("Tracking failed: " + (err && err.message || err));
10455
+ return null;
10456
+ } finally {
10457
+ trackUi.run = null;
10458
+ state.dirtyTimeline = true;
10459
+ renderInspector(); drawFrame();
10460
+ }
10461
+ }
10462
+ function trackMaskFromPlayhead(c, i, dir) {
10463
+ const raw = maskList(c)[i], t = maskLocalT(c);
10464
+ if (!raw || !activeAt(c, state.time)) { toast("Move the playhead over the clip first"); return; }
10465
+ const box = maskBoundsAt(c, raw, t);
10466
+ if (!box) { toast("The mask is outside the picture here"); return; }
10467
+ const name = raw.name || `Mask ${i + 1}`, opts = { scale: trackUi.scale, rotation: trackUi.rotation };
10468
+ trackWithUi(c, `Tracking ${name}`, { box, from: t, to: dir > 0 ? c.duration : 0, dir, mask: i, ...opts }, (r) => {
10469
+ storeTrack(c, name, "box", box.w, box.h, r.samples);
10470
+ bakeTrackIntoMask(c, i, r.samples, opts, t);
10471
+ });
9285
10472
  }
9286
- function hasTextBox(p) { return +p.boxW > 0 && +p.boxH > 0; }
9287
- /* Target width for justify: text box width when set, else max(natural, ~85% canvas). */
9288
- function textJustifyTarget(p, naturalBlockW) {
9289
- if (+p.boxW > 0) return +p.boxW;
9290
- const sc = Math.max(0.001, +p.scale || 1);
9291
- return Math.max(naturalBlockW, project.width / sc * 0.85);
10473
+ function trackPointFromPlayhead(c, name, box, dir) {
10474
+ const t = clamp(state.time - c.start, 0, c.duration);
10475
+ trackWithUi(c, `Tracking ${name}`, { box, from: t, to: dir > 0 ? c.duration : 0, dir, scale: trackUi.scale, rotation: trackUi.rotation }, (r) => {
10476
+ storeTrack(c, name, trackUi.scale || trackUi.rotation ? "box" : "point", box.w, box.h, r.samples);
10477
+ trackUi.sel = clipTracks(c).findIndex((x) => x.name === name);
10478
+ });
9292
10479
  }
9293
- /* Expand a line to ≈ targetW by inserting whole spaces between words. */
9294
- function justifyLineBySpaces(ctx, ln, targetW) {
9295
- const words = String(ln).split(/\s+/).filter(Boolean);
9296
- if (words.length < 2) return ln;
10480
+ /** The region a track continues from at the playhead: its position there, its size. */
10481
+ function trackBoxAt(c, tr) {
10482
+ const s = Tracker.trackAt(tr, state.time - c.start);
10483
+ return { x: s.x, y: s.y, w: tr.w * s.s, h: tr.h * s.s };
10484
+ }
10485
+ function trackSectionHtml(c) {
10486
+ if (trackUi.clip !== c.id) Object.assign(trackUi, { clip: c.id, sel: -1, tool: null });
10487
+ const list = clipTracks(c), tr = list[trackUi.sel], run = trackUi.run && trackUi.run.clip === c.id ? trackUi.run : null;
10488
+ const others = project.clips.filter((x) => x.id !== c.id && isVisualClip(x) && x.start < c.start + c.duration && c.start < x.start + x.duration);
10489
+ 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("");
10490
+ let html = `<div class="insp-section" data-track-section><h3>Tracking</h3>
10491
+ <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>
10492
+ <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>
10493
+ <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>
10494
+ <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>
10495
+ <input type="number" class="cp-num" data-tsize min="2" max="40" step="1" value="${Math.round(trackUi.size * 100)}"></div>
10496
+ ${run ? `<p class="cl-note" data-track-progress>${escapeHtml(run.label)}: ${Math.round(run.pct * 100)}% — Esc stops</p>` : ""}
10497
+ ${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>` : ""}
10498
+ ${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>`}`;
10499
+ if (tr) {
10500
+ const S = tr.samples;
10501
+ 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>
10502
+ <button type="button" class="btn tiny" data-tact="fwd" ${run ? "disabled" : ""} title="Track from the playhead to the clip's end">Track on ▶</button>
10503
+ <button type="button" class="btn tiny" data-tact="del" title="Delete this track (what follows it keeps its keys)">Delete</button></div>
10504
+ <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>
10505
+ <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>
10506
+ <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>`;
10507
+ }
10508
+ return html + `</div>`;
10509
+ }
10510
+ function bindTrackControls(c) {
10511
+ const root = els.inspector.querySelector("[data-track-section]");
10512
+ if (!root) return;
10513
+ const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
10514
+ const size = root.querySelector("[data-tsize]");
10515
+ if (size) size.addEventListener("change", () => { trackUi.size = clamp((+size.value || 8) / 100, 0.02, 0.4); size.blur(); });
10516
+ for (const b of root.querySelectorAll("[data-tsel]")) b.addEventListener("click", () => {
10517
+ const j = +b.dataset.tsel;
10518
+ trackUi.sel = trackUi.sel === j ? -1 : j; trackUi.tool = null;
10519
+ renderInspector(); drawFrame();
10520
+ });
10521
+ for (const b of root.querySelectorAll("[data-tact]")) b.addEventListener("click", () => {
10522
+ const a = b.dataset.tact, tr = clipTracks(c)[trackUi.sel];
10523
+ if (a === "scale" || a === "rotation") { trackUi[a] = !trackUi[a]; renderInspector(); return; }
10524
+ if (!guard()) return;
10525
+ if (a === "point") {
10526
+ trackUi.tool = trackUi.tool === "point" ? null : "point";
10527
+ if (trackUi.tool && !activeAt(c, state.time)) toast("Move the playhead over the clip, then click the spot to follow");
10528
+ renderInspector(); return;
10529
+ }
10530
+ if (!tr) return;
10531
+ if (a === "del") { pushUndo(); writeTracks(c, clipTracks(c).filter((x) => x !== tr)); trackUi.sel = -1; scheduleSave(); renderInspector(); drawFrame(); return; }
10532
+ if (a === "back" || a === "fwd") {
10533
+ if (!activeAt(c, state.time)) { toast("Move the playhead over the clip first"); return; }
10534
+ trackPointFromPlayhead(c, tr.name, trackBoxAt(c, tr), a === "fwd" ? 1 : -1);
10535
+ return;
10536
+ }
10537
+ if (a === "attach") {
10538
+ const other = getClip(root.querySelector("[data-tattach]").value);
10539
+ if (!other) { toast("Pick a clip to attach first"); return; }
10540
+ if (isGroupLocked(other)) { toastLocked(); return; }
10541
+ pushUndo();
10542
+ const n = attachToTrack(c, tr, other, { scale: trackUi.scale, rotation: trackUi.rotation });
10543
+ scheduleSave(); renderInspector(); drawFrame();
10544
+ toast(n ? `${other.name || other.kind} follows ${tr.name} (${n} keys)` : "That clip and the track don't overlap in time");
10545
+ }
10546
+ });
10547
+ }
10548
+ /** Monitor: the selected track's path, and its region at the playhead. */
10549
+ function drawTrackOverlay(c, W) {
10550
+ if (!c || trackUi.clip !== c.id || !els.inspector.querySelector("[data-track-section]")) return;
10551
+ const tr = clipTracks(c)[trackUi.sel], B = maskBox(c);
10552
+ if (!tr || !B || !activeAt(c, state.time)) return;
10553
+ const lw = Math.max(1.5, W / 900), S = tr.samples, step = Math.max(1, Math.floor(S.length / 400));
10554
+ ctx2d.save(); ctx2d.setTransform(1, 0, 0, 1, 0, 0);
10555
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(255, 196, 64, .8)";
10556
+ ctx2d.beginPath();
10557
+ 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); }
10558
+ ctx2d.stroke();
10559
+ const s = Tracker.trackAt(tr, state.time - c.start), p = B.toCanvas(s.x * B.bw, s.y * B.bh);
10560
+ 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));
10561
+ ctx2d.strokeStyle = s.inside ? "#ffc440" : "rgba(255, 196, 64, .45)";
10562
+ ctx2d.strokeRect(p.x - r, p.y - r, r * 2, r * 2);
10563
+ 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();
10564
+ ctx2d.restore();
10565
+ }
10566
+ els.preview.addEventListener("pointerdown", (e) => {
10567
+ if (e.button !== 0 || trackUi.tool !== "point" || isSourceMode()) return;
10568
+ const c = getClip(state.selId);
10569
+ if (!c || c.id !== trackUi.clip || !activeAt(c, state.time)) return;
10570
+ const B = maskBox(c);
10571
+ if (!B) return;
10572
+ e.preventDefault(); e.stopImmediatePropagation();
10573
+ const bp = B.toBox(canvasPt(e)), u = bp.x / B.bw, v = bp.y / B.bh;
10574
+ if (u < 0 || u > 1 || v < 0 || v > 1) { toast("Click inside the clip's picture"); return; }
10575
+ const used = new Set(clipTracks(c).map((x) => x.name));
10576
+ let n = 1;
10577
+ while (used.has(`Track ${n}`)) n++;
10578
+ trackUi.tool = null;
10579
+ trackPointFromPlayhead(c, `Track ${n}`, { x: u, y: v, w: trackUi.size, h: trackUi.size * B.bw / B.bh }, 1);
10580
+ }, true);
10581
+ window.addEventListener("keydown", (e) => {
10582
+ if (e.key !== "Escape" || (!trackUi.run && trackUi.tool !== "point")) return;
10583
+ e.preventDefault(); e.stopImmediatePropagation();
10584
+ if (trackUi.run) trackUi.run.cancel = true;
10585
+ else { trackUi.tool = null; renderInspector(); }
10586
+ }, true);
10587
+ function maskSectionHtml(c) {
10588
+ const ui = maskUiFor(c), list = maskList(c), i = ui.sel, m = i >= 0 ? maskNowAt(c, i) : null, raw = list[i];
10589
+ 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>`;
10590
+ 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}"
10591
+ 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("");
10592
+ let html = `<div class="insp-section" data-mask-section><h3>Masks</h3>
10593
+ <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")}
10594
+ ${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>
10595
+ ${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>` : ""}
10596
+ ${ui.tool === "draw" ? `<p class="cl-note">Draw: drag around what to keep. Esc cancels.</p>` : ""}
10597
+ ${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>`}`;
10598
+ if (m) {
10599
+ const keyed = raw.keys ? raw.keys.length : 0, onKey = maskKeyIndex(raw, maskLocalT(c)) >= 0;
10600
+ const mrow = (p) => {
10601
+ const [lab, min, max, step] = MASK_ROWS_UI[p];
10602
+ return `<div class="insp-row cp-row"><label class="cp-label">${lab}</label>
10603
+ <input type="range" data-mk="${p}" min="${min}" max="${max}" step="${step}" value="${m[p]}">
10604
+ <input type="number" class="cp-num" data-mknum="${p}" step="${step}" value="${fmtG(m[p], step)}"></div>`;
10605
+ };
10606
+ 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">
10607
+ <button type="button" class="btn tiny${raw.on === false ? "" : " toggle on"}" data-mact="on" title="Mask on / off">${raw.on === false ? "Off" : "On"}</button>
10608
+ <button type="button" class="btn tiny" data-mact="up" ${i === 0 ? "disabled" : ""} title="Move down the stack (combined earlier)">↑</button>
10609
+ <button type="button" class="btn tiny" data-mact="down" ${i >= list.length - 1 ? "disabled" : ""} title="Move up the stack (combined later)">↓</button>
10610
+ <button type="button" class="btn tiny" data-mact="dup" ${list.length >= Mask.MAX_MASKS ? "disabled" : ""} title="Duplicate">Copy</button>
10611
+ <button type="button" class="btn tiny" data-mact="del" title="Delete this mask">Delete</button></div>
10612
+ <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>
10613
+ <button type="button" class="btn tiny${raw.invert ? " toggle on" : ""}" data-mact="invert" title="Use everything outside the shape">Invert</button>
10614
+ <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>
10615
+ <span class="cl-note">${keyed ? `${keyed} key${keyed > 1 ? "s" : ""}` : ""}</span>
10616
+ ${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>
10617
+ ${c.kind === "video" ? `<div class="cl-row"><span class="cl-note">Track</span>
10618
+ <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>
10619
+ <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>
10620
+ <button type="button" class="btn tiny${trackUi.scale ? " toggle on" : ""}" data-mtrackopt="scale" title="Also key its scale">Scale</button>
10621
+ <button type="button" class="btn tiny${trackUi.rotation ? " toggle on" : ""}" data-mtrackopt="rotation" title="Also key its rotation">Rotate</button></div>
10622
+ ${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>` : ""}` : ""}
10623
+ ${mrow("opacity")}${mrow("feather")}${mrow("expand")}${mrow("x")}${mrow("y")}${m.shape !== "bezier" ? mrow("w") + mrow("h") : ""}${mrow("scale")}${mrow("rotation")}
10624
+ <p class="cl-note">On the monitor: drag inside to move, the knob rotates (Shift: 15° steps)${m.shape === "bezier"
10625
+ ? "; 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"
10626
+ : ", the edge handles resize"}. Esc deselects. Every change undoes with Ctrl+Z (redo: Ctrl+Shift+Z or Ctrl+Y).</p>`;
10627
+ }
10628
+ return html + `</div>`;
10629
+ }
10630
+ function bindMaskControls(c) {
10631
+ const root = els.inspector.querySelector("[data-mask-section]");
10632
+ if (!root) return;
10633
+ const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
10634
+ const rerender = () => { state.dirtyTimeline = true; renderInspector(); };
10635
+ for (const b of root.querySelectorAll("[data-mtool]")) b.addEventListener("click", () => {
10636
+ const k = b.dataset.mtool;
10637
+ if (!guard()) return;
10638
+ if (k === "rect" || k === "ellipse") {
10639
+ pushUndo();
10640
+ const list = maskList(c).slice();
10641
+ list.push({ shape: k, x: 0.5, y: 0.5, w: k === "rect" ? 0.5 : 0.45, h: k === "rect" ? 0.5 : 0.6 });
10642
+ writeMasks(c, list); maskUi.sel = list.length - 1; maskUi.tool = null; maskUi.pt = -1;
10643
+ scheduleSave(); rerender();
10644
+ if (!activeAt(c, state.time)) toast("Move the playhead over the clip to see the mask on the monitor");
10645
+ return;
10646
+ }
10647
+ maskUi.tool = maskUi.tool === k ? null : k; maskUi.pen = null; maskUi.stroke = null;
10648
+ 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"); }
10649
+ rerender();
10650
+ });
10651
+ for (const b of root.querySelectorAll("[data-mtrackopt]")) b.addEventListener("click", () => { trackUi[b.dataset.mtrackopt] = !trackUi[b.dataset.mtrackopt]; rerender(); });
10652
+ for (const b of root.querySelectorAll("[data-mtrack]")) b.addEventListener("click", () => {
10653
+ if (!guard() || maskUi.sel < 0) return;
10654
+ trackMaskFromPlayhead(c, maskUi.sel, +b.dataset.mtrack);
10655
+ });
10656
+ for (const b of root.querySelectorAll("[data-msel]")) b.addEventListener("click", () => {
10657
+ const j = +b.dataset.msel;
10658
+ maskUi.sel = maskUi.sel === j ? -1 : j; maskUi.pt = -1; maskUi.tool = null;
10659
+ if (maskUi.sel >= 0) { shapeUi.sel = null; shapeUi.tool = null; }
10660
+ rerender();
10661
+ });
10662
+ for (const b of root.querySelectorAll("[data-mact]")) b.addEventListener("click", () => {
10663
+ const a = b.dataset.mact, i = maskUi.sel;
10664
+ if (a === "view") { runtime.maskView = runtime.maskView ? null : { id: c.id }; rerender(); return; }
10665
+ const list = maskList(c).slice(), m = list[i];
10666
+ if (!m || !guard()) return;
10667
+ pushUndo();
10668
+ if (a === "on") list[i] = Mask.mergeMask(m, { on: m.on === false ? null : false });
10669
+ else if (a === "invert") list[i] = Mask.mergeMask(m, { invert: m.invert ? null : true });
10670
+ else if (a === "del") { list.splice(i, 1); maskUi.sel = Math.min(i, list.length - 1); maskUi.pt = -1; }
10671
+ 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; }
10672
+ 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; }
10673
+ else if (a === "clearkeys") { const now = Mask.maskAt(m, maskLocalT(c)); list[i] = Mask.mergeMask(m, { ...maskSnapshotKey(now, 0), t: null, keys: null }); }
10674
+ else if (a === "key") {
10675
+ const t = maskLocalT(c), j = maskKeyIndex(m, t), now = Mask.maskAt(m, t);
10676
+ const keys = (m.keys || []).map((k) => ({ ...k }));
10677
+ if (j >= 0) keys.splice(j, 1); else keys.push(maskSnapshotKey(now, t));
10678
+ const set = { keys: keys.length ? keys : null };
10679
+ if (!keys.length) Object.assign(set, maskSnapshotKey(now, 0), { t: null });
10680
+ list[i] = Mask.mergeMask(m, set);
10681
+ }
10682
+ writeMasks(c, list); scheduleSave(); rerender();
10683
+ });
10684
+ const mode = root.querySelector("[data-mmode]");
10685
+ if (mode) mode.addEventListener("change", () => {
10686
+ if (!guard()) return;
10687
+ pushUndo(); writeMaskSet(c, maskUi.sel, { mode: mode.value }); scheduleSave(); rerender();
10688
+ document.activeElement?.blur?.(); // hand Ctrl+Z back to the editor
10689
+ });
10690
+ const name = root.querySelector("[data-mname]");
10691
+ if (name) name.addEventListener("change", () => {
10692
+ if (!guard()) return;
10693
+ pushUndo(); writeMaskSet(c, maskUi.sel, { name: name.value.trim() || null }); scheduleSave(); rerender();
10694
+ });
10695
+ for (const r of root.querySelectorAll("[data-mk]")) {
10696
+ const p = r.dataset.mk;
10697
+ r.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
10698
+ r.addEventListener("input", () => {
10699
+ if (isGroupLocked(c)) return;
10700
+ writeMaskSet(c, maskUi.sel, { [p]: +r.value }); scheduleSave();
10701
+ const n = root.querySelector(`[data-mknum="${p}"]`); if (n) n.value = fmtG(+r.value, MASK_ROWS_UI[p][3]);
10702
+ });
10703
+ }
10704
+ for (const n of root.querySelectorAll("[data-mknum]")) {
10705
+ const p = n.dataset.mknum;
10706
+ n.addEventListener("change", () => {
10707
+ const x = parseFloat(n.value);
10708
+ if (!Number.isFinite(x) || !guard()) return;
10709
+ pushUndo(); writeMaskSet(c, maskUi.sel, { [p]: x }); scheduleSave(); rerender();
10710
+ document.activeElement?.blur?.();
10711
+ });
10712
+ }
10713
+ }
10714
+ /** Keyed masks: the sliders and ◆ follow the playhead (called from the inspector sync). */
10715
+ function syncMaskInspector(c) {
10716
+ const root = els.inspector.querySelector("[data-mask-section]");
10717
+ if (!root || maskUi.clip !== c.id || maskUi.sel < 0 || maskUi.drag) return;
10718
+ const raw = maskList(c)[maskUi.sel], m = maskNowAt(c, maskUi.sel);
10719
+ if (!m) return;
10720
+ const active = document.activeElement;
10721
+ for (const p of Object.keys(MASK_ROWS_UI)) {
10722
+ const r = root.querySelector(`[data-mk="${p}"]`), n = root.querySelector(`[data-mknum="${p}"]`);
10723
+ if (r && r !== active) r.value = m[p];
10724
+ if (n && n !== active) n.value = fmtG(m[p], MASK_ROWS_UI[p][3]);
10725
+ }
10726
+ const k = root.querySelector("[data-mact=key]"), on = maskKeyIndex(raw, maskLocalT(c)) >= 0;
10727
+ if (k) { k.classList.toggle("on", on); k.classList.toggle("toggle", on); }
10728
+ }
10729
+
10730
+ /* Monitor geometry: the picture box (bw × bh px, origin top-left) ↔ canvas px. */
10731
+ function maskBox(c) {
10732
+ const g = runtime.maskGeom && runtime.maskGeom.get(c.id);
10733
+ if (!g) return null;
10734
+ const M = g.m.translate(g.x0, g.y0), inv = M.inverse();
10735
+ return {
10736
+ bw: g.w, bh: g.h,
10737
+ toCanvas: (x, y) => { const p = M.transformPoint(new DOMPoint(x, y)); return { x: p.x, y: p.y }; },
10738
+ toBox: (pt) => { const p = inv.transformPoint(new DOMPoint(pt.x, pt.y)); return { x: p.x, y: p.y }; },
10739
+ };
10740
+ }
10741
+ /** Box px ↔ a mask's own frame (fractions of the picture, before its scale / rotation). */
10742
+ function maskBoxToLocal(m, B, bx, by, vector = false) {
10743
+ const th = (m.rotation || 0) * Math.PI / 180, s = m.scale || 1, cs = Math.cos(th), sn = Math.sin(th);
10744
+ const dx = bx - (vector ? 0 : m.x * B.bw), dy = by - (vector ? 0 : m.y * B.bh);
10745
+ return { x: (dx * cs + dy * sn) / s / B.bw, y: (-dx * sn + dy * cs) / s / B.bh };
10746
+ }
10747
+ function maskLocalToBox(m, B, lx, ly) {
10748
+ const th = (m.rotation || 0) * Math.PI / 180, s = m.scale || 1, cs = Math.cos(th), sn = Math.sin(th);
10749
+ const px = lx * B.bw * s, py = ly * B.bh * s;
10750
+ return { x: m.x * B.bw + px * cs - py * sn, y: m.y * B.bh + px * sn + py * cs };
10751
+ }
10752
+ function maskEditActive(c) {
10753
+ return !!c && maskUi.clip === c.id && state.selId === c.id && (maskUi.sel >= 0 || !!maskUi.tool) &&
10754
+ activeAt(c, state.time) && !!maskBox(c) && !state.playing;
10755
+ }
10756
+ /** Handles for the selected mask, in canvas px. */
10757
+ function clipMaskHandles(c) {
10758
+ const m = maskNowAt(c, maskUi.sel), B = m && maskBox(c);
10759
+ if (!B) return null;
10760
+ const at = (lx, ly) => { const q = maskLocalToBox(m, B, lx, ly); return B.toCanvas(q.x, q.y); };
10761
+ 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) }));
10762
+ const top = m.shape === "bezier" ? Math.min(...m.points.map((p) => p[1])) : -m.h / 2;
10763
+ const base = at(0, top), up = at(0, top - 0.01), d = Math.hypot(up.x - base.x, up.y - base.y) || 1;
10764
+ const W = els.preview.width, H = els.preview.height, gap = Math.max(24, W / 34), inset = Math.max(8, W / 110);
10765
+ 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) };
10766
+ 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) }));
10767
+ return { m, B, A, centre: at(0, 0), knob, knobBase: base, edges };
10768
+ }
10769
+ function strokeOutline(ctx, pts, closed) {
10770
+ ctx.beginPath();
10771
+ pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])));
10772
+ if (closed) ctx.closePath();
10773
+ ctx.stroke();
10774
+ }
10775
+ /** Drawn instead of the selection box while a mask is picked or a mask tool is on. */
10776
+ function drawClipMaskOverlay(c, W) {
10777
+ const B = maskBox(c);
10778
+ if (!B) return false;
10779
+ const lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
10780
+ ctx2d.save();
10781
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
10782
+ ctx2d.lineJoin = "round";
10783
+ 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]; });
10784
+ maskList(c).forEach((raw, j) => { // the other masks, faint
10785
+ if (j === maskUi.sel) return;
10786
+ ctx2d.setLineDash([lw * 3, lw * 3]); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(255,210,90,0.45)";
10787
+ strokeOutline(ctx2d, outline(Mask.maskAt(raw, maskLocalT(c))), true);
10788
+ });
10789
+ ctx2d.setLineDash([]);
10790
+ const dot = (p, r, fill, square) => {
10791
+ ctx2d.beginPath();
10792
+ 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);
10793
+ ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.7)"; ctx2d.stroke();
10794
+ };
10795
+ const h = maskUi.sel >= 0 ? clipMaskHandles(c) : null;
10796
+ if (h) {
10797
+ const pts = outline(h.m);
10798
+ ctx2d.lineWidth = lw * 2.2; ctx2d.strokeStyle = "rgba(0,0,0,0.55)"; strokeOutline(ctx2d, pts, true);
10799
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = h.m.invert ? "#ff9f5a" : "#ffd25a"; strokeOutline(ctx2d, pts, true);
10800
+ ctx2d.beginPath(); ctx2d.moveTo(h.knobBase.x, h.knobBase.y); ctx2d.lineTo(h.knob.x, h.knob.y); ctx2d.stroke();
10801
+ for (const e of h.edges) dot(e, hs * 0.8, "#fff");
10802
+ if (h.m.shape === "bezier") {
10803
+ const sp = h.A[maskUi.pt];
10804
+ if (sp) {
10805
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#9fd0ff";
10806
+ 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(); }
10807
+ dot(sp.i, hs * 0.6, "#9fd0ff"); dot(sp.o, hs * 0.6, "#9fd0ff");
10808
+ }
10809
+ h.A.forEach((p, j) => dot(p.a, hs * 0.62, j === maskUi.pt ? "#ffd25a" : "#fff", true));
10810
+ }
10811
+ dot(h.centre, hs * 0.5, "#ffd25a");
10812
+ dot(h.knob, hs * 0.9, "#ffce5c");
10813
+ }
10814
+ if (maskUi.tool === "pen" && maskUi.pen && maskUi.pen.pts.length) {
10815
+ 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] }));
10816
+ 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]; });
10817
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#ffd25a";
10818
+ if (A.length > 1) strokeOutline(ctx2d, open, false);
10819
+ 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));
10820
+ }
10821
+ if (maskUi.tool === "draw" && maskUi.stroke && maskUi.stroke.length > 1) {
10822
+ ctx2d.lineWidth = lw * 1.5; ctx2d.strokeStyle = "#ffd25a";
10823
+ strokeOutline(ctx2d, maskUi.stroke.map(([u, v]) => { const q = B.toCanvas(u * B.bw, v * B.bh); return [q.x, q.y]; }), false);
10824
+ }
10825
+ ctx2d.restore();
10826
+ return true;
10827
+ }
10828
+ function addMaskFrom(c, mask) {
10829
+ const list = maskList(c).slice();
10830
+ if (list.length >= Mask.MAX_MASKS) { toast(`A clip has at most ${Mask.MAX_MASKS} masks`); return; }
10831
+ const m = Mask.normalizeMask(mask);
10832
+ if (!m) return;
10833
+ pushUndo();
10834
+ list.push(m); writeMasks(c, list);
10835
+ maskUi.sel = list.length - 1; maskUi.pt = -1; maskUi.tool = null; maskUi.pen = null; maskUi.stroke = null;
10836
+ scheduleSave(); renderInspector();
10837
+ }
10838
+ function finishPen(c) {
10839
+ const B = maskBox(c), P = maskUi.pen && maskUi.pen.pts;
10840
+ if (!B || !P || P.length < 3) { toast("A pen mask needs at least 3 points"); return; }
10841
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
10842
+ 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); }
10843
+ const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
10844
+ addMaskFrom(c, {
10845
+ shape: "bezier", x: cx / B.bw, y: cy / B.bh,
10846
+ 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]),
10847
+ });
10848
+ }
10849
+ /** The segment and parameter of the selected bezier's outline nearest `bp` (box px). */
10850
+ function nearestMaskSegment(m, B, bp) {
10851
+ const A = Mask.anchors(m, B.bw, B.bh);
10852
+ let best = null;
10853
+ for (let i = 0; i < A.length; i++) {
10854
+ const a = A[i], b = A[(i + 1) % A.length];
10855
+ for (let k = 1; k < 24; k++) {
10856
+ const t = k / 24, u = 1 - t;
10857
+ const x = u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x;
10858
+ const y = u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y;
10859
+ const d = Math.hypot(x - bp.x, y - bp.y);
10860
+ if (!best || d < best.d) best = { d, i, t };
10861
+ }
10862
+ }
10863
+ return best;
10864
+ }
10865
+ /** Add or remove bezier points on mask i: the base outline and every keyed outline change together. */
10866
+ function writeMaskTopology(c, i, edit) {
10867
+ const list = maskList(c).slice();
10868
+ if (!list[i]) return;
10869
+ list[i] = Mask.mergeMask(list[i], Mask.editTopology(list[i], edit)) || list[i];
10870
+ writeMasks(c, list);
10871
+ }
10872
+ els.preview.addEventListener("pointerdown", (e) => {
10873
+ if (e.button !== 0 || isSourceMode()) return;
10874
+ const c = getClip(state.selId);
10875
+ if (!maskEditActive(c)) return;
10876
+ const B = maskBox(c), pt = canvasPt(e), bp = B.toBox(pt), W = els.preview.width, grab = Math.max(8, W / 110);
10877
+ const near = (p) => Math.hypot(p.x - pt.x, p.y - pt.y) <= grab;
10878
+ if (maskUi.tool) {
10879
+ e.preventDefault(); e.stopImmediatePropagation();
10880
+ if (isGroupLocked(c)) { toastLocked(); return; }
10881
+ if (maskUi.tool === "draw") maskUi.stroke = [[bp.x / B.bw, bp.y / B.bh]];
10882
+ else if (maskUi.tool === "pen") {
10883
+ const P = (maskUi.pen ||= { pts: [] }).pts;
10884
+ if (P.length >= 3 && near(B.toCanvas(P[0][0], P[0][1]))) { finishPen(c); return; }
10885
+ if (P.length >= Mask.MAX_POINTS) { toast(`A mask has at most ${Mask.MAX_POINTS} points`); return; }
10886
+ P.push([bp.x, bp.y, 0, 0, 0, 0]);
10887
+ maskUi.pen.redo = [];
10888
+ maskUi.pen.dragging = true;
10889
+ } else return;
10890
+ maskUi.drag = { tool: true };
10891
+ els.preview.setPointerCapture(e.pointerId);
10892
+ state.dirtyTimeline = true;
10893
+ return;
10894
+ }
10895
+ const h = clipMaskHandles(c);
10896
+ if (!h) return;
10897
+ const { m } = h;
10898
+ let drag = null;
10899
+ const sp = m.shape === "bezier" ? h.A[maskUi.pt] : null;
10900
+ if (near(h.knob)) drag = { mode: "rotate" };
10901
+ else if (sp && near(sp.o)) drag = { mode: "handle", side: "o" };
10902
+ else if (sp && near(sp.i)) drag = { mode: "handle", side: "i" };
10903
+ else if (m.shape === "bezier" && h.A.some((p) => near(p.a))) {
10904
+ maskUi.pt = h.A.findIndex((p) => near(p.a));
10905
+ drag = { mode: e.altKey ? "pull" : "point" };
10906
+ } else if (h.edges.some(near)) drag = { mode: h.edges.find(near).k };
10907
+ else if (m.shape === "bezier" && (e.ctrlKey || e.metaKey)) {
10908
+ if (m.points.length >= Mask.MAX_POINTS) { toast(`A mask has at most ${Mask.MAX_POINTS} points`); e.stopImmediatePropagation(); return; }
10909
+ if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
10910
+ const r = nearestMaskSegment(m, h.B, bp);
10911
+ if (!r) return;
10912
+ pushUndo(); writeMaskTopology(c, maskUi.sel, (pts) => Mask.splitPoints(pts, r.i, r.t)); scheduleSave();
10913
+ maskUi.pt = r.i + 1;
10914
+ drag = { mode: "point", pushed: true };
10915
+ } 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" };
10916
+ if (!drag) return; // outside the mask: the monitor's usual clicks
10917
+ e.preventDefault(); e.stopImmediatePropagation();
10918
+ if (isGroupLocked(c)) { toastLocked(); return; }
10919
+ if (!drag.pushed) pushUndo();
10920
+ maskUi.drag = { ...drag, id: c.id, start: bp, m0: JSON.parse(JSON.stringify(maskNowAt(c, maskUi.sel))) };
10921
+ els.preview.setPointerCapture(e.pointerId);
10922
+ state.dirtyTimeline = true;
10923
+ }, true);
10924
+ els.preview.addEventListener("pointermove", (e) => {
10925
+ const d = maskUi.drag;
10926
+ if (!d) return;
10927
+ e.stopImmediatePropagation();
10928
+ const c = getClip(state.selId), B = c && maskBox(c);
10929
+ if (!B) return;
10930
+ const bp = B.toBox(canvasPt(e));
10931
+ if (d.tool) {
10932
+ if (maskUi.tool === "draw" && maskUi.stroke) {
10933
+ const last = maskUi.stroke[maskUi.stroke.length - 1], u = bp.x / B.bw, v = bp.y / B.bh;
10934
+ if (Math.hypot((u - last[0]) * B.bw, (v - last[1]) * B.bh) > 2) maskUi.stroke.push([u, v]);
10935
+ } else if (maskUi.tool === "pen" && maskUi.pen && maskUi.pen.dragging) {
10936
+ const p = maskUi.pen.pts[maskUi.pen.pts.length - 1], ox = bp.x - p[0], oy = bp.y - p[1];
10937
+ if (Math.hypot(ox, oy) > 3) { p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
10938
+ }
10939
+ state.dirtyTimeline = true;
10940
+ return;
10941
+ }
10942
+ const m0 = d.m0;
10943
+ let set;
10944
+ 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) };
10945
+ else if (d.mode === "w" || d.mode === "h") {
10946
+ const l = maskBoxToLocal(m0, B, bp.x, bp.y);
10947
+ 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) };
10948
+ } else if (d.mode === "rotate") {
10949
+ let r = Math.atan2(bp.x - m0.x * B.bw, -(bp.y - m0.y * B.bh)) * 180 / Math.PI;
10950
+ if (e.shiftKey) r = Math.round(r / 15) * 15;
10951
+ set = { rotation: +r.toFixed(1) };
10952
+ } else if (m0.shape === "bezier") {
10953
+ 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];
10954
+ if (!p) return;
10955
+ const l = maskBoxToLocal(m0, B, bp.x, bp.y);
10956
+ if (d.mode === "point") { p[0] = l.x; p[1] = l.y; }
10957
+ 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; }
10958
+ else if (d.mode === "handle") {
10959
+ const hx = l.x - p[0], hy = l.y - p[1], o = d.side === "o" ? 4 : 2, q = d.side === "o" ? 2 : 4;
10960
+ p[o] = hx; p[o + 1] = hy;
10961
+ if (!e.altKey) { // keep the pair smooth: the other handle mirrors the direction, keeps its length
10962
+ const len = Math.hypot(p[q], p[q + 1]) || Math.hypot(hx, hy), k = Math.hypot(hx, hy) || 1;
10963
+ p[q] = -hx / k * len; p[q + 1] = -hy / k * len;
10964
+ }
10965
+ }
10966
+ set = { points: pts.map((r) => r.map((v) => +v.toFixed(4))) };
10967
+ }
10968
+ if (set) { writeMaskSet(c, maskUi.sel, set); syncMaskInspector(c); }
10969
+ }, true);
10970
+ const endClipMaskDrag = (e) => {
10971
+ const d = maskUi.drag;
10972
+ if (!d) return;
10973
+ e.stopImmediatePropagation();
10974
+ maskUi.drag = null;
10975
+ const c = getClip(state.selId);
10976
+ if (d.tool) {
10977
+ if (maskUi.tool === "draw" && maskUi.stroke) {
10978
+ const B = c && maskBox(c), st = maskUi.stroke;
10979
+ maskUi.stroke = null;
10980
+ if (c && B && st.length >= 6) addMaskFrom(c, { shape: "freehand", stroke: st, aspect: B.bw / B.bh });
10981
+ else toast("Drag around what the mask should keep");
10982
+ } else if (maskUi.pen) maskUi.pen.dragging = false;
10983
+ state.dirtyTimeline = true;
10984
+ return;
10985
+ }
10986
+ scheduleSave(); renderInspector();
10987
+ };
10988
+ els.preview.addEventListener("pointerup", endClipMaskDrag, true);
10989
+ els.preview.addEventListener("pointercancel", endClipMaskDrag, true);
10990
+ els.preview.addEventListener("dblclick", (e) => {
10991
+ const c = getClip(state.selId);
10992
+ if (!maskEditActive(c) || maskUi.tool) return;
10993
+ const h = clipMaskHandles(c), pt = canvasPt(e), grab = Math.max(8, els.preview.width / 110);
10994
+ if (!h || h.m.shape !== "bezier") return;
10995
+ const i = h.A.findIndex((p) => Math.hypot(p.a.x - pt.x, p.a.y - pt.y) <= grab);
10996
+ if (i < 0) return;
10997
+ e.preventDefault(); e.stopImmediatePropagation();
10998
+ if (h.m.points.length <= 3) { toast("A mask needs at least 3 points"); return; }
10999
+ if (isGroupLocked(c)) { toastLocked(); return; }
11000
+ pushUndo();
11001
+ writeMaskTopology(c, maskUi.sel, (pts) => pts.filter((_, j) => j !== i));
11002
+ maskUi.pt = -1; scheduleSave(); renderInspector();
11003
+ }, true);
11004
+ window.addEventListener("keydown", (e) => {
11005
+ if (e.key === "Escape" && colorState.maskDraw) { // the Color tab's Draw
11006
+ e.preventDefault(); e.stopImmediatePropagation();
11007
+ colorState.maskDraw = false; colorState.maskStroke = null; state.dirtyTimeline = true; renderColorPage(true);
11008
+ return;
11009
+ }
11010
+ if (!maskUi.clip || (maskUi.sel < 0 && !maskUi.tool)) return;
11011
+ const tg = e.target;
11012
+ if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
11013
+ const c = getClip(state.selId);
11014
+ if (!c || c.id !== maskUi.clip) return;
11015
+ if (e.key === "Escape") {
11016
+ e.preventDefault(); e.stopImmediatePropagation();
11017
+ if (maskUi.tool) { maskUi.tool = null; maskUi.pen = null; maskUi.stroke = null; }
11018
+ else if (maskUi.pt >= 0) maskUi.pt = -1;
11019
+ else maskUi.sel = -1;
11020
+ state.dirtyTimeline = true; renderInspector();
11021
+ } else if (e.key === "Enter" && maskUi.tool === "pen") {
11022
+ e.preventDefault(); e.stopImmediatePropagation();
11023
+ finishPen(c);
11024
+ } else if (maskUi.tool === "pen" && maskUi.pen && !maskUi.drag) {
11025
+ // while placing points the outline is not in the project yet: undo / redo step through the points
11026
+ const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
11027
+ const back = (mod && k === "z" && !e.shiftKey) || ((e.key === "Backspace" || e.key === "Delete") && !mod);
11028
+ const fwd = mod && ((k === "z" && e.shiftKey) || k === "y");
11029
+ if (!back && !fwd) return;
11030
+ e.preventDefault(); e.stopImmediatePropagation();
11031
+ const P = maskUi.pen.pts, R = (maskUi.pen.redo ||= []);
11032
+ if (back && P.length) R.push(P.pop());
11033
+ else if (fwd && R.length) P.push(R.pop());
11034
+ state.dirtyTimeline = true;
11035
+ }
11036
+ }, true);
11037
+
11038
+ /* ── Shape layers, nulls and parenting in the editor ──
11039
+ A shape clip draws props.shapes (motion.js). The Shape layer section lists
11040
+ its items; picking one shows its box, knob and points on the monitor. Pen:
11041
+ click to place points (drag pulls handles), click the first point to close,
11042
+ Enter to finish an open path. Item values with ◆ keys animate per value. */
11043
+ const SHAPE_PRESETS = {
11044
+ rect: { label: "Rectangle", item: { type: "rect", w: 420, h: 240, radius: 24, fill: "#ffd166" } },
11045
+ ellipse: { label: "Ellipse", item: { type: "ellipse", w: 260, h: 260, fill: "#7b6cff" } },
11046
+ polygon: { label: "Polygon", item: { type: "polygon", sides: 6, w: 260, h: 260, fill: "#22d3ee" } },
11047
+ star: { label: "Star", item: { type: "star", sides: 5, inner: 0.45, w: 280, h: 280, fill: "#ff7ac8" } },
11048
+ line: { label: "Line", item: { type: "line", points: [[-220, 0], [220, 0]], stroke: "#ffffff", strokeWidth: 10 } },
11049
+ };
11050
+ const shapeUi = { clip: null, sel: null, pt: -1, tool: null, pen: null, drag: null };
11051
+ const SHAPE_ROWS_UI = {
11052
+ x: ["X", -2000, 2000, 1], y: ["Y", -2000, 2000, 1], rotation: ["Rotation", -360, 360, 0.5], scale: ["Scale", 0.05, 6, 0.01],
11053
+ opacity: ["Opacity", 0, 1, 0.01], w: ["Width", 0, 2000, 1], h: ["Height", 0, 2000, 1], radius: ["Radius", 0, 500, 1],
11054
+ inner: ["Inner", 0.05, 1, 0.01], strokeWidth: ["Stroke px", 0, 100, 0.5],
11055
+ trimStart: ["Trim start", 0, 100, 0.5], trimEnd: ["Trim end", 0, 100, 0.5], trimOffset: ["Trim offset", -100, 100, 0.5],
11056
+ };
11057
+ const pathKey = (path) => (path || []).join(".");
11058
+ const samePath = (a, b) => !!a && !!b && pathKey(a) === pathKey(b);
11059
+ function trackFree(id) { return !isTrackLocked(id); }
11060
+ function shapeTrack() { return ["V2", "V3", "V1"].find((id) => TRACKS.some((tr) => tr.id === id) && trackFree(id)) || null; }
11061
+ function newVisualClip(kind, name, track, props = {}) {
11062
+ return { id: "c_" + uid(), mediaId: null, kind, track, start: state.time, in: 0, duration: 4, name, props: { ...DEFAULT_PROPS, ...props } };
11063
+ }
11064
+ function addShapeLayer(kind) {
11065
+ const track = shapeTrack();
11066
+ if (!track) { toast("V1–V3 are locked — unlock a video track to add a shape"); return; }
11067
+ pushUndo();
11068
+ const pre = SHAPE_PRESETS[kind];
11069
+ const c = newVisualClip("shape", pre ? pre.label : "Shape", track);
11070
+ const list = pre ? Motion.normalizeShapes([pre.item]) : null;
11071
+ if (list) c.props.shapes = list;
11072
+ project.clips.push(c);
11073
+ selectClip(c.id);
11074
+ Object.assign(shapeUi, { clip: c.id, sel: list ? [0] : null, pt: -1, tool: pre ? null : "pen", pen: null, drag: null });
11075
+ if (!pre && state.playing) pause();
11076
+ scheduleSave(); renderInspector();
11077
+ if (!pre) toast("Pen: click points on the monitor (drag to curve); click the first point to close, Enter for an open path");
11078
+ }
11079
+ function addNull() {
11080
+ const track = ["V3", "V2", "V1"].find((id) => TRACKS.some((tr) => tr.id === id) && trackFree(id));
11081
+ if (!track) { toast("V1–V3 are locked — unlock a video track to add a null"); return; }
11082
+ pushUndo();
11083
+ const c = newVisualClip("null", "Null", track);
11084
+ project.clips.push(c);
11085
+ selectClip(c.id); scheduleSave(); renderInspector();
11086
+ }
11087
+ /* The bin's Shapes tab: each row adds a new layer at the playhead (a selected
11088
+ shape layer takes more items from its own Shape layer section). */
11089
+ const SHAPE_ICONS = {
11090
+ rect: '<rect x="3" y="6" width="18" height="12" rx="2" fill="#ffd166"/>',
11091
+ ellipse: '<circle cx="12" cy="12" r="8" fill="#7b6cff"/>',
11092
+ polygon: '<polygon points="12,3 20,7.5 20,16.5 12,21 4,16.5 4,7.5" fill="#22d3ee"/>',
11093
+ star: '<polygon points="12,2.5 14.6,9 21.5,9.3 16.1,13.6 18,20.3 12,16.4 6,20.3 7.9,13.6 2.5,9.3 9.4,9" fill="#ff7ac8"/>',
11094
+ line: '<line x1="4" y1="18" x2="20" y2="6" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>',
11095
+ pen: '<path d="M4 18 C 8 4, 16 4, 20 16" fill="none" stroke="#5ad1ff" stroke-width="2.5" stroke-linecap="round"/><rect x="2.5" y="16.5" width="3" height="3" fill="#fff"/><rect x="18.5" y="14.5" width="3" height="3" fill="#fff"/>',
11096
+ null: '<rect x="5" y="5" width="14" height="14" fill="none" stroke="#ff6b6b" stroke-width="2"/><path d="M12 8v8M8 12h8" stroke="#ff6b6b" stroke-width="2"/>',
11097
+ };
11098
+ function renderShapesTab() {
11099
+ const rows = [
11100
+ ...Object.entries(SHAPE_PRESETS).map(([k, pre]) => ({ k, name: pre.label, sub: "Shape layer", run: () => addShapeLayer(k) })),
11101
+ { k: "pen", name: "Pen path", sub: "Click points on the monitor", run: () => addShapeLayer("pen") },
11102
+ { k: "null", name: "Null object", sub: "Invisible parent for other clips", run: addNull },
11103
+ ];
11104
+ els.libList.innerHTML = "";
11105
+ for (const r of rows) {
11106
+ const item = document.createElement("div");
11107
+ item.className = "bin-item lib-item";
11108
+ item.title = r.k === "null" ? "Add a null object at the playhead — parent clips to it to move them together" : `Add a ${r.name.toLowerCase()} layer at the playhead`;
11109
+ item.innerHTML = `<div class="bin-thumb shape-thumb"><svg viewBox="0 0 24 24" aria-hidden="true">${SHAPE_ICONS[r.k]}</svg></div>
11110
+ <div class="bin-meta"><div class="bin-name">${escapeHtml(r.name)}</div><div class="bin-sub">${escapeHtml(r.sub)}</div></div>
11111
+ <button class="btn tiny accent lib-add" title="Add at playhead">+</button>`;
11112
+ item.addEventListener("dblclick", r.run);
11113
+ item.querySelector(".lib-add").addEventListener("click", r.run);
11114
+ els.libList.appendChild(item);
11115
+ }
11116
+ }
11117
+ /** Parent the selected visual clips to a new null at their centre (a layer group). */
11118
+ function parentSelectionToNull() {
11119
+ const sel = selectedClips().filter((x) => isVisualClip(x) && x.kind !== "null");
11120
+ if (!sel.length) return;
11121
+ if (sel.some(isGroupLocked)) { toastLocked(); return; }
11122
+ const track = ["V3", "V2", "V1"].find((id) => TRACKS.some((tr) => tr.id === id) && trackFree(id));
11123
+ if (!track) { toast("V1–V3 are locked — unlock a video track for the null"); return; }
11124
+ pushUndo();
11125
+ const W = els.preview.width, H = els.preview.height;
11126
+ let sx = 0, sy = 0;
11127
+ for (const x of sel) { const b = clipBounds(x, evalProps(x, clamp(state.time, x.start, x.start + x.duration - 1e-3)), W, H); sx += b.cx; sy += b.cy; }
11128
+ const start = Math.min(...sel.map((x) => x.start)), end = Math.max(...sel.map((x) => x.start + x.duration));
11129
+ const n = { ...newVisualClip("null", "Null", track, { x: Math.round(sx / sel.length - W / 2), y: Math.round(sy / sel.length - H / 2) }), start, duration: +(end - start).toFixed(4) };
11130
+ project.clips.push(n);
11131
+ for (const x of sel) setClipParent(x, n.id);
11132
+ selectClip(n.id); scheduleSave(); renderInspector();
11133
+ toast(`${sel.length} clip${sel.length > 1 ? "s" : ""} now follow the null — move, turn or scale it`);
11134
+ }
11135
+ /** Change a clip's parent, keeping it where it is at the playhead (static values; keyed ones get a key). */
11136
+ function setClipParent(c, parentId) {
11137
+ if (parentId && Motion.wouldCycle(c.id, parentId, getClip)) { toast("That would parent the clip to itself"); return false; }
11138
+ const t = clamp(state.time, c.start, c.start + c.duration);
11139
+ const v = Motion.reparentValues(c, parentId || null, getClip, t);
11140
+ if (parentId) c.parent = parentId; else delete c.parent;
11141
+ for (const [k, val] of Object.entries(v)) {
11142
+ if (c.keyframes?.[k]?.length && playheadOverClip(c)) setAnimProp(c, k, val);
11143
+ else if (!c.keyframes?.[k]?.length) c.props[k] = val;
11144
+ }
11145
+ state.dirtyTimeline = true; inspPropGen++;
11146
+ return true;
11147
+ }
11148
+ /** Clips this clip may be parented to: other visual clips that are not its children. */
11149
+ function parentCandidates(c) {
11150
+ return project.clips.filter((x) => x.id !== c.id && isVisualClip(x) && !Motion.wouldCycle(c.id, x.id, getClip));
11151
+ }
11152
+ const clipShortLabel = (x) => x.track + " · " + (x.kind === "text" ? (x.props.text || "").split("\n")[0] : x.name || x.kind);
11153
+ function parentRowHtml(c) {
11154
+ const cands = parentCandidates(c), cur = c.parent && getClip(c.parent);
11155
+ const opts = [`<option value="">None</option>`, ...cands.map((x) => `<option value="${x.id}" ${x.id === c.parent ? "selected" : ""}>${escapeHtml(clipShortLabel(x))}${x.kind === "null" ? " (null)" : ""}</option>`)];
11156
+ if (c.parent && !cur) opts.push(`<option value="${escapeHtml(c.parent)}" selected>Missing clip</option>`);
11157
+ return `<div class="insp-row"><label title="Move, turn and scale with another clip (its opacity and effects are not inherited)">Parent</label><select data-parent>${opts.join("")}</select></div>`;
11158
+ }
11159
+ function anchorRowsHtml(c, p, row) {
11160
+ return `<div class="insp-row"><label>Anchor</label><span class="insp-ctrls">
11161
+ <button type="button" class="btn tiny${runtime.anchorTool ? " toggle on" : ""}" data-anchor-tool title="Drag on the monitor to move the anchor point — the picture stays put (Shift: snap to its centre)">✥ Move</button>
11162
+ <button type="button" class="btn tiny" data-anchor-centre title="Put the anchor point back on the picture's centre, keeping the picture where it is">Centre</button></span></div>
11163
+ ${row("Anchor X", `<input type="number" data-k="anchorX" value="${fmtInspNum(p.anchorX)}">`, "anchorX")}
11164
+ ${row("Anchor Y", `<input type="number" data-k="anchorY" value="${fmtInspNum(p.anchorY)}">`, "anchorY")}`;
11165
+ }
11166
+ function bindParentAnchorControls(c) {
11167
+ const root = els.inspector;
11168
+ root.querySelector("[data-parent]")?.addEventListener("change", (e) => {
11169
+ if (isGroupLocked(c)) { toastLocked(); renderInspector(); return; }
11170
+ pushUndo();
11171
+ setClipParent(c, e.target.value || null);
11172
+ scheduleSave(); renderInspector();
11173
+ });
11174
+ root.querySelector("[data-anchor-tool]")?.addEventListener("click", () => { runtime.anchorTool = !runtime.anchorTool; state.dirtyTimeline = true; renderInspector(); });
11175
+ root.querySelector("[data-anchor-centre]")?.addEventListener("click", () => {
11176
+ if (isGroupLocked(c)) { toastLocked(); return; }
11177
+ if (!playheadOverClip(c)) { toast("Move the playhead over the clip"); return; }
11178
+ pushUndo();
11179
+ const p = propsAtPlayhead(c), box = layerBox(c, p, els.preview.width, els.preview.height, state.time), as = Motion.contentScale(c, p);
11180
+ const v = Motion.anchorMoveValues(c, p, Math.round(box.ox / as), Math.round(box.oy / as));
11181
+ for (const k of ["anchorX", "anchorY", "x", "y"]) setAnimProp(c, k, v[k]);
11182
+ scheduleSave(); renderInspector();
11183
+ });
11184
+ }
11185
+
11186
+ /* ── The Shape layer section ── */
11187
+ function shapeList(c) { return clipShapes(c) || []; }
11188
+ function shapeUiFor(c) {
11189
+ if (!c || shapeUi.clip !== c.id) Object.assign(shapeUi, { clip: c ? c.id : null, sel: null, pt: -1, tool: null, pen: null, drag: null });
11190
+ if (shapeUi.sel && !Motion.getItem(editList(c), shapeUi.sel)) { shapeUi.sel = null; shapeUi.pt = -1; }
11191
+ return shapeUi;
11192
+ }
11193
+ function writeShapes(c, list) {
11194
+ const n = list && list.length ? Motion.normalizeShapes(list) : null;
11195
+ if (n) c.props.shapes = n; else delete c.props.shapes;
11196
+ state.dirtyTimeline = true; inspPropGen++;
11197
+ }
11198
+ const shapeLocalT = (c) => state.time - c.start;
11199
+ function shapeKeyIndex(keys, t) {
11200
+ const tol = 0.5 / (projectFps() || 30);
11201
+ return keys ? keys.findIndex((k) => Math.abs(k.t - t) < tol) : -1;
11202
+ }
11203
+ const isAnimated = (it, k) => !!(it.keys && it.keys.some((x) => x[k] != null));
11204
+ /** The selected item as it is at the playhead. */
11205
+ function shapeNow(c, path = shapeUi.sel) { return path ? Motion.getItem(editItemsAt(c, state.time), path) : null; }
11206
+ /** Change item `path`; values that are animated land in a key at the playhead. */
11207
+ function writeShapeSet(c, path, set) {
11208
+ const list = editList(c), it = Motion.getItem(list, path);
11209
+ if (!it) return;
11210
+ const t = shapeLocalT(c), statics = {}, keyed = {};
11211
+ for (const [k, v] of Object.entries(set)) (Motion.KEYED.includes(k) && isAnimated(it, k) ? keyed : statics)[k] = v;
11212
+ const next = { ...statics };
11213
+ if (Object.keys(keyed).length) {
11214
+ // like setAnimProp: off the clip there is no "at the playhead" to key
11215
+ if (!playheadOverClip(c)) { toast("Move the playhead over the clip to edit its keyframes"); return false; }
11216
+ const keys = it.keys.map((k) => ({ ...k })), kt = +clamp(t, 0, c.duration).toFixed(4);
11217
+ let j = shapeKeyIndex(keys, kt);
11218
+ if (j < 0) { keys.push({ t: kt }); keys.sort((a, b) => a.t - b.t); j = shapeKeyIndex(keys, kt); }
11219
+ Object.assign(keys[j], keyed);
11220
+ next.keys = keys;
11221
+ }
11222
+ writeEditList(c, Motion.updateItem(list, path, (x) => Motion.mergeItem(x, next) || x));
11223
+ }
11224
+ /** ◆ on an item value: key it at the playhead, or remove the key there. */
11225
+ function toggleShapeKey(c, path, k) {
11226
+ const list = editList(c), it = Motion.getItem(list, path), now = shapeNow(c, path);
11227
+ if (!it || !now || now[k] == null) return;
11228
+ const t = shapeLocalT(c), keys = (it.keys || []).map((x) => ({ ...x })), j = shapeKeyIndex(keys, t);
11229
+ const set = {};
11230
+ if (j >= 0 && keys[j][k] != null) {
11231
+ delete keys[j][k];
11232
+ if (!Object.keys(keys[j]).some((p) => Motion.KEYED.includes(p))) keys.splice(j, 1);
11233
+ if (!keys.some((x) => x[k] != null)) set[k] = k === "points" ? now.points.map((q) => q.map((v) => +v.toFixed(3))) : now[k]; // last key gone: keep the value
11234
+ } else {
11235
+ const v = k === "points" ? now.points.map((q) => q.map((x) => +x.toFixed(3))) : typeof now[k] === "number" ? +now[k].toFixed(4) : now[k];
11236
+ if (j >= 0) keys[j][k] = v; else { keys.push({ t: +clamp(t, 0, c.duration).toFixed(4), [k]: v }); keys.sort((a, b) => a.t - b.t); }
11237
+ }
11238
+ set.keys = keys.length ? keys : null;
11239
+ writeEditList(c, Motion.updateItem(list, path, (x) => Motion.mergeItem(x, set) || x));
11240
+ }
11241
+ function shapeItemLabel(it, path) { return it.name || `${it.type[0].toUpperCase() + it.type.slice(1)} ${path.map((i) => i + 1).join(".")}`; }
11242
+ function shapeSectionHtml(c, p, row) {
11243
+ const ui = shapeUiFor(c), list = shapeList(c), full = Motion.countItems(list) >= Motion.MAX_ITEMS;
11244
+ const btn = (attr, label, title, on, dis) => `<button type="button" class="btn tiny${on ? " toggle on" : ""}" ${attr} title="${title}" ${dis ? "disabled" : ""}>${label}</button>`;
11245
+ const chips = Motion.itemPaths(list).map((path) => {
11246
+ const it = Motion.getItem(list, path);
11247
+ return `<button type="button" class="btn tiny cl-chip${samePath(path, ui.sel) ? " toggle on" : ""}${it.on === false ? " off" : ""}" data-ssel="${pathKey(path)}"
11248
+ title="${escapeHtml(it.type)} — click to edit on the monitor">${"› ".repeat(path.length - 1)}${escapeHtml(shapeItemLabel(it, path))}</button>`;
11249
+ }).join("");
11250
+ const slider = (k, min, max, step, unit = "") => {
11251
+ const shown = fmtInspNum(p[k], step);
11252
+ return row(KF_GRAPH_LABEL[k], `<input type="range" data-k="${k}" min="${min}" max="${max}" step="${step}" value="${shown}" title="Ctrl/Cmd-click: reset to default">
11253
+ <span class="val" data-val="${k}" data-unit="${unit}">${shown}${unit}</span>`, k);
11254
+ };
11255
+ let html = `<div class="insp-section" data-shape-section><h3>Shape layer</h3>
11256
+ <div class="cl-row">${Object.entries(SHAPE_PRESETS).map(([k, pre]) => btn(`data-sadd="${k}"`, "+ " + pre.label, `Add a ${pre.label.toLowerCase()} to this layer`, false, full)).join("")}
11257
+ ${btn('data-stool="pen"', "Pen", "Click points on the monitor (drag to curve); click the first point to close, Enter for an open path", ui.tool === "pen", full)}</div>
11258
+ ${ui.tool === "pen" ? `<p class="cl-note">Pen: click to add points, drag to curve. Click the first point to close, Enter keeps the path open. Backspace / Ctrl+Z removes the last point, Esc cancels.</p>` : ""}
11259
+ ${list.length ? `<div class="cl-row shape-items">${chips}</div>` : ui.tool ? "" : `<p class="cl-note">No shapes yet — add one above.</p>`}
11260
+ ${slider("trimStart", 0, 100, 0.5, "%")}${slider("trimEnd", 0, 100, 0.5, "%")}${slider("trimOffset", -100, 100, 0.5, "%")}`;
11261
+ const raw = ui.sel && Motion.getItem(list, ui.sel), now = raw && shapeNow(c);
11262
+ if (raw && now) {
11263
+ const t = shapeLocalT(c), j = shapeKeyIndex(raw.keys, t);
11264
+ const kbtn = (k) => `<button type="button" class="kf-btn${isAnimated(raw, k) ? " has" : ""}${j >= 0 && raw.keys[j][k] != null ? " on" : ""}" data-skey="${k}" title="Key this value at the playhead / remove the key there">◆</button>`;
11265
+ const srow = (k) => {
11266
+ const [lab, min, max, step] = SHAPE_ROWS_UI[k];
11267
+ return `<div class="insp-row cp-row"><label class="cp-label">${lab}</label>
11268
+ <input type="range" data-sk="${k}" min="${min}" max="${max}" step="${step}" value="${now[k]}">
11269
+ <input type="number" class="cp-num" data-sknum="${k}" step="${step}" value="${fmtG(now[k], step)}">${kbtn(k)}</div>`;
11270
+ };
11271
+ const color = (k, lab) => `<div class="insp-row"><label>${lab}</label><span class="insp-ctrls">
11272
+ <input type="color" data-scolor="${k}" value="${now[k] && now[k] !== "none" ? now[k].slice(0, 7) : "#ffffff"}">
11273
+ ${btn(`data-snone="${k}"`, "None", `No ${lab.toLowerCase()}`, now[k] === "none")}${kbtn(k)}</span></div>`;
11274
+ const path = ui.sel, inGroup = path.length > 1, siblings = inGroup ? Motion.getItem(list, path.slice(0, -1)).items : list, i = path[path.length - 1];
11275
+ const own = Motion.OWN[raw.type];
11276
+ html += `<div class="cl-row"><input type="text" class="cl-name" data-sname value="${escapeHtml(raw.name || "")}" placeholder="${escapeHtml(shapeItemLabel({ type: raw.type }, path))}" maxlength="40" title="Item name">
11277
+ ${btn('data-sact="on"', raw.on === false ? "Off" : "On", "Item on / off", raw.on !== false)}
11278
+ ${btn('data-sact="up"', "↑", "Move down the stack (drawn earlier)", false, i === 0)}
11279
+ ${btn('data-sact="down"', "↓", "Move up the stack (drawn later)", false, i >= siblings.length - 1)}
11280
+ ${btn('data-sact="dup"', "Copy", "Duplicate", false, full)}
11281
+ ${btn('data-sact="group"', "Group", "Put this item in a new group (move, turn, scale and trim them together)", false, full || path.length >= Motion.MAX_DEPTH)}
11282
+ ${raw.type === "group" ? btn('data-sact="ungroup"', "Ungroup", "Take the items out of this group") : ""}
11283
+ ${inGroup ? btn('data-sact="out"', "Out", "Move this item out of its group") : ""}
11284
+ ${btn('data-sact="del"', "Delete", "Delete this item")}
11285
+ ${raw.keys ? btn('data-sact="clearkeys"', "Clear keys", "Remove every key (keeps the item as it is at the playhead)") : ""}</div>
11286
+ ${srow("x")}${srow("y")}${srow("rotation")}${srow("scale")}${srow("opacity")}
11287
+ ${own.includes("w") ? srow("w") + srow("h") : ""}${own.includes("radius") ? srow("radius") : ""}
11288
+ ${own.includes("sides") ? `<div class="insp-row"><label>Points</label><input type="number" data-ssides min="3" max="64" step="1" value="${raw.sides}"></div>` : ""}
11289
+ ${own.includes("inner") ? srow("inner") : ""}
11290
+ ${raw.type === "path" ? `<div class="insp-row"><label>Closed</label><input type="checkbox" data-sclosed ${raw.closed !== false ? "checked" : ""}>${kbtn("points")}</div>` : ""}
11291
+ ${raw.type !== "group" ? color("fill", "Fill") + color("stroke", "Stroke") + srow("strokeWidth") +
11292
+ `<div class="insp-row"><label>Ends / joins</label><span class="insp-ctrls"><select data-scap>${Motion.CAPS.map((v) => `<option ${raw.lineCap === v ? "selected" : ""}>${v}</option>`).join("")}</select>
11293
+ <select data-sjoin>${Motion.JOINS.map((v) => `<option ${raw.lineJoin === v ? "selected" : ""}>${v}</option>`).join("")}</select></span></div>` : ""}
11294
+ ${srow("trimStart")}${srow("trimEnd")}${srow("trimOffset")}
11295
+ <p class="cl-note">On the monitor: drag the item to move it, the knob turns it (Shift: 15° steps), the corners resize it${raw.type === "line" || raw.type === "path"
11296
+ ? "; drag a point to move it, its handles to curve it (Alt: break the pair), Alt-drag a point to pull new handles" + (raw.type === "path" ? ", Ctrl-click the outline to add a point, double-click a point to remove it" : "")
11297
+ : ""}. Esc deselects.</p>`;
11298
+ }
11299
+ return html + `</div>`;
11300
+ }
11301
+ function bindShapeControls(c) {
11302
+ const root = els.inspector.querySelector("[data-shape-section]");
11303
+ if (!root) return;
11304
+ const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
11305
+ const rerender = () => { state.dirtyTimeline = true; renderInspector(); };
11306
+ const sel = () => shapeUi.sel;
11307
+ const edit = (set) => { writeShapeSet(c, sel(), set); scheduleSave(); };
11308
+ for (const b of root.querySelectorAll("[data-sadd]")) b.addEventListener("click", () => {
11309
+ if (!guard()) return;
11310
+ pushUndo();
11311
+ const list = shapeList(c).slice();
11312
+ list.push(SHAPE_PRESETS[b.dataset.sadd].item);
11313
+ writeShapes(c, list);
11314
+ Object.assign(shapeUi, { sel: [shapeList(c).length - 1], pt: -1, tool: null, pen: null });
11315
+ maskUi.sel = -1;
11316
+ scheduleSave(); rerender();
11317
+ });
11318
+ root.querySelector('[data-stool="pen"]')?.addEventListener("click", () => {
11319
+ if (!guard()) return;
11320
+ shapeUi.tool = shapeUi.tool === "pen" ? null : "pen"; shapeUi.pen = null; shapeUi.sel = null; maskUi.sel = -1; maskUi.tool = null;
11321
+ if (shapeUi.tool && state.playing) pause();
11322
+ if (shapeUi.tool && !activeAt(c, state.time)) toast("Move the playhead over the clip, then draw on the monitor");
11323
+ rerender();
11324
+ });
11325
+ for (const b of root.querySelectorAll("[data-ssel]")) b.addEventListener("click", () => {
11326
+ const path = b.dataset.ssel.split(".").map(Number);
11327
+ shapeUi.sel = samePath(shapeUi.sel, path) ? null : path; shapeUi.pt = -1; shapeUi.tool = null;
11328
+ if (shapeUi.sel) { maskUi.sel = -1; maskUi.tool = null; }
11329
+ rerender();
11330
+ });
11331
+ for (const b of root.querySelectorAll("[data-sact]")) b.addEventListener("click", () => {
11332
+ const a = b.dataset.sact, path = sel(), list = shapeList(c), it = path && Motion.getItem(list, path);
11333
+ if (!it || !guard()) return;
11334
+ pushUndo();
11335
+ const parentPath = path.slice(0, -1), i = path[path.length - 1];
11336
+ const withSiblings = (fn) => parentPath.length
11337
+ ? Motion.updateItem(list, parentPath, (g) => ({ ...g, items: fn(g.items.slice()) }))
11338
+ : fn(list.slice());
11339
+ let next = list;
11340
+ if (a === "on") next = Motion.updateItem(list, path, (x) => Motion.mergeItem(x, { on: x.on === false ? null : false }));
11341
+ else if (a === "del") { next = Motion.updateItem(list, path, () => null); shapeUi.sel = null; }
11342
+ else if (a === "dup") { next = withSiblings((s) => { s.splice(i + 1, 0, JSON.parse(JSON.stringify({ ...it, name: it.name ? `${it.name} copy`.slice(0, 40) : undefined }))); return s; }); shapeUi.sel = [...parentPath, i + 1]; }
11343
+ else if (a === "up" || a === "down") {
11344
+ const j = a === "up" ? i - 1 : i + 1;
11345
+ next = withSiblings((s) => { [s[i], s[j]] = [s[j], s[i]]; return s; });
11346
+ shapeUi.sel = [...parentPath, j];
11347
+ } else if (a === "group") next = Motion.updateItem(list, path, (x) => ({ type: "group", x: x.x, y: x.y, items: [{ ...x, x: 0, y: 0 }] }));
11348
+ else if (a === "ungroup") {
11349
+ // the children keep their place: fold the group's transform into each (position, rotation, scale)
11350
+ const M = Motion.itemMatrix(it);
11351
+ const kids = (it.items || []).map((k) => {
11352
+ const m = Motion.mul(M, Motion.itemMatrix(k));
11353
+ return { ...k, x: +m[4].toFixed(3), y: +m[5].toFixed(3), rotation: +(Math.atan2(m[1], m[0]) * 180 / Math.PI).toFixed(3),
11354
+ scale: +Math.hypot(m[0], m[1]).toFixed(4), opacity: +((k.opacity ?? 1) * (it.opacity ?? 1)).toFixed(4) };
11355
+ });
11356
+ next = withSiblings((s) => { s.splice(i, 1, ...kids); return s; });
11357
+ shapeUi.sel = kids.length ? [...parentPath, i] : null;
11358
+ } else if (a === "out") {
11359
+ const gm = Motion.itemMatrix(Motion.getItem(list, parentPath)), m = Motion.mul(gm, Motion.itemMatrix(it));
11360
+ const moved = { ...it, x: +m[4].toFixed(3), y: +m[5].toFixed(3), rotation: +(Math.atan2(m[1], m[0]) * 180 / Math.PI).toFixed(3), scale: +Math.hypot(m[0], m[1]).toFixed(4) };
11361
+ const gp = parentPath.slice(0, -1), gi = parentPath[parentPath.length - 1];
11362
+ next = Motion.updateItem(list, path, () => null);
11363
+ next = gp.length ? Motion.updateItem(next, gp, (g) => { const s = g.items.slice(); s.splice(gi + 1, 0, moved); return { ...g, items: s }; })
11364
+ : (() => { const s = next.slice(); s.splice(gi + 1, 0, moved); return s; })();
11365
+ shapeUi.sel = [...gp, gi + 1];
11366
+ } else if (a === "clearkeys") {
11367
+ const now = shapeNow(c, path), set = { keys: null };
11368
+ for (const k of Motion.KEYED) if (isAnimated(it, k)) set[k] = k === "points" ? now.points.map((q) => q.map((v) => +v.toFixed(3))) : now[k];
11369
+ next = Motion.updateItem(list, path, (x) => Motion.mergeItem(x, set));
11370
+ }
11371
+ writeShapes(c, next); shapeUi.pt = -1; scheduleSave(); rerender();
11372
+ });
11373
+ for (const b of root.querySelectorAll("[data-skey]")) b.addEventListener("click", () => {
11374
+ if (!guard() || !sel()) return;
11375
+ if (!playheadOverClip(c)) { toast("Move the playhead over the clip to key it"); return; }
11376
+ pushUndo(); toggleShapeKey(c, sel(), b.dataset.skey); scheduleSave(); rerender();
11377
+ });
11378
+ root.querySelector("[data-sname]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ name: e.target.value.trim() || null }); rerender(); });
11379
+ root.querySelector("[data-ssides]")?.addEventListener("change", (e) => { const v = Math.round(+e.target.value); if (!guard() || !(v >= 3 && v <= 64)) return; pushUndo(); edit({ sides: v }); rerender(); });
11380
+ root.querySelector("[data-sclosed]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ closed: e.target.checked ? null : false }); rerender(); });
11381
+ root.querySelector("[data-scap]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ lineCap: e.target.value }); rerender(); });
11382
+ root.querySelector("[data-sjoin]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ lineJoin: e.target.value }); rerender(); });
11383
+ for (const inp of root.querySelectorAll("[data-scolor]")) {
11384
+ const k = inp.dataset.scolor;
11385
+ inp.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
11386
+ inp.addEventListener("input", () => {
11387
+ if (isGroupLocked(c)) return;
11388
+ const set = { [k]: inp.value };
11389
+ if (k === "stroke" && !(shapeNow(c)?.strokeWidth > 0)) set.strokeWidth = 6;
11390
+ edit(set);
11391
+ });
11392
+ inp.addEventListener("change", rerender);
11393
+ }
11394
+ for (const b of root.querySelectorAll("[data-snone]")) b.addEventListener("click", () => {
11395
+ if (!guard()) return;
11396
+ pushUndo(); edit({ [b.dataset.snone]: "none" }); rerender();
11397
+ });
11398
+ for (const r of root.querySelectorAll("[data-sk]")) {
11399
+ const k = r.dataset.sk;
11400
+ r.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
11401
+ r.addEventListener("input", () => {
11402
+ if (isGroupLocked(c)) return;
11403
+ edit({ [k]: +r.value });
11404
+ const n = root.querySelector(`[data-sknum="${k}"]`); if (n) n.value = fmtG(+r.value, SHAPE_ROWS_UI[k][3]);
11405
+ });
11406
+ }
11407
+ for (const n of root.querySelectorAll("[data-sknum]")) {
11408
+ const k = n.dataset.sknum;
11409
+ n.addEventListener("change", () => {
11410
+ const x = parseFloat(n.value);
11411
+ if (!Number.isFinite(x) || !guard()) return;
11412
+ pushUndo(); edit({ [k]: x }); rerender();
11413
+ document.activeElement?.blur?.();
11414
+ });
11415
+ }
11416
+ }
11417
+ /** Animated items: the rows and ◆ follow the playhead (called from the inspector sync). */
11418
+ function syncShapeInspector(c) {
11419
+ const root = els.inspector.querySelector("[data-shape-section]");
11420
+ if (!root || shapeUi.clip !== c.id || !shapeUi.sel || shapeUi.drag) return;
11421
+ const raw = Motion.getItem(editList(c), shapeUi.sel), now = shapeNow(c);
11422
+ if (!raw || !now) return;
11423
+ const active = document.activeElement, j = shapeKeyIndex(raw.keys, shapeLocalT(c));
11424
+ for (const k of Object.keys(SHAPE_ROWS_UI)) {
11425
+ const r = root.querySelector(`[data-sk="${k}"]`), n = root.querySelector(`[data-sknum="${k}"]`);
11426
+ if (r && r !== active) r.value = now[k];
11427
+ if (n && n !== active) n.value = fmtG(now[k], SHAPE_ROWS_UI[k][3]);
11428
+ }
11429
+ for (const b of root.querySelectorAll("[data-skey]")) b.classList.toggle("on", j >= 0 && raw.keys[j][b.dataset.skey] != null);
11430
+ for (const inp of root.querySelectorAll("[data-scolor]")) {
11431
+ const v = now[inp.dataset.scolor];
11432
+ if (inp !== active && v && v !== "none") inp.value = v.slice(0, 7);
11433
+ }
11434
+ }
11435
+
11436
+ /* ── Shape items on the monitor ── */
11437
+ function shapeEditActive(c) {
11438
+ return !!c && (c.kind === "shape" || (c.kind === "text" && !!clipTextPath(c)) || (c.kind === "text" && shapeUi.tool === "pen")) && shapeUi.clip === c.id && state.selId === c.id && (!!shapeUi.sel || !!shapeUi.tool) &&
11439
+ activeAt(c, state.time) && !state.playing && !maskEditActive(c);
11440
+ }
11441
+ /** Layer px → canvas px for a shape clip at the playhead. */
11442
+ function shapeLayerMatrix(c, W, H, t = state.time) {
11443
+ const p = evalProps(c, t);
11444
+ return layerScreenMatrix(c, p, W, H, t).scale(Motion.contentScale(c, p));
11445
+ }
11446
+ /* The item list the editor works on: a shape layer's items, or a title's path as a one-item list. */
11447
+ function editList(c) { return c.kind === "text" ? (clipTextPath(c) ? [clipTextPath(c)] : []) : shapeList(c); }
11448
+ function editItemsAt(c, t) { return c.kind === "text" ? (clipTextPath(c) ? [textPathAt(c, t)] : null) : shapeItemsAt(c, t); }
11449
+ function writeEditList(c, list) {
11450
+ if (c.kind !== "text") { writeShapes(c, list); return; }
11451
+ const n = list && list[0] ? Motion.normalizeTextPath(list[0]) : null;
11452
+ if (n) c.props.textPath = n; else delete c.props.textPath;
11453
+ state.dirtyTimeline = true; inspPropGen++;
11454
+ }
11455
+ /** The picked item's frames and handles in canvas px. */
11456
+ function shapeHandles(c, W, H) {
11457
+ const items = editItemsAt(c, state.time), path = shapeUi.sel, it = path && Motion.getItem(items, path);
11458
+ if (!it) return null;
11459
+ const L = shapeLayerMatrix(c, W, H);
11460
+ const F = L.multiply(new DOMMatrix(Motion.itemFrame(items, path.slice(0, -1)))); // the space the item's x / y live in
11461
+ const I = F.multiply(new DOMMatrix(Motion.itemMatrix(it))); // the item's own frame
11462
+ const own = it.type === "group" ? Motion.bounds(it.items) : Motion.bounds([{ ...it, x: 0, y: 0, rotation: 0, scale: 1, opacity: 1, on: true }]);
11463
+ const b = own || { x0: -20, y0: -20, x1: 20, y1: 20 };
11464
+ const at = (x, y) => { const q = I.transformPoint(new DOMPoint(x, y)); return { x: q.x, y: q.y }; };
11465
+ const corners = [[b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]].map(([x, y]) => ({ ...at(x, y), lx: x, ly: y }));
11466
+ const topMid = at((b.x0 + b.x1) / 2, b.y0), up = at((b.x0 + b.x1) / 2, b.y0 - 1), d = Math.hypot(up.x - topMid.x, up.y - topMid.y) || 1;
11467
+ const gap = Math.max(24, W / 34), inset = Math.max(8, W / 110);
11468
+ const knob = { x: clamp(topMid.x + (up.x - topMid.x) / d * gap, inset, W - inset), y: clamp(topMid.y + (up.y - topMid.y) / d * gap, inset, H - inset) };
11469
+ const ol = it.type === "group" ? null : Motion.outline(it);
11470
+ const A = ol ? ol.A.map((a) => ({ a: at(a.x, a.y), i: at(a.ix, a.iy), o: at(a.ox, a.oy) })) : [];
11471
+ return { it, F, I, corners, topMid, knob, centre: at(0, 0), outline: ol, A, editable: it.type === "line" || it.type === "path" };
11472
+ }
11473
+ function drawShapeOverlay(c, W, H) {
11474
+ const lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
11475
+ const L = shapeLayerMatrix(c, W, H);
11476
+ ctx2d.save();
11477
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
11478
+ ctx2d.lineJoin = "round";
11479
+ const dot = (p, r, fill, square) => {
11480
+ ctx2d.beginPath();
11481
+ 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);
11482
+ ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.7)"; ctx2d.stroke();
11483
+ };
11484
+ const h = shapeUi.sel ? shapeHandles(c, W, H) : null;
11485
+ if (h) {
11486
+ if (h.outline) {
11487
+ const pts = Motion.flatten(h.outline.A, h.outline.closed, 24).map(([x, y]) => { const q = h.I.transformPoint(new DOMPoint(x, y)); return [q.x, q.y]; });
11488
+ ctx2d.lineWidth = lw * 2.2; ctx2d.strokeStyle = "rgba(0,0,0,0.55)"; strokeOutline(ctx2d, pts, h.outline.closed);
11489
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#5ad1ff"; strokeOutline(ctx2d, pts, h.outline.closed);
11490
+ }
11491
+ ctx2d.setLineDash([lw * 4, lw * 3]); ctx2d.strokeStyle = "#4f8cff";
11492
+ strokeOutline(ctx2d, h.corners.map((q) => [q.x, q.y]), true);
11493
+ ctx2d.setLineDash([]);
11494
+ ctx2d.beginPath(); ctx2d.moveTo(h.topMid.x, h.topMid.y); ctx2d.lineTo(h.knob.x, h.knob.y); ctx2d.stroke();
11495
+ for (const q of h.corners) dot(q, hs * 0.8, "#fff", true);
11496
+ if (h.editable) {
11497
+ const sp = h.A[shapeUi.pt];
11498
+ if (sp) {
11499
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#9fd0ff";
11500
+ 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(); }
11501
+ dot(sp.i, hs * 0.6, "#9fd0ff"); dot(sp.o, hs * 0.6, "#9fd0ff");
11502
+ }
11503
+ h.A.forEach((p, j) => dot(p.a, hs * 0.62, j === shapeUi.pt ? "#5ad1ff" : "#fff", true));
11504
+ }
11505
+ dot(h.centre, hs * 0.5, "#5ad1ff");
11506
+ dot(h.knob, hs * 0.9, "#ffce5c");
11507
+ }
11508
+ if (shapeUi.tool === "pen" && shapeUi.pen && shapeUi.pen.pts.length) {
11509
+ const P = shapeUi.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] }));
11510
+ const open = Motion.flatten(A, false, 24).map(([x, y]) => { const q = L.transformPoint(new DOMPoint(x, y)); return [q.x, q.y]; });
11511
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#5ad1ff";
11512
+ if (A.length > 1) strokeOutline(ctx2d, open, false);
11513
+ A.forEach((a, j) => { const q = L.transformPoint(new DOMPoint(a.x, a.y)); dot(q, hs * (j === 0 && A.length >= 3 ? 0.9 : 0.62), j === 0 ? "#5ad1ff" : "#fff", true); });
11514
+ }
11515
+ ctx2d.restore();
11516
+ return true;
11517
+ }
11518
+ /** Finish the pen: a path item around the points' centre (closed when it was closed on the first point). */
11519
+ function finishShapePen(c, closed) {
11520
+ const P = shapeUi.pen && shapeUi.pen.pts;
11521
+ if (!P || P.length < (closed ? 3 : 2)) { toast(closed ? "A closed path needs at least 3 points" : "A path needs at least 2 points"); return; }
11522
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
11523
+ 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); }
11524
+ const cx = Math.round((x0 + x1) / 2), cy = Math.round((y0 + y1) / 2);
11525
+ const item = { type: "path", x: cx, y: cy, points: P.slice(0, Motion.MAX_POINTS).map(([x, y, ix, iy, ox, oy]) => [x - cx, y - cy, ix, iy, ox, oy].map((v) => +v.toFixed(2))) };
11526
+ if (c.kind === "text") { if (!closed) item.closed = false; }
11527
+ else if (closed) Object.assign(item, { fill: "#ffffff" }); else Object.assign(item, { closed: false, stroke: "#ffffff", strokeWidth: 8 });
11528
+ pushUndo();
11529
+ const list = c.kind === "text" ? [] : editList(c).slice();
11530
+ list.push(item);
11531
+ writeEditList(c, list);
11532
+ Object.assign(shapeUi, { sel: [editList(c).length - 1], pt: -1, tool: null, pen: null });
11533
+ scheduleSave(); renderInspector();
11534
+ }
11535
+ function nearestShapeSegment(h, pt) {
11536
+ let best = null;
11537
+ Motion.segments(h.outline.A, h.outline.closed).forEach(([a, b], i) => {
11538
+ for (let k = 1; k < 24; k++) {
11539
+ const t = k / 24, u = 1 - t;
11540
+ const x = u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x;
11541
+ const y = u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y;
11542
+ const q = h.I.transformPoint(new DOMPoint(x, y)), d = Math.hypot(q.x - pt.x, q.y - pt.y);
11543
+ if (!best || d < best.d) best = { d, i, t };
11544
+ }
11545
+ });
11546
+ return best;
11547
+ }
11548
+ /** Add or remove points of a path: the base points and every keyed outline change together. */
11549
+ function writeShapeTopology(c, path, edit) {
11550
+ writeEditList(c, Motion.updateItem(editList(c), path, (it) => Motion.mergeItem(it, {
11551
+ points: edit(it.points), keys: it.keys ? it.keys.map((k) => (k.points ? { ...k, points: edit(k.points) } : k)) : null,
11552
+ }) || it));
11553
+ }
11554
+ els.preview.addEventListener("pointerdown", (e) => {
11555
+ if (e.button !== 0 || isSourceMode()) return;
11556
+ const c = getClip(state.selId);
11557
+ if (!shapeEditActive(c)) return;
11558
+ const W = els.preview.width, H = els.preview.height, pt = canvasPt(e), grab = Math.max(8, W / 110);
11559
+ const near = (p) => Math.hypot(p.x - pt.x, p.y - pt.y) <= grab;
11560
+ if (shapeUi.tool === "pen") {
11561
+ e.preventDefault(); e.stopImmediatePropagation();
11562
+ if (isGroupLocked(c)) { toastLocked(); return; }
11563
+ const L = shapeLayerMatrix(c, W, H), lp = L.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
11564
+ const P = (shapeUi.pen ||= { pts: [] }).pts;
11565
+ if (P.length >= 3 && near(L.transformPoint(new DOMPoint(P[0][0], P[0][1])))) { finishShapePen(c, true); return; }
11566
+ if (P.length >= Motion.MAX_POINTS) { toast(`A path has at most ${Motion.MAX_POINTS} points`); return; }
11567
+ P.push([lp.x, lp.y, 0, 0, 0, 0]);
11568
+ shapeUi.pen.redo = []; shapeUi.pen.dragging = true;
11569
+ shapeUi.drag = { tool: true, L };
11570
+ els.preview.setPointerCapture(e.pointerId);
11571
+ state.dirtyTimeline = true;
11572
+ return;
11573
+ }
11574
+ const h = shapeHandles(c, W, H);
11575
+ if (!h) return;
11576
+ const it = h.it, sp = h.editable ? h.A[shapeUi.pt] : null;
11577
+ const lp = h.I.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
11578
+ let drag = null;
11579
+ if (near(h.knob)) drag = { mode: "rotate", a0: Math.atan2(pt.y - h.centre.y, pt.x - h.centre.x) };
11580
+ else if (sp && near(sp.o)) drag = { mode: "handle", side: "o" };
11581
+ else if (sp && near(sp.i)) drag = { mode: "handle", side: "i" };
11582
+ else if (h.editable && h.A.some((p) => near(p.a))) { shapeUi.pt = h.A.findIndex((p) => near(p.a)); drag = { mode: e.altKey ? "pull" : "point" }; }
11583
+ else if (h.corners.some(near)) { const q = h.corners.find(near); drag = { mode: "size", lx0: q.lx, ly0: q.ly, d0: Math.hypot(pt.x - h.centre.x, pt.y - h.centre.y) || 1 }; }
11584
+ else if (it.type === "path" && (e.ctrlKey || e.metaKey)) {
11585
+ if (it.points.length >= Motion.MAX_POINTS) { toast(`A path has at most ${Motion.MAX_POINTS} points`); e.stopImmediatePropagation(); return; }
11586
+ if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
11587
+ const r = nearestShapeSegment(h, pt);
11588
+ if (!r || r.d > grab * 3) return;
11589
+ pushUndo(); writeShapeTopology(c, shapeUi.sel, (pts) => Mask.splitPoints(pts, r.i, r.t)); scheduleSave();
11590
+ shapeUi.pt = r.i + 1;
11591
+ drag = { mode: "point", pushed: true };
11592
+ } else {
11593
+ const xs = h.corners.map((q) => q.lx), ys = h.corners.map((q) => q.ly);
11594
+ if (lp.x >= Math.min(...xs) && lp.x <= Math.max(...xs) && lp.y >= Math.min(...ys) && lp.y <= Math.max(...ys)) drag = { mode: "move" };
11595
+ }
11596
+ if (!drag) return; // outside the item: the monitor's usual clicks
11597
+ e.preventDefault(); e.stopImmediatePropagation();
11598
+ if (isGroupLocked(c)) { toastLocked(); return; }
11599
+ if (!drag.pushed) pushUndo();
11600
+ shapeUi.drag = { ...drag, start: pt, it0: JSON.parse(JSON.stringify(shapeNow(c))), F: h.F, I: h.I };
11601
+ els.preview.setPointerCapture(e.pointerId);
11602
+ state.dirtyTimeline = true;
11603
+ }, true);
11604
+ els.preview.addEventListener("pointermove", (e) => {
11605
+ const d = shapeUi.drag;
11606
+ if (!d) return;
11607
+ e.stopImmediatePropagation();
11608
+ const c = getClip(state.selId);
11609
+ if (!c) return;
11610
+ const pt = canvasPt(e);
11611
+ if (d.tool) {
11612
+ const P = shapeUi.pen && shapeUi.pen.pts, p = P && P[P.length - 1];
11613
+ if (p && shapeUi.pen.dragging) {
11614
+ const q = d.L.inverse().transformPoint(new DOMPoint(pt.x, pt.y)), ox = q.x - p[0], oy = q.y - p[1];
11615
+ if (Math.hypot(ox, oy) > 3) { p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
11616
+ }
11617
+ state.dirtyTimeline = true;
11618
+ return;
11619
+ }
11620
+ const it0 = d.it0, r3 = (v) => +v.toFixed(3);
11621
+ let set = null;
11622
+ if (d.mode === "move") {
11623
+ const Fi = d.F.inverse(), a = Fi.transformPoint(new DOMPoint(d.start.x, d.start.y)), b = Fi.transformPoint(new DOMPoint(pt.x, pt.y));
11624
+ set = { x: Math.round(it0.x + b.x - a.x), y: Math.round(it0.y + b.y - a.y) };
11625
+ } else if (d.mode === "rotate") {
11626
+ const c0 = d.I.transformPoint(new DOMPoint(0, 0));
11627
+ let r = it0.rotation + (Math.atan2(pt.y - c0.y, pt.x - c0.x) - d.a0) * 180 / Math.PI;
11628
+ if (e.shiftKey) r = Math.round(r / 15) * 15;
11629
+ set = { rotation: r3(r) };
11630
+ } else if (d.mode === "size") {
11631
+ const l = d.I.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
11632
+ if (Motion.OWN[it0.type].includes("w")) {
11633
+ let kx = Math.abs(l.x) / Math.max(1e-6, Math.abs(d.lx0)), ky = Math.abs(l.y) / Math.max(1e-6, Math.abs(d.ly0));
11634
+ if (e.shiftKey) kx = ky = Math.max(kx, ky);
11635
+ set = { w: Math.round(clamp(it0.w * kx, 1, 20000)), h: Math.round(clamp(it0.h * ky, 1, 20000)) };
11636
+ } else {
11637
+ const c0 = d.I.transformPoint(new DOMPoint(0, 0));
11638
+ set = { scale: +clamp(it0.scale * Math.hypot(pt.x - c0.x, pt.y - c0.y) / d.d0, 0.01, 100).toFixed(4) };
11639
+ }
11640
+ } else if (it0.points) {
11641
+ const pts = it0.points.map((p) => [p[0], p[1], p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0]), p = pts[shapeUi.pt];
11642
+ if (!p) return;
11643
+ const l = d.I.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
11644
+ if (d.mode === "point") { p[0] = l.x; p[1] = l.y; }
11645
+ 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; }
11646
+ else if (d.mode === "handle") {
11647
+ const hx = l.x - p[0], hy = l.y - p[1], o = d.side === "o" ? 4 : 2, q = d.side === "o" ? 2 : 4;
11648
+ p[o] = hx; p[o + 1] = hy;
11649
+ if (!e.altKey) { // keep the pair smooth: the other handle mirrors the direction, keeps its length
11650
+ const len = Math.hypot(p[q], p[q + 1]) || Math.hypot(hx, hy), k = Math.hypot(hx, hy) || 1;
11651
+ p[q] = -hx / k * len; p[q + 1] = -hy / k * len;
11652
+ }
11653
+ }
11654
+ set = { points: pts.map((r) => r.map(r3)) };
11655
+ }
11656
+ if (set) { writeShapeSet(c, shapeUi.sel, set); syncShapeInspector(c); }
11657
+ }, true);
11658
+ const endShapeDrag = (e) => {
11659
+ const d = shapeUi.drag;
11660
+ if (!d) return;
11661
+ e.stopImmediatePropagation();
11662
+ shapeUi.drag = null;
11663
+ if (d.tool) { if (shapeUi.pen) shapeUi.pen.dragging = false; state.dirtyTimeline = true; return; }
11664
+ scheduleSave(); renderInspector();
11665
+ };
11666
+ els.preview.addEventListener("pointerup", endShapeDrag, true);
11667
+ els.preview.addEventListener("pointercancel", endShapeDrag, true);
11668
+ els.preview.addEventListener("dblclick", (e) => {
11669
+ const c = getClip(state.selId);
11670
+ if (!shapeEditActive(c) || shapeUi.tool) return;
11671
+ const h = shapeHandles(c, els.preview.width, els.preview.height), pt = canvasPt(e), grab = Math.max(8, els.preview.width / 110);
11672
+ if (!h || h.it.type !== "path") return;
11673
+ const i = h.A.findIndex((p) => Math.hypot(p.a.x - pt.x, p.a.y - pt.y) <= grab);
11674
+ if (i < 0) return;
11675
+ e.preventDefault(); e.stopImmediatePropagation();
11676
+ if (h.it.points.length <= (h.it.closed === false ? 2 : 3)) { toast("The path needs the points it has"); return; }
11677
+ if (isGroupLocked(c)) { toastLocked(); return; }
11678
+ pushUndo();
11679
+ writeShapeTopology(c, shapeUi.sel, (pts) => pts.filter((_, j) => j !== i));
11680
+ shapeUi.pt = -1; scheduleSave(); renderInspector();
11681
+ }, true);
11682
+ window.addEventListener("keydown", (e) => {
11683
+ if (e.key === "Escape" && runtime.anchorTool && !shapeUi.sel && !shapeUi.tool) {
11684
+ const tg0 = e.target;
11685
+ if (!(tg0 && (tg0.tagName === "INPUT" || tg0.tagName === "TEXTAREA" || tg0.tagName === "SELECT"))) {
11686
+ e.preventDefault(); e.stopImmediatePropagation();
11687
+ runtime.anchorTool = false; state.dirtyTimeline = true; renderInspector();
11688
+ return;
11689
+ }
11690
+ }
11691
+ if (!shapeUi.clip || (!shapeUi.sel && !shapeUi.tool)) return;
11692
+ const tg = e.target;
11693
+ if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
11694
+ const c = getClip(state.selId);
11695
+ if (!c || c.id !== shapeUi.clip) return;
11696
+ if (e.key === "Escape") {
11697
+ e.preventDefault(); e.stopImmediatePropagation();
11698
+ if (shapeUi.tool) { shapeUi.tool = null; shapeUi.pen = null; }
11699
+ else if (shapeUi.pt >= 0) shapeUi.pt = -1;
11700
+ else shapeUi.sel = null;
11701
+ state.dirtyTimeline = true; renderInspector();
11702
+ } else if (e.key === "Enter" && shapeUi.tool === "pen") {
11703
+ e.preventDefault(); e.stopImmediatePropagation();
11704
+ finishShapePen(c, false);
11705
+ } else if (shapeUi.tool === "pen" && shapeUi.pen && !shapeUi.drag) {
11706
+ // while placing points the path is not in the project yet: undo / redo step through the points
11707
+ const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
11708
+ const back = (mod && k === "z" && !e.shiftKey) || ((e.key === "Backspace" || e.key === "Delete") && !mod);
11709
+ const fwd = mod && ((k === "z" && e.shiftKey) || k === "y");
11710
+ if (!back && !fwd) return;
11711
+ e.preventDefault(); e.stopImmediatePropagation();
11712
+ const P = shapeUi.pen.pts, R = (shapeUi.pen.redo ||= []);
11713
+ if (back && P.length) R.push(P.pop());
11714
+ else if (fwd && R.length) P.push(R.pop());
11715
+ state.dirtyTimeline = true;
11716
+ }
11717
+ }, true);
11718
+
11719
+ /* ── Text on a path ──
11720
+ props.textPath is one shape item (motion.js): its outline is the baseline the
11721
+ title's letters sit on, one line, each letter turned with the path.
11722
+ props.pathOffset (%, keyable) slides the text along it; props.pathAlign says
11723
+ which end of the text sits at the offset. The path is edited on the monitor
11724
+ with the shape editor (its points, handles, move, turn, size and pen). */
11725
+ const TEXT_PATH_PRESETS = {
11726
+ line: { label: "Line", item: Motion.TEXT_PATH_PRESETS.line },
11727
+ arc: { label: "Arc", item: Motion.TEXT_PATH_PRESETS.arc },
11728
+ circle: { label: "Circle", item: Motion.TEXT_PATH_PRESETS.circle },
11729
+ wave: { label: "Wave", item: Motion.TEXT_PATH_PRESETS.wave },
11730
+ };
11731
+ const textPathNorm = new WeakMap();
11732
+ function clipTextPath(c) {
11733
+ const v = c && c.kind === "text" && c.props && c.props.textPath;
11734
+ if (!v || typeof v !== "object") return null;
11735
+ let n = textPathNorm.get(v);
11736
+ if (n === undefined) { n = Motion.normalizeTextPath(v); textPathNorm.set(v, n); }
11737
+ return n;
11738
+ }
11739
+ /** The title's path at timeline t (keys resolved). */
11740
+ function textPathAt(c, t) { const n = clipTextPath(c); return n ? Motion.itemAt(n, t - c.start) : null; }
11741
+ /** The box of the text on its path, in the title's own px (for the selection box and masks). */
11742
+ function textPathBox(c, p, t) {
11743
+ const it = textPathAt(c, t);
11744
+ if (!it) return null;
11745
+ const lines = Math.max(1, String(p.text || "").split("\n").length), size = +p.fontSize || 72, lh = size * lineHeightOf(p);
11746
+ const { A, closed } = Motion.outline(it), m = Motion.itemMatrix(it);
11747
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
11748
+ for (const off of lines > 1 ? [0, (lines - 1) * lh] : [0]) { // the first and the last line's tracks
11749
+ for (const [x, y] of Motion.pathSampler(A, closed, off).points) {
11750
+ const [px, py] = Motion.apply(m, x, y);
11751
+ x0 = Math.min(x0, px); y0 = Math.min(y0, py); x1 = Math.max(x1, px); y1 = Math.max(y1, py);
11752
+ }
11753
+ }
11754
+ if (!(x1 >= x0)) return null;
11755
+ const pad = size * 0.8, s = Motion.contentScale(c, p);
11756
+ return { ox: (x0 + x1) / 2 * s, oy: (y0 + y1) / 2 * s, hw: ((x1 - x0) / 2 + pad) * s, hh: ((y1 - y0) / 2 + pad) * s };
11757
+ }
11758
+ function drawTextOnPath(c, p, local) {
11759
+ const it = textPathAt(c, c.start + local);
11760
+ if (!it) return;
11761
+ const sc = +p.scale || 1, size = +p.fontSize || 72, lh = size * lineHeightOf(p);
11762
+ ctx2d.scale(sc, sc);
11763
+ const m = Motion.itemMatrix(it);
11764
+ ctx2d.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
11765
+ const anim = TEXT_ANIMS.includes(p.textAnim) ? p.textAnim : "none";
11766
+ const rate = clamp(+p.wordRate || 0.15, 0.03, 2);
11767
+ let fam = p.font || "Segoe UI";
11768
+ if (anim === "font-cut") { // swap the typeface rhythmically, then settle
11769
+ const setF = (Array.isArray(p.fontCutSet) && p.fontCutSet.length) ? p.fontCutSet : FONT_CUT_DEFAULT;
11770
+ setF.forEach(ensureFont);
11771
+ if (local < 0.6) fam = setF[Math.floor(local / 0.06) % setF.length];
11772
+ }
11773
+ setTextFont(ctx2d, { ...p, font: fam }, size);
11774
+ try { ctx2d.letterSpacing = "0px"; } catch { } // spacing is laid out by hand along the path
11775
+ ctx2d.textBaseline = "alphabetic"; ctx2d.textAlign = "center";
11776
+ const { A, closed } = Motion.outline(it), ls = +p.letterSpacing || 0;
11777
+ const tracks = []; // line i runs on a parallel curve i line-heights below the path
11778
+ const track = (i) => tracks[i] || (tracks[i] = Motion.pathSampler(A, closed, i * lh));
11779
+ const shadowBlur = (p.textShadow === 0 ? 0 : (+p.textShadow || 12)) * size / 100;
11780
+ const fill = () => {
11781
+ if (!p.color2) return p.color || "#fff";
11782
+ const g = ctx2d.createLinearGradient(0, -size * 0.8, 0, size * 0.1);
11783
+ g.addColorStop(0, p.color || "#fff"); g.addColorStop(1, p.color2);
11784
+ return g;
11785
+ };
11786
+ // Lay out every line along the path (line i sits i line-heights below it), glyph by glyph,
11787
+ // remembering the glyph's word and line so the animations can group them.
11788
+ const lines = textSourceString(p).split("\n");
11789
+ const glyphs = []; // {g, w, d (centre along the path), line, word, ci (letter index), gi}
11790
+ const lineRuns = []; // {from, to} along the path, per line
11791
+ let wi = 0, ci = 0, gi = 0;
11792
+ lines.forEach((ln, line) => {
11793
+ const gs = graphemeSegments(ln), ws = gs.map((g) => ctx2d.measureText(g).width);
11794
+ const total = ws.reduce((a, b) => a + b, 0) + ls * Math.max(0, gs.length - 1);
11795
+ const base = (+p.pathOffset || 0) / 100 * track(line).length;
11796
+ let d = p.pathAlign === "center" ? base - total / 2 : p.pathAlign === "end" ? base - total : base;
11797
+ lineRuns.push({ from: d, to: d + total });
11798
+ let inWord = false;
11799
+ gs.forEach((g, j) => {
11800
+ const space = !g.trim();
11801
+ if (!space && !inWord) { inWord = true; } else if (space && inWord) { inWord = false; wi++; }
11802
+ glyphs.push({ g, w: ws[j], d: d + ws[j] / 2, line, word: wi, ci: space ? -1 : ci, gi, space });
11803
+ if (!space) ci++;
11804
+ gi++;
11805
+ d += ws[j] + ls;
11806
+ });
11807
+ if (inWord) wi++;
11808
+ });
11809
+ // group centres (for word-pop / zoom-in, which scale a word or a line about its middle)
11810
+ const centre = (key) => {
11811
+ const out = new Map();
11812
+ for (const q of glyphs) { if (q.space) continue; const k = q[key], o = out.get(k) || { a: Infinity, b: -Infinity }; o.a = Math.min(o.a, q.d - q.w / 2); o.b = Math.max(o.b, q.d + q.w / 2); out.set(k, o); }
11813
+ return out;
11814
+ };
11815
+ const words = anim === "word-pop" ? centre("word") : null, lineBox = anim === "zoom-in" ? centre("line") : null;
11816
+ const shown = anim === "typewriter" ? Math.floor(local / (rate / 4)) : Infinity;
11817
+ const ease = EASE["ease-out"];
11818
+ for (const q of glyphs) {
11819
+ if (q.space || q.gi >= shown) continue;
11820
+ let d = q.d, scale = 1, alpha = 1, dx = 0, dy = 0, clipL = null, clipR = null, rise = null;
11821
+ const uw = clamp((local - q.word * rate) / 0.25, 0, 1); // per word
11822
+ const ul = (dur) => clamp((local - q.line * rate) / dur, 0, 1); // per line
11823
+ if (anim === "letter-pop") {
11824
+ const u = clamp((local - q.ci * rate / 3) / 0.18, 0, 1);
11825
+ if (u <= 0) continue;
11826
+ scale = Math.max(0.001, backOut(u)); alpha = Math.min(1, u * 2.5);
11827
+ } else if (anim === "wave") dy += Math.sin(local * 4 + q.ci * 0.55) * size * 0.12;
11828
+ else if (anim === "word-pop") {
11829
+ if (uw <= 0) continue;
11830
+ scale = Math.max(0.001, backOut(uw)); alpha = Math.min(1, uw * 2.5);
11831
+ const o = words.get(q.word), mid = (o.a + o.b) / 2;
11832
+ d = mid + (d - mid) * scale;
11833
+ } else if (anim === "word-slide") {
11834
+ if (uw <= 0) continue;
11835
+ dy += (1 - ease(uw)) * size * 0.7; alpha = uw;
11836
+ } else if (anim === "bounce") dy -= Math.abs(Math.sin(local * 3.2 + q.word * 0.9)) * size * 0.18;
11837
+ else if (anim === "shake") { dx += Math.sin(local * 31 + q.word * 7.3) * size * 0.035; dy += Math.cos(local * 27 + q.word * 3.1) * size * 0.035; }
11838
+ else if (anim === "karaoke") alpha = uw >= 1 ? 1 : 0.3 + uw * 0.7;
11839
+ else if (anim === "clip-reveal") { // a wipe runs along each line in reading order
11840
+ const u = ul(0.5);
11841
+ if (u <= 0) continue;
11842
+ const run = lineRuns[q.line], edge = run.from + (run.to - run.from) * ease(u);
11843
+ if (edge <= d - q.w / 2) continue;
11844
+ if (edge < d + q.w / 2) clipR = edge - d;
11845
+ } else if (anim === "zoom-in") {
11846
+ const u = ul(0.45);
11847
+ if (u <= 0) continue;
11848
+ scale = 1.35 - 0.35 * ease(u); alpha = Math.min(1, u * 1.6);
11849
+ const o = lineBox.get(q.line), mid = (o.a + o.b) / 2;
11850
+ d = mid + (d - mid) * scale;
11851
+ } else if (anim === "rise-mask") { // each line rises from behind its own baseline
11852
+ const u = ul(0.5);
11853
+ if (u <= 0) continue;
11854
+ rise = (1 - ease(u)) * lh;
11855
+ }
11856
+ const at = track(q.line).at(d);
11857
+ ctx2d.save();
11858
+ ctx2d.translate(at.x, at.y);
11859
+ ctx2d.rotate(at.angle * Math.PI / 180);
11860
+ ctx2d.translate(dx, dy);
11861
+ if (clipL != null || clipR != null || rise != null) {
11862
+ ctx2d.beginPath();
11863
+ const x0 = clipL ?? -q.w / 2 - size, x1 = clipR ?? q.w / 2 + size;
11864
+ ctx2d.rect(x0, -size * 1.1, x1 - x0, size * 1.4); // the glyph's line box: ascenders to descenders
11865
+ ctx2d.clip();
11866
+ if (rise != null) ctx2d.translate(0, rise);
11867
+ }
11868
+ if (scale !== 1) ctx2d.scale(scale, scale);
11869
+ const keep = ctx2d.globalAlpha;
11870
+ ctx2d.globalAlpha = keep * clamp(alpha, 0, 1);
11871
+ if (p.strokeWidth > 0) {
11872
+ ctx2d.lineJoin = "round"; ctx2d.miterLimit = 2; ctx2d.lineWidth = p.strokeWidth; ctx2d.strokeStyle = p.strokeColor || "#000";
11873
+ ctx2d.strokeText(q.g, 0, 0);
11874
+ }
11875
+ if (p.glow > 0) { ctx2d.shadowColor = p.glowColor || p.color || "#fff"; ctx2d.shadowBlur = (+p.glow) * size / 40; }
11876
+ else if (shadowBlur > 0) { ctx2d.shadowColor = "rgba(0,0,0,.7)"; ctx2d.shadowBlur = shadowBlur; ctx2d.shadowOffsetY = shadowBlur / 3; }
11877
+ ctx2d.fillStyle = fill();
11878
+ ctx2d.fillText(q.g, 0, 0);
11879
+ if (p.glow > 0) ctx2d.fillText(q.g, 0, 0);
11880
+ ctx2d.globalAlpha = keep;
11881
+ ctx2d.restore();
11882
+ }
11883
+ }
11884
+ function textPathSectionHtml(c, p, row) {
11885
+ const has = !!clipTextPath(c), editing = has && shapeUi.clip === c.id && samePath(shapeUi.sel, [0]);
11886
+ const btn = (attr, label, title, on, dis) => `<button type="button" class="btn tiny${on ? " toggle on" : ""}" ${attr} title="${title}" ${dis ? "disabled" : ""}>${label}</button>`;
11887
+ return `<div class="insp-section" data-textpath-section><h3>Text path</h3>
11888
+ <div class="cl-row">${Object.entries(TEXT_PATH_PRESETS).map(([k, pre]) => btn(`data-tpath="${k}"`, pre.label, `Set the title on a ${pre.label.toLowerCase()}`)).join("")}
11889
+ ${btn('data-tpath="pen"', "Pen", "Draw the path on the monitor: click points (drag to curve), Enter to finish, click the first point to close it", shapeUi.clip === c.id && shapeUi.tool === "pen")}</div>
11890
+ ${has ? `<div class="cl-row">${btn('data-tpath="edit"', "Edit path", "Show the path's points and handles on the monitor", editing)}${btn('data-tpath="off"', "Remove", "Back to a normal title")}</div>
11891
+ ${row("Offset", `<input type="range" data-k="pathOffset" min="-100" max="200" step="0.5" value="${fmtInspNum(p.pathOffset, 0.5)}" title="Slide the text along the path, % of its length — key it to make the text travel">
11892
+ <span class="val" data-val="pathOffset" data-unit="%">${fmtInspNum(p.pathOffset, 0.5)}%</span>`, "pathOffset")}
11893
+ ${row("Align", `<select data-k="pathAlign">${["start", "center", "end"].map((v) => `<option value="${v}" ${(p.pathAlign || "start") === v ? "selected" : ""}>${v}</option>`).join("")}</select>`, "pathAlign")}
11894
+ <p class="cl-note">Each line runs along the path, the next one a line below it. Every text animation plays along the path.</p>`
11895
+ : `<p class="cl-note">Set the title along a line, an arc, a circle, a wave or a path you draw.</p>`}
11896
+ </div>`;
11897
+ }
11898
+ function bindTextPathControls(c) {
11899
+ for (const b of els.inspector.querySelectorAll("[data-tpath]")) b.addEventListener("click", () => {
11900
+ if (isGroupLocked(c)) { toastLocked(); return; }
11901
+ const k = b.dataset.tpath;
11902
+ if (k === "edit") {
11903
+ const on = shapeUi.clip === c.id && samePath(shapeUi.sel, [0]);
11904
+ Object.assign(shapeUi, { clip: c.id, sel: on ? null : [0], pt: -1, tool: null, pen: null });
11905
+ if (!on) { maskUi.sel = -1; maskUi.tool = null; }
11906
+ state.dirtyTimeline = true; renderInspector();
11907
+ return;
11908
+ }
11909
+ if (k === "pen") {
11910
+ const on = shapeUi.clip === c.id && shapeUi.tool === "pen";
11911
+ Object.assign(shapeUi, { clip: c.id, sel: null, pt: -1, tool: on ? null : "pen", pen: null });
11912
+ if (!on && state.playing) pause();
11913
+ if (!on && !activeAt(c, state.time)) toast("Move the playhead over the title, then draw on the monitor");
11914
+ state.dirtyTimeline = true; renderInspector();
11915
+ return;
11916
+ }
11917
+ pushUndo();
11918
+ if (k === "off") { delete c.props.textPath; shapeUi.sel = null; }
11919
+ else {
11920
+ c.props.textPath = Motion.normalizeTextPath(TEXT_PATH_PRESETS[k].item);
11921
+ Object.assign(shapeUi, { clip: c.id, sel: [0], pt: -1, tool: null, pen: null });
11922
+ }
11923
+ state.dirtyTimeline = true; inspPropGen++;
11924
+ scheduleSave(); renderInspector();
11925
+ });
11926
+ }
11927
+ /* ── Motion paths on the monitor ──
11928
+ With two or more position keys (x and y keyed at the same time) the selected
11929
+ clip's path shows over the picture: the curve, a dot per frame (their spacing
11930
+ is the speed), a square per key and the keys' curve handles. Drag a key to move
11931
+ it, its handles to bend the path (Alt breaks the pair), Alt-drag a key to pull
11932
+ new handles, double-click a key to make it a corner again. */
11933
+ const pathUi = { clip: null, key: -1, drag: null };
11934
+ function motionPathActive(c) {
11935
+ return !!c && isVisualClip(c) && !state.playing && !state.exporting && !runtime.anchorTool && !shapeUi.tool &&
11936
+ Motion.posKeys(c).length >= 2 && activeAt(c, state.time);
11937
+ }
11938
+ /** Screen geometry of the path: the curve, frame dots and keys with their handles, in canvas px. */
11939
+ function motionPathGeom(c, W, H, t) {
11940
+ const P = Motion.posKeys(c);
11941
+ const M = parentScreenMatrix(c, W, H, t);
11942
+ const scr = (x, y) => { const q = M.transformPoint(new DOMPoint(x, y)); return { x: q.x, y: q.y }; };
11943
+ const t0 = P[0].t, t1 = P[P.length - 1].t, step = 1 / (projectFps() || 30);
11944
+ const dots = [];
11945
+ for (let u = t0; u <= t1 + 1e-9; u += step) { const q = Motion.positionAt(c, u); dots.push(scr(q.x, q.y)); }
11946
+ const keys = P.map((k) => ({ k, a: scr(k.x, k.y), i: scr(k.x + k.ix, k.y + k.iy), o: scr(k.x + k.ox, k.y + k.oy), hasIn: !!(k.ix || k.iy), hasOut: !!(k.ox || k.oy) }));
11947
+ return { P, M, dots, keys };
11948
+ }
11949
+ function drawMotionPathOverlay(c, W, H, t) {
11950
+ if (!motionPathActive(c)) return;
11951
+ if (pathUi.clip !== c.id) Object.assign(pathUi, { clip: c.id, key: -1, drag: null });
11952
+ const g = motionPathGeom(c, W, H, t), lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
11953
+ ctx2d.save();
11954
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
11955
+ ctx2d.globalAlpha = 1; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
11956
+ ctx2d.lineJoin = "round";
11957
+ const line = g.dots.map((q) => [q.x, q.y]);
11958
+ ctx2d.lineWidth = lw * 2.4; ctx2d.strokeStyle = "rgba(0,0,0,0.5)"; strokeOutline(ctx2d, line, false);
11959
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#c084fc"; strokeOutline(ctx2d, line, false);
11960
+ ctx2d.fillStyle = "#e9d5ff";
11961
+ for (const q of g.dots) { ctx2d.beginPath(); ctx2d.arc(q.x, q.y, lw * 1.1, 0, Math.PI * 2); ctx2d.fill(); }
11962
+ const dot = (p, r, fill, square) => {
11963
+ ctx2d.beginPath();
11964
+ 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);
11965
+ ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.75)"; ctx2d.stroke();
11966
+ };
11967
+ g.keys.forEach((k, j) => {
11968
+ const show = j === pathUi.key || k.hasIn || k.hasOut;
11969
+ if (show) {
11970
+ ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#c084fc";
11971
+ for (const [q, on] of [[k.i, j > 0], [k.o, j < g.keys.length - 1]]) {
11972
+ if (!on || (!(j === pathUi.key) && !(q === k.i ? k.hasIn : k.hasOut))) continue;
11973
+ ctx2d.beginPath(); ctx2d.moveTo(k.a.x, k.a.y); ctx2d.lineTo(q.x, q.y); ctx2d.stroke();
11974
+ dot(q, hs * 0.55, "#c084fc");
11975
+ }
11976
+ }
11977
+ dot(k.a, hs * 0.7, j === pathUi.key ? "#c084fc" : "#ffffff", true);
11978
+ });
11979
+ ctx2d.restore();
11980
+ }
11981
+ /** Write a position key's value / handles (pin / pout on the x and y keys). */
11982
+ function setPathKey(k, set) {
11983
+ const put = (kf, prop, v) => { if (v == null || Math.abs(v) < 1e-3) delete kf[prop]; else kf[prop] = +v.toFixed(3); };
11984
+ if ("x" in set) k.xk.v = +set.x.toFixed(3);
11985
+ if ("y" in set) k.yk.v = +set.y.toFixed(3);
11986
+ if ("ix" in set) { put(k.xk, "pin", set.ix); put(k.yk, "pin", set.iy); }
11987
+ if ("ox" in set) { put(k.xk, "pout", set.ox); put(k.yk, "pout", set.oy); }
11988
+ inspPropGen++; state.dirtyTimeline = true;
11989
+ }
11990
+ els.preview.addEventListener("pointerdown", (e) => {
11991
+ if (e.button !== 0 || isSourceMode()) return;
11992
+ const c = getClip(state.selId);
11993
+ if (!motionPathActive(c) || maskEditActive(c) || shapeEditActive(c)) return;
11994
+ const W = els.preview.width, H = els.preview.height, pt = canvasPt(e), grab = Math.max(8, W / 110);
11995
+ const g = motionPathGeom(c, W, H, state.time);
11996
+ const near = (q) => Math.hypot(q.x - pt.x, q.y - pt.y) <= grab;
11997
+ let drag = null;
11998
+ // handles first (the picked key's, then any shown), then the keys themselves
11999
+ const last = g.keys.length - 1, order = [pathUi.key, ...g.keys.map((_, j) => j).filter((j) => j !== pathUi.key)].filter((j) => j >= 0);
12000
+ for (const j of order) {
12001
+ const k = g.keys[j], shown = (has) => j === pathUi.key || has;
12002
+ if (j < last && shown(k.hasOut) && near(k.o)) { drag = { mode: "handle", side: "o", j }; break; }
12003
+ if (j > 0 && shown(k.hasIn) && near(k.i)) { drag = { mode: "handle", side: "i", j }; break; }
12004
+ }
12005
+ if (!drag) {
12006
+ const j = g.keys.findIndex((k) => near(k.a));
12007
+ if (j >= 0) drag = { mode: e.altKey ? "pull" : "key", j };
12008
+ }
12009
+ if (!drag) return; // not on the path: the monitor's usual clicks
12010
+ e.preventDefault(); e.stopImmediatePropagation();
12011
+ if (isGroupLocked(c)) { toastLocked(); return; }
12012
+ pushUndo();
12013
+ pathUi.key = drag.j;
12014
+ const k = g.P[drag.j];
12015
+ pathUi.drag = { ...drag, inv: g.M.inverse(), k0: { x: k.x, y: k.y, ix: k.ix, iy: k.iy, ox: k.ox, oy: k.oy }, start: pt };
12016
+ els.preview.setPointerCapture(e.pointerId);
12017
+ state.dirtyTimeline = true;
12018
+ }, true);
12019
+ els.preview.addEventListener("pointermove", (e) => {
12020
+ const d = pathUi.drag;
12021
+ if (!d) return;
12022
+ e.stopImmediatePropagation();
12023
+ const c = getClip(state.selId), k = c && Motion.posKeys(c)[d.j];
12024
+ if (!k) return;
12025
+ const pt = canvasPt(e), q = d.inv.transformPoint(new DOMPoint(pt.x, pt.y)), k0 = d.k0;
12026
+ if (d.mode === "key") {
12027
+ const a = d.inv.transformPoint(new DOMPoint(d.start.x, d.start.y));
12028
+ setPathKey(k, { x: k0.x + q.x - a.x, y: k0.y + q.y - a.y });
12029
+ } else if (d.mode === "pull") {
12030
+ const ox = q.x - k0.x, oy = q.y - k0.y;
12031
+ if (Math.hypot(ox, oy) > 2) setPathKey(k, { ox, oy, ix: -ox, iy: -oy });
12032
+ } else {
12033
+ const hx = q.x - k0.x, hy = q.y - k0.y;
12034
+ const mine = d.side === "o" ? { ox: hx, oy: hy } : { ix: hx, iy: hy };
12035
+ if (!e.altKey) { // keep the key smooth: the other handle mirrors the direction, keeps its length
12036
+ const ol = d.side === "o" ? Math.hypot(k0.ix, k0.iy) : Math.hypot(k0.ox, k0.oy);
12037
+ const len = ol || Math.hypot(hx, hy), m = Math.hypot(hx, hy) || 1;
12038
+ if (d.side === "o") Object.assign(mine, { ix: -hx / m * len, iy: -hy / m * len });
12039
+ else Object.assign(mine, { ox: -hx / m * len, oy: -hy / m * len });
12040
+ } else if (d.side === "o") Object.assign(mine, { ix: k0.ix, iy: k0.iy });
12041
+ else Object.assign(mine, { ox: k0.ox, oy: k0.oy });
12042
+ const P = Motion.posKeys(c);
12043
+ if (d.j === 0) { mine.ix = 0; mine.iy = 0; }
12044
+ if (d.j === P.length - 1) { mine.ox = 0; mine.oy = 0; }
12045
+ setPathKey(k, { ix: mine.ix ?? k0.ix, iy: mine.iy ?? k0.iy, ox: mine.ox ?? k0.ox, oy: mine.oy ?? k0.oy });
12046
+ }
12047
+ syncInspectorPlayhead();
12048
+ }, true);
12049
+ const endPathDrag = (e) => {
12050
+ if (!pathUi.drag) return;
12051
+ e.stopImmediatePropagation();
12052
+ pathUi.drag = null;
12053
+ scheduleSave(); renderInspector();
12054
+ };
12055
+ els.preview.addEventListener("pointerup", endPathDrag, true);
12056
+ els.preview.addEventListener("pointercancel", endPathDrag, true);
12057
+ els.preview.addEventListener("dblclick", (e) => {
12058
+ const c = getClip(state.selId);
12059
+ if (!motionPathActive(c) || maskEditActive(c) || shapeEditActive(c)) return;
12060
+ const W = els.preview.width, H = els.preview.height, pt = canvasPt(e), grab = Math.max(8, W / 110);
12061
+ const g = motionPathGeom(c, W, H, state.time), j = g.keys.findIndex((k) => Math.hypot(k.a.x - pt.x, k.a.y - pt.y) <= grab);
12062
+ if (j < 0) return;
12063
+ e.preventDefault(); e.stopImmediatePropagation();
12064
+ if (isGroupLocked(c)) { toastLocked(); return; }
12065
+ pushUndo();
12066
+ setPathKey(g.P[j], { ix: 0, iy: 0, ox: 0, oy: 0 });
12067
+ scheduleSave(); renderInspector();
12068
+ }, true);
12069
+ /** The Inspector's Motion section: motion path, auto-orient and motion blur. */
12070
+ function motionSectionHtml(c, p, row) {
12071
+ const keys = Motion.posKeys(c).length, curved = Motion.hasPathHandles(c);
12072
+ const btn = (attr, label, title, dis) => `<button type="button" class="btn tiny" ${attr} title="${title}" ${dis ? "disabled" : ""}>${label}</button>`;
12073
+ return `<div class="insp-section" data-motion-section><h3>Motion</h3>
12074
+ <div class="insp-row"><label title="Position keys (X and Y keyed at the same time) make a path you can bend on the monitor">Path</label><span class="insp-ctrls">
12075
+ ${btn("data-path-act=\"smooth\"", "Smooth", "Curve the path smoothly through every position key", keys < 2)}
12076
+ ${btn("data-path-act=\"straight\"", "Straight", "Straight lines between the position keys", !curved)}
12077
+ <span class="cl-note">${keys >= 2 ? `${keys} position keys${curved ? ", curved" : ""}` : "key X and Y at two times"}</span></span></div>
12078
+ ${row("Auto-orient", `<input type="checkbox" data-k="autoOrient" ${p.autoOrient ? "checked" : ""} title="Turn the clip with its direction of travel (its Rotation adds on top)">`, "autoOrient")}
12079
+ ${c.kind !== "null" ? row("Motion blur", `<input type="range" data-k="motionBlur" min="0" max="720" step="15" value="${+p.motionBlur || 0}" title="Shutter angle — 180° is the film look, 0 is off">
12080
+ <span class="val" data-val="motionBlur" data-unit="°">${+p.motionBlur || 0}°</span>`, "motionBlur")
12081
+ + row("Blur samples", `<input type="range" data-k="motionBlurSamples" min="2" max="32" step="1" value="${+p.motionBlurSamples || 8}" title="Sub-frame samples per frame — more is smoother and slower (playback uses as many as fit in the frame time; a paused frame and export use them all)">
12082
+ <span class="val" data-val="motionBlurSamples" data-unit="">${+p.motionBlurSamples || 8}</span>`, "motionBlurSamples") : ""}
12083
+ ${presetRowsHtml(c, row)}
12084
+ </div>`;
12085
+ }
12086
+ function bindMotionControls(c) {
12087
+ for (const b of els.inspector.querySelectorAll("[data-path-act]")) b.addEventListener("click", () => {
12088
+ if (isGroupLocked(c)) { toastLocked(); return; }
12089
+ pushUndo();
12090
+ c.keyframes = b.dataset.pathAct === "smooth" ? Motion.smoothPath(c.keyframes) : Motion.straightPath(c.keyframes);
12091
+ inspPropGen++; state.dirtyTimeline = true;
12092
+ scheduleSave(); renderInspector();
12093
+ });
12094
+ }
12095
+
12096
+ /* ── Text direction (LTR / RTL) — per-line when direction is "auto".
12097
+ Auto-detect uses a DOM probe + getComputedStyle; cache by line text so
12098
+ preview/export frames don't re-resolve style every tick. ── */
12099
+ let _textDirProbe;
12100
+ const _textDirCache = new Map(); // line text → "ltr" | "rtl"
12101
+ const TEXT_DIR_CACHE_MAX = 256;
12102
+ function detectTextDirection(text) {
12103
+ const key = (text && String(text).trim()) ? String(text) : " ";
12104
+ const hit = _textDirCache.get(key);
12105
+ if (hit) return hit;
12106
+ if (!_textDirProbe) {
12107
+ _textDirProbe = document.createElement("p");
12108
+ _textDirProbe.style.cssText = "position:fixed;left:-9999px;visibility:hidden;white-space:nowrap";
12109
+ document.body.appendChild(_textDirProbe);
12110
+ }
12111
+ _textDirProbe.dir = "auto";
12112
+ _textDirProbe.textContent = key;
12113
+ const dir = getComputedStyle(_textDirProbe).direction === "rtl" ? "rtl" : "ltr";
12114
+ if (_textDirCache.size >= TEXT_DIR_CACHE_MAX) _textDirCache.clear();
12115
+ _textDirCache.set(key, dir);
12116
+ return dir;
12117
+ }
12118
+ function lineDirections(p, lines) {
12119
+ if (p.direction === "rtl" || p.direction === "ltr") {
12120
+ const forced = p.direction;
12121
+ return lines.map(() => forced);
12122
+ }
12123
+ return lines.map((ln) => detectTextDirection(ln));
12124
+ }
12125
+ function revealWipeRect(cx, w, lh, y, e, rtl, pad = 6) {
12126
+ const clipW = (w + pad * 2) * e;
12127
+ const x0 = rtl ? cx + w / 2 + pad - clipW : cx - w / 2 - pad;
12128
+ ctx2d.rect(x0, y - lh / 2, clipW, lh);
12129
+ }
12130
+ function typewriterX(cx, fullW, shownW, rtl) {
12131
+ const dx = (fullW - shownW) / 2;
12132
+ return rtl ? cx + dx : cx - dx;
12133
+ }
12134
+ function runOrigin(cx, totalW, rtl) {
12135
+ return rtl ? cx + totalW / 2 : cx - totalW / 2;
12136
+ }
12137
+ // Arabic/Indic letters change shape when drawn in isolation — letter-pop/wave must
12138
+ // paint shaped clusters (whole words), not individual code points.
12139
+ const SHAPING_SCRIPT_RE = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F]/;
12140
+ function needsContextualShaping(text) {
12141
+ return SHAPING_SCRIPT_RE.test(text);
12142
+ }
12143
+ let _graphemeSeg;
12144
+ const _graphemeCache = new Map(); // line text → string[]
12145
+ const _letterAnimCache = new Map(); // line text → {text, animate}[]
12146
+ const TEXT_SEG_CACHE_MAX = 256;
12147
+ function graphemeSegments(text) {
12148
+ const key = String(text ?? "");
12149
+ const hit = _graphemeCache.get(key);
12150
+ if (hit) return hit;
12151
+ let segs;
12152
+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
12153
+ if (!_graphemeSeg) _graphemeSeg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
12154
+ segs = [..._graphemeSeg.segment(key)].map((s) => s.segment);
12155
+ } else {
12156
+ segs = [...key];
12157
+ }
12158
+ if (_graphemeCache.size >= TEXT_SEG_CACHE_MAX) _graphemeCache.clear();
12159
+ _graphemeCache.set(key, segs);
12160
+ return segs;
12161
+ }
12162
+ function letterAnimSegments(line) {
12163
+ const key = String(line ?? "");
12164
+ const hit = _letterAnimCache.get(key);
12165
+ if (hit) return hit;
12166
+ let segs;
12167
+ if (needsContextualShaping(key)) {
12168
+ segs = [];
12169
+ const re = /(\s+|[^\s]+)/g;
12170
+ let m;
12171
+ while ((m = re.exec(key))) segs.push({ text: m[0], animate: !!m[0].trim() });
12172
+ } else {
12173
+ segs = graphemeSegments(key).map((text) => ({ text, animate: !!text.trim() }));
12174
+ }
12175
+ if (_letterAnimCache.size >= TEXT_SEG_CACHE_MAX) _letterAnimCache.clear();
12176
+ _letterAnimCache.set(key, segs);
12177
+ return segs;
12178
+ }
12179
+
12180
+ /* ── Text rendering: styling (stroke / background pill) + kinetic animations.
12181
+ `local` is clip-local time in seconds. Word timing: word i enters at
12182
+ i * wordRate; each entrance animation lasts ~0.25 s. ── */
12183
+ const backOut = (u) => { const c1 = 1.70158, c3 = c1 + 1; const v = u - 1; return 1 + c3 * v * v * v + c1 * v * v; };
12184
+ function hexToRgba(hex, a) {
12185
+ const n = parseInt(String(hex).replace("#", ""), 16) || 0;
12186
+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
12187
+ }
12188
+ function hasTextBox(p) { return +p.boxW > 0 && +p.boxH > 0; }
12189
+ /* Target width for justify: text box width when set, else max(natural, ~85% canvas). */
12190
+ function textJustifyTarget(p, naturalBlockW) {
12191
+ if (+p.boxW > 0) return +p.boxW;
12192
+ const sc = Math.max(0.001, +p.scale || 1);
12193
+ return Math.max(naturalBlockW, project.width / sc * 0.85);
12194
+ }
12195
+ /* Expand a line to ≈ targetW by inserting whole spaces between words. */
12196
+ function justifyLineBySpaces(ctx, ln, targetW) {
12197
+ const words = String(ln).split(/\s+/).filter(Boolean);
12198
+ if (words.length < 2) return ln;
9297
12199
  const natural = words.join(" ");
9298
12200
  if (ctx.measureText(natural).width >= targetW - 0.5) return natural;
9299
12201
  let lo = 1, hi = 2, best = natural;
@@ -9402,6 +12304,7 @@ function beginTextBoxDrag(c, pt, W, H) {
9402
12304
  };
9403
12305
  }
9404
12306
  function drawText(c, p, local) {
12307
+ if (p.textPath && clipTextPath(c)) { drawTextOnPath(c, p, local); return; }
9405
12308
  const useBox = hasTextBox(p);
9406
12309
  const boxW = +p.boxW, boxH = +p.boxH;
9407
12310
  const scaleToFit = useBox && !!p.boxFit;
@@ -9781,7 +12684,7 @@ function ensureFont(name) {
9781
12684
  an export that frame is final. Bounded so a dead network can't stall it. */
9782
12685
  async function loadProjectFonts() {
9783
12686
  const faces = new Map(); // "italic 700 64px "Anton"" -> "Anton"
9784
- for (const c of project.clips) {
12687
+ for (const c of mediaClips()) {
9785
12688
  if (c.kind !== "text" || c.disabled) continue;
9786
12689
  const p = c.props || {};
9787
12690
  const fams = [p.font || "Segoe UI"];
@@ -10289,7 +13192,7 @@ function waitMediaEl(el, ms = 2500) {
10289
13192
  * compositor. Preview leaves them no-cors so a stream without ACAO still plays. */
10290
13193
  async function armExportCors() {
10291
13194
  const jobs = [];
10292
- for (const c of project.clips) {
13195
+ for (const c of mediaClips()) {
10293
13196
  if (c.kind !== "video" || !clipRenders(c)) continue;
10294
13197
  const m = getMedia(c.mediaId);
10295
13198
  if (!m || !isCrossOriginSrc(m.src)) continue;
@@ -10312,7 +13215,7 @@ async function armExportCors() {
10312
13215
  }
10313
13216
  await Promise.all(jobs);
10314
13217
  const missing = [];
10315
- for (const c of project.clips) {
13218
+ for (const c of mediaClips()) {
10316
13219
  if (c.kind !== "video" || !clipRenders(c)) continue;
10317
13220
  const m = getMedia(c.mediaId);
10318
13221
  if (!m || !isCrossOriginSrc(m.src)) continue;
@@ -10445,7 +13348,16 @@ function videoRangeBuffered(el, from, to) {
10445
13348
  return el.readyState >= 3;
10446
13349
  }
10447
13350
  function notePresented(el, mediaTime) {
10448
- if (Number.isFinite(mediaTime)) el._fcPresentedTime = mediaTime;
13351
+ if (!Number.isFinite(mediaTime)) return;
13352
+ // learn the source's frame duration from consecutive presented frames (the smallest
13353
+ // step seen: playback can drop frames, never split one)
13354
+ const prev = el._fcLastShown;
13355
+ if (prev != null) {
13356
+ const d = mediaTime - prev;
13357
+ if (d > 1 / 240 && d < 0.1) { el._fcFrameDur = Math.min(el._fcFrameDur || Infinity, d); el._fcFrameSeen = (el._fcFrameSeen || 0) + 1; }
13358
+ }
13359
+ el._fcLastShown = mediaTime;
13360
+ el._fcPresentedTime = mediaTime;
10449
13361
  }
10450
13362
  function presentedClose(el, mt, eps) {
10451
13363
  return el._fcPresentedTime != null && Math.abs(el._fcPresentedTime - mt) <= eps;
@@ -10482,15 +13394,32 @@ function assignVideoTime(el, mt, accurate) {
10482
13394
  }
10483
13395
  el.currentTime = mt;
10484
13396
  }
10485
- function hardSeekVideo(el, mt, attempt = 0) {
13397
+ /** A finished seek to mt still shows the frame presented at `shown`: mt lies inside that one
13398
+ * source frame, by the frame duration learned from presented frames (unknown → no). */
13399
+ function sameSourceFrame(el, mt, shown) {
13400
+ const dur = el._fcFrameDur;
13401
+ if (shown == null || !(el._fcFrameSeen >= 3) || !(dur > 0)) return false;
13402
+ return el.readyState >= 2 && !el.seeking && Math.abs(el.currentTime - mt) < 0.002 &&
13403
+ mt >= shown - 1e-4 && mt < shown + dur - 1e-4;
13404
+ }
13405
+ function hardSeekVideo(el, mt, attempt = 0, shown = el._fcPresentedTime) {
10486
13406
  const hasRvfc = typeof el.requestVideoFrameCallback === "function";
10487
13407
  const maxAttempts = 3;
10488
13408
  return new Promise((res, rej) => {
10489
- const after = () => waitForPresentedFrame(el)
13409
+ // a busy frame (grades, masks, blur) can present late: each retry waits longer
13410
+ const after = () => waitForPresentedFrame(el, [80, 250, 600][attempt] || 600)
10490
13411
  .then(res)
10491
13412
  .catch((err) => {
10492
- if (attempt + 1 >= maxAttempts) rej(err);
10493
- else hardSeekVideo(el, mt, attempt + 1).then(res, rej);
13413
+ // Slow motion lands several output frames on one source frame: seeking within the
13414
+ // frame already on screen presents nothing new, so no frame callback ever comes.
13415
+ // The seek finished on mt and the frame shown just before is within one source frame
13416
+ // of it (≥ 20 fps footage): that frame is the right picture.
13417
+ if (sameSourceFrame(el, mt, shown)) { notePresented(el, shown); res(); return; }
13418
+ if (attempt + 1 < maxAttempts) { hardSeekVideo(el, mt, attempt + 1, shown).then(res, rej); return; }
13419
+ // Last try: the seek finished on mt with the frame's data loaded, so the element holds
13420
+ // that frame even though no callback reported it. Better than failing the whole export.
13421
+ if (el.readyState >= 2 && !el.seeking && Math.abs(el.currentTime - mt) < 0.002) { notePresented(el, mt); res(); return; }
13422
+ rej(err);
10494
13423
  });
10495
13424
  if (presentedClose(el, mt, 0.002) && el.readyState >= 2) {
10496
13425
  res();
@@ -10603,7 +13532,7 @@ function playAdvanceVideo(el, mt, eps, rate, { keepPlaying } = {}) {
10603
13532
  }
10604
13533
  const EXPORT_PREFETCH_S = 1.25;
10605
13534
  function prefetchExportVideos(t) {
10606
- for (const c of project.clips) {
13535
+ for (const c of mediaClips()) {
10607
13536
  if (c.kind !== "video" || !clipRenders(c)) continue;
10608
13537
  if (activeAt(c, t)) continue;
10609
13538
  if (c.start > t + EXPORT_PREFETCH_S || clipEnd(c) <= t) continue;
@@ -10628,7 +13557,7 @@ async function seekVideosTo(t, { queueBusy } = {}) {
10628
13557
  const keepPlaying = fast && !behind && !queueBusy;
10629
13558
  const waits = [];
10630
13559
  const restoreGain = [];
10631
- for (const c of project.clips) {
13560
+ for (const c of mediaClips()) {
10632
13561
  if (c.kind !== "video") continue;
10633
13562
  if (!clipRenders(c)) continue;
10634
13563
  const el = getClipEl(c); if (!el) continue;
@@ -10709,7 +13638,7 @@ function encodeWAV(buf) {
10709
13638
  const FX_AUTOMATION_STEP = 0.02; // export automation resolution, seconds
10710
13639
  async function renderAudioMix(t0, t1) {
10711
13640
  const jobs = [];
10712
- for (const c of project.clips) {
13641
+ for (const c of mediaClips()) {
10713
13642
  if (c.kind !== "audio" && c.kind !== "video") continue;
10714
13643
  if (!clipRenders(c)) continue;
10715
13644
  const m = getMedia(c.mediaId); if (!m) continue;
@@ -10791,7 +13720,7 @@ async function renderAudioMix(t0, t1) {
10791
13720
  /* Frame-exact asset prep for the fast exporter: rasterize the SVG frame for
10792
13721
  this exact time, and refresh AI person masks synchronously. */
10793
13722
  async function prepareFrameAssets(t) {
10794
- for (const c of project.clips) {
13723
+ for (const c of mediaClips()) {
10795
13724
  if (!activeAt(c, t) || !clipRenders(c)) continue;
10796
13725
  if (c.kind === "svg") await prepareSvgFrame(c, t);
10797
13726
  if (c.props?.bgRemove && (c.kind === "video" || c.kind === "image")) {
@@ -10970,6 +13899,7 @@ async function runExportJob(ticket) {
10970
13899
  await new Promise((r) => setTimeout(r, 100));
10971
13900
  if (ticket.revision != null && (project.revision || 0) < ticket.revision) await syncFromServer();
10972
13901
  if (ticket.kind === "scopes") return runScopesJob(ticket, fail);
13902
+ if (ticket.kind === "track") return runTrackJob(ticket, fail);
10973
13903
  if (!state.ffmpeg) {
10974
13904
  const j = await fetch("/api/export/ffmpeg").then((r) => r.json()).catch(() => ({}));
10975
13905
  state.ffmpeg = !!j.available;
@@ -10989,12 +13919,116 @@ async function runExportJob(ticket) {
10989
13919
  exportRangeForced = null;
10990
13920
  }
10991
13921
  }
13922
+ /* fablecut_track: follow a region of a clip in this tab, store the track,
13923
+ optionally bake it into a mask or another clip, save, and report. */
13924
+ async function runTrackJob(ticket, fail) {
13925
+ const spec = ticket.track || {}, c = getClip(spec.clip);
13926
+ if (!c) return fail(`no clip ${spec.clip}`);
13927
+ if (c.kind !== "video") return fail(`${c.id} is a ${c.kind} clip — tracking follows a video clip's picture`);
13928
+ if (isGroupLocked(c) && spec.force !== true) return fail(`${c.id} is locked — pass force:true only if the user asked to change it`);
13929
+ const el = getClipEl(c);
13930
+ if (el && !el.videoWidth) await new Promise((r) => { el.addEventListener("loadedmetadata", r, { once: true }); setTimeout(r, 15000); });
13931
+ if (!pictureRect(c, evalProps(c, c.start))) return fail("the clip's video did not load");
13932
+ const list = maskList(c), tracks = clipTracks(c), ap = spec.apply || null;
13933
+ let maskIdx = -1, other = null;
13934
+ if (ap && ap.mask != null) {
13935
+ maskIdx = typeof ap.mask === "string" ? list.findIndex((m) => m.name === ap.mask) : ap.mask;
13936
+ 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"}`);
13937
+ } else if (ap && ap.clip != null) {
13938
+ other = getClip(ap.clip);
13939
+ if (!other || !isVisualClip(other) || other.id === c.id) return fail(`apply.clip must be another visual clip (${ap.clip} is not)`);
13940
+ if (isGroupLocked(other) && spec.force !== true) return fail(`${other.id} is locked`);
13941
+ }
13942
+ const at = clamp(Number.isFinite(spec.at) ? spec.at : c.start, c.start, c.start + c.duration), from = at - c.start;
13943
+ let name = typeof spec.name === "string" && spec.name.trim() ? spec.name.trim().slice(0, 60) : null;
13944
+ if (!name && maskIdx >= 0) name = list[maskIdx].name || `Mask ${maskIdx + 1}`;
13945
+ if (!name) { let n = 1; while (tracks.some((x) => x.name === `Track ${n}`)) n++; name = `Track ${n}`; }
13946
+ const g = pictureRect(c, evalProps(c, at)) || pictureRect(c, evalProps(c, c.start));
13947
+ let box = null;
13948
+ if (spec.region) box = { x: +spec.region.x, y: +spec.region.y, w: +spec.region.w, h: +spec.region.h };
13949
+ 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 }; }
13950
+ else if (maskIdx >= 0 && !tracks.some((x) => x.name === name)) box = maskBoundsAt(c, list[maskIdx], from);
13951
+ const existing = tracks.find((x) => x.name === name);
13952
+ 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");
13953
+ 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)");
13954
+ const opts = { scale: spec.scale === true, rotation: spec.rotation === true };
13955
+ const dirs = spec.direction === "backward" ? [-1] : spec.direction === "both" ? [-1, 1] : [1];
13956
+ const to = Number.isFinite(spec.to) ? clamp(spec.to - c.start, 0, c.duration) : null;
13957
+ if (state.playing) pause();
13958
+ renderCancelled = false;
13959
+ const stamp = trackStamp(c), otherStamp = other ? trackStamp(other) : "", maskStamp = maskIdx >= 0 ? JSON.stringify(list[maskIdx]) : null;
13960
+ let samples = [], cancelled = false;
13961
+ const lostAt = [];
13962
+ try {
13963
+ if (box) {
13964
+ for (const [k, dir] of dirs.entries()) {
13965
+ const run = { cancel: false };
13966
+ const end = to != null && Math.sign(to - from) === dir ? to : dir > 0 ? c.duration : 0;
13967
+ const r = await runTrack(c, { box, from, to: end, dir, ...opts, run, onProgress: (f) => {
13968
+ run.cancel = renderCancelled;
13969
+ reportExportJob(ticket.id, { progress: (k + f) / dirs.length });
13970
+ } });
13971
+ if (r.lostAt != null) lostAt.push(+(c.start + r.lostAt).toFixed(3));
13972
+ cancelled = cancelled || r.cancelled;
13973
+ samples = dir < 0 ? [...r.samples, ...samples] : [...samples, ...r.samples];
13974
+ if (cancelled) break;
13975
+ }
13976
+ samples = samples.filter((sm, i) => i === 0 || sm[0] > samples[i - 1][0] + 1e-6);
13977
+ 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");
13978
+ }
13979
+ // the project may have been reloaded or edited while tracking: apply only onto what was measured
13980
+ if (getClip(c.id) !== c || trackStamp(c) !== stamp || (other && (getClip(other.id) !== other || trackStamp(other) !== otherStamp)) ||
13981
+ (maskStamp != null && JSON.stringify(maskList(c)[maskIdx] ?? null) !== maskStamp))
13982
+ return fail("the clip changed while tracking (an edit or a reload) — nothing was applied; run it again");
13983
+ const beforeJob = undoSnapshot();
13984
+ pushUndo(beforeJob);
13985
+ const tr = box ? storeTrack(c, name, opts.scale || opts.rotation ? "box" : "point", box.w, box.h, samples) : existing;
13986
+ const used = box ? samples : tr.samples;
13987
+ let applied = null;
13988
+ if (maskIdx >= 0) applied = `mask ${JSON.stringify(list[maskIdx].name || maskIdx)}: ${bakeTrackIntoMask(c, maskIdx, used, opts, from)} keys`;
13989
+ else if (other) {
13990
+ const o = { scale: ap.scale === true, rotation: ap.rotation === true, at: Number.isFinite(ap.at) ? ap.at : at };
13991
+ const n = attachToTrack(c, tr, other, o);
13992
+ 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`;
13993
+ }
13994
+ state.dirtyTimeline = true;
13995
+ if (state.connected) {
13996
+ clearTimeout(runtime.saveTimer); runtime.saveTimer = null;
13997
+ project.revision++;
13998
+ const res = await fetch("/api/project", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(projectJSON(), null, 2) });
13999
+ if (!res.ok) {
14000
+ project.revision--;
14001
+ // put back only the clips this job changed (edits elsewhere since stay), and drop its undo step
14002
+ const was = JSON.parse(beforeJob), wasClips = Array.isArray(was) ? was : was.clips;
14003
+ for (const id of [c.id, other && other.id].filter(Boolean)) {
14004
+ const i = project.clips.findIndex((x) => x.id === id), old = wasClips.find((x) => x.id === id);
14005
+ if (i >= 0 && old) project.clips[i] = old;
14006
+ }
14007
+ const u = runtime.undo.lastIndexOf(beforeJob);
14008
+ if (u >= 0) runtime.undo.splice(u, 1);
14009
+ pruneSelection(); renderInspector(); drawFrame();
14010
+ 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`);
14011
+ }
14012
+ }
14013
+ renderInspector(); drawFrame();
14014
+ const qs = used.map((sm) => sm[5]);
14015
+ await reportExportJob(ticket.id, { status: "done", result: {
14016
+ clip: c.id, track: tr.name, kind: tr.kind, describe: Tracker.describe([tr]),
14017
+ from: +(c.start + used[0][0]).toFixed(3), to: +(c.start + used[used.length - 1][0]).toFixed(3),
14018
+ samples: used.length, tracked: !!box, lostAt, cancelled,
14019
+ quality: { min: +Math.min(...qs).toFixed(3), mean: +(qs.reduce((a, b) => a + b, 0) / qs.length).toFixed(3) },
14020
+ applied, revision: project.revision,
14021
+ } }, true);
14022
+ } catch (err) {
14023
+ fail("tracking failed: " + (err && err.message || err));
14024
+ }
14025
+ }
10992
14026
  /* fablecut_scopes: render one still exactly as export would (graded, all
10993
14027
  tracks), then measure it with color.js scopeStats. The user's playhead
10994
14028
  stays where it is. */
10995
14029
  async function renderStillAt(t) {
10996
14030
  const waits = [];
10997
- for (const c of project.clips) {
14031
+ for (const c of mediaClips()) {
10998
14032
  if (!clipRenders(c) || !activeAt(c, t)) continue;
10999
14033
  if (c.kind === "video") {
11000
14034
  const el = getClipEl(c);
@@ -11023,7 +14057,12 @@ async function runScopesJob(ticket, fail) {
11023
14057
  runtime.matte = null; // measure the graded picture, not the matte the user may be looking at
11024
14058
  const mClip = ticket.matte && getClip(ticket.matte.clip);
11025
14059
  if (mClip) runtime.gradeCapture = { id: mClip.id, args: null };
11026
- try { await renderStillAt(t); } finally { runtime.matte = shown; }
14060
+ const kClip = ticket.mask && getClip(ticket.mask.clip), view = runtime.maskView;
14061
+ runtime.maskCapture = kClip ? { id: kClip.id } : null;
14062
+ runtime.maskView = null;
14063
+ try { await renderStillAt(t); } finally { runtime.matte = shown; runtime.maskView = view; }
14064
+ const kcap = runtime.maskCapture;
14065
+ runtime.maskCapture = null;
11027
14066
  const cap = runtime.gradeCapture;
11028
14067
  runtime.gradeCapture = null;
11029
14068
  const W = els.preview.width, H = els.preview.height;
@@ -11067,15 +14106,32 @@ async function runScopesJob(ticket, fail) {
11067
14106
  }
11068
14107
  }
11069
14108
  }
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);
14109
+ let mask = null;
14110
+ if (ticket.mask) {
14111
+ mask = { clip: ticket.mask.clip };
14112
+ if (!kClip) mask.error = "no such clip";
14113
+ else if (!clipMasks(kClip)) mask.error = "the clip has no masks";
14114
+ else if (!kcap || kcap.coverage == null) mask.error = "the clip is not on screen at that time (or its masks are all off)";
14115
+ else Object.assign(mask, { masks: Mask.describe(clipMasks(kClip)), coverage: kcap.coverage, frameCoverage: kcap.frameCoverage });
14116
+ }
14117
+ const onScreen = visibleClipsAt(t), mattes = matteTracksAt(onScreen);
14118
+ const clips = onScreen.map((c) => {
14119
+ const tm = clipMatte(c);
14120
+ return {
14121
+ id: c.id, name: c.name, kind: c.kind, track: c.track,
14122
+ grade: Color.summarizeGrade(c.props.grade),
14123
+ ...(clipMasks(c) ? { masks: Mask.describe(clipMasks(c)) } : {}),
14124
+ ...(tm ? { matte: `${tm.mode} from ${tm.track}` } : {}),
14125
+ ...(mattes.has(c.track) ? { isMatte: true } : {}), // cuts a clip below; not drawn itself
14126
+ ...(c.props.blend && c.props.blend !== "normal" ? { blend: c.props.blend } : {}),
14127
+ };
14128
+ });
14129
+ 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
14130
  } catch (err) {
11076
14131
  fail("could not measure the frame: " + (err && err.message || err));
11077
14132
  } finally {
11078
14133
  runtime.gradeCapture = null;
14134
+ runtime.maskCapture = null;
11079
14135
  state.rendering = false;
11080
14136
  seekMediaWhilePaused(); // the videos sat on the job's frame — back to the playhead
11081
14137
  if (wasPlaying) play();
@@ -11329,6 +14385,7 @@ els.importUrlOverlay?.addEventListener("click", (e) => {
11329
14385
  });
11330
14386
  $("btnTitle").addEventListener("click", addTitle);
11331
14387
  $("btnAdjust").addEventListener("click", addAdjust);
14388
+
11332
14389
  $("btnSplit").addEventListener("click", splitAtPlayhead);
11333
14390
  $("btnCloseGap").addEventListener("click", closeGapAtPlayhead);
11334
14391
  $("btnLift").addEventListener("click", () => liftExtract(false));
@@ -11418,8 +14475,37 @@ function startSourceScrub(e) {
11418
14475
  window.addEventListener("pointerup", onUp);
11419
14476
  }
11420
14477
  els.sourceScrubTrack?.addEventListener("pointerdown", startSourceScrub);
11421
- $("btnHelp").addEventListener("click", () => $("helpOverlay").classList.remove("hidden"));
14478
+ $("btnHelp").addEventListener("click", () => {
14479
+ $("helpOverlay").classList.remove("hidden");
14480
+ $("helpFilter").focus();
14481
+ });
11422
14482
  $("btnCloseHelp").addEventListener("click", () => $("helpOverlay").classList.add("hidden"));
14483
+ /** Keyboard shortcuts filter: keeps the rows (and whole sections, by heading) that match every typed word. */
14484
+ function filterHelp() {
14485
+ const words = $("helpFilter").value.toLowerCase().split(/\s+/).filter(Boolean);
14486
+ let any = false;
14487
+ for (const sec of $("helpKeys").querySelectorAll(".keys-sec")) {
14488
+ const head = sec.querySelector("h3").textContent.toLowerCase();
14489
+ let shown = 0;
14490
+ for (const tr of sec.querySelectorAll("tr")) {
14491
+ const text = head + " " + tr.textContent.toLowerCase();
14492
+ const hit = words.every((w) => text.includes(w));
14493
+ tr.classList.toggle("hidden", !hit);
14494
+ if (hit) shown++;
14495
+ }
14496
+ sec.classList.toggle("hidden", !shown);
14497
+ if (shown) any = true;
14498
+ }
14499
+ $("helpEmpty").classList.toggle("hidden", any);
14500
+ }
14501
+ $("helpFilter").addEventListener("input", filterHelp);
14502
+ $("helpFilter").addEventListener("keydown", (e) => {
14503
+ if (e.key !== "Escape") return;
14504
+ e.preventDefault();
14505
+ e.stopPropagation();
14506
+ if ($("helpFilter").value) { $("helpFilter").value = ""; filterHelp(); }
14507
+ else $("helpOverlay").classList.add("hidden");
14508
+ });
11423
14509
  function settingsFocusables() {
11424
14510
  const root = $("settingsDialog");
11425
14511
  if (!root) return [];
@@ -11509,6 +14595,16 @@ function openProjectTabMenu(clientX, clientY) {
11509
14595
  addFolder(null);
11510
14596
  });
11511
14597
  menu.appendChild(item);
14598
+ const compItem = document.createElement("div");
14599
+ compItem.className = "ctx-opt";
14600
+ compItem.textContent = "New composition";
14601
+ compItem.addEventListener("click", () => {
14602
+ closeBinCtxMenu();
14603
+ if (state.binTab !== "project") setBinTab("project");
14604
+ const comp = addNewComp();
14605
+ enterComp(comp.id);
14606
+ });
14607
+ menu.appendChild(compItem);
11512
14608
  document.body.appendChild(menu);
11513
14609
  const pad = 6;
11514
14610
  const w = menu.offsetWidth, h = menu.offsetHeight;
@@ -11544,17 +14640,7 @@ function syncFpsSel() {
11544
14640
  }
11545
14641
  els.aspectSel.addEventListener("change", () => {
11546
14642
  const a = ASPECT_PRESETS[+els.aspectSel.value];
11547
- if (!a) return;
11548
- project.width = a.w; project.height = a.h;
11549
- els.preview.width = a.w; els.preview.height = a.h;
11550
- if (project.exportFrame)
11551
- project.exportFrame = normalizeExportFrame(project.exportFrame, a.w, a.h);
11552
- updateMonitorRes();
11553
- syncAspectSel();
11554
- syncExportFrameSel();
11555
- updateExportFrameOverlay();
11556
- seekMediaWhilePaused();
11557
- scheduleSave();
14643
+ if (a) setCanvasSize(a.w, a.h);
11558
14644
  });
11559
14645
  els.fpsSel?.addEventListener("change", () => {
11560
14646
  const v = FPS_PRESETS[+els.fpsSel.value];
@@ -12244,7 +15330,7 @@ try {
12244
15330
  if (Array.isArray(s) && s.length === 2 && s.every((k) => SCOPE_KINDS[k])) scopes.slots = s;
12245
15331
  } catch { }
12246
15332
 
12247
- function isGradable(c) { return !!c && (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust"); }
15333
+ function isGradable(c) { return !!c && (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust" || c.kind === "comp"); }
12248
15334
  function setWorkspace(ws) {
12249
15335
  colorState.ws = ws === "color" ? "color" : "edit";
12250
15336
  $("app").classList.toggle("ws-color", colorState.ws === "color");
@@ -12845,12 +15931,13 @@ function layerSectionsHtml(c) {
12845
15931
  </div>
12846
15932
  <div class="insp-section cp-mask"><h3>Mask</h3>
12847
15933
  <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("")}
15934
+ <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
15935
  ${m ? `<button type="button" class="btn tiny${m.invert ? " toggle on" : ""}" data-mact="invert" title="Grade outside the shape">Invert</button>` : ""}</div>
12849
15936
  ${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
15937
  <span class="cl-note">${keyed ? `${keyed} key${keyed > 1 ? "s" : ""}` : "not animated"}</span>
12851
15938
  ${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
15939
  ${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>` : ""}
15940
+ <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
15941
  </div>`;
12855
15942
  }
12856
15943
  function bindLayerControls(c, guard) {
@@ -12919,6 +16006,13 @@ function bindLayerControls(c, guard) {
12919
16006
  scheduleSave(); rerender();
12920
16007
  });
12921
16008
  // mask
16009
+ const draw = root.querySelector("[data-mdraw]");
16010
+ if (draw) draw.addEventListener("click", () => {
16011
+ if (!guard()) return;
16012
+ colorState.maskDraw = !colorState.maskDraw; colorState.maskStroke = null;
16013
+ if (colorState.maskDraw) { if (state.playing) pause(); toast("Drag around what this layer should grade"); }
16014
+ rerender();
16015
+ });
12922
16016
  for (const b of root.querySelectorAll("[data-mshape]")) b.addEventListener("click", () => {
12923
16017
  if (!guard()) return;
12924
16018
  const s = b.dataset.mshape, cur = maskNow(c);
@@ -13044,11 +16138,11 @@ function maskHandles(c) {
13044
16138
  if (!sp) return null;
13045
16139
  const a = sp.aspect, at = (px, py) => { const q = maskLocalToUv(m, a, px, py); return sp.toCanvas(q.u, q.v); };
13046
16140
  const rx = (m.w / 2) * a, ry = m.h / 2;
13047
- const top = m.shape === "poly" ? Math.min(...m.points.map(([, y]) => y)) : -ry;
16141
+ const top = m.points ? Math.min(...m.points.map((p) => p[1])) : -ry;
13048
16142
  const W = els.preview.width, H = els.preview.height, inset = Math.max(8, W / 110), knob = at(0, top - 0.08);
13049
16143
  knob.x = clamp(knob.x, inset, W - inset); knob.y = clamp(knob.y, inset, H - inset); // stays grabbable above the frame
13050
16144
  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));
16145
+ if (m.points) h.points = m.points.map(([x, y]) => at(x * a, y));
13052
16146
  else h.edges = [["w", rx, 0], ["w", -rx, 0], ["h", 0, ry], ["h", 0, -ry]].map(([k, x, y]) => ({ k, ...at(x, y) }));
13053
16147
  return h;
13054
16148
  }
@@ -13058,6 +16152,9 @@ function maskOutline(c, h) {
13058
16152
  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
16153
  } else if (m.shape === "rect") {
13060
16154
  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)); }
16155
+ } else if (m.shape === "bezier") {
16156
+ const A = Mask.anchors({ shape: "bezier", x: 0, y: 0, scale: 1, rotation: 0, points: m.points }, 1, 1);
16157
+ 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
16158
  } else pts.push(...h.points);
13062
16159
  return pts;
13063
16160
  }
@@ -13085,10 +16182,34 @@ function drawMaskOverlay(c, W) {
13085
16182
  ctx2d.restore();
13086
16183
  return true;
13087
16184
  }
16185
+ /** Color tab Draw: a free-hand outline for the selected layer's mask. */
16186
+ function drawingLayerMask(c) {
16187
+ return colorState.maskDraw && colorState.ws === "color" && colorState.layer >= 0 && c && state.selId === c.id && activeAt(c, state.time) && !!maskSpace(c);
16188
+ }
16189
+ function drawLayerStroke(c, W) {
16190
+ const sp = maskSpace(c), st = colorState.maskStroke;
16191
+ if (!sp || !st || st.length < 2) return;
16192
+ ctx2d.save();
16193
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
16194
+ ctx2d.lineWidth = Math.max(2, W / 600); ctx2d.strokeStyle = "#ffd25a";
16195
+ ctx2d.beginPath();
16196
+ 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); });
16197
+ ctx2d.stroke();
16198
+ ctx2d.restore();
16199
+ }
13088
16200
  let maskDrag = null;
13089
16201
  els.preview.addEventListener("pointerdown", (e) => {
13090
16202
  if (e.button !== 0 || e.altKey || isSourceMode() || colorState.pick || colorState.curvePick || colorState.qualPick) return;
13091
16203
  const c = getClip(state.selId);
16204
+ if (drawingLayerMask(c)) {
16205
+ e.preventDefault(); e.stopImmediatePropagation();
16206
+ if (isGroupLocked(c)) { toastLocked(); return; }
16207
+ const uv = maskSpace(c).toUv(canvasPt(e));
16208
+ colorState.maskStroke = [[uv.u, uv.v]];
16209
+ maskDrag = { mode: "draw", id: c.id };
16210
+ els.preview.setPointerCapture(e.pointerId);
16211
+ return;
16212
+ }
13092
16213
  if (!maskEditing(c)) return;
13093
16214
  const h = maskHandles(c);
13094
16215
  if (!h) return;
@@ -13099,7 +16220,7 @@ els.preview.addEventListener("pointerdown", (e) => {
13099
16220
  if (near(h.knob)) drag = { mode: "rotate" };
13100
16221
  else if (h.points.some(near)) drag = { mode: "point", i: h.points.findIndex(near) };
13101
16222
  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
16223
+ else if (m.points && (e.ctrlKey || e.metaKey)) { // add a point on the nearest edge
13103
16224
  const lp = uvToMaskLocal(m, a, uv.u, uv.v), P = m.points.map(([x, y]) => [x * a, y]);
13104
16225
  let best = 0, bd = Infinity;
13105
16226
  for (let i = 0; i < P.length; i++) {
@@ -13108,7 +16229,8 @@ els.preview.addEventListener("pointerdown", (e) => {
13108
16229
  const d = Math.hypot(lp.x - x1 - ex * k, lp.y - y1 - ey * k);
13109
16230
  if (d < bd) { bd = d; best = i; }
13110
16231
  }
13111
- if (m.points.length >= Color.MAX_POLY) { toast(`A mask has at most ${Color.MAX_POLY} points`); e.stopImmediatePropagation(); return; }
16232
+ const most = m.shape === "bezier" ? Color.MAX_BEZIER : Color.MAX_POLY;
16233
+ if (m.points.length >= most) { toast(`A mask has at most ${most} points`); e.stopImmediatePropagation(); return; }
13112
16234
  if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
13113
16235
  const pts = m.points.map((p) => p.slice());
13114
16236
  pts.splice(best + 1, 0, [lp.x / a, lp.y]);
@@ -13128,6 +16250,11 @@ els.preview.addEventListener("pointermove", (e) => {
13128
16250
  e.stopImmediatePropagation();
13129
16251
  const c = getClip(maskDrag.id), sp = c && maskSpace(c);
13130
16252
  if (!sp) return;
16253
+ if (maskDrag.mode === "draw") {
16254
+ const uv = sp.toUv(canvasPt(e)), st = colorState.maskStroke, last = st && st[st.length - 1];
16255
+ 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; }
16256
+ return;
16257
+ }
13131
16258
  const uv = sp.toUv(canvasPt(e)), m0 = maskDrag.m0, a = sp.aspect, d = maskDrag;
13132
16259
  const lp = uvToMaskLocal(m0, a, uv.u, uv.v);
13133
16260
  let set;
@@ -13141,7 +16268,7 @@ els.preview.addEventListener("pointermove", (e) => {
13141
16268
  set = { rotation: +r.toFixed(1) };
13142
16269
  } else if (d.mode === "point") {
13143
16270
  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)];
16271
+ pts[d.i] = [+(lp.x / a).toFixed(4), +lp.y.toFixed(4), ...pts[d.i].slice(2)]; // a bezier anchor keeps its handles
13145
16272
  set = { points: pts };
13146
16273
  }
13147
16274
  if (set) { writeMask(c, set); syncMaskInputs(c); state.dirtyTimeline = true; }
@@ -13149,6 +16276,19 @@ els.preview.addEventListener("pointermove", (e) => {
13149
16276
  const endMaskDrag = (e) => {
13150
16277
  if (!maskDrag) return;
13151
16278
  e.stopImmediatePropagation();
16279
+ if (maskDrag.mode === "draw") {
16280
+ const c = getClip(maskDrag.id), sp = c && maskSpace(c), st = colorState.maskStroke;
16281
+ maskDrag = null; colorState.maskStroke = null; state.dirtyTimeline = true;
16282
+ if (!c || !sp || !st || st.length < 6) { toast("Drag around what this layer should grade"); return; }
16283
+ const cur = maskNow(c), m = { shape: "freehand", stroke: st, aspect: sp.aspect, feather: cur ? cur.feather : 0.03 };
16284
+ if (cur && cur.invert) m.invert = true;
16285
+ pushUndo();
16286
+ writeLayer(c, colorState.layer, { mask: null });
16287
+ writeLayer(c, colorState.layer, { mask: m });
16288
+ colorState.maskDraw = false;
16289
+ scheduleSave(); renderColorPage(true);
16290
+ return;
16291
+ }
13152
16292
  maskDrag = null; colorState.mdrag = false;
13153
16293
  scheduleSave(); renderColorPage(true);
13154
16294
  };