fablecut 1.13.0 → 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,7 +82,7 @@ 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"];
@@ -429,6 +434,10 @@ function showClipCtxMenu(clientX, clientY, c) {
429
434
  item("Crossfade with neighbours", "Shift+D", () => crossfadeSelected(), locked ? "Locked — unlock to change" : null);
430
435
  item("Auto-duck under other tracks", "", () => autoDuckClips(selectedClips()), locked ? "Locked — unlock to change" : null);
431
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));
432
441
  const linked = selectedClips().some((x) => x.linkGroup || x.linkedId);
433
442
  if (linked) item("Unlink audio / video", "Ctrl+L", toggleLinkSelected, locked ? "Locked — unlock to change" : null);
434
443
  else if (c.kind === "video" || c.kind === "audio") {
@@ -556,7 +565,7 @@ const state = {
556
565
  webCodecs: false, // VideoEncoder + Annex-B H.264 supported
557
566
  dirtyTimeline: true, gesture: false,
558
567
  workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT
559
- binTab: "project", // project | elements | sfx | svg
568
+ binTab: "project", // project | elements | sfx | svg | shapes
560
569
  disabledTracks: new Set(), // mirror of project.disabledTracks for fast lookup
561
570
  lockedTracks: new Set(), // mirror of project.lockedTracks
562
571
  untargetedTracks: new Set(), // mirror of project.untargetedTracks
@@ -609,7 +618,11 @@ const {
609
618
  listAlignedGaps, adjacentClip, rippleTrim, rollEdit, slipClip, slideClip, crossfadeCuts, crossfadeCut,
610
619
  } = EDIT;
611
620
  /** Does this clip reach the picture / the mix? Track output and clip enable. */
612
- 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
+ }
613
626
  function toastLocked(partial = false) {
614
627
  toast(partial ? "Locked clips were left untouched — unlock them to edit"
615
628
  : "Locked — unlock the clip or its track to edit");
@@ -873,7 +886,7 @@ function projDur() {
873
886
  }
874
887
  return mx;
875
888
  }
876
- const clipSpeed = (c) => clamp(+(c.props?.speed) || 1, 0.1, 8);
889
+ const clipSpeed = FableCutEdit.clipSpeed; // comp clips run at 1×
877
890
 
878
891
  /* ── Speed ramps (time remapping) ──
879
892
  `speed` is keyframable: media time = in + ∫ speed(t) dt over the clip.
@@ -921,8 +934,17 @@ function propsAtPlayhead(c) {
921
934
  const v = kfChannel(c, k, local, +(p[k] ?? DEFAULT_PROPS[k] ?? 0));
922
935
  if (typeof v === "number" && !isNaN(v)) p[k] = v;
923
936
  }
937
+ applyMotionPath(c, p, local, false);
924
938
  return p;
925
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
+ }
926
948
  function fmtInspNum(v, step) {
927
949
  const n = +v;
928
950
  if (!Number.isFinite(n)) return "0";
@@ -980,6 +1002,11 @@ function setAnimProp(c, k, v) {
980
1002
  const eps = kfTimeEps();
981
1003
  const dup = arr.find((kf) => Math.abs(kf.t - lt) < eps);
982
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
+ }
983
1010
  arr.push({ t: lt, v });
984
1011
  arr.sort((a, b) => a.t - b.t);
985
1012
  state.dirtyTimeline = true;
@@ -1073,7 +1100,7 @@ function toggleKfAtPlayhead(c, k) {
1073
1100
  return true;
1074
1101
  }
1075
1102
  function hasSpeedRamp(c) {
1076
- 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×
1077
1104
  }
1078
1105
  function mediaTimeAt(c, t) {
1079
1106
  const base = clipSpeed(c);
@@ -1390,7 +1417,19 @@ function syncExportFrameSel() {
1390
1417
  html += `<option value="custom" selected>Custom · ${ef.w}×${ef.h}</option>`;
1391
1418
  els.exportFrameSel.innerHTML = html;
1392
1419
  }
1393
- 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
+ }
1394
1433
  const wa = normalizeWorkArea(data.inPoint, data.outPoint);
1395
1434
  Object.assign(project, {
1396
1435
  name: data.name || "Untitled Project",
@@ -1408,7 +1447,13 @@ function applyProject(data) {
1408
1447
  encodeProfile: data.encodeProfile || null,
1409
1448
  master: normalizeMaster(data.master),
1410
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
+ }) })),
1411
1455
  });
1456
+ runtime.compGen = (runtime.compGen || 0) + 1;
1412
1457
  applyTracksFromProject(data.tracks);
1413
1458
  ensureTracksCoverClips();
1414
1459
  project.tracks = serializeTracks();
@@ -1433,7 +1478,7 @@ function applyProject(data) {
1433
1478
  c.fx = normFx(c.fx);
1434
1479
  if (!c.fx) delete c.fx;
1435
1480
  const raw = c.props || {};
1436
- c.props = { ...DEFAULT_PROPS, ...raw };
1481
+ c.props = { ...DEFAULT_PROPS, ...(c.kind === "comp" ? { fit: "none" } : {}), ...raw };
1437
1482
  if (c.props.grade !== undefined) {
1438
1483
  const g = self.FableCutColor.normalizeGrade(c.props.grade);
1439
1484
  if (g) c.props.grade = g; else delete c.props.grade;
@@ -1494,7 +1539,28 @@ function scheduleSave() {
1494
1539
  } catch { }
1495
1540
  }, 400);
1496
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. */
1497
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() {
1498
1564
  const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, encodeProfile } = project;
1499
1565
  const out = {
1500
1566
  name, width, height, fps, background, revision,
@@ -1507,17 +1573,7 @@ function projectJSON() {
1507
1573
  if (derivedFrom) { mo.derivedFrom = derivedFrom; if (denoise) mo.denoise = denoise; }
1508
1574
  return mo;
1509
1575
  }),
1510
- clips: clips.map(({ id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup, locked, disabled, unlinked, fx }) => {
1511
- const clipOut = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut };
1512
- if (fx?.length) clipOut.fx = fx;
1513
- if (linkGroup) clipOut.linkGroup = linkGroup;
1514
- if (linkedId) clipOut.linkedId = linkedId;
1515
- // Written only when set, so untouched projects stay byte-identical.
1516
- if (locked === true) clipOut.locked = true;
1517
- if (disabled === true) clipOut.disabled = true;
1518
- if (unlinked === true) clipOut.unlinked = true;
1519
- return clipOut;
1520
- }),
1576
+ clips: clips.map(serializeClip),
1521
1577
  markers: normalizeMarkers(markers),
1522
1578
  inPoint: inPoint == null ? null : inPoint,
1523
1579
  outPoint: outPoint == null ? null : outPoint,
@@ -1534,6 +1590,7 @@ function projectJSON() {
1534
1590
  if (master) out.master = master;
1535
1591
  const buses = normalizeBuses(project.buses);
1536
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) }));
1537
1594
  return out;
1538
1595
  }
1539
1596
  function listenSSE() {
@@ -1559,7 +1616,9 @@ function listenSSE() {
1559
1616
  async function syncFromServer(force) {
1560
1617
  if (state.gesture || state.exporting) { runtime.pendingSync = true; return; }
1561
1618
  runtime.pendingSync = false;
1562
- 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");
1563
1622
  loadLibraryFonts();
1564
1623
  fetchEncodeProfiles();
1565
1624
  try {
@@ -1570,6 +1629,8 @@ async function syncFromServer(force) {
1570
1629
  if (!force && (data.revision || 0) === (project.revision || 0)) return; // our own save
1571
1630
  if (runtime.saveTimer) { // unsaved local edit vs. external write: external wins, tell the user
1572
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;
1573
1634
  toast("Project was updated externally — your last change may need redoing.");
1574
1635
  }
1575
1636
  applyProject(data);
@@ -1833,10 +1894,12 @@ async function importFromUrl(url) {
1833
1894
  }
1834
1895
 
1835
1896
  function renderBin() {
1836
- els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root").forEach((n) => n.remove());
1837
- 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;
1838
1900
  els.binEmpty.style.display = empty ? "" : "none";
1839
1901
  if (empty) return;
1902
+ for (const comp of comps) els.binList.appendChild(makeCompItem(comp));
1840
1903
 
1841
1904
  const clearBinDropHints = () => {
1842
1905
  els.binList.querySelectorAll(".bin-drop-over").forEach((n) => n.classList.remove("bin-drop-over"));
@@ -1944,9 +2007,13 @@ function renderBin() {
1944
2007
  });
1945
2008
  item.querySelector(".bin-del").addEventListener("click", (e) => {
1946
2009
  e.stopPropagation();
1947
- 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
1948
2012
  project.media = project.media.filter((x) => x.id !== m.id);
1949
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;
1950
2017
  if (state.source.mediaId === m.id) clearSource();
1951
2018
  renderBin(); scheduleSave(); renderInspector();
1952
2019
  });
@@ -2161,14 +2228,16 @@ function setBinTab(tab) {
2161
2228
  const isProj = tab === "project";
2162
2229
  els.binList.classList.toggle("hidden", !isProj);
2163
2230
  els.libList.classList.toggle("hidden", isProj);
2164
- 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);
2165
2234
  }
2166
2235
 
2167
2236
  /* ═══════════════════════════ EDIT OPERATIONS ═══════════════════════════ */
2168
2237
  /* Undo entries are {clips, markers} snapshots. Drag gestures still push a bare
2169
2238
  clips array (taken at pointerdown) — restoring one leaves markers alone. */
2170
2239
  function undoSnapshot() {
2171
- return JSON.stringify({ clips: project.clips, markers: project.markers || [] });
2240
+ return JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [] });
2172
2241
  }
2173
2242
  /** Record an undo step — `snap` lets an edit snapshot first and record only
2174
2243
  * once it knows it changed something. */
@@ -2180,7 +2249,8 @@ function pushUndo(snap = undoSnapshot()) {
2180
2249
  function restoreSnapshot(json) {
2181
2250
  const snap = JSON.parse(json);
2182
2251
  if (Array.isArray(snap)) project.clips = snap;
2183
- 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;
2184
2254
  }
2185
2255
  function undo() {
2186
2256
  if (!runtime.undo.length) return;
@@ -3302,10 +3372,19 @@ function rebuildClips() {
3302
3372
  })();
3303
3373
  const label = c.kind === "text" ? "T · " + (c.props.text || "").split("\n")[0]
3304
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")
3305
3378
  : c.kind === "audio" ? chTag + (c.name || "")
3306
3379
  : (c.name || "");
3307
3380
  body += `<div class="fade"></div>
3308
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
+ }
3309
3388
  body += clipKeyframesHtml(c);
3310
3389
  let inner = `<div class="clip-body">${body}</div>`;
3311
3390
  inner += transitionMarksHtml(c, tr.h);
@@ -3909,6 +3988,7 @@ function startTrimToolGesture(e, c, mode) {
3909
3988
  transitionIn: cloneTr(x.transitionIn), transitionOut: cloneTr(x.transitionOut),
3910
3989
  masks: x.props && x.props.masks, // head trims re-base mask keys: each step starts from the originals
3911
3990
  tracks: x.props && x.props.tracks,
3991
+ shapes: x.props && x.props.shapes,
3912
3992
  }]));
3913
3993
  const restore = () => {
3914
3994
  for (const x of project.clips) {
@@ -3919,6 +3999,7 @@ function startTrimToolGesture(e, c, mode) {
3919
3999
  if (x.props) {
3920
4000
  if (b.masks) x.props.masks = b.masks; else delete x.props.masks;
3921
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;
3922
4003
  }
3923
4004
  }
3924
4005
  };
@@ -4050,6 +4131,7 @@ els.tracksContent.addEventListener("dblclick", (e) => {
4050
4131
  }
4051
4132
  if (e.target.closest(".handle, .trans-mark, .trans-dur-handle, .clip-kf, .fade-grip, .vol-hit")) return;
4052
4133
  e.preventDefault();
4134
+ if (c.kind === "comp") { enterComp(c.compId, c); return; } // a comp opens on the timeline
4053
4135
  loadSourceFromClip(c, { timelineTime: timeAtEvent(e) });
4054
4136
  });
4055
4137
 
@@ -4103,6 +4185,7 @@ function startClipGesture(e, c, mode, collapseOnClick) {
4103
4185
  keyframes: c.keyframes ? JSON.parse(JSON.stringify(c.keyframes)) : undefined,
4104
4186
  masks: c.props.masks,
4105
4187
  tracks: c.props.tracks,
4188
+ shapes: c.props.shapes,
4106
4189
  };
4107
4190
  // moving a clip that belongs to a multi-selection drags the whole group;
4108
4191
  // AV-linked partners (video+audio from one file) always move together
@@ -4150,21 +4233,23 @@ function startClipGesture(e, c, mode, collapseOnClick) {
4150
4233
  } else if (mode === "trim-l") {
4151
4234
  let ns = snapTime(orig.start + dt, groupIds);
4152
4235
  const sp = clipSpeed(c);
4153
- const maxShiftLeft = (c.kind === "video" || c.kind === "audio") ? orig.in / sp : 1e6;
4236
+ const maxShiftLeft = FableCutEdit.hasSourceTime(c) ? orig.in / sp : 1e6;
4154
4237
  ns = clamp(ns, Math.max(0, orig.start - maxShiftLeft), orig.start + orig.duration - MIN_DUR);
4155
4238
  const d = ns - orig.start;
4156
4239
  c.start = ns;
4157
- c.in = (c.kind === "video" || c.kind === "audio") ? orig.in + d * sp : 0;
4240
+ c.in = FableCutEdit.hasSourceTime(c) ? orig.in + d * sp : 0;
4158
4241
  c.duration = orig.duration - d;
4159
4242
  c.keyframes = shiftKF(orig.keyframes, d, c.duration);
4160
4243
  if (orig.masks) c.props.masks = Mask.shiftKeys(orig.masks, d);
4244
+ if (orig.shapes) c.props.shapes = Motion.shiftKeys(orig.shapes, d);
4161
4245
  if (orig.tracks) { const tr = Tracker.shiftTracks(orig.tracks, d); if (tr.length) c.props.tracks = tr; else delete c.props.tracks; }
4162
4246
  syncLinkedTiming(c);
4163
4247
  } else { // trim-r
4164
4248
  let ne = snapTime(orig.start + orig.duration + dt, groupIds);
4165
4249
  let maxDur = 1e6;
4166
- if ((c.kind === "video" || c.kind === "audio") && media?.duration)
4167
- 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));
4168
4253
  c.duration = clamp(ne - orig.start, MIN_DUR, maxDur);
4169
4254
  c.keyframes = shiftKF(orig.keyframes, 0, c.duration);
4170
4255
  syncLinkedTiming(c);
@@ -4623,7 +4708,7 @@ function updateWorkArea() {
4623
4708
 
4624
4709
  /* ── Drag & drop: bin → timeline, files → window ── */
4625
4710
  els.timelineScroll.addEventListener("dragover", (e) => {
4626
- 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))) {
4627
4712
  e.preventDefault();
4628
4713
  e.dataTransfer.dropEffect = "copy";
4629
4714
  for (const row of els.tracks.children)
@@ -4635,6 +4720,10 @@ els.timelineScroll.addEventListener("dragleave", () => {
4635
4720
  });
4636
4721
  els.timelineScroll.addEventListener("drop", (e) => {
4637
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; }
4638
4727
  const mid = e.dataTransfer.getData("text/fablecut-media");
4639
4728
  if (!mid) return;
4640
4729
  e.preventDefault();
@@ -4861,7 +4950,7 @@ function renderInspector(lite) {
4861
4950
  let html = (state.selIds.size > 1
4862
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>`
4863
4952
  : "") + clipStatusBar(c) + `<div class="insp-section"><h3>Clip — ${c.kind}</h3>
4864
- ${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 || "")}">`)}
4865
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>`) : ""}
4866
4955
  ${row("Start (s)", `<input type="number" data-k="start" step="0.01" value="${c.start.toFixed(2)}">`)}
4867
4956
  ${row("Length (s)", `<input type="number" data-k="duration" step="0.01" value="${c.duration.toFixed(2)}">`)}
@@ -4882,6 +4971,16 @@ function renderInspector(lite) {
4882
4971
  ${slider("opacity", 0, 1, 0.01, p.opacity)}
4883
4972
  ${matteRows()}
4884
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)}
4983
+ </div>`;
4885
4984
  } else if (c.kind !== "audio") {
4886
4985
  html += `<div class="insp-section"><h3>Transform</h3>
4887
4986
  ${row("Position X", `<input type="number" data-k="x" value="${fmtInspNum(p.x)}">`, "x")}
@@ -4889,11 +4988,15 @@ function renderInspector(lite) {
4889
4988
  ${slider("scale", 0.1, 4, 0.01, p.scale)}
4890
4989
  ${slider("rotation", -180, 180, 1, p.rotation, "°")}
4891
4990
  ${slider("opacity", 0, 1, 0.01, p.opacity)}
4991
+ ${anchorRowsHtml(c, p, row)}
4992
+ ${parentRowHtml(c)}
4892
4993
  ${sel("Blend", "blend", BLEND_MODES, p.blend)}
4893
4994
  ${matteRows()}
4894
4995
  </div>`;
4895
4996
  }
4896
- 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") {
4897
5000
  html += `<div class="insp-section"><h3>Layout</h3>
4898
5001
  ${sel("Fit", "fit", ["contain", "cover", "stretch", "none"], p.fit)}
4899
5002
  ${row("Crop L/R %", `<input type="number" data-k="cropL" min="0" max="95" value="${p.cropL}" style="max-width:58px">
@@ -4905,7 +5008,7 @@ function renderInspector(lite) {
4905
5008
  ${check("Flip V", "flipV", p.flipV)}
4906
5009
  </div>`;
4907
5010
  }
4908
- 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") {
4909
5012
  html += `<div class="insp-section"><h3>Filter / Color</h3>
4910
5013
  ${sel("Preset", "filterPreset", Object.keys(FILTER_PRESETS), p.filterPreset)}
4911
5014
  ${slider("brightness", 0, 200, 1, p.brightness, "%")}
@@ -4981,7 +5084,9 @@ function renderInspector(lite) {
4981
5084
  const curveSel = (label, k, tr) => tr?.type === "fade" && c.kind === "audio"
4982
5085
  ? row(label, `<select data-k="${k}" title="Fade shape (audio)">${[["", "Smooth (eased)"], ...Object.entries(AUDIO_FADE_CURVES)].map(([v, l]) =>
4983
5086
  `<option value="${v}" ${(tr.curve || "") === v ? "selected" : ""}>${l}</option>`).join("")}</select>`) : "";
4984
- if (c.kind !== "audio") html += maskSectionHtml(c);
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);
4985
5090
  if (c.kind === "video") html += trackSectionHtml(c);
4986
5091
  html += `<div class="insp-section"><h3>Transition</h3>
4987
5092
  ${tsel("In", "transIn", c.transitionIn)}
@@ -5241,6 +5346,13 @@ function renderInspector(lite) {
5241
5346
  });
5242
5347
  bindMaskControls(c);
5243
5348
  bindTrackControls(c);
5349
+ bindParentAnchorControls(c);
5350
+ bindShapeControls(c);
5351
+ bindMotionControls(c);
5352
+ bindTextPathControls(c);
5353
+ bindCompControls(c);
5354
+ bindTemplateControls(c);
5355
+ bindPresetControls(c);
5244
5356
  syncInspectorOffClip(c);
5245
5357
  renderKfGraphsPanel();
5246
5358
  }
@@ -5328,6 +5440,7 @@ function syncInspectorPlayhead() {
5328
5440
  if (btn.textContent !== label) btn.textContent = label;
5329
5441
  }
5330
5442
  syncMaskInspector(c);
5443
+ syncShapeInspector(c);
5331
5444
  syncInspectorOffClip(c); // after the class pass, so ◆ titles read the fresh "on" state
5332
5445
  }
5333
5446
 
@@ -5339,6 +5452,7 @@ const KF_GRAPH_LABEL = {
5339
5452
  invert: "Invert", temperature: "Temp", tint: "Tint", vignette: "Vignette",
5340
5453
  cornerRadius: "Radius", shake: "Shake", rgbSplit: "RGB", grain: "Grain",
5341
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",
5342
5456
  };
5343
5457
  function toggleKfGraph(key) {
5344
5458
  if (!ANIMATABLE.includes(key)) return;
@@ -5893,8 +6007,8 @@ function loadSourceFromMedia(m, opts = {}) {
5893
6007
  }
5894
6008
  function loadSourceFromClip(c, opts = {}) {
5895
6009
  if (!c || !c.mediaId) {
5896
- if (c && (c.kind === "text" || c.kind === "adjust"))
5897
- 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");
5898
6012
  return;
5899
6013
  }
5900
6014
  const m = getMedia(c.mediaId);
@@ -8048,7 +8162,7 @@ function refreshAudioHold() {
8048
8162
  for (const el of runtime.clipEls.values()) { if (!el.paused) el.pause(); }
8049
8163
  for (const chain of runtime.clipGain.values()) muteClipChain(chain);
8050
8164
 
8051
- for (const c of project.clips) {
8165
+ for (const c of mediaClips()) {
8052
8166
  if (c.kind !== "audio" && c.kind !== "video") continue;
8053
8167
  if (!clipRenders(c) || !activeAt(c, t)) continue;
8054
8168
  const m = getMedia(c.mediaId);
@@ -8141,7 +8255,7 @@ function syncMedia() {
8141
8255
  const reversing = state.playing && rate < 0;
8142
8256
  // Timeline lookahead grows with preview rate so wall-clock budget stays ≈VIDEO_PREFETCH_SEC.
8143
8257
  const prefetchTl = VIDEO_PREFETCH_SEC * Math.max(rate, 1);
8144
- for (const c of project.clips) {
8258
+ for (const c of mediaClips()) {
8145
8259
  if (c.kind === "text" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust") continue;
8146
8260
  const el = getClipEl(c); if (!el) continue;
8147
8261
  const enabled = clipRenders(c);
@@ -8184,7 +8298,7 @@ function syncMedia() {
8184
8298
  function seekMediaWhilePaused() {
8185
8299
  if (state.playing) return;
8186
8300
  const t = state.time;
8187
- for (const c of project.clips) {
8301
+ for (const c of mediaClips()) {
8188
8302
  if (c.kind !== "video") continue;
8189
8303
  if (!clipRenders(c)) continue;
8190
8304
  if (!activeAt(c, t)) continue;
@@ -8226,6 +8340,7 @@ function evalProps(c, t) {
8226
8340
  }
8227
8341
  if (typeof v === "number" && !isNaN(v)) p[k] = v;
8228
8342
  }
8343
+ applyMotionPath(c, p, local, true);
8229
8344
  }
8230
8345
  applyFilterPreset(p);
8231
8346
  const W = els.preview.width, H = els.preview.height;
@@ -8522,7 +8637,7 @@ function drawAdjust(c, W, H, t) {
8522
8637
  if (adjScratch.height !== H) adjScratch.height = H;
8523
8638
  const a = adjScratch.getContext("2d");
8524
8639
  a.clearRect(0, 0, W, H);
8525
- a.drawImage(els.preview, 0, 0);
8640
+ a.drawImage(runtime.adjSource || els.preview, 0, 0); // inside a comp: the comp's own layers below
8526
8641
  ctx2d.save();
8527
8642
  ctx2d.setTransform(1, 0, 0, 1, 0, 0);
8528
8643
  if (p.shake > 0) { // whole-frame impact shake
@@ -8858,28 +8973,541 @@ function drawFrame(t = state.time) {
8858
8973
  if (!state.exporting && !runtime.sampling) drawMaskView(W, H);
8859
8974
  if (!runtime.sampling) updateScopes(); // before the handles, so they never reach the scopes
8860
8975
  // on-canvas selection handles (never during export or playback)
8861
- 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 };
8862
9000
  }
8863
9001
 
8864
9002
  /* ═══════════ Direct manipulation on the program monitor ═══════════
8865
9003
  Drag a clip to move it, corner handles to resize (scale), the top
8866
9004
  handle to rotate. Maps gestures straight onto props.x/y/scale/rotation. */
8867
- function clipBounds(c, p, W, H) {
8868
- const cx = W / 2 + (+p.x || 0), cy = H / 2 + (+p.y || 0);
8869
- const rot = (p.rotation || 0) * Math.PI / 180, sc = +p.scale || 1;
8870
- 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;
8871
9014
  if (c.kind === "text") {
8872
- if (hasTextBox(p)) {
8873
- hw = +p.boxW / 2; hh = +p.boxH / 2;
8874
- } else {
8875
- const half = measureTextHalfSize(p);
8876
- 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 };
8877
9033
  }
8878
- } else { // media/svg: canvas-sized base box, scaled
8879
- hw = (W / 2) * sc; hh = (H / 2) * sc;
8880
9034
  }
8881
- return { cx, cy, hw, hh, rot };
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
+ };
8882
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);
9323
+ }
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);
9337
+ }
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
+ });
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; }
8883
9511
  function isVisualClip(c) { return c && c.kind !== "adjust" && c.kind !== "audio"; }
8884
9512
  /* Screen-space handle positions for the selection overlay. Each handle is
8885
9513
  clamped into the visible canvas (inset by its own size) so it stays visible
@@ -8904,7 +9532,10 @@ function drawSelectionOverlay(W, H, t) {
8904
9532
  if (maskEditActive(c) && drawClipMaskOverlay(c, W)) return; // so does a clip mask being edited
8905
9533
  drawTrackOverlay(c, W);
8906
9534
  if (!isVisualClip(c) || !activeAt(c, t) || !clipRenders(c)) return;
8907
- 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);
8908
9539
  const lw = Math.max(2, W / 640);
8909
9540
  const hd = overlayHandles(b, W, H), hs = hd.hs;
8910
9541
  ctx2d.setTransform(1, 0, 0, 1, 0, 0);
@@ -8923,6 +9554,12 @@ function drawSelectionOverlay(W, H, t) {
8923
9554
  }
8924
9555
  ctx2d.beginPath(); ctx2d.arc(hd.rotate.x, hd.rotate.y, hs * 1.1, 0, Math.PI * 2);
8925
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();
8926
9563
  ctx2d.restore();
8927
9564
  }
8928
9565
  function canvasPt(e) {
@@ -8939,9 +9576,13 @@ function toLocal(pt, b) {
8939
9576
  function pickClipAt(pt, W, H) {
8940
9577
  const all = visibleClipsAt(state.time), mattes = matteTracksAt(all);
8941
9578
  const seq = all.filter((c) => isVisualClip(c) && !mattes.has(c.track));
8942
- for (let i = seq.length - 1; i >= 0; i--) {
8943
- const c = seq[i], b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
8944
- if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) return c;
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
+ }
8945
9586
  }
8946
9587
  return null;
8947
9588
  }
@@ -8975,11 +9616,14 @@ els.preview.addEventListener("pointerdown", (e) => {
8975
9616
  if (isVisualClip(cur) && activeAt(cur, state.time)) {
8976
9617
  const b = clipBounds(cur, evalProps(cur, state.time), W, H), lp = toLocal(pt, b);
8977
9618
  const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
8978
- 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) {
8979
9623
  const ep = propsAtPlayhead(cur);
8980
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 };
8981
9625
  } else if (hd.corners.some((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab)) {
8982
- if (cur.kind === "text") {
9626
+ if (cur.kind === "text" && !cur.props.textPath) { // a title on a path scales; its box is the path's
8983
9627
  canvasDrag = {
8984
9628
  ...beginTextBoxDrag(cur, pt, W, H),
8985
9629
  seedBox: !hasTextBox(cur.props),
@@ -9029,7 +9673,8 @@ function updateCanvasCursor(e) {
9029
9673
  const W = els.preview.width, H = els.preview.height, pt = canvasPt(e);
9030
9674
  const cur = getClip(state.selId);
9031
9675
  let cursor = "default";
9032
- 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) {
9033
9678
  const b = clipBounds(cur, evalProps(cur, state.time), W, H);
9034
9679
  const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
9035
9680
  const corner = hd.corners.find((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab);
@@ -9060,8 +9705,9 @@ els.preview.addEventListener("pointermove", (e) => {
9060
9705
  }
9061
9706
  }
9062
9707
  if (canvasDrag.mode === "move") {
9063
- setAnimProp(c, "x", Math.round(canvasDrag.startX + (pt.x - canvasDrag.startPt.x)));
9064
- 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));
9065
9711
  } else if (canvasDrag.mode === "box") {
9066
9712
  const aspect = canvasDrag.aspect || 1;
9067
9713
  const lockAR = e.shiftKey;
@@ -9072,8 +9718,8 @@ els.preview.addEventListener("pointermove", (e) => {
9072
9718
  const env = transOffsetAt(c, state.time);
9073
9719
  if (e.ctrlKey || e.metaKey) {
9074
9720
  // Ctrl/Cmd: resize from center (all corners move).
9075
- const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
9076
- 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;
9077
9723
  if (lockAR) {
9078
9724
  if (bw / aspect >= bh) bh = bw / aspect;
9079
9725
  else bw = bh * aspect;
@@ -9110,11 +9756,22 @@ els.preview.addEventListener("pointermove", (e) => {
9110
9756
  const c2 = Math.cos(rot), s2 = Math.sin(rot);
9111
9757
  const freeX = fix.x + ldx * c2 - ldy * s2;
9112
9758
  const freeY = fix.y + ldx * s2 + ldy * c2;
9113
- setAnimProp(c, "x", Math.round((fix.x + freeX) / 2 - W / 2 - env.x));
9114
- setAnimProp(c, "y", Math.round((fix.y + freeY) / 2 - H / 2 - env.y));
9115
- c.props.boxW = +Math.abs(ldx).toFixed(1);
9116
- 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);
9117
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]);
9118
9775
  } else if (canvasDrag.mode === "scale") {
9119
9776
  const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
9120
9777
  setAnimProp(c, "scale", clamp(+(canvasDrag.startScale * (Math.hypot(lp.x, lp.y) / canvasDrag.startDist)).toFixed(3), 0.05, 12));
@@ -9136,9 +9793,77 @@ els.preview.addEventListener("pointerup", endCanvasDrag);
9136
9793
  els.preview.addEventListener("pointercancel", endCanvasDrag);
9137
9794
 
9138
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) {
9139
9802
  const masks = clipMasksAt(c, t), matte = asMatte ? null : clipMatte(c);
9140
- if (masks || matte) drawMaskedClip(c, W, H, t, masks, matte);
9141
- else drawClipPicture(c, W, H, t, false);
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);
9142
9867
  }
9143
9868
  /** Draw one clip. `layered`: into a mask layer — opacity and blend wait for
9144
9869
  * the composite. Returns { p, geom } — geom = where the clip's picture landed
@@ -9169,7 +9894,10 @@ function drawClipPicture(c, W, H, t, layered) {
9169
9894
  ctx2d.globalAlpha = clamp(p.opacity, 0, 1);
9170
9895
  if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) ctx2d.globalCompositeOperation = p.blend;
9171
9896
  }
9172
- ctx2d.translate(W / 2 + (+p.x || 0), H / 2 + (+p.y || 0));
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);
9173
9901
  ctx2d.rotate((p.rotation || 0) * Math.PI / 180);
9174
9902
  if (p.shake > 0) { // deterministic multi-sine handheld/impact shake
9175
9903
  const a = +p.shake, tt = t * clamp(+p.shakeSpeed || 8, 0.5, 40) * Math.PI * 2;
@@ -9178,9 +9906,23 @@ function drawClipPicture(c, W, H, t, layered) {
9178
9906
  Math.cos(tt * 1.7) * a * 0.5 + Math.sin(tt * 3.13) * a * 0.35);
9179
9907
  ctx2d.rotate(Math.sin(tt * 0.9) * a * 0.0022);
9180
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
+ }
9181
9923
  if (c.kind === "text") {
9182
- const b = clipBounds(c, p, W, H);
9183
- geom = noteMaskGeom(c, ctx2d.getTransform(), -b.hw, -b.hh, b.hw * 2, b.hh * 2);
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);
9184
9926
  drawText(c, p, t - c.start);
9185
9927
  ctx2d.restore();
9186
9928
  return { p, geom };
@@ -9195,6 +9937,9 @@ function drawClipPicture(c, W, H, t, layered) {
9195
9937
  } else if (c.kind === "video") {
9196
9938
  src = getClipEl(c);
9197
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; }
9198
9943
  }
9199
9944
  if (src && sw && sh) {
9200
9945
  // source crop (percent per edge)
@@ -9257,6 +10002,7 @@ function drawClipPicture(c, W, H, t, layered) {
9257
10002
  const Mask = self.FableCutMask;
9258
10003
  const maskCv = {};
9259
10004
  function maskCtx(key, W, H) {
10005
+ if (runtime.compDepth) key += ":" + runtime.compDepth;
9260
10006
  let cv = maskCv[key];
9261
10007
  if (!cv) cv = maskCv[key] = document.createElement("canvas");
9262
10008
  if (cv.width !== W) cv.width = W;
@@ -9292,7 +10038,7 @@ const videoTrackIds = () => TRACKS.filter((tr) => tr.kind === "video").map((tr)
9292
10038
  /** { mode, track } when the clip has a track matte with a track to take it from. */
9293
10039
  function clipMatte(c) {
9294
10040
  if (c.kind === "audio" || !c.props || !c.props.matte) return null;
9295
- const track = Mask.matteTrackFor(c, videoTrackIds());
10041
+ const track = Mask.matteTrackFor(c, runtime.scope ? runtime.scope.videoTracks : videoTrackIds());
9296
10042
  return track ? { mode: c.props.matte, track } : null;
9297
10043
  }
9298
10044
  /** The tracks that are mattes for clips in `clips` (the frame's clips): they only cut. */
@@ -9308,7 +10054,7 @@ function trackMatteAt(matte, W, H, t) {
9308
10054
  src.setTransform(1, 0, 0, 1, 0, 0);
9309
10055
  src.globalAlpha = 1; src.globalCompositeOperation = "source-over"; src.filter = "none";
9310
10056
  src.clearRect(0, 0, W, H);
9311
- const clips = project.clips.filter((x) => x.track === matte.track && x.disabled !== true && x.kind !== "audio" &&
10057
+ const clips = (runtime.scope ? runtime.scope.clips : project.clips).filter((x) => x.track === matte.track && clipRenders(x) && x.kind !== "audio" &&
9312
10058
  x.kind !== "adjust" && activeAt(x, t)).sort((a, b) => a.start - b.start);
9313
10059
  if (!clips.length) return { canvas: src.canvas, empty: true };
9314
10060
  if (luma) { src.fillStyle = "#000"; src.fillRect(0, 0, W, H); }
@@ -9357,7 +10103,7 @@ function makeLumaFilter() {
9357
10103
  } catch { return ""; }
9358
10104
  }
9359
10105
  /** A clip that is cut before it composites: by its masks, by a track matte, or both. */
9360
- function drawMaskedClip(c, W, H, t, masks, matte = null) {
10106
+ function drawMaskedClip(c, W, H, t, masks, matte = null, raw = false) {
9361
10107
  const tm = matte ? trackMatteAt(matte, W, H, t) : null; // first: the matte's clips use the layer buffers too
9362
10108
  const layer = maskCtx("layer", W, H);
9363
10109
  layer.setTransform(1, 0, 0, 1, 0, 0);
@@ -9379,7 +10125,7 @@ function drawMaskedClip(c, W, H, t, masks, matte = null) {
9379
10125
  main.save();
9380
10126
  main.setTransform(1, 0, 0, 1, 0, 0);
9381
10127
  const p = r.p;
9382
- if (c.kind !== "adjust") { // an adjustment layer already drew at its own opacity
10128
+ if (c.kind !== "adjust" && !raw) { // an adjustment layer already drew at its own opacity; a raw sample composites later
9383
10129
  main.globalAlpha = clamp(p.opacity, 0, 1);
9384
10130
  if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) main.globalCompositeOperation = p.blend;
9385
10131
  }
@@ -9910,6 +10656,7 @@ function bindMaskControls(c) {
9910
10656
  for (const b of root.querySelectorAll("[data-msel]")) b.addEventListener("click", () => {
9911
10657
  const j = +b.dataset.msel;
9912
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; }
9913
10660
  rerender();
9914
10661
  });
9915
10662
  for (const b of root.querySelectorAll("[data-mact]")) b.addEventListener("click", () => {
@@ -10288,6 +11035,1064 @@ window.addEventListener("keydown", (e) => {
10288
11035
  }
10289
11036
  }, true);
10290
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
+
10291
12096
  /* ── Text direction (LTR / RTL) — per-line when direction is "auto".
10292
12097
  Auto-detect uses a DOM probe + getComputedStyle; cache by line text so
10293
12098
  preview/export frames don't re-resolve style every tick. ── */
@@ -10499,6 +12304,7 @@ function beginTextBoxDrag(c, pt, W, H) {
10499
12304
  };
10500
12305
  }
10501
12306
  function drawText(c, p, local) {
12307
+ if (p.textPath && clipTextPath(c)) { drawTextOnPath(c, p, local); return; }
10502
12308
  const useBox = hasTextBox(p);
10503
12309
  const boxW = +p.boxW, boxH = +p.boxH;
10504
12310
  const scaleToFit = useBox && !!p.boxFit;
@@ -10878,7 +12684,7 @@ function ensureFont(name) {
10878
12684
  an export that frame is final. Bounded so a dead network can't stall it. */
10879
12685
  async function loadProjectFonts() {
10880
12686
  const faces = new Map(); // "italic 700 64px "Anton"" -> "Anton"
10881
- for (const c of project.clips) {
12687
+ for (const c of mediaClips()) {
10882
12688
  if (c.kind !== "text" || c.disabled) continue;
10883
12689
  const p = c.props || {};
10884
12690
  const fams = [p.font || "Segoe UI"];
@@ -11386,7 +13192,7 @@ function waitMediaEl(el, ms = 2500) {
11386
13192
  * compositor. Preview leaves them no-cors so a stream without ACAO still plays. */
11387
13193
  async function armExportCors() {
11388
13194
  const jobs = [];
11389
- for (const c of project.clips) {
13195
+ for (const c of mediaClips()) {
11390
13196
  if (c.kind !== "video" || !clipRenders(c)) continue;
11391
13197
  const m = getMedia(c.mediaId);
11392
13198
  if (!m || !isCrossOriginSrc(m.src)) continue;
@@ -11409,7 +13215,7 @@ async function armExportCors() {
11409
13215
  }
11410
13216
  await Promise.all(jobs);
11411
13217
  const missing = [];
11412
- for (const c of project.clips) {
13218
+ for (const c of mediaClips()) {
11413
13219
  if (c.kind !== "video" || !clipRenders(c)) continue;
11414
13220
  const m = getMedia(c.mediaId);
11415
13221
  if (!m || !isCrossOriginSrc(m.src)) continue;
@@ -11542,7 +13348,16 @@ function videoRangeBuffered(el, from, to) {
11542
13348
  return el.readyState >= 3;
11543
13349
  }
11544
13350
  function notePresented(el, mediaTime) {
11545
- 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;
11546
13361
  }
11547
13362
  function presentedClose(el, mt, eps) {
11548
13363
  return el._fcPresentedTime != null && Math.abs(el._fcPresentedTime - mt) <= eps;
@@ -11579,15 +13394,32 @@ function assignVideoTime(el, mt, accurate) {
11579
13394
  }
11580
13395
  el.currentTime = mt;
11581
13396
  }
11582
- 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) {
11583
13406
  const hasRvfc = typeof el.requestVideoFrameCallback === "function";
11584
13407
  const maxAttempts = 3;
11585
13408
  return new Promise((res, rej) => {
11586
- 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)
11587
13411
  .then(res)
11588
13412
  .catch((err) => {
11589
- if (attempt + 1 >= maxAttempts) rej(err);
11590
- 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);
11591
13423
  });
11592
13424
  if (presentedClose(el, mt, 0.002) && el.readyState >= 2) {
11593
13425
  res();
@@ -11700,7 +13532,7 @@ function playAdvanceVideo(el, mt, eps, rate, { keepPlaying } = {}) {
11700
13532
  }
11701
13533
  const EXPORT_PREFETCH_S = 1.25;
11702
13534
  function prefetchExportVideos(t) {
11703
- for (const c of project.clips) {
13535
+ for (const c of mediaClips()) {
11704
13536
  if (c.kind !== "video" || !clipRenders(c)) continue;
11705
13537
  if (activeAt(c, t)) continue;
11706
13538
  if (c.start > t + EXPORT_PREFETCH_S || clipEnd(c) <= t) continue;
@@ -11725,7 +13557,7 @@ async function seekVideosTo(t, { queueBusy } = {}) {
11725
13557
  const keepPlaying = fast && !behind && !queueBusy;
11726
13558
  const waits = [];
11727
13559
  const restoreGain = [];
11728
- for (const c of project.clips) {
13560
+ for (const c of mediaClips()) {
11729
13561
  if (c.kind !== "video") continue;
11730
13562
  if (!clipRenders(c)) continue;
11731
13563
  const el = getClipEl(c); if (!el) continue;
@@ -11806,7 +13638,7 @@ function encodeWAV(buf) {
11806
13638
  const FX_AUTOMATION_STEP = 0.02; // export automation resolution, seconds
11807
13639
  async function renderAudioMix(t0, t1) {
11808
13640
  const jobs = [];
11809
- for (const c of project.clips) {
13641
+ for (const c of mediaClips()) {
11810
13642
  if (c.kind !== "audio" && c.kind !== "video") continue;
11811
13643
  if (!clipRenders(c)) continue;
11812
13644
  const m = getMedia(c.mediaId); if (!m) continue;
@@ -11888,7 +13720,7 @@ async function renderAudioMix(t0, t1) {
11888
13720
  /* Frame-exact asset prep for the fast exporter: rasterize the SVG frame for
11889
13721
  this exact time, and refresh AI person masks synchronously. */
11890
13722
  async function prepareFrameAssets(t) {
11891
- for (const c of project.clips) {
13723
+ for (const c of mediaClips()) {
11892
13724
  if (!activeAt(c, t) || !clipRenders(c)) continue;
11893
13725
  if (c.kind === "svg") await prepareSvgFrame(c, t);
11894
13726
  if (c.props?.bgRemove && (c.kind === "video" || c.kind === "image")) {
@@ -12196,7 +14028,7 @@ async function runTrackJob(ticket, fail) {
12196
14028
  stays where it is. */
12197
14029
  async function renderStillAt(t) {
12198
14030
  const waits = [];
12199
- for (const c of project.clips) {
14031
+ for (const c of mediaClips()) {
12200
14032
  if (!clipRenders(c) || !activeAt(c, t)) continue;
12201
14033
  if (c.kind === "video") {
12202
14034
  const el = getClipEl(c);
@@ -12553,6 +14385,7 @@ els.importUrlOverlay?.addEventListener("click", (e) => {
12553
14385
  });
12554
14386
  $("btnTitle").addEventListener("click", addTitle);
12555
14387
  $("btnAdjust").addEventListener("click", addAdjust);
14388
+
12556
14389
  $("btnSplit").addEventListener("click", splitAtPlayhead);
12557
14390
  $("btnCloseGap").addEventListener("click", closeGapAtPlayhead);
12558
14391
  $("btnLift").addEventListener("click", () => liftExtract(false));
@@ -12642,8 +14475,37 @@ function startSourceScrub(e) {
12642
14475
  window.addEventListener("pointerup", onUp);
12643
14476
  }
12644
14477
  els.sourceScrubTrack?.addEventListener("pointerdown", startSourceScrub);
12645
- $("btnHelp").addEventListener("click", () => $("helpOverlay").classList.remove("hidden"));
14478
+ $("btnHelp").addEventListener("click", () => {
14479
+ $("helpOverlay").classList.remove("hidden");
14480
+ $("helpFilter").focus();
14481
+ });
12646
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
+ });
12647
14509
  function settingsFocusables() {
12648
14510
  const root = $("settingsDialog");
12649
14511
  if (!root) return [];
@@ -12733,6 +14595,16 @@ function openProjectTabMenu(clientX, clientY) {
12733
14595
  addFolder(null);
12734
14596
  });
12735
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);
12736
14608
  document.body.appendChild(menu);
12737
14609
  const pad = 6;
12738
14610
  const w = menu.offsetWidth, h = menu.offsetHeight;
@@ -12768,17 +14640,7 @@ function syncFpsSel() {
12768
14640
  }
12769
14641
  els.aspectSel.addEventListener("change", () => {
12770
14642
  const a = ASPECT_PRESETS[+els.aspectSel.value];
12771
- if (!a) return;
12772
- project.width = a.w; project.height = a.h;
12773
- els.preview.width = a.w; els.preview.height = a.h;
12774
- if (project.exportFrame)
12775
- project.exportFrame = normalizeExportFrame(project.exportFrame, a.w, a.h);
12776
- updateMonitorRes();
12777
- syncAspectSel();
12778
- syncExportFrameSel();
12779
- updateExportFrameOverlay();
12780
- seekMediaWhilePaused();
12781
- scheduleSave();
14643
+ if (a) setCanvasSize(a.w, a.h);
12782
14644
  });
12783
14645
  els.fpsSel?.addEventListener("change", () => {
12784
14646
  const v = FPS_PRESETS[+els.fpsSel.value];
@@ -13468,7 +15330,7 @@ try {
13468
15330
  if (Array.isArray(s) && s.length === 2 && s.every((k) => SCOPE_KINDS[k])) scopes.slots = s;
13469
15331
  } catch { }
13470
15332
 
13471
- 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"); }
13472
15334
  function setWorkspace(ws) {
13473
15335
  colorState.ws = ws === "color" ? "color" : "edit";
13474
15336
  $("app").classList.toggle("ws-color", colorState.ws === "color");