fablecut 1.14.0 → 1.15.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
@@ -438,6 +438,7 @@ function showClipCtxMenu(clientX, clientY, c) {
438
438
  item("Parent to a new null", "", parentSelectionToNull, locked ? "Locked — unlock to change" : null);
439
439
  item("Precompose", "", precomposeSelection, locked ? "Locked — unlock to change" : null);
440
440
  if (c.kind === "comp") item("Open composition", "", () => enterComp(c.compId, c));
441
+ if (c.mediaId || c.kind === "comp") item("Reveal in project", "", () => revealInProject(c));
441
442
  const linked = selectedClips().some((x) => x.linkGroup || x.linkedId);
442
443
  if (linked) item("Unlink audio / video", "Ctrl+L", toggleLinkSelected, locked ? "Locked — unlock to change" : null);
443
444
  else if (c.kind === "video" || c.kind === "audio") {
@@ -467,10 +468,13 @@ const SETTINGS_KEY = "fablecut-settings";
467
468
  const DEFAULT_SETTINGS = {
468
469
  linkSelect: false, // timeline ↔ project bin selection sync
469
470
  encodeProfile: null, // null = server default from encoding-profiles.json
471
+ exportKind: null, // "delivery" | "profile" — what the Export dialog last used
472
+ delivery: null, // last delivery spec used in the Export dialog (delivery.js)
470
473
  // WebCodecs has no CRF — bitrate (Mbps) + constant|variable mode
471
474
  webCodecsBitrateMbps: null, // null = auto from canvas size
472
475
  webCodecsBitrateMode: "variable", // "variable" | "constant"
473
476
  snapTargets: { clips: true, playhead: true, markers: true, inout: true, keyframes: false, frames: true },
477
+ useProxies: true, // play proxies (when a file has one) while editing; export always reads the original
474
478
  };
475
479
  let settings = { ...DEFAULT_SETTINGS };
476
480
  function loadSettings() {
@@ -529,6 +533,8 @@ const project = {
529
533
  untargetedTracks: [], // track ids edits skip (split, insert, ripple, close gap…)
530
534
  exportFrame: null, // optional {x,y,w,h} delivery crop inside width×height canvas
531
535
  encodeProfile: null, // optional fast-export profile id (overrides browser setting)
536
+ delivery: null, // optional delivery spec (delivery.js) — the default for exports, agents' included
537
+ outputs: null, // optional batch outputs: [{id, on, …delivery spec}] — one render, several files
532
538
  tracks: null, // optional [{id, kind, gain?, pan?}] — null means default V3…V1 + A1…A4
533
539
  master: null, // optional {gain} — master bus fader (dB)
534
540
  buses: [], // submix buses [{id:"B1", name?, gain?, pan?, mute?, fx?}]; tracks[].out routes into one
@@ -757,6 +763,8 @@ const $ = (id) => document.getElementById(id);
757
763
  const els = {
758
764
  binList: $("binList"), binEmpty: $("binEmpty"), fileInput: $("fileInput"),
759
765
  binTabs: $("binTabs"), libList: $("libList"), toast: $("toast"),
766
+ binTools: $("binTools"), binSearch: $("binSearch"), binFilter: $("binFilter"), binSort: $("binSort"),
767
+ binSortDir: $("binSortDir"), binViewBtn: $("binViewBtn"), btnProxy: $("btnProxy"),
760
768
  preview: $("preview"), tcCurrent: $("tcCurrent"), tcTotal: $("tcTotal"),
761
769
  tcIo: $("tcIo"), tcIn: $("tcIn"), tcOut: $("tcOut"), tcDur: $("tcDur"),
762
770
  btnPlay: $("btnPlay"), inspector: $("inspector"),
@@ -1238,6 +1246,23 @@ function normalizeWorkArea(i, o, t0 = TIMELINE_START_TIME) {
1238
1246
  }
1239
1247
  return { inPoint, outPoint };
1240
1248
  }
1249
+ /* Project-bin media management: metadata, search / filter / sort, tags,
1250
+ collections and usage live in media-lib.js (shared with the agent ops). */
1251
+ const MediaLib = self.FableCutMedia;
1252
+ const BIN_PREFS_KEY = "fablecut.binPrefs";
1253
+ const binView = (() => {
1254
+ let saved = {};
1255
+ try { saved = JSON.parse(localStorage.getItem(BIN_PREFS_KEY) || "{}") || {}; } catch { }
1256
+ return {
1257
+ q: "", filter: "", // per session: search text and the "Show" filter
1258
+ sort: MediaLib.SORT_KEYS.includes(saved.sort) ? saved.sort : "name",
1259
+ dir: saved.dir === "desc" ? "desc" : "asc",
1260
+ list: saved.list === true, // list view: metadata columns
1261
+ };
1262
+ })();
1263
+ function saveBinPrefs() {
1264
+ try { localStorage.setItem(BIN_PREFS_KEY, JSON.stringify({ sort: binView.sort, dir: binView.dir, list: binView.list })); } catch { }
1265
+ }
1241
1266
  function getFolder(id) {
1242
1267
  return id ? project.folders.find((f) => f.id === id) : null;
1243
1268
  }
@@ -1261,17 +1286,20 @@ function normalizeFolders(list) {
1261
1286
  }
1262
1287
  function normalizeMediaEntry(m) {
1263
1288
  if (!m || typeof m !== "object") return m;
1264
- return { ...m, folderId: m.folderId || null };
1289
+ const out = { ...m, folderId: m.folderId || null };
1290
+ const tags = MediaLib.normalizeTags(m.tags);
1291
+ if (tags.length) out.tags = tags; else delete out.tags;
1292
+ if (out.favorite !== true) delete out.favorite;
1293
+ return out;
1265
1294
  }
1266
1295
  function folderChildren(parentId) {
1267
1296
  return project.folders
1268
1297
  .filter((f) => (f.parentId || null) === (parentId || null))
1269
1298
  .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
1270
1299
  }
1271
- function mediaInFolder(folderId) {
1272
- return project.media
1273
- .filter((m) => (m.folderId || null) === (folderId || null))
1274
- .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
1300
+ function mediaInFolder(folderId, ctx) {
1301
+ return MediaLib.sortMedia(project.media.filter((m) => (m.folderId || null) === (folderId || null)),
1302
+ binView.sort, binView.dir, ctx);
1275
1303
  }
1276
1304
  /* True if ancestorId is the same as nodeId or an ancestor of nodeId. */
1277
1305
  function isSelfOrFolderAncestor(ancestorId, nodeId) {
@@ -1439,12 +1467,15 @@ function applyProject(data, opts = {}) {
1439
1467
  revision: data.revision || 0,
1440
1468
  folders: normalizeFolders(data.folders),
1441
1469
  media: (data.media || []).map(normalizeMediaEntry),
1470
+ collections: MediaLib.normalizeCollections(data.collections, data.media || []),
1442
1471
  clips: data.clips || [],
1443
1472
  markers: normalizeMarkers(data.markers),
1444
1473
  inPoint: wa.inPoint,
1445
1474
  outPoint: wa.outPoint,
1446
1475
  exportFrame: normalizeExportFrame(data.exportFrame, data.width || 1280, data.height || 720),
1447
1476
  encodeProfile: data.encodeProfile || null,
1477
+ delivery: data.delivery && typeof data.delivery === "object" ? data.delivery : null,
1478
+ outputs: Array.isArray(data.outputs) ? data.outputs.filter((o) => o && typeof o === "object") : null,
1448
1479
  master: normalizeMaster(data.master),
1449
1480
  buses: normalizeBuses(data.buses),
1450
1481
  comps: (Array.isArray(data.comps) ? data.comps : []).filter((cp) => cp && cp.id).map((cp) => ({
@@ -1561,15 +1592,19 @@ function projectJSON() {
1561
1592
  }
1562
1593
  /** The project as it is loaded right now (an open comp's stand-in project, or the root). */
1563
1594
  function projectJSONHere() {
1564
- const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, encodeProfile } = project;
1595
+ const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, encodeProfile, delivery, outputs } = project;
1565
1596
  const out = {
1566
1597
  name, width, height, fps, background, revision,
1567
1598
  panSchema: 1,
1568
1599
  folders: (folders || []).map(({ id, name, parentId, open }) =>
1569
1600
  ({ id, name, parentId: parentId || null, open: open !== false })),
1570
1601
  tracks: serializeTracks(),
1571
- media: media.filter((m) => !m.transient).map(({ id, name, kind, src, duration, width, height, folderId, derivedFrom, denoise }) => {
1602
+ media: media.filter((m) => !m.transient).map((m) => {
1603
+ const { id, name, kind, src, duration, width, height, folderId, derivedFrom, denoise } = m;
1572
1604
  const mo = { id, name, kind, src, duration, width, height, folderId: folderId || null };
1605
+ for (const k of MediaLib.INFO_KEYS) if (m[k] != null) mo[k] = m[k];
1606
+ if (m.favorite === true) mo.favorite = true;
1607
+ if (m.tags && m.tags.length) mo.tags = m.tags.slice();
1573
1608
  if (derivedFrom) { mo.derivedFrom = derivedFrom; if (denoise) mo.denoise = denoise; }
1574
1609
  return mo;
1575
1610
  }),
@@ -1579,6 +1614,8 @@ function projectJSONHere() {
1579
1614
  outPoint: outPoint == null ? null : outPoint,
1580
1615
  disabledTracks: normalizeDisabledTracks(disabledTracks),
1581
1616
  };
1617
+ const collections = MediaLib.normalizeCollections(project.collections, out.media);
1618
+ if (collections.length) out.collections = collections;
1582
1619
  const locked = normalizeDisabledTracks(project.lockedTracks);
1583
1620
  const untargeted = normalizeDisabledTracks(project.untargetedTracks);
1584
1621
  if (locked.length) out.lockedTracks = locked;
@@ -1586,6 +1623,8 @@ function projectJSONHere() {
1586
1623
  const ef = getExportFrame();
1587
1624
  if (ef) out.exportFrame = ef;
1588
1625
  if (encodeProfile) out.encodeProfile = encodeProfile;
1626
+ if (delivery) out.delivery = delivery;
1627
+ if (outputs && outputs.length) out.outputs = outputs;
1589
1628
  const master = normalizeMaster(project.master);
1590
1629
  if (master) out.master = master;
1591
1630
  const buses = normalizeBuses(project.buses);
@@ -1596,10 +1635,23 @@ function projectJSONHere() {
1596
1635
  function listenSSE() {
1597
1636
  const es = new EventSource("/api/events");
1598
1637
  // Named events: "change" = project/media/library; "profiles" = encoding-profiles.json only
1599
- es.addEventListener("change", () => syncFromServer());
1638
+ es.addEventListener("change", () => { noteActivity(); syncFromServer(); scheduleOfflineCheck(); });
1639
+ es.addEventListener("proxy", (e) => onProxyEvent(e.data));
1600
1640
  es.addEventListener("export", (e) => {
1641
+ noteActivity();
1601
1642
  try { runExportJob(JSON.parse(e.data)); } catch { }
1602
1643
  });
1644
+ es.addEventListener("delivery-presets", () => {
1645
+ fetchDeliveryInfo().then(() => {
1646
+ if (els.exportSetup && !els.exportSetup.classList.contains("hidden")) {
1647
+ const keep = els.exportProfileSel.value;
1648
+ populateExportProfileSelect();
1649
+ els.exportProfileSel.value = keep;
1650
+ if (els.exportProfileSel.value !== keep) els.exportProfileSel.value = "d:" + Delivery.DEFAULT_PRESET;
1651
+ syncExportChoiceUi(dlvSource);
1652
+ }
1653
+ });
1654
+ });
1603
1655
  es.addEventListener("profiles", () => {
1604
1656
  fetchEncodeProfiles().then(() => {
1605
1657
  if (els.exportSetup && !els.exportSetup.classList.contains("hidden"))
@@ -1607,7 +1659,7 @@ function listenSSE() {
1607
1659
  });
1608
1660
  });
1609
1661
  // Legacy default "message" events (old servers that emit bare `data:`)
1610
- es.onmessage = () => syncFromServer();
1662
+ es.onmessage = () => { noteActivity(); syncFromServer(); };
1611
1663
  }
1612
1664
  /* Pull the server's project if it moved past our revision (an external tool —
1613
1665
  e.g. Claude — wrote it). Our own saves land at our exact local revision, so
@@ -1646,7 +1698,7 @@ async function loadMediaMetadata(m) {
1646
1698
  if (m.kind === "svg") { await loadSvgMedia(m); return; }
1647
1699
  if (m.kind === "image") {
1648
1700
  const img = await loadImage(m.src);
1649
- runtime.mediaAux.set(m.id, { ...(runtime.mediaAux.get(m.id) || {}), img });
1701
+ runtime.mediaAux.set(m.id, { ...(runtime.mediaAux.get(m.id) || {}), img, imgSrc: m.src });
1650
1702
  m.width = img.naturalWidth; m.height = img.naturalHeight;
1651
1703
  return;
1652
1704
  }
@@ -1673,6 +1725,9 @@ async function probeMissingMeta() {
1673
1725
  }
1674
1726
  if (changed) { renderBin(); scheduleSave(); }
1675
1727
  state.dirtyTimeline = true;
1728
+ refreshMediaInfo();
1729
+ scheduleOfflineCheck(0);
1730
+ syncProxyButton();
1676
1731
  }
1677
1732
  function probeAV(src, kind) {
1678
1733
  return new Promise((resolve, reject) => {
@@ -1816,6 +1871,7 @@ async function importFiles(fileList) {
1816
1871
  added++;
1817
1872
  }
1818
1873
  renderBin(); scheduleSave();
1874
+ refreshMediaInfo(addedMedia);
1819
1875
  if (!added && skipped)
1820
1876
  toast("Couldn't import — unsupported or unreadable file type");
1821
1877
  else if (skipped)
@@ -1894,12 +1950,24 @@ async function importFromUrl(url) {
1894
1950
  }
1895
1951
 
1896
1952
  function renderBin() {
1897
- els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root, .comp-item").forEach((n) => n.remove());
1953
+ els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root, .comp-item, .bin-filterbar, .bin-colhead, .bin-noresult").forEach((n) => n.remove());
1954
+ syncBinTools();
1898
1955
  const comps = project.comps || [];
1899
1956
  const empty = !project.media.length && !project.folders.length && !comps.length;
1900
1957
  els.binEmpty.style.display = empty ? "" : "none";
1958
+ els.binList.classList.toggle("list-view", binView.list && !empty);
1901
1959
  if (empty) return;
1902
- for (const comp of comps) els.binList.appendChild(makeCompItem(comp));
1960
+ const ctx = binCtx();
1961
+ const flat = !!(binView.q.trim() || binView.filter); // searching / filtering → one flat list of hits
1962
+ if (mediaOffline.size && binView.filter !== "offline") {
1963
+ const warn = document.createElement("div");
1964
+ warn.className = "bin-filterbar bin-offline-bar";
1965
+ warn.innerHTML = `<span>⚠ ${mediaOffline.size} file${mediaOffline.size === 1 ? " is" : "s are"} offline</span>
1966
+ <button type="button" class="btn tiny" data-show>Show</button><button type="button" class="btn tiny accent" data-relink>Relink…</button>`;
1967
+ warn.querySelector("[data-show]").addEventListener("click", () => setBinFilter("offline"));
1968
+ warn.querySelector("[data-relink]").addEventListener("click", () => openRelinkDialog());
1969
+ els.binList.appendChild(warn);
1970
+ }
1903
1971
 
1904
1972
  const clearBinDropHints = () => {
1905
1973
  els.binList.querySelectorAll(".bin-drop-over").forEach((n) => n.classList.remove("bin-drop-over"));
@@ -1956,26 +2024,46 @@ function renderBin() {
1956
2024
 
1957
2025
  const makeMediaItem = (m, depth) => {
1958
2026
  const item = document.createElement("div");
1959
- item.className = "bin-item";
2027
+ const offline = isMediaOffline(m.id);
2028
+ item.className = "bin-item" + (binView.list ? " bin-row" : "") + (offline ? " offline" : "");
1960
2029
  item.draggable = true;
1961
2030
  item.dataset.mediaId = m.id;
1962
- item.style.setProperty("--bin-depth", depth);
2031
+ item.style.setProperty("--bin-depth", flat ? 0 : depth);
1963
2032
  const aux = runtime.mediaAux.get(m.id) || {};
1964
2033
  const icon = mediaKindIcon(m.kind, "🎞");
1965
2034
  const thumbSrc = aux.thumb || (m.kind === "image" || m.kind === "svg" ? m.src : null);
1966
- item.innerHTML = `
1967
- <div class="bin-thumb"></div>
1968
- <div class="bin-meta">
1969
- <div class="bin-name"></div>
1970
- <div class="bin-sub">${m.kind}${m.duration ? " · " + fmt(m.duration) : ""}</div>
1971
- </div>
1972
- <span class="bin-del" title="Remove (and its clips)">✕</span>`;
2035
+ const uses = ctx.usage.get(m.id) || 0;
2036
+ const folder = flat && m.folderId ? getFolder(m.folderId) : null;
2037
+ const usesHtml = `<button type="button" class="bin-uses${uses ? "" : " none"}" title="${uses ? `Used ${uses}× — click to find every instance` : "Not used on the timeline"}">${uses}</button>`;
2038
+ const favHtml = `<button type="button" class="bin-fav${m.favorite ? " on" : ""}" title="${m.favorite ? "Favorite — click to unstar" : "Mark as favorite"}" aria-pressed="${!!m.favorite}">${m.favorite ? "★" : "☆"}</button>`;
2039
+ if (binView.list) {
2040
+ const cell = (v, cls = "") => `<span class="bin-cell${cls}">${escapeHtml(v || "—")}</span>`;
2041
+ item.innerHTML = `
2042
+ <span class="bin-cell bin-cell-name">${favHtml}<span class="bin-thumb sm"></span>${offline ? `<span class="bin-offline" title="The file is missing — right-click → Relink…">OFFLINE</span>` : ""}<span class="bin-name"></span></span>
2043
+ ${cell(MediaLib.fmtDur(m.duration), " num c-duration")}${cell(MediaLib.fmtRes(m), " num c-resolution")}${cell(MediaLib.fmtFps(m.fps), " num c-fps")}
2044
+ ${cell(m.codec, " c-codec")}${cell(MediaLib.fmtAudio(m), " c-audio")}${cell(MediaLib.fmtSize(m.size), " num c-size")}
2045
+ <span class="bin-cell c-proxy">${proxyBadgeHtml(m) || escapeHtml(m.kind === "video" ? "—" : "")}</span>
2046
+ <span class="bin-cell num c-usage">${usesHtml}</span>
2047
+ <span class="bin-del" title="Remove (and its clips)">✕</span>`;
2048
+ } else {
2049
+ const sub = [folder ? "📁 " + folder.name : "", m.kind, m.duration ? fmt(m.duration) : "", MediaLib.fmtRes(m), m.fps ? MediaLib.fmtFps(m.fps) + " fps" : ""]
2050
+ .filter(Boolean).join(" · ");
2051
+ item.innerHTML = `
2052
+ <div class="bin-thumb"></div>
2053
+ <div class="bin-meta">
2054
+ <div class="bin-name"></div>
2055
+ <div class="bin-sub">${offline ? `<span class="bin-offline" title="The file is missing — right-click → Relink…">OFFLINE</span> ` : ""}${escapeHtml(sub)}</div>
2056
+ ${m.tags && m.tags.length ? `<div class="bin-tags">${m.tags.map((t) => `<span class="bin-tag">#${escapeHtml(t)}</span>`).join("")}</div>` : ""}
2057
+ </div>
2058
+ <div class="bin-side">${favHtml}${usesHtml}${proxyBadgeHtml(m)}</div>
2059
+ <span class="bin-del" title="Remove (and its clips)">✕</span>`;
2060
+ }
1973
2061
  const thumbEl = item.querySelector(".bin-thumb");
1974
2062
  if (thumbSrc) thumbEl.style.backgroundImage = `url(${JSON.stringify(thumbSrc)})`;
1975
2063
  else thumbEl.textContent = icon;
1976
2064
  const nameEl = item.querySelector(".bin-name");
1977
2065
  nameEl.textContent = m.name;
1978
- nameEl.title = m.name;
2066
+ item.title = [m.name, offline ? "⚠ Offline: " + decodeURIComponent(String(m.src).split("?")[0]) + " is missing — right-click → Relink…" : "", MediaLib.describe(m), m.tags && m.tags.length ? m.tags.map((t) => "#" + t).join(" ") : ""].filter(Boolean).join("\n");
1979
2067
  item.addEventListener("dragstart", (e) => {
1980
2068
  runtime.binDragFolderId = null;
1981
2069
  e.dataTransfer.setData("text/fablecut-media", m.id);
@@ -1990,13 +2078,14 @@ function renderBin() {
1990
2078
  // Don't bubble to the root drop zone while hovering a media row
1991
2079
  item.addEventListener("dragover", (e) => e.stopPropagation());
1992
2080
  item.addEventListener("click", (e) => {
1993
- if (e.target.closest(".bin-del")) return;
2081
+ if (e.target.closest(".bin-del, .bin-fav, .bin-uses")) return;
1994
2082
  if (e.ctrlKey || e.metaKey) return; // reserved for import
1995
2083
  if (!getSetting("linkSelect")) return;
1996
2084
  e.preventDefault();
1997
2085
  selectClipsByMediaId(m.id);
1998
2086
  });
1999
- item.addEventListener("dblclick", () => {
2087
+ item.addEventListener("dblclick", (e) => {
2088
+ if (e.target.closest(".bin-del, .bin-fav, .bin-uses")) return;
2000
2089
  // Double-click loads Source (single-click only selects when link-select is on).
2001
2090
  if (state.source.mediaId === m.id) {
2002
2091
  setMonitorMode("source");
@@ -2005,17 +2094,16 @@ function renderBin() {
2005
2094
  }
2006
2095
  if (getSetting("linkSelect")) flashMonitorAttention();
2007
2096
  });
2097
+ item.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); showMediaMenu(e.clientX, e.clientY, m); });
2098
+ item.querySelector(".bin-fav").addEventListener("click", (e) => { e.stopPropagation(); setMediaFavorite([m.id], !m.favorite); });
2099
+ item.querySelector(".bin-uses").addEventListener("click", (e) => {
2100
+ e.stopPropagation();
2101
+ const r = e.currentTarget.getBoundingClientRect();
2102
+ showMediaInstances(m, r.left, r.bottom + 4);
2103
+ });
2008
2104
  item.querySelector(".bin-del").addEventListener("click", (e) => {
2009
2105
  e.stopPropagation();
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
2012
- project.media = project.media.filter((x) => x.id !== m.id);
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;
2017
- if (state.source.mediaId === m.id) clearSource();
2018
- renderBin(); scheduleSave(); renderInspector();
2106
+ removeMediaFromBin(m);
2019
2107
  });
2020
2108
  return item;
2021
2109
  };
@@ -2074,20 +2162,784 @@ function renderBin() {
2074
2162
  els.binList.appendChild(row);
2075
2163
  if (f.open) renderLevel(f.id, depth + 1);
2076
2164
  }
2077
- for (const m of mediaInFolder(parentId)) els.binList.appendChild(makeMediaItem(m, depth));
2165
+ for (const m of mediaInFolder(parentId, ctx)) els.binList.appendChild(makeMediaItem(m, depth));
2078
2166
  };
2079
2167
 
2080
- renderLevel(null, 0);
2081
-
2082
- // Root drop zone at the bottom so items can be moved out of folders
2083
- const root = document.createElement("div");
2084
- root.className = "bin-drop-root";
2085
- root.textContent = "Drop here to move to root";
2086
- bindFolderDropTarget(root, null);
2087
- els.binList.appendChild(root);
2168
+ if (flat) {
2169
+ const hits = MediaLib.query(project.media, binView, ctx);
2170
+ els.binList.appendChild(makeFilterBar(hits.length));
2171
+ if (binView.list) els.binList.appendChild(makeColumnHead());
2172
+ const q = binView.q.trim().toLowerCase();
2173
+ if (!binView.filter) for (const comp of comps) if (q && String(comp.name).toLowerCase().includes(q)) els.binList.appendChild(makeCompItem(comp));
2174
+ for (const m of hits) els.binList.appendChild(makeMediaItem(m, 0));
2175
+ if (!hits.length) {
2176
+ const none = document.createElement("div");
2177
+ none.className = "bin-noresult";
2178
+ none.textContent = "No media matches.";
2179
+ els.binList.appendChild(none);
2180
+ }
2181
+ } else {
2182
+ if (binView.list) els.binList.appendChild(makeColumnHead());
2183
+ for (const comp of comps) els.binList.appendChild(makeCompItem(comp));
2184
+ renderLevel(null, 0);
2185
+ // Root drop zone at the bottom so items can be moved out of folders
2186
+ const root = document.createElement("div");
2187
+ root.className = "bin-drop-root";
2188
+ root.textContent = "Drop here to move to root";
2189
+ bindFolderDropTarget(root, null);
2190
+ els.binList.appendChild(root);
2191
+ }
2088
2192
  syncBinSelectionFromTimeline();
2089
2193
  }
2090
2194
 
2195
+ /* ── Project bin: media management ──
2196
+ Search, the Show filter, sort, list view, favorites, tags, collections,
2197
+ usage counts, find instances, reveal in project, server-probed metadata. */
2198
+ /** The whole document as it stands now — with an open comp's live clips written into it. */
2199
+ function usageDoc() {
2200
+ if (!state.compCtx) return project;
2201
+ const id = compCtxId();
2202
+ return {
2203
+ media: project.media,
2204
+ clips: state.compCtx.root.clips || [],
2205
+ comps: (project.comps || []).map((c) => (c.id === id ? { ...c, clips: project.clips } : c)),
2206
+ };
2207
+ }
2208
+ function binCtx() {
2209
+ return {
2210
+ usage: MediaLib.usageCounts(usageDoc()), collections: project.collections || [],
2211
+ extraFilter: (m, f) => (f === "offline" ? isMediaOffline(m.id)
2212
+ : f === "proxy" ? !!proxyOf(m) : f === "noproxy" ? m.kind === "video" && !proxyOf(m) : true),
2213
+ };
2214
+ }
2215
+ function getCollection(id) { return (project.collections || []).find((c) => c.id === id) || null; }
2216
+
2217
+ /* Fill the Show / Sort controls from the project (tags and collections change). */
2218
+ function syncBinTools() {
2219
+ const tools = els.binTools;
2220
+ if (!tools) return;
2221
+ const tags = MediaLib.allTags(project.media);
2222
+ const cols = project.collections || [];
2223
+ // a filter whose tag / collection is gone falls back to everything
2224
+ const f = binView.filter;
2225
+ if ((f.startsWith("tag:") && !tags.some((t) => t.toLowerCase() === f.slice(4).toLowerCase())) ||
2226
+ (f.startsWith("col:") && !getCollection(f.slice(4)))) binView.filter = "";
2227
+ const opt = (v, label) => `<option value="${escapeHtml(v)}"${v === binView.filter ? " selected" : ""}>${escapeHtml(label)}</option>`;
2228
+ const html = opt("", "All media") + opt("fav", "★ Favorites") + opt("used", "Used in the edit") + opt("unused", "Unused") +
2229
+ (mediaOffline.size || binView.filter === "offline" ? opt("offline", `⚠ Offline (${mediaOffline.size})`) : "") +
2230
+ (project.media.some((m) => m.kind === "video") ? opt("proxy", "Has a proxy") + opt("noproxy", "Video without a proxy") : "") +
2231
+ `<optgroup label="Kind">${MediaLib.KINDS.map((k) => opt("kind:" + k, k[0].toUpperCase() + k.slice(1))).join("")}</optgroup>` +
2232
+ (cols.length ? `<optgroup label="Collections">${cols.map((c) => opt("col:" + c.id, c.name + ` (${c.media.length})`)).join("")}</optgroup>` : "") +
2233
+ (tags.length ? `<optgroup label="Tags">${tags.map((t) => opt("tag:" + t, "#" + t)).join("")}</optgroup>` : "");
2234
+ if (els.binFilter.innerHTML !== html) els.binFilter.innerHTML = html;
2235
+ els.binFilter.value = binView.filter;
2236
+ els.binFilter.classList.toggle("on", !!binView.filter);
2237
+ els.binSort.value = binView.sort;
2238
+ els.binSortDir.textContent = binView.dir === "desc" ? "↓" : "↑";
2239
+ els.binSortDir.title = binView.dir === "desc" ? "Descending — click for ascending" : "Ascending — click for descending";
2240
+ els.binViewBtn.classList.toggle("on", binView.list);
2241
+ els.binViewBtn.title = binView.list ? "Thumbnail view" : "List view: metadata columns";
2242
+ if (els.binSearch.value !== binView.q && document.activeElement !== els.binSearch) els.binSearch.value = binView.q;
2243
+ }
2244
+ function setBinFilter(filter) {
2245
+ binView.filter = filter || "";
2246
+ renderBin();
2247
+ }
2248
+ /* The strip above search / filter results: how many, the active collection's actions, Clear. */
2249
+ function makeFilterBar(n) {
2250
+ const bar = document.createElement("div");
2251
+ bar.className = "bin-filterbar";
2252
+ const col = binView.filter.startsWith("col:") ? getCollection(binView.filter.slice(4)) : null;
2253
+ bar.innerHTML = `<span class="bin-filter-count">${n} of ${project.media.length}</span>` +
2254
+ (col ? `<span class="bin-filter-name" title="Collection">▣ ${escapeHtml(col.name)}</span>
2255
+ <button type="button" class="btn tiny" data-col-rename>Rename</button>
2256
+ <button type="button" class="btn tiny" data-col-delete title="Delete the collection (keeps the media)">Delete</button>` : "") +
2257
+ (binView.filter === "offline" && mediaOffline.size ? `<button type="button" class="btn tiny accent" data-relink-all>Relink…</button>` : "") +
2258
+ `<button type="button" class="btn tiny" data-clear title="Show everything (Esc in the search box)">Clear</button>`;
2259
+ bar.querySelector("[data-relink-all]")?.addEventListener("click", () => openRelinkDialog());
2260
+ bar.querySelector("[data-clear]").addEventListener("click", () => { binView.q = ""; els.binSearch.value = ""; setBinFilter(""); });
2261
+ if (col) {
2262
+ bar.querySelector("[data-col-rename]").addEventListener("click", (e) => {
2263
+ const r = e.currentTarget.getBoundingClientRect();
2264
+ promptBinText(r.left, r.bottom + 4, "Collection name", col.name, (name) => { col.name = name.slice(0, 80); scheduleSave(); renderBin(); });
2265
+ });
2266
+ bar.querySelector("[data-col-delete]").addEventListener("click", () => {
2267
+ project.collections = (project.collections || []).filter((c) => c.id !== col.id);
2268
+ binView.filter = "";
2269
+ scheduleSave(); renderBin();
2270
+ toast(`Deleted the collection “${col.name}” — its media stay in the bin`);
2271
+ });
2272
+ }
2273
+ return bar;
2274
+ }
2275
+ /* List view header: click a column to sort by it (again to flip the direction). */
2276
+ const BIN_COLUMNS = [["name", "Name"], ["duration", "Duration"], ["resolution", "Resolution"], ["fps", "FPS"],
2277
+ ["codec", "Codec"], ["audio", "Audio"], ["size", "Size"], ["proxy", "Proxy"], ["usage", "Uses"]];
2278
+ function makeColumnHead() {
2279
+ const head = document.createElement("div");
2280
+ head.className = "bin-colhead bin-row";
2281
+ for (const [key, label] of BIN_COLUMNS) {
2282
+ if (key === "proxy") { const sp = document.createElement("span"); sp.className = "bin-cell c-proxy"; sp.textContent = label; head.appendChild(sp); continue; }
2283
+ const b = document.createElement("button");
2284
+ b.type = "button";
2285
+ b.className = "bin-cell c-" + key + (key === "name" ? " bin-cell-name" : "") + (["duration", "resolution", "fps", "size", "usage"].includes(key) ? " num" : "") + (binView.sort === key ? " on" : "");
2286
+ b.textContent = label + (binView.sort === key ? (binView.dir === "desc" ? " ↓" : " ↑") : "");
2287
+ b.addEventListener("click", () => {
2288
+ if (binView.sort === key) binView.dir = binView.dir === "desc" ? "asc" : "desc";
2289
+ else { binView.sort = key; binView.dir = key === "name" || key === "codec" ? "asc" : "desc"; }
2290
+ saveBinPrefs(); renderBin();
2291
+ });
2292
+ head.appendChild(b);
2293
+ }
2294
+ head.appendChild(document.createElement("span"));
2295
+ return head;
2296
+ }
2297
+
2298
+ /* Server-probed metadata (fps, codecs, channels, sample rate, size) for media
2299
+ that has none yet. Without ffprobe the server still answers the size. */
2300
+ let mediaInfoBusy = false, mediaInfoAgain = false;
2301
+ async function refreshMediaInfo(list) {
2302
+ if (!state.connected) return;
2303
+ if (mediaInfoBusy) { mediaInfoAgain = true; return; } // run once more when this one ends
2304
+ // blob: srcs are files a hosted editor reads from a folder (a local session's own blobs are transient)
2305
+ const todo = (list || project.media).filter((m) => !m.transient && m.size == null && (isLocalSrc(m.src) || /^blob:/.test(m.src || "")));
2306
+ if (!todo.length) return;
2307
+ mediaInfoBusy = true;
2308
+ try {
2309
+ const res = await fetch("/api/media-info", {
2310
+ method: "POST", headers: { "Content-Type": "application/json" },
2311
+ body: JSON.stringify({ srcs: [...new Set(todo.map((m) => m.src))] }),
2312
+ });
2313
+ if (!res.ok) return;
2314
+ const { info } = await res.json();
2315
+ let changed = false;
2316
+ for (const m of project.media) {
2317
+ const i = info && info[m.src];
2318
+ if (!i) continue;
2319
+ for (const k of MediaLib.INFO_KEYS) if (i[k] != null && m[k] !== i[k]) { m[k] = i[k]; changed = true; }
2320
+ if (!(m.width > 0) && i.width > 0) { m.width = i.width; m.height = i.height; changed = true; }
2321
+ }
2322
+ if (changed) { renderBin(); scheduleSave(); if (state.selId) renderInspector(); }
2323
+ } catch { } finally {
2324
+ mediaInfoBusy = false;
2325
+ // files imported (or a Media info opened) meanwhile: everything still without details
2326
+ if (mediaInfoAgain) { mediaInfoAgain = false; refreshMediaInfo(); }
2327
+ }
2328
+ }
2329
+
2330
+ function setMediaFavorite(ids, on) {
2331
+ for (const id of ids) {
2332
+ const m = getMedia(id);
2333
+ if (!m) continue;
2334
+ if (on) m.favorite = true; else delete m.favorite;
2335
+ }
2336
+ scheduleSave(); renderBin();
2337
+ }
2338
+ function setMediaTags(m, tags) {
2339
+ const t = MediaLib.normalizeTags(tags);
2340
+ if (t.length) m.tags = t; else delete m.tags;
2341
+ scheduleSave(); renderBin();
2342
+ }
2343
+ function addToCollection(colId, mediaId, on = true) {
2344
+ const col = getCollection(colId);
2345
+ if (!col) return;
2346
+ if (on && !col.media.includes(mediaId)) col.media.push(mediaId);
2347
+ if (!on) col.media = col.media.filter((id) => id !== mediaId);
2348
+ scheduleSave(); renderBin();
2349
+ }
2350
+ function newCollection(name, mediaIds) {
2351
+ const col = { id: "col_" + uid(), name: String(name).trim().slice(0, 80) || "Collection", media: [...new Set(mediaIds || [])] };
2352
+ project.collections = [...(project.collections || []), col];
2353
+ scheduleSave(); renderBin();
2354
+ return col;
2355
+ }
2356
+ function removeMediaFromBin(m) {
2357
+ 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; }
2358
+ pushUndo(JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media })); // undo brings the media back too
2359
+ project.media = project.media.filter((x) => x.id !== m.id);
2360
+ project.clips = project.clips.filter((c) => c.mediaId !== m.id);
2361
+ // and its clips inside comps (and on the main timeline while a comp is open)
2362
+ project.comps = (project.comps || []).map((cp) => ({ ...cp, clips: (cp.clips || []).filter((c) => c.mediaId !== m.id) }));
2363
+ runtime.compGen = (runtime.compGen || 0) + 1;
2364
+ if (state.source.mediaId === m.id) clearSource();
2365
+ renderBin(); scheduleSave(); renderInspector();
2366
+ }
2367
+
2368
+ /* A one-line text prompt as a popover (Enter saves, Esc cancels). */
2369
+ function promptBinText(x, y, label, value, onOk, opts = {}) {
2370
+ const pop = document.createElement("div");
2371
+ pop.className = "ctx-menu bin-pop";
2372
+ pop.innerHTML = `<label class="bin-pop-label">${escapeHtml(label)}</label>
2373
+ <input type="text" class="bin-pop-input" maxlength="${opts.max || 80}" spellcheck="false">
2374
+ ${opts.hint ? `<div class="bin-pop-hint">${escapeHtml(opts.hint)}</div>` : ""}
2375
+ <div class="bin-pop-chips"></div>
2376
+ <div class="bin-pop-btns"><button type="button" class="btn tiny" data-cancel>Cancel</button><button type="button" class="btn tiny accent" data-ok>${escapeHtml(opts.ok || "OK")}</button></div>`;
2377
+ const input = pop.querySelector("input");
2378
+ input.value = value || "";
2379
+ if (opts.placeholder) input.placeholder = opts.placeholder;
2380
+ const chips = pop.querySelector(".bin-pop-chips");
2381
+ for (const c of opts.chips || []) {
2382
+ const b = document.createElement("button");
2383
+ b.type = "button"; b.className = "bin-tag chip"; b.textContent = "#" + c;
2384
+ b.title = "Add or remove this tag";
2385
+ b.addEventListener("click", () => {
2386
+ const cur = MediaLib.normalizeTags(input.value);
2387
+ const has = cur.some((t) => t.toLowerCase() === c.toLowerCase());
2388
+ input.value = (has ? cur.filter((t) => t.toLowerCase() !== c.toLowerCase()) : [...cur, c]).join(", ");
2389
+ input.focus();
2390
+ });
2391
+ chips.appendChild(b);
2392
+ }
2393
+ if (!chips.children.length) chips.remove();
2394
+ const ok = () => {
2395
+ const v = input.value.trim();
2396
+ if (!v && !opts.allowEmpty) { input.focus(); return; }
2397
+ hideTrackCtxMenu();
2398
+ onOk(v);
2399
+ };
2400
+ pop.querySelector("[data-ok]").addEventListener("click", ok);
2401
+ pop.querySelector("[data-cancel]").addEventListener("click", () => hideTrackCtxMenu());
2402
+ input.addEventListener("keydown", (e) => {
2403
+ if (e.key === "Enter") { e.preventDefault(); ok(); }
2404
+ else if (e.key === "Escape") { e.preventDefault(); hideTrackCtxMenu(); }
2405
+ e.stopPropagation();
2406
+ });
2407
+ showPopover(pop, x, y);
2408
+ input.focus(); input.select();
2409
+ }
2410
+
2411
+ /* Right-click a media item in the Project bin. */
2412
+ function showMediaMenu(x, y, m) {
2413
+ hideTrackCtxMenu();
2414
+ closeBinCtxMenu();
2415
+ const menu = document.createElement("div");
2416
+ menu.className = "ctx-menu";
2417
+ const uses = MediaLib.placements(usageDoc(), m.id).length;
2418
+ const item = (label, run, why, keys) => {
2419
+ const b = document.createElement("button");
2420
+ b.type = "button"; b.className = "ctx-item";
2421
+ b.innerHTML = `<span>${escapeHtml(label)}</span>` + (keys ? `<kbd>${escapeHtml(keys)}</kbd>` : "");
2422
+ if (why) { b.disabled = true; b.title = why; } else b.addEventListener("click", () => { hideTrackCtxMenu(); run(); });
2423
+ menu.appendChild(b);
2424
+ return b;
2425
+ };
2426
+ const sep = () => { const d = document.createElement("div"); d.className = "ctx-sep"; menu.appendChild(d); };
2427
+ item("Open in Source", () => loadSourceFromMedia(m));
2428
+ item(`Find timeline instances (${uses})`, () => showMediaInstances(m, x, y), uses ? null : "Not used on the timeline");
2429
+ item("Media info…", () => showMediaInfo(m, x, y));
2430
+ item(isMediaOffline(m.id) ? "Relink…" : "Relink to another copy…", () => openRelinkDialog([m]), state.connected ? null : "Needs the FableCut server");
2431
+ item("Replace file…", () => replaceMediaFile(m), state.connected ? null : "Needs the FableCut server");
2432
+ if (m.kind === "video") {
2433
+ const ps = proxyStatus.get(m.id);
2434
+ const why = !state.connected ? "Needs the FableCut server" : !state.ffmpeg ? "Needs ffmpeg on the server's PATH" : isMediaOffline(m.id) ? "The file is offline" : null;
2435
+ if (ps && (ps.state === "ready" || ps.state === "running" || ps.state === "queued")) item(ps.state === "ready" ? "Delete proxy" : "Stop making the proxy", () => deleteProxies([m]));
2436
+ else {
2437
+ item("Create proxy (540p)", () => createProxies([m], 540), why);
2438
+ item("Create proxy (720p)", () => createProxies([m], 720), why);
2439
+ }
2440
+ }
2441
+ sep();
2442
+ item(m.favorite ? "Remove from favorites" : "Add to favorites", () => setMediaFavorite([m.id], !m.favorite));
2443
+ item("Tags…", () => promptBinText(x, y, `Tags for ${m.name}`, (m.tags || []).join(", "), (v) => setMediaTags(m, v),
2444
+ { allowEmpty: true, max: 400, ok: "Save", placeholder: "interview, b-roll, day 1", hint: "Comma-separated. Search #tag to find them.",
2445
+ chips: MediaLib.allTags(project.media) }));
2446
+ for (const col of project.collections || []) {
2447
+ const inIt = col.media.includes(m.id);
2448
+ item(inIt ? `Remove from “${col.name}”` : `Add to “${col.name}”`, () => addToCollection(col.id, m.id, !inIt));
2449
+ }
2450
+ item("New collection…", () => promptBinText(x, y, "New collection", "", (name) => {
2451
+ const col = newCollection(name, [m.id]);
2452
+ toast(`Added ${m.name} to the collection “${col.name}”`);
2453
+ }, { placeholder: "Selects, Interviews…", ok: "Create" }));
2454
+ sep();
2455
+ item("Remove from project", () => removeMediaFromBin(m));
2456
+ document.body.appendChild(menu);
2457
+ menu.id = "trackCtxMenu";
2458
+ trackCtxMenu = menu;
2459
+ menu.style.left = Math.max(6, Math.min(x, innerWidth - menu.offsetWidth - 6)) + "px";
2460
+ menu.style.top = Math.max(6, Math.min(y, innerHeight - menu.offsetHeight - 6)) + "px";
2461
+ }
2462
+
2463
+ /* Media info: everything the bin knows about one file. */
2464
+ function showMediaInfo(m, x, y) {
2465
+ const doc = usageDoc();
2466
+ const uses = MediaLib.placements(doc, m.id);
2467
+ const folder = m.folderId ? getFolder(m.folderId) : null;
2468
+ const cols = (project.collections || []).filter((c) => c.media.includes(m.id)).map((c) => c.name);
2469
+ const audio = m.audioChannels == null ? "" : m.audioChannels === 0 ? "none" :
2470
+ [m.audioCodec, MediaLib.fmtAudio(m)].filter(Boolean).join(" · ");
2471
+ const rows = [
2472
+ ["Kind", m.kind], ["File", decodeURIComponent(String(m.src || "").split("?")[0]) + (isMediaOffline(m.id) ? " — MISSING (offline)" : "")],
2473
+ ["Duration", m.duration > 0 ? MediaLib.fmtDur(m.duration) + ` (${(+m.duration).toFixed(3)} s)` : ""],
2474
+ ["Resolution", MediaLib.fmtRes(m)], ["Frame rate", m.fps ? MediaLib.fmtFps(m.fps) + " fps" : ""],
2475
+ ["Video codec", m.kind === "video" ? m.codec : ""], ["Format", m.kind !== "video" && m.codec ? m.codec : ""],
2476
+ ["Audio", audio], ["Bitrate", m.bitrate ? (m.bitrate >= 1e6 ? (m.bitrate / 1e6).toFixed(1) + " Mb/s" : Math.round(m.bitrate / 1000) + " kb/s") : ""],
2477
+ ["Size", MediaLib.fmtSize(m.size)], ["Used", uses.length ? uses.length + "×" : "not used"],
2478
+ ["Proxy", m.kind !== "video" ? "" : (() => {
2479
+ const ps = proxyStatus.get(m.id);
2480
+ if (!ps) return "none";
2481
+ if (ps.state === "ready") return `${ps.width ? ps.width + "×" : ""}${ps.height} · ${MediaLib.fmtSize(ps.size)}${proxiesOn() ? " · playing it" : " · Proxy button off"}`;
2482
+ return ps.state === "running" ? `making it — ${Math.round((ps.progress || 0) * 100)}%` : ps.state + (ps.error ? ": " + ps.error : "");
2483
+ })()],
2484
+ ["Folder", folder ? folder.name : ""], ["Tags", (m.tags || []).map((t) => "#" + t).join(" ")],
2485
+ ["Collections", cols.join(", ")], ["Favorite", m.favorite ? "★ yes" : ""],
2486
+ ].filter(([, v]) => v);
2487
+ const pop = document.createElement("div");
2488
+ pop.className = "ctx-menu bin-pop bin-info";
2489
+ pop.innerHTML = `<div class="bin-pop-title"></div><table>${rows.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("")}</table>` +
2490
+ (m.size == null && state.connected ? `<div class="bin-pop-hint">Reading the file's details…</div>` : "");
2491
+ pop.querySelector(".bin-pop-title").textContent = m.name;
2492
+ showPopover(pop, x, y);
2493
+ if (m.size == null) refreshMediaInfo([m]);
2494
+ }
2495
+
2496
+ /* Find all timeline instances: every placement (main timeline and inside comps); click one to go there. */
2497
+ function showMediaInstances(m, x, y) {
2498
+ const list = MediaLib.placements(usageDoc(), m.id);
2499
+ if (!list.length) { toast(`${m.name} isn't used on the timeline`); return; }
2500
+ // select the instances on the timeline that is open now
2501
+ const here = compCtxId();
2502
+ const local = list.filter((p) => p.comp === here).map((p) => p.clipId);
2503
+ if (local.length) selectClipsByMediaId(m.id);
2504
+ const pop = document.createElement("div");
2505
+ pop.className = "ctx-menu bin-pop bin-instances";
2506
+ pop.innerHTML = `<div class="bin-pop-title"></div><div class="bin-pop-hint">${list.length} instance${list.length === 1 ? "" : "s"} — click one to jump to it</div>`;
2507
+ pop.querySelector(".bin-pop-title").textContent = m.name;
2508
+ for (const p of list) {
2509
+ const b = document.createElement("button");
2510
+ b.type = "button"; b.className = "ctx-item";
2511
+ b.innerHTML = `<span>${escapeHtml(p.comp ? `▣ ${p.compName}` : "Main")} · ${escapeHtml(p.track)}</span><kbd>${fmt(p.start)} → ${fmt(p.start + p.duration)}</kbd>`;
2512
+ b.addEventListener("click", () => { goToPlacement(p); });
2513
+ pop.appendChild(b);
2514
+ }
2515
+ showPopover(pop, x, y);
2516
+ }
2517
+ function goToPlacement(p) {
2518
+ const here = compCtxId();
2519
+ if (p.comp !== here) {
2520
+ if (state.compCtx) exitComp(state.compCtx.path.length);
2521
+ if (p.comp) enterComp(p.comp);
2522
+ }
2523
+ const c = getClip(p.clipId);
2524
+ if (!c) return;
2525
+ if (isSourceMode()) setMonitorMode("program");
2526
+ setSelection([...withLinked([c])].map((x) => x.id), c.id);
2527
+ setTime(c.start);
2528
+ revealTime(c.start);
2529
+ }
2530
+
2531
+ /* Reveal in project: show a clip's media (or comp) in the Project bin. */
2532
+ function revealInProject(c) {
2533
+ const id = c.kind === "comp" ? null : c.mediaId;
2534
+ if (state.binTab !== "project") setBinTab("project");
2535
+ let sel;
2536
+ if (id) {
2537
+ const m = getMedia(id);
2538
+ if (!m) { toast("Its media is no longer in the project"); return; }
2539
+ if ((binView.q || binView.filter) && !MediaLib.query([m], binView, binCtx()).length) { binView.q = ""; binView.filter = ""; }
2540
+ for (let f = getFolder(m.folderId); f; f = getFolder(f.parentId)) f.open = true;
2541
+ renderBin();
2542
+ sel = `.bin-item[data-media-id="${CSS.escape(id)}"]`;
2543
+ } else if (c.compId) {
2544
+ binView.q = ""; binView.filter = "";
2545
+ renderBin();
2546
+ sel = `.comp-item[data-comp-id="${CSS.escape(c.compId)}"]`;
2547
+ }
2548
+ const el = sel && els.binList.querySelector(sel);
2549
+ if (!el) return;
2550
+ el.scrollIntoView({ block: "nearest" });
2551
+ el.classList.remove("bin-flash");
2552
+ void el.offsetWidth;
2553
+ el.classList.add("bin-flash");
2554
+ }
2555
+
2556
+ /* ── Offline media, relink and replace ──
2557
+ A file is offline when its src names nothing under media/ or library/ (it was
2558
+ moved, renamed or deleted). Its clips stay on the timeline and draw a "Media
2559
+ offline" slate until it is relinked: pointed at the same footage somewhere
2560
+ else (clips keep their In points). Replace puts a different file behind the
2561
+ media entry (a new render): clips keep their timing, trimmed to fit. */
2562
+ const mediaOffline = new Set(); // media ids whose file is missing (runtime only, re-checked on every change)
2563
+ function isMediaOffline(id) { return !!id && mediaOffline.has(id); }
2564
+ const isLocalSrc = (src) => /^\/(media|library)\//.test(String(src || ""));
2565
+ let offlineCheckTimer = null, offlineChecking = false;
2566
+ function scheduleOfflineCheck(delay = 300) {
2567
+ clearTimeout(offlineCheckTimer);
2568
+ offlineCheckTimer = setTimeout(() => { checkOffline(); refreshProxyStatus(); }, delay);
2569
+ }
2570
+ async function checkOffline() {
2571
+ if (!state.connected || offlineChecking) return;
2572
+ const list = project.media.filter((m) => !m.transient && (isLocalSrc(m.src) || /^blob:/.test(m.src || "")));
2573
+ offlineChecking = true;
2574
+ try {
2575
+ const res = await fetch("/api/media-info", {
2576
+ method: "POST", headers: { "Content-Type": "application/json" },
2577
+ body: JSON.stringify({ srcs: [...new Set(list.map((m) => m.src))], stat: true }),
2578
+ });
2579
+ if (!res.ok) return;
2580
+ const { info } = await res.json();
2581
+ const was = new Set(mediaOffline);
2582
+ mediaOffline.clear();
2583
+ for (const m of list) if (info && info[m.src] === null) mediaOffline.add(m.id);
2584
+ // a file that came back (put back, or relinked by an agent): reload what was cached from it
2585
+ for (const id of was) if (!mediaOffline.has(id)) { const m = getMedia(id); if (m) reloadMediaSource(m); }
2586
+ const changed = was.size !== mediaOffline.size || [...was].some((id) => !mediaOffline.has(id));
2587
+ if (changed) {
2588
+ renderBin(); state.dirtyTimeline = true;
2589
+ const fresh = [...mediaOffline].filter((id) => !was.has(id));
2590
+ if (fresh.length) toast(`${fresh.length === 1 ? `“${getMedia(fresh[0])?.name}” is` : fresh.length + " files are"} offline — right-click it in the bin → Relink…`);
2591
+ }
2592
+ } catch { } finally { offlineChecking = false; }
2593
+ }
2594
+ /* Everything cached from a media file (players, picture, waveform, thumbnail) is dropped and read again. */
2595
+ function reloadMediaSource(m, oldSrc) {
2596
+ // a proxy belongs to a file, not to the entry: the old file's must not stand in for the new one
2597
+ const proxySrc = proxyStatus.get(m.id)?.src;
2598
+ proxyStatus.delete(m.id);
2599
+ for (const [id, el] of runtime.clipEls)
2600
+ if (el._fcSrc === m.src || (oldSrc && el._fcSrc === oldSrc) || (proxySrc && el._fcSrc === proxySrc)) releaseClipEl(id);
2601
+ runtime.mediaAux.delete(m.id);
2602
+ runtime.wavePeaks.delete(m.id);
2603
+ if (state.source.mediaId === m.id) { const sm = m; clearSource(); loadSourceFromMedia(sm); }
2604
+ seekMediaWhilePaused(); // fresh players start at 0 — park them on the playhead
2605
+ loadMediaMetadata(m).then(() => {
2606
+ if (m.kind === "video") grabThumb(m).catch(() => { });
2607
+ ensureWave(m);
2608
+ seekMediaWhilePaused();
2609
+ renderBin(); state.dirtyTimeline = true;
2610
+ }).catch(() => { });
2611
+ if (m.kind === "video") refreshProxyStatus(); // the new file may have a proxy of its own
2612
+ }
2613
+
2614
+ /** Point a media entry at another file. mode "relink" (same footage) or "replace" (a new file). */
2615
+ async function setMediaSource(m, src, mode, name) {
2616
+ const oldSrc = m.src, oldDur = m.duration;
2617
+ pushUndo(JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media }));
2618
+ m.src = src;
2619
+ for (const k of MediaLib.INFO_KEYS) delete m[k];
2620
+ if (mode === "replace") {
2621
+ m.name = name || MediaLib.srcName(src);
2622
+ delete m.duration; delete m.width; delete m.height;
2623
+ }
2624
+ mediaOffline.delete(m.id);
2625
+ reloadMediaSource(m, oldSrc);
2626
+ try { await loadMediaMetadata(m); } catch { }
2627
+ let msg = mode === "replace" ? `Replaced ${m.name}` : `Relinked ${m.name}`;
2628
+ if (mode === "relink" && oldDur > 0 && m.duration > 0 && Math.abs(oldDur - m.duration) > 0.1)
2629
+ msg += ` — but it is ${fmt(m.duration)} long, the original was ${fmt(oldDur)}: is it the same footage?`;
2630
+ if (mode === "replace" && m.duration > 0) {
2631
+ let trimmed = 0;
2632
+ const fit = (clips) => {
2633
+ for (const c of clips || []) {
2634
+ if (c.mediaId !== m.id || c.kind === "image" || c.kind === "svg") continue;
2635
+ const speed = c.props?.speed || 1;
2636
+ const maxIn = Math.max(0, m.duration - 0.001);
2637
+ if (c.in > maxIn) { c.in = maxIn; trimmed++; }
2638
+ const maxDur = Math.max(0.05, (m.duration - c.in) / speed);
2639
+ if (c.duration > maxDur + 1e-6) { c.duration = maxDur; trimmed++; }
2640
+ }
2641
+ };
2642
+ fit(project.clips);
2643
+ for (const comp of project.comps || []) fit(comp.clips);
2644
+ if (state.compCtx) fit(state.compCtx.root.clips);
2645
+ runtime.compGen = (runtime.compGen || 0) + 1;
2646
+ if (trimmed) msg += ` — ${trimmed} clip edge${trimmed === 1 ? "" : "s"} trimmed to fit`;
2647
+ if (m.kind === "video") for (const c of project.clips) if (c.mediaId === m.id && c.kind === "video") reconcileAudioChannels(c);
2648
+ }
2649
+ state.dirtyTimeline = true;
2650
+ renderBin(); scheduleSave(); renderInspector();
2651
+ refreshMediaInfo([m]);
2652
+ toast(msg);
2653
+ }
2654
+
2655
+ /* Upload local files into media/ (like import, without adding bin entries) → [{file, src}]. */
2656
+ async function uploadForRelink(files, onProgress) {
2657
+ const out = [];
2658
+ for (const file of files) {
2659
+ onProgress?.(file);
2660
+ try {
2661
+ const res = await fetch("/api/upload?name=" + encodeURIComponent(file.name), { method: "POST", body: file });
2662
+ if (!res.ok) throw new Error("upload " + res.status);
2663
+ out.push({ file, src: (await res.json()).src });
2664
+ } catch { out.push({ file, src: null }); }
2665
+ }
2666
+ return out;
2667
+ }
2668
+ let relinkPicker = null;
2669
+ /** A file input of our own for relink / replace (never the shared #fileInput). */
2670
+ function pickFilesForRelink({ folder = false, accept = els.fileInput.accept } = {}) {
2671
+ return new Promise((resolve) => {
2672
+ relinkPicker?.remove();
2673
+ const input = relinkPicker = document.createElement("input");
2674
+ input.type = "file"; input.className = "sr-only";
2675
+ if (folder) { input.webkitdirectory = true; input.multiple = true; } else input.accept = accept;
2676
+ document.body.appendChild(input);
2677
+ input.addEventListener("change", () => { resolve([...input.files]); input.remove(); }, { once: true });
2678
+ input.addEventListener("cancel", () => { resolve([]); input.remove(); }, { once: true });
2679
+ input.click();
2680
+ });
2681
+ }
2682
+
2683
+ /* Replace file…: a different file behind this media entry, for every clip that uses it. */
2684
+ async function replaceMediaFile(m) {
2685
+ const files = await pickFilesForRelink({ accept: m.kind === "audio" ? "audio/*,.mp3,.wav,.m4a,.flac,.ogg" : els.fileInput.accept });
2686
+ const file = files[0];
2687
+ if (!file) return;
2688
+ const kind = mediaKindFromFile(file);
2689
+ if (kind !== m.kind && !(m.kind === "audio" && kind === "video")) { toast(`That is ${kind ? (kind === "audio" || kind === "image" ? "an " : "a ") + kind : "an unsupported"} file — ${m.name} is ${m.kind}`); return; }
2690
+ toast(`Copying ${file.name} into the project…`);
2691
+ const [up] = await uploadForRelink([file]);
2692
+ if (!up || !up.src) { toast("Couldn't copy the file into media/"); return; }
2693
+ await setMediaSource(m, up.src, "replace", file.name);
2694
+ }
2695
+
2696
+ /* The Relink dialog: one row per file, each with candidates found on disk
2697
+ (same name, same name with another extension, similar name, same size),
2698
+ Browse… for one file and Choose folder… to match a whole folder by name. */
2699
+ async function openRelinkDialog(list) {
2700
+ list = (list || project.media.filter((m) => isMediaOffline(m.id))).filter(Boolean);
2701
+ if (!list.length) { toast("Nothing is offline"); return; }
2702
+ document.getElementById("relinkOverlay")?.remove();
2703
+ const overlay = document.createElement("div");
2704
+ overlay.className = "overlay";
2705
+ overlay.id = "relinkOverlay";
2706
+ overlay.innerHTML = `<div class="dialog relink" role="dialog" aria-modal="true" aria-labelledby="relinkTitle" tabindex="-1">
2707
+ <h2 id="relinkTitle">Relink media</h2>
2708
+ <p class="dim">Point ${list.length === 1 ? "this file" : `these ${list.length} files`} at the footage where it is now. Clips keep their In points and edits.</p>
2709
+ <div class="relink-tools">
2710
+ <button type="button" class="btn tiny" data-folder title="Pick the folder the files are in now — they are matched by name and copied into the project">Choose folder…</button>
2711
+ <span class="relink-status dim"></span>
2712
+ </div>
2713
+ <div class="relink-rows"></div>
2714
+ <div class="dialog-actions"><button type="button" class="btn" data-cancel>Cancel</button><button type="button" class="btn primary" data-apply>Relink</button></div>
2715
+ </div>`;
2716
+ document.body.appendChild(overlay);
2717
+ const dialog = overlay.querySelector(".dialog");
2718
+ const rowsEl = overlay.querySelector(".relink-rows"), status = overlay.querySelector(".relink-status");
2719
+ const pick = new Map(); // media id → {src, label} | null
2720
+ const cands = new Map(); // media id → [{src, name, size, why}]
2721
+ const close = () => { overlay.remove(); };
2722
+ const render = () => {
2723
+ rowsEl.innerHTML = "";
2724
+ for (const m of list) {
2725
+ const row = document.createElement("div");
2726
+ row.className = "relink-row";
2727
+ const cur = pick.get(m.id);
2728
+ const options = (cands.get(m.id) || []);
2729
+ row.innerHTML = `<div class="relink-name"><b></b><span class="dim relink-was"></span></div>
2730
+ <div class="relink-pick">
2731
+ <select aria-label="New location"></select>
2732
+ <button type="button" class="btn tiny" data-browse title="Pick the file (it is copied into the project)">Browse…</button>
2733
+ </div>`;
2734
+ row.querySelector("b").textContent = (isMediaOffline(m.id) ? "⚠ " : "") + m.name;
2735
+ row.querySelector(".relink-was").textContent = "was " + decodeURIComponent(String(m.src).split("?")[0]) + (m.duration ? " · " + fmt(m.duration) : "") + (m.size ? " · " + MediaLib.fmtSize(m.size) : "");
2736
+ const sel = row.querySelector("select");
2737
+ const opt = (v, label) => { const o = document.createElement("option"); o.value = v; o.textContent = label; sel.appendChild(o); };
2738
+ opt("", options.length || cur ? "— leave it —" : "— not found: Browse… or Choose folder… —");
2739
+ if (cur && !options.some((o) => o.src === cur.src)) opt(cur.src, cur.label);
2740
+ for (const o of options) opt(o.src, `${decodeURIComponent(o.path || o.src)} · ${MediaLib.fmtSize(o.size)} (${o.why})`);
2741
+ sel.value = cur ? cur.src : "";
2742
+ sel.addEventListener("change", () => {
2743
+ const o = options.find((x) => x.src === sel.value);
2744
+ pick.set(m.id, sel.value ? { src: sel.value, label: o ? o.name : sel.value } : null);
2745
+ syncApply();
2746
+ });
2747
+ row.querySelector("[data-browse]").addEventListener("click", async () => {
2748
+ const [file] = await pickFilesForRelink({});
2749
+ if (!file) return;
2750
+ status.textContent = `Copying ${file.name}…`;
2751
+ const [up] = await uploadForRelink([file]);
2752
+ status.textContent = up?.src ? "" : "Couldn't copy " + file.name;
2753
+ if (up?.src) { pick.set(m.id, { src: up.src, label: `${file.name} (chosen)` }); render(); }
2754
+ });
2755
+ rowsEl.appendChild(row);
2756
+ }
2757
+ syncApply();
2758
+ };
2759
+ const syncApply = () => {
2760
+ const n = list.filter((m) => pick.get(m.id)).length;
2761
+ const b = overlay.querySelector("[data-apply]");
2762
+ b.disabled = !n;
2763
+ b.textContent = n ? `Relink ${n}` : "Relink";
2764
+ };
2765
+ overlay.querySelector("[data-cancel]").addEventListener("click", close);
2766
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
2767
+ overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); } });
2768
+ overlay.querySelector("[data-folder]").addEventListener("click", async () => {
2769
+ const files = await pickFilesForRelink({ folder: true });
2770
+ if (!files.length) return;
2771
+ // match each row by file name (then by size), copy only the matches
2772
+ const want = [];
2773
+ for (const m of list) {
2774
+ let best = null, bestScore = 0;
2775
+ for (const f of files) {
2776
+ const { score } = MediaLib.relinkScore(m, { name: f.name, size: f.size });
2777
+ if (score > bestScore && score >= 60) { best = f; bestScore = score; }
2778
+ }
2779
+ if (best) want.push([m, best]);
2780
+ }
2781
+ if (!want.length) { status.textContent = `None of the ${files.length} files in that folder match by name.`; return; }
2782
+ let i = 0;
2783
+ const ups = await uploadForRelink(want.map(([, f]) => f), (f) => { status.textContent = `Copying ${++i}/${want.length}: ${f.name}…`; });
2784
+ want.forEach(([m, f], k) => { if (ups[k].src) pick.set(m.id, { src: ups[k].src, label: `${f.name} (from the folder)` }); });
2785
+ status.textContent = `Matched ${ups.filter((u) => u.src).length} of ${list.length} by name.`;
2786
+ render();
2787
+ });
2788
+ overlay.querySelector("[data-apply]").addEventListener("click", async () => {
2789
+ const todo = list.filter((m) => pick.get(m.id));
2790
+ close();
2791
+ for (const m of todo) await setMediaSource(m, pick.get(m.id).src, "relink");
2792
+ scheduleOfflineCheck(0);
2793
+ });
2794
+ render();
2795
+ dialog.focus();
2796
+ // candidates found on disk (same name, other extension, similar name, same size)
2797
+ if (state.connected) {
2798
+ status.textContent = "Looking for the files…";
2799
+ try {
2800
+ const res = await fetch("/api/relink/candidates", {
2801
+ method: "POST", headers: { "Content-Type": "application/json" },
2802
+ body: JSON.stringify({ media: list.map((m) => ({ id: m.id, name: m.name, src: m.src, size: m.size, kind: m.kind })) }),
2803
+ });
2804
+ const { candidates } = await res.json();
2805
+ const used = new Set(project.media.map((x) => x.src));
2806
+ let found = 0;
2807
+ for (const m of list) {
2808
+ const opts = (candidates[m.id] || []).filter((o) => o.src !== m.src && (!used.has(o.src) || !isMediaOffline(m.id)));
2809
+ cands.set(m.id, opts);
2810
+ // a confident match is picked for you (same name, or same name with a matching size)
2811
+ if (opts[0] && opts[0].score >= 100 && !pick.get(m.id)) { pick.set(m.id, { src: opts[0].src, label: opts[0].name }); found++; }
2812
+ }
2813
+ status.textContent = found ? `Found ${found} of ${list.length} in the project folders.` : "";
2814
+ if (overlay.isConnected) render();
2815
+ } catch { status.textContent = ""; }
2816
+ }
2817
+ }
2818
+
2819
+ /* ── Proxies ──
2820
+ The server makes light H.264 copies of heavy footage (proxy.js). With the
2821
+ monitor's Proxy button on, video clips and the Source monitor play the
2822
+ proxy; export, still renders for agents and scopes always switch back to
2823
+ the original — so a proxy changes how smoothly you edit, never the result. */
2824
+ const proxyStatus = new Map(); // media id → {state, src?, height, width?, size?, progress?, error?}
2825
+ function proxyOf(m) { const s = m && proxyStatus.get(m.id); return s && s.state === "ready" ? s : null; }
2826
+ function proxiesOn() { return getSetting("useProxies") !== false; }
2827
+ /** The file a player should load for this media now: the proxy while editing, the original for output. */
2828
+ function mediaPlaySrc(m) {
2829
+ if (!m || m.kind !== "video" || state.exporting || runtime.forceOriginals || !proxiesOn()) return m && m.src;
2830
+ const p = proxyOf(m);
2831
+ return p ? p.src : m.src;
2832
+ }
2833
+ /** Switch every player to the original files and wait until they can draw (export, still renders). */
2834
+ async function useOriginalPlayers() {
2835
+ runtime.forceOriginals = true;
2836
+ const waits = [];
2837
+ for (const c of mediaClips()) {
2838
+ if (c.kind !== "video" || !clipRenders(c)) continue;
2839
+ const el = getClipEl(c);
2840
+ if (el) waits.push(waitMediaEl(el));
2841
+ }
2842
+ await Promise.all(waits);
2843
+ }
2844
+ function releaseOriginalPlayers() {
2845
+ if (!runtime.forceOriginals) return;
2846
+ runtime.forceOriginals = false;
2847
+ state.dirtyTimeline = true;
2848
+ seekMediaWhilePaused();
2849
+ }
2850
+ async function refreshProxyStatus() {
2851
+ if (!state.connected) return;
2852
+ const vids = project.media.filter((m) => m.kind === "video" && !m.transient && isLocalSrc(m.src));
2853
+ if (!vids.length) { if (proxyStatus.size) { proxyStatus.clear(); renderBin(); } return; }
2854
+ try {
2855
+ const res = await fetch("/api/proxy/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ srcs: [...new Set(vids.map((m) => m.src))] }) });
2856
+ if (!res.ok) return;
2857
+ const { status } = await res.json();
2858
+ const before = JSON.stringify([...proxyStatus]);
2859
+ proxyStatus.clear();
2860
+ for (const m of vids) { const s = status[m.src]; if (s && s.state !== "none") proxyStatus.set(m.id, s); }
2861
+ if (JSON.stringify([...proxyStatus]) !== before) onProxiesChanged();
2862
+ } catch { }
2863
+ }
2864
+ /** SSE "proxy": one file's proxy moved on (queued, progress, ready, failed, deleted). */
2865
+ function onProxyEvent(data) {
2866
+ let ev; try { ev = JSON.parse(data); } catch { return; }
2867
+ let hit = false;
2868
+ for (const m of project.media) {
2869
+ if (m.src !== ev.src) continue;
2870
+ hit = true;
2871
+ const s = ev.status || { state: "none" };
2872
+ if (s.state === "none") proxyStatus.delete(m.id); else proxyStatus.set(m.id, s);
2873
+ if (s.state === "ready" && runtime.proxyAsked?.has(m.id)) { runtime.proxyAsked.delete(m.id); toast(`Proxy ready: ${m.name}`); }
2874
+ if (s.state === "failed" && runtime.proxyAsked?.has(m.id)) { runtime.proxyAsked.delete(m.id); toast(`Couldn't make a proxy of ${m.name}: ${s.error}`); }
2875
+ }
2876
+ if (!hit) return;
2877
+ if (ev.status && ev.status.state === "running") { renderBinProxyBadges(); return; } // progress: just the badges
2878
+ onProxiesChanged();
2879
+ }
2880
+ function onProxiesChanged() {
2881
+ renderBin();
2882
+ syncProxyButton();
2883
+ state.dirtyTimeline = true;
2884
+ seekMediaWhilePaused(); // players switch file on the next frame; park the new ones on the playhead
2885
+ }
2886
+ /* Progress ticks repaint only the badges, not the whole bin. */
2887
+ function renderBinProxyBadges() {
2888
+ for (const el of els.binList.querySelectorAll(".bin-item[data-media-id] .bin-proxy")) {
2889
+ const s = proxyStatus.get(el.closest(".bin-item").dataset.mediaId);
2890
+ if (s && s.state === "running") el.textContent = `P ${Math.round((s.progress || 0) * 100)}%`;
2891
+ }
2892
+ }
2893
+ function proxyBadgeHtml(m) {
2894
+ const s = proxyStatus.get(m.id);
2895
+ if (!s || m.kind !== "video") return "";
2896
+ if (s.state === "ready") return `<span class="bin-proxy ready${proxiesOn() ? " on" : ""}" title="Proxy ready (${s.width ? s.width + "×" : ""}${s.height}p, ${MediaLib.fmtSize(s.size)})${proxiesOn() ? " — playing it while you edit" : " — the Proxy button is off"}">P</span>`;
2897
+ if (s.state === "running" || s.state === "queued") return `<span class="bin-proxy busy" title="Making a ${s.height}p proxy">P ${s.state === "running" ? Math.round((s.progress || 0) * 100) + "%" : "…"}</span>`;
2898
+ if (s.state === "failed") return `<span class="bin-proxy failed" title="Proxy failed: ${escapeHtml(s.error || "")}">P ✕</span>`;
2899
+ return "";
2900
+ }
2901
+ function proxyLabel(m) {
2902
+ const s = proxyStatus.get(m.id);
2903
+ if (m.kind !== "video") return "";
2904
+ if (!s) return "none";
2905
+ if (s.state === "ready") return `${s.height}p`;
2906
+ if (s.state === "running") return Math.round((s.progress || 0) * 100) + "%";
2907
+ return s.state;
2908
+ }
2909
+ async function createProxies(list, height) {
2910
+ list = list.filter((m) => m.kind === "video" && !m.transient && isLocalSrc(m.src) && !isMediaOffline(m.id));
2911
+ if (!list.length) { toast("No video to make proxies of"); return; }
2912
+ if (!state.ffmpeg) { toast("Proxies need ffmpeg on the server's PATH"); return; }
2913
+ (runtime.proxyAsked ||= new Set());
2914
+ for (const m of list) runtime.proxyAsked.add(m.id);
2915
+ try {
2916
+ const res = await fetch("/api/proxy", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ srcs: [...new Set(list.map((m) => m.src))], height }) });
2917
+ const body = await res.json();
2918
+ if (!res.ok) throw new Error(body.error || res.status);
2919
+ for (const m of list) { const s = body.status[m.src]; if (s) proxyStatus.set(m.id, s); if (s && s.state === "ready") runtime.proxyAsked.delete(m.id); }
2920
+ onProxiesChanged();
2921
+ const queued = list.filter((m) => proxyStatus.get(m.id)?.state !== "ready").length;
2922
+ toast(queued ? `Making ${queued} prox${queued === 1 ? "y" : "ies"} in the background — the bin shows the progress` : "Already has a proxy");
2923
+ } catch (e) { toast("Couldn't start the proxies: " + (e.message || e)); }
2924
+ }
2925
+ async function deleteProxies(list) {
2926
+ try {
2927
+ await fetch("/api/proxy/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ srcs: list.map((m) => m.src) }) });
2928
+ } catch { }
2929
+ for (const m of list) proxyStatus.delete(m.id);
2930
+ onProxiesChanged();
2931
+ }
2932
+ function syncProxyButton() {
2933
+ const b = els.btnProxy;
2934
+ if (!b) return;
2935
+ const n = project.media.filter((m) => proxyOf(m)).length;
2936
+ b.classList.toggle("on", proxiesOn());
2937
+ b.classList.toggle("has", n > 0);
2938
+ b.title = (proxiesOn() ? "Proxies on: " : "Proxies off: ") +
2939
+ (n ? `${n} file${n === 1 ? " has a proxy" : "s have proxies"}` : "no proxies yet (right-click a video in the bin → Create proxy)") +
2940
+ ". Export always uses the originals.";
2941
+ }
2942
+
2091
2943
  /* Open the native file dialog. Windows anchors it to the <input>'s screen
2092
2944
  position — park the input under the cursor and open after the mouse
2093
2945
  gesture finishes so the dialog doesn't appear then jump. */
@@ -2101,6 +2953,23 @@ function openFileImport(clientX, clientY) {
2101
2953
  setTimeout(() => input.click(), 0);
2102
2954
  }
2103
2955
 
2956
+ /* Bin search / Show / Sort / view controls. */
2957
+ els.binSearch.addEventListener("input", () => { binView.q = els.binSearch.value; renderBin(); });
2958
+ els.binSearch.addEventListener("keydown", (e) => {
2959
+ if (e.key === "Escape") { e.preventDefault(); binView.q = ""; els.binSearch.value = ""; renderBin(); els.binSearch.blur(); }
2960
+ e.stopPropagation();
2961
+ });
2962
+ els.binFilter.addEventListener("change", () => setBinFilter(els.binFilter.value));
2963
+ els.binSort.addEventListener("change", () => { binView.sort = els.binSort.value; saveBinPrefs(); renderBin(); });
2964
+ els.binSortDir.addEventListener("click", () => { binView.dir = binView.dir === "desc" ? "asc" : "desc"; saveBinPrefs(); renderBin(); });
2965
+ els.binViewBtn.addEventListener("click", () => { binView.list = !binView.list; saveBinPrefs(); renderBin(); });
2966
+
2967
+ els.btnProxy?.addEventListener("click", () => {
2968
+ setSetting("useProxies", !proxiesOn());
2969
+ onProxiesChanged();
2970
+ toast(proxiesOn() ? "Proxies on — files with a proxy play it while you edit" : "Proxies off — every clip plays its original");
2971
+ });
2972
+
2104
2973
  /* Ctrl/Cmd+click in the Project bin opens the file importer. */
2105
2974
  els.binList.addEventListener("click", (e) => {
2106
2975
  if (!(e.ctrlKey || e.metaKey) || e.button !== 0) return;
@@ -2228,6 +3097,7 @@ function setBinTab(tab) {
2228
3097
  const isProj = tab === "project";
2229
3098
  els.binList.classList.toggle("hidden", !isProj);
2230
3099
  els.libList.classList.toggle("hidden", isProj);
3100
+ if (els.binTools) els.binTools.classList.toggle("hidden", !isProj);
2231
3101
  if (tab === "shapes") renderShapesTab();
2232
3102
  else if (tab === "templates") { renderTemplatesTab(); fetchSaved("templates").then(() => { if (state.binTab === "templates") renderTemplatesTab(); }); }
2233
3103
  else if (!isProj) fetchLibrary(tab).then(renderLibrary);
@@ -2249,20 +3119,37 @@ function pushUndo(snap = undoSnapshot()) {
2249
3119
  function restoreSnapshot(json) {
2250
3120
  const snap = JSON.parse(json);
2251
3121
  if (Array.isArray(snap)) project.clips = snap;
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(); } }
3122
+ else {
3123
+ project.clips = snap.clips; project.markers = snap.markers; if (snap.comps) project.comps = snap.comps;
3124
+ if (snap.media) {
3125
+ const before = new Map(project.media.map((m) => [m.id, m.src]));
3126
+ project.media = snap.media;
3127
+ // a relink / replace undone or redone: the players and pictures follow the src
3128
+ for (const m of project.media) if (before.has(m.id) && before.get(m.id) !== m.src) reloadMediaSource(m, before.get(m.id));
3129
+ renderBin(); scheduleOfflineCheck(0);
3130
+ }
3131
+ }
2253
3132
  runtime.compGen = (runtime.compGen || 0) + 1;
2254
3133
  }
3134
+ /** The current state to step back to, carrying the media list when `snap` does
3135
+ * (removing, relinking or replacing media), so redo puts it back too. */
3136
+ function counterSnapshot(snap) {
3137
+ if (!snap.includes('"media":')) return undoSnapshot();
3138
+ return JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media });
3139
+ }
2255
3140
  function undo() {
2256
3141
  if (!runtime.undo.length) return;
2257
- runtime.redo.push(undoSnapshot());
2258
- restoreSnapshot(runtime.undo.pop());
3142
+ const snap = runtime.undo.pop();
3143
+ runtime.redo.push(counterSnapshot(snap));
3144
+ restoreSnapshot(snap);
2259
3145
  pruneSelection();
2260
3146
  scheduleSave(); renderInspector();
2261
3147
  }
2262
3148
  function redo() {
2263
3149
  if (!runtime.redo.length) return;
2264
- runtime.undo.push(undoSnapshot());
2265
- restoreSnapshot(runtime.redo.pop());
3150
+ const snap = runtime.redo.pop();
3151
+ runtime.undo.push(counterSnapshot(snap));
3152
+ restoreSnapshot(snap);
2266
3153
  pruneSelection();
2267
3154
  scheduleSave(); renderInspector();
2268
3155
  }
@@ -3077,8 +3964,9 @@ function exportRangeMode() {
3077
3964
  if (v === "in-out" && !hasWorkArea()) return "entire";
3078
3965
  return v;
3079
3966
  }
3080
- function exportRange() {
3081
- const fps = projectFps();
3967
+ /** fps: the output's frame rate when it differs from the project's (delivery). */
3968
+ function exportRange(fpsOverride) {
3969
+ const fps = fpsOverride || projectFps();
3082
3970
  const span = Math.max(projDur(), 0);
3083
3971
  let start = 0, end = span;
3084
3972
  if (exportRangeMode() === "in-out") {
@@ -3352,7 +4240,8 @@ function rebuildClips() {
3352
4240
  div.className = `clip c-${c.kind}` +
3353
4241
  (state.selIds.has(c.id) ? " selected" : "") + (c.id === state.selId ? " primary" : "") +
3354
4242
  (isClipLocked(c) ? " locked" : "") + (c.disabled === true ? " disabled" : "") +
3355
- (c.unlinked === true ? " unlinked" : "") + (c.kind !== "audio" && isMatte(c) ? " matte-src" : "");
4243
+ (c.unlinked === true ? " unlinked" : "") + (c.kind !== "audio" && isMatte(c) ? " matte-src" : "") +
4244
+ (isMediaOffline(c.mediaId) ? " offline" : "");
3356
4245
  div.dataset.id = c.id;
3357
4246
  div.style.left = c.start * state.pps + "px";
3358
4247
  div.style.width = Math.max(8, c.duration * state.pps) + "px";
@@ -3363,7 +4252,7 @@ function rebuildClips() {
3363
4252
  }
3364
4253
  const hasWave = c.kind === "audio" && !!wavePeaksFor(c);
3365
4254
  if (hasWave) body += `<canvas class="wave"></canvas>`;
3366
- const badge = (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") +
4255
+ const badge = (isMediaOffline(c.mediaId) ? "⚠ Offline · " : "") + (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") +
3367
4256
  (c.transitionIn || c.transitionOut ? "⇄ " : "") +
3368
4257
  (clipMatte(c) ? "◐ " : "");
3369
4258
  const chTag = (() => {
@@ -5746,13 +6635,18 @@ function ensureSourceEl(m) {
5746
6635
  });
5747
6636
  runtime.sourceEl = el;
5748
6637
  }
5749
- if (el.dataset.src !== m.src) {
5750
- el.src = m.src;
5751
- el.dataset.src = m.src;
6638
+ const want = mediaPlaySrc(m);
6639
+ if (el.dataset.src !== want) {
6640
+ el.src = want;
6641
+ el.dataset.src = want;
5752
6642
  runtime.sourceHold = null;
5753
6643
  runtime.sourceHoldOk = false;
5754
6644
  runtime.sourceSeekPending = null;
5755
6645
  runtime.sourceSeekBusy = false;
6646
+ // a new file (proxy ↔ original) loads at 0 — put it back on the Source playhead
6647
+ el.addEventListener("loadedmetadata", () => {
6648
+ if (runtime.sourceEl === el && el.dataset.src === want && !state.source.playing) seekSourceEl(state.source.time);
6649
+ }, { once: true });
5756
6650
  }
5757
6651
  return el;
5758
6652
  }
@@ -5945,6 +6839,7 @@ async function playSource() {
5945
6839
  audio.ctx.resume();
5946
6840
  state.source.playing = true;
5947
6841
  syncPlayButton();
6842
+ noteActivity();
5948
6843
  }
5949
6844
  function toggleTransportPlay() {
5950
6845
  // Space always resumes forward — drop a reverse / crawl rate left by J or K+L
@@ -6316,13 +7211,17 @@ function drawSourceFrame() {
6316
7211
  function getClipEl(c) {
6317
7212
  let el = runtime.clipEls.get(c.id);
6318
7213
  const m = getMedia(c.mediaId);
6319
- // The clip now plays another file (noise reduction, or undoing it): rebuild.
6320
- if (el && m && el._fcSrc !== m.src) { releaseClipEl(c.id); el = null; }
7214
+ // video plays its proxy while editing (never during export); audio always the original
7215
+ const src = c.kind === "video" ? mediaPlaySrc(m) : m && m.src;
7216
+ // The clip now plays another file (noise reduction or undoing it, a relink, proxies on / off): rebuild.
7217
+ if (el && m && el._fcSrc !== src) { releaseClipEl(c.id); el = null; }
6321
7218
  if (el) return el;
6322
7219
  if (!m) return null;
6323
7220
  el = document.createElement(c.kind === "audio" ? "audio" : "video");
6324
- el.preload = "auto"; el.src = m.src; el.playsInline = true;
6325
- el._fcSrc = m.src;
7221
+ el.preload = "auto"; el.src = src; el.playsInline = true;
7222
+ el._fcSrc = src;
7223
+ // a proxy is smaller than the original: fit "none" (native pixels) scales it back up
7224
+ el._fcScale = src !== m.src && m.height > 0 ? (proxyOf(m)?.height || m.height) / m.height : 1;
6326
7225
  runtime.clipEls.set(c.id, el);
6327
7226
  hookAudio(c, el);
6328
7227
  return el;
@@ -8058,6 +8957,7 @@ function play() {
8058
8957
  }
8059
8958
  state.playing = true;
8060
8959
  syncPlayButton();
8960
+ noteActivity();
8061
8961
  syncMedia(); // start active seeks + cut lookahead without waiting a RAF
8062
8962
  }
8063
8963
  function pause() {
@@ -8676,9 +9576,161 @@ function hexToRgb(hex) {
8676
9576
  const n = parseInt(String(hex).replace("#", ""), 16) || 0;
8677
9577
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
8678
9578
  }
9579
+ /* The same pixel loop as a WebGL2 shader: no getImageData read-back and no
9580
+ per-pixel JS, so a keyed or tinted 1080p layer costs a draw call, not ~20 ms
9581
+ of main thread. The input is staged through a 2D canvas exactly as the CPU
9582
+ path draws it (same resize, same cut-out mask, unpremultiplied on upload
9583
+ like getImageData), so both paths see the same pixels; the CPU loop below
9584
+ stays as the fallback when WebGL2 is unavailable. */
9585
+ const pixGL = { tried: false, gl: null, canvas: null, loc: null, stage: null, stageCtx: null, outs: new Map() };
9586
+ const PIX_VERT = `#version 300 es
9587
+ in vec2 aPos;
9588
+ void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
9589
+ const PIX_FRAG = `#version 300 es
9590
+ precision highp float;
9591
+ uniform sampler2D uTex;
9592
+ uniform ivec2 uSize; // picture size: the buffers only grow, so it sits in their top-left corner
9593
+ uniform int uSplit; // RGB split in pixels (R from x+split, B from x−split)
9594
+ uniform int uKey;
9595
+ uniform vec2 uKeyC; // key colour Cb, Cr (0…255)
9596
+ uniform vec2 uKeyT; // tolerance t0, t1 (CbCr distance)
9597
+ uniform vec2 uShift; // temperature (R+, B−), tint (G+), in 0…255 units
9598
+ out vec4 o;
9599
+ void main() {
9600
+ ivec2 sz = uSize;
9601
+ ivec2 q = ivec2(int(gl_FragCoord.x), sz.y - 1 - int(gl_FragCoord.y)); // texture row 0 = top
9602
+ vec4 c = texelFetch(uTex, q, 0);
9603
+ vec3 rgb = c.rgb * 255.0;
9604
+ float a = c.a;
9605
+ if (uSplit > 0) {
9606
+ rgb.r = texelFetch(uTex, ivec2(min(sz.x - 1, q.x + uSplit), q.y), 0).r * 255.0;
9607
+ rgb.b = texelFetch(uTex, ivec2(max(0, q.x - uSplit), q.y), 0).b * 255.0;
9608
+ }
9609
+ if (uKey == 1) {
9610
+ float cb = 128.0 - 0.168736 * rgb.r - 0.331264 * rgb.g + 0.5 * rgb.b;
9611
+ float cr = 128.0 + 0.5 * rgb.r - 0.418688 * rgb.g - 0.081312 * rgb.b;
9612
+ float dist = distance(vec2(cb, cr), uKeyC);
9613
+ if (dist < uKeyT.x) { o = vec4(0.0); return; }
9614
+ if (dist < uKeyT.y) {
9615
+ float k = (dist - uKeyT.x) / (uKeyT.y - uKeyT.x);
9616
+ a *= k;
9617
+ float avg = (rgb.r + rgb.b) * 0.5; // spill suppression
9618
+ if (rgb.g > avg) rgb.g = rgb.g * k + avg * (1.0 - k);
9619
+ }
9620
+ }
9621
+ rgb += vec3(uShift.x, uShift.y, -uShift.x);
9622
+ rgb = clamp(rgb, 0.0, 255.0) / 255.0;
9623
+ o = vec4(rgb * a, a);
9624
+ }`;
9625
+ function initPixGL() {
9626
+ if (pixGL.tried) return pixGL.gl;
9627
+ pixGL.tried = true;
9628
+ try {
9629
+ const cv = document.createElement("canvas");
9630
+ const gl = cv.getContext("webgl2", { premultipliedAlpha: true, preserveDrawingBuffer: true, antialias: false, alpha: true });
9631
+ if (!gl) return null;
9632
+ // A software rasterizer (SwiftShader in headless Chrome, llvmpipe) runs the
9633
+ // shader on the CPU anyway, no faster than the loop: keep the loop there.
9634
+ const info = gl.getExtension("WEBGL_debug_renderer_info");
9635
+ if (info && /swiftshader|llvmpipe|softpipe|software/i.test(gl.getParameter(info.UNMASKED_RENDERER_WEBGL))) return null;
9636
+ const sh = (type, src) => {
9637
+ const s = gl.createShader(type);
9638
+ gl.shaderSource(s, src); gl.compileShader(s);
9639
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
9640
+ return s;
9641
+ };
9642
+ const prog = gl.createProgram();
9643
+ gl.attachShader(prog, sh(gl.VERTEX_SHADER, PIX_VERT));
9644
+ gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, PIX_FRAG));
9645
+ gl.linkProgram(prog);
9646
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
9647
+ gl.useProgram(prog);
9648
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
9649
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
9650
+ const aPos = gl.getAttribLocation(prog, "aPos");
9651
+ gl.enableVertexAttribArray(aPos);
9652
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
9653
+ gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
9654
+ for (const [k, v] of [[gl.TEXTURE_MIN_FILTER, gl.NEAREST], [gl.TEXTURE_MAG_FILTER, gl.NEAREST],
9655
+ [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE]]) gl.texParameteri(gl.TEXTURE_2D, k, v);
9656
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
9657
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); // straight alpha, as getImageData returns it
9658
+ const loc = {};
9659
+ for (const n of ["uTex", "uSize", "uSplit", "uKey", "uKeyC", "uKeyT", "uShift"]) loc[n] = gl.getUniformLocation(prog, n);
9660
+ gl.uniform1i(loc.uTex, 0);
9661
+ cv.addEventListener("webglcontextlost", (e) => { e.preventDefault(); pixGL.gl = null; });
9662
+ cv.addEventListener("webglcontextrestored", () => { pixGL.tried = false; });
9663
+ const stage = document.createElement("canvas"); // GPU-backed: no willReadFrequently
9664
+ Object.assign(pixGL, { gl, canvas: cv, loc, stage, stageCtx: stage.getContext("2d") });
9665
+ return gl;
9666
+ } catch (err) {
9667
+ console.warn("[FableCut] WebGL2 pixel pass unavailable, using the CPU path:", err && err.message);
9668
+ return null;
9669
+ }
9670
+ }
9671
+ /** pixelPass on the GPU; null when WebGL2 isn't usable (the caller falls back). */
9672
+ function pixelPassGL(c, p, src, sx, sy, sw, sh, w, h, dw) {
9673
+ const gl = initPixGL();
9674
+ if (!gl) return null;
9675
+ const { stage, stageCtx: g, canvas: cv, loc } = pixGL;
9676
+ // Resizing a canvas reallocates it, and the layers of one frame differ in
9677
+ // size, so the stage and the GL buffer only grow; each clip gets its own
9678
+ // output canvas at its own size, which keeps its size from frame to frame.
9679
+ if (stage.width < w) stage.width = w;
9680
+ if (stage.height < h) stage.height = h;
9681
+ g.clearRect(0, 0, w, h);
9682
+ g.drawImage(src, sx, sy, sw, sh, 0, 0, w, h);
9683
+ const mask = p.bgRemove ? bgSeg.masks.get(c.id) : null;
9684
+ if (mask && mask.width) {
9685
+ g.globalCompositeOperation = "destination-in";
9686
+ g.drawImage(mask, 0, 0, w, h);
9687
+ g.globalCompositeOperation = "source-over";
9688
+ }
9689
+ const doKey = !!p.chromaKey, temp = +p.temperature || 0, tint = +p.tint || 0;
9690
+ const split = Math.round(clamp(+p.rgbSplit || 0, 0, 60) * (w / Math.max(1, dw)));
9691
+ const run = doKey || temp || tint || split > 0;
9692
+ let out = pixGL.outs.get(c.id);
9693
+ if (!out) {
9694
+ if (pixGL.outs.size >= 32) pixGL.outs.delete(pixGL.outs.keys().next().value);
9695
+ out = document.createElement("canvas");
9696
+ pixGL.outs.set(c.id, out);
9697
+ }
9698
+ if (out.width !== w) out.width = w;
9699
+ if (out.height !== h) out.height = h;
9700
+ const o = out.getContext("2d");
9701
+ o.clearRect(0, 0, w, h);
9702
+ if (!run) { o.drawImage(stage, 0, 0, w, h, 0, 0, w, h); return out; }
9703
+ try {
9704
+ if (cv.width < w) cv.width = w;
9705
+ if (cv.height < h) cv.height = h;
9706
+ gl.viewport(0, 0, w, h);
9707
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, stage);
9708
+ gl.uniform2i(loc.uSize, w, h);
9709
+ gl.uniform1i(loc.uSplit, split);
9710
+ gl.uniform1i(loc.uKey, doKey ? 1 : 0);
9711
+ if (doKey) {
9712
+ const [kr, kg, kb] = hexToRgb(p.chromaKey);
9713
+ const t0 = clamp(+p.chromaTolerance || 0, 0, 100) * 1.2;
9714
+ gl.uniform2f(loc.uKeyC, 128 - 0.168736 * kr - 0.331264 * kg + 0.5 * kb, 128 + 0.5 * kr - 0.418688 * kg - 0.081312 * kb);
9715
+ gl.uniform2f(loc.uKeyT, t0, t0 + Math.max(1, clamp(+p.chromaSoftness || 0, 0, 100) * 1.2));
9716
+ }
9717
+ gl.uniform2f(loc.uShift, temp * 0.6, tint * 0.5);
9718
+ gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
9719
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
9720
+ o.drawImage(cv, 0, cv.height - h, w, h, 0, 0, w, h); // the viewport is the buffer's bottom-left
9721
+ return out;
9722
+ } catch (err) {
9723
+ if (err && err.name === "SecurityError") throw err; // tainted source: same as getImageData on the CPU path
9724
+ console.warn("[FableCut] pixel pass failed, using the CPU path:", err && err.message);
9725
+ pixGL.gl = null;
9726
+ return null;
9727
+ }
9728
+ }
8679
9729
  function pixelPass(c, p, src, sx, sy, sw, sh, dw, dh) {
8680
9730
  const w = Math.max(2, Math.min(Math.round(dw), 1920));
8681
9731
  const h = Math.max(2, Math.min(Math.round(dh), 1920));
9732
+ const viaGL = pixelPassGL(c, p, src, sx, sy, sw, sh, w, h, dw);
9733
+ if (viaGL) return viaGL;
8682
9734
  if (scratch.width !== w) scratch.width = w;
8683
9735
  if (scratch.height !== h) scratch.height = h;
8684
9736
  scratchCtx.clearRect(0, 0, w, h);
@@ -9133,7 +10185,7 @@ function commitCompCtx() {
9133
10185
  const comps = (cur.comps || []).map((c) => (c.id === id
9134
10186
  ? { ...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
10187
  : 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 };
10188
+ x.root = { ...x.root, media: cur.media, folders: cur.folders, collections: cur.collections, fps: cur.fps, master: cur.master, buses: cur.buses, revision: cur.revision, comps };
9137
10189
  return x.root;
9138
10190
  }
9139
10191
  function enterComp(compId, viaClip) {
@@ -9227,6 +10279,7 @@ function addCompClip(comp, trackId, at) {
9227
10279
  function makeCompItem(comp) {
9228
10280
  const item = document.createElement("div");
9229
10281
  item.className = "bin-item comp-item";
10282
+ item.dataset.compId = comp.id;
9230
10283
  item.draggable = true;
9231
10284
  const used = CompLib.usages(state.compCtx ? state.compCtx.root : project, comp.id).length;
9232
10285
  item.innerHTML = `<div class="bin-thumb comp-thumb">▣</div>
@@ -9865,6 +10918,28 @@ function shapeKeyed(c) {
9865
10918
  const any = (list) => (list || []).some((it) => (it.keys && it.keys.length) || (it.type === "group" && any(it.items)));
9866
10919
  return any(c.props.shapes);
9867
10920
  }
10921
+ /** A missing file's picture: the box it would fill, marked "Media offline" (preview and export). */
10922
+ function drawOfflineSlate(c, p, W, H) {
10923
+ const m = getMedia(c.mediaId);
10924
+ const mw = m && m.width > 0 ? m.width : W, mh = m && m.height > 0 ? m.height : H, sc = p.scale || 1;
10925
+ let dw, dh;
10926
+ if (p.fit === "cover") { const f = Math.max(W / mw, H / mh) * sc; dw = mw * f; dh = mh * f; }
10927
+ else if (p.fit === "stretch") { dw = W * sc; dh = H * sc; }
10928
+ else if (p.fit === "none") { dw = mw * sc; dh = mh * sc; }
10929
+ else { const f = Math.min(W / mw, H / mh) * sc; dw = mw * f; dh = mh * f; }
10930
+ ctx2d.fillStyle = "#3a0d10";
10931
+ ctx2d.fillRect(-dw / 2, -dh / 2, dw, dh);
10932
+ ctx2d.strokeStyle = "#e5484d"; ctx2d.lineWidth = Math.max(2, dh / 120);
10933
+ ctx2d.strokeRect(-dw / 2, -dh / 2, dw, dh);
10934
+ const fs = Math.max(9, Math.min(dw / 20, dh / 9));
10935
+ ctx2d.fillStyle = "#ff8a8d";
10936
+ ctx2d.textAlign = "center"; ctx2d.textBaseline = "middle";
10937
+ ctx2d.font = `700 ${fs}px "Segoe UI", system-ui, sans-serif`;
10938
+ ctx2d.fillText("Media offline", 0, -fs * 0.35, dw * 0.9);
10939
+ ctx2d.font = `400 ${fs * 0.42}px "Segoe UI", system-ui, sans-serif`;
10940
+ ctx2d.fillStyle = "#f2c4c5";
10941
+ ctx2d.fillText(m ? m.name : "", 0, fs * 0.55, dw * 0.9);
10942
+ }
9868
10943
  /** Draw one clip. `layered`: into a mask layer — opacity and blend wait for
9869
10944
  * the composite. Returns { p, geom } — geom = where the clip's picture landed
9870
10945
  * (canvas transform + its box), the space its masks live in. */
@@ -9929,7 +11004,9 @@ function drawClipPicture(c, W, H, t, layered) {
9929
11004
  }
9930
11005
  let src = null, sw = 0, sh = 0;
9931
11006
  if (c.kind === "image") {
9932
- src = runtime.mediaAux.get(c.mediaId)?.img;
11007
+ const aux = runtime.mediaAux.get(c.mediaId), im = aux?.imgSrc && getMedia(c.mediaId);
11008
+ if (im && aux.imgSrc !== im.src && !aux.reloading) { aux.reloading = true; reloadMediaSource(im); } // relinked / undone
11009
+ src = aux?.img;
9933
11010
  if (src) { sw = src.naturalWidth; sh = src.naturalHeight; }
9934
11011
  } else if (c.kind === "svg") {
9935
11012
  src = getSvgImage(c, t);
@@ -9941,6 +11018,7 @@ function drawClipPicture(c, W, H, t, layered) {
9941
11018
  src = renderComp(c, t);
9942
11019
  if (src) { sw = src.width; sh = src.height; }
9943
11020
  }
11021
+ if ((!src || !sw || !sh) && isMediaOffline(c.mediaId)) drawOfflineSlate(c, p, W, H);
9944
11022
  if (src && sw && sh) {
9945
11023
  // source crop (percent per edge)
9946
11024
  const sx = sw * clamp(+p.cropL || 0, 0, 95) / 100;
@@ -9952,7 +11030,7 @@ function drawClipPicture(c, W, H, t, layered) {
9952
11030
  let dw, dh;
9953
11031
  if (p.fit === "cover") { const f = Math.max(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
9954
11032
  else if (p.fit === "stretch") { dw = W * sc; dh = H * sc; }
9955
- else if (p.fit === "none") { dw = cw * sc; dh = ch * sc; }
11033
+ else if (p.fit === "none") { const k = src._fcScale || 1; dw = cw * sc / k; dh = ch * sc / k; }
9956
11034
  else { const f = Math.min(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
9957
11035
  if (p.flipH || p.flipV) ctx2d.scale(p.flipH ? -1 : 1, p.flipV ? -1 : 1);
9958
11036
  if (p.cornerRadius > 0) {
@@ -10375,7 +11453,7 @@ function pictureRect(c, p) {
10375
11453
  let dw, dh;
10376
11454
  if (p.fit === "cover") { const f = Math.max(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
10377
11455
  else if (p.fit === "stretch") { dw = W * sc; dh = H * sc; }
10378
- else if (p.fit === "none") { dw = cw * sc; dh = ch * sc; }
11456
+ else if (p.fit === "none") { const ps = el._fcScale || 1; dw = cw * sc / ps; dh = ch * sc / ps; } // a proxy is drawn at the original's size
10379
11457
  else { const f = Math.min(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
10380
11458
  return { cx: W / 2 + (+p.x || 0), cy: H / 2 + (+p.y || 0), dw, dh, rot: (p.rotation || 0) * Math.PI / 180, fx: p.flipH ? -1 : 1, fy: p.flipV ? -1 : 1 };
10381
11459
  }
@@ -12703,6 +13781,52 @@ async function loadProjectFonts() {
12703
13781
  let lastTs = null;
12704
13782
  let exportWindow = null;
12705
13783
  let playheadPx = -1;
13784
+ /* The loop runs at the display's refresh rate (240 Hz on some laptops), but
13785
+ the picture only changes once per project frame. Redraw the program
13786
+ monitor and the playhead-driven panels when the playhead reaches a new
13787
+ frame; while paused, at the project rate for a moment after any input
13788
+ (sliders, drags, hover handles) and at 4 fps otherwise, which still picks
13789
+ up late seeks, font loads and agent edits. */
13790
+ let lastDrawKey = null, lastDrawAt = 0, lastActivity = 0, lastRulerKey = "";
13791
+ /* An idle editor doesn't need a callback per display refresh either: even an
13792
+ empty requestAnimationFrame loop keeps the compositor ticking at 240 Hz.
13793
+ When nothing moves the loop sleeps on a 4 fps timer, and any input or
13794
+ server event wakes it at once. */
13795
+ let loopSleep = 0;
13796
+ const IDLE_MS = 250, ACTIVE_MS = 1500, AUDIO_IDLE_MS = 3000;
13797
+ function noteActivity() {
13798
+ lastActivity = performance.now();
13799
+ if (loopSleep) { clearTimeout(loopSleep); loopSleep = 0; lastTs = null; requestAnimationFrame(loop); }
13800
+ }
13801
+ function loopBusy(now) {
13802
+ return state.playing || state.source.playing || state.exporting || state.rendering || state.audioHold ||
13803
+ state.gesture || state.dirtyTimeline || now - lastActivity < ACTIVE_MS;
13804
+ }
13805
+ /* The audio graph (fx worklets, meter, convolvers) renders silence for as long
13806
+ as its context runs, which costs a realtime thread a quarter of a core.
13807
+ Suspend it a few seconds after the last sound — reverb and delay tails
13808
+ finish first — and let every play path resume it as it already does. */
13809
+ let audioIdleSince = 0;
13810
+ function idleAudio(now) {
13811
+ const au = runtime.audio;
13812
+ if (!au || au.ctx.state !== "running") { audioIdleSince = 0; return; }
13813
+ let sounding = state.playing || state.source.playing || state.exporting || state.rendering || state.audioHold;
13814
+ if (!sounding) for (const el of runtime.clipEls.values()) if (!el.paused) { sounding = true; break; }
13815
+ if (sounding) { audioIdleSince = 0; return; }
13816
+ if (!audioIdleSince) audioIdleSince = now;
13817
+ else if (now - audioIdleSince > AUDIO_IDLE_MS) { audioIdleSince = 0; au.ctx.suspend().catch(() => { }); }
13818
+ }
13819
+ for (const ev of ["pointerdown", "pointermove", "pointerup", "wheel", "keydown", "keyup", "input", "change", "dragover", "drop", "resize"])
13820
+ window.addEventListener(ev, noteActivity, { capture: true, passive: true });
13821
+ function frameDue(now) {
13822
+ if (state.exporting) return true;
13823
+ const src = isSourceMode(), fps = projectFps();
13824
+ const key = (src ? "s" : "p") + Math.floor((src ? state.source.time : state.time) * fps + 1e-6);
13825
+ const gap = now - lastActivity < ACTIVE_MS ? 1000 / fps : IDLE_MS;
13826
+ if (key === lastDrawKey && now - lastDrawAt < gap - 1) return false;
13827
+ lastDrawKey = key; lastDrawAt = now;
13828
+ return true;
13829
+ }
12706
13830
  function loop(ts) {
12707
13831
  if (lastTs == null) lastTs = ts;
12708
13832
  const dt = Math.min(0.1, (ts - lastTs) / 1000);
@@ -12755,13 +13879,14 @@ function loop(ts) {
12755
13879
  if (px < sc.scrollLeft || px > sc.scrollLeft + sc.clientWidth - 40)
12756
13880
  sc.scrollLeft = Math.max(0, px - 60);
12757
13881
  }
13882
+ const draw = frameDue(ts);
12758
13883
  if (!state.rendering) { // fast export owns media seeking + the canvas
12759
13884
  if (isSourceMode()) {
12760
13885
  syncSourceMedia();
12761
- drawSourceFrame();
13886
+ if (draw) drawSourceFrame();
12762
13887
  } else {
12763
13888
  syncMedia();
12764
- drawFrame();
13889
+ if (draw) drawFrame();
12765
13890
  }
12766
13891
  }
12767
13892
  if (state.dirtyTimeline) rebuildClips();
@@ -12770,14 +13895,17 @@ function loop(ts) {
12770
13895
  playheadPx = phX;
12771
13896
  els.playhead.style.left = phX + "px";
12772
13897
  }
12773
- drawRuler();
12774
- if (!isSourceMode()) updateSafeOverlay();
12775
- updateKfGraphs();
12776
- syncInspectorPlayhead();
12777
- syncFxEditors();
12778
- syncColorPlayhead();
13898
+ const rulerKey = `${els.timelineScroll.scrollLeft} ${els.timelineScroll.clientWidth} ${state.pps}`;
13899
+ if (draw || rulerKey !== lastRulerKey) { lastRulerKey = rulerKey; drawRuler(); }
13900
+ if (draw) {
13901
+ if (!isSourceMode()) updateSafeOverlay();
13902
+ updateKfGraphs();
13903
+ syncInspectorPlayhead();
13904
+ syncFxEditors();
13905
+ syncColorPlayhead();
13906
+ updateTimecode(dur);
13907
+ }
12779
13908
  updateMeterUI(dt);
12780
- updateTimecode(dur);
12781
13909
  if (isSourceMode()) updateSourceScrub();
12782
13910
  if (state.exporting && !state.rendering) {
12783
13911
  const w = exportWindow;
@@ -12787,7 +13915,9 @@ function loop(ts) {
12787
13915
  els.exportProgress.style.width = pct.toFixed(1) + "%";
12788
13916
  els.exportTitle.textContent = `Exporting… ${pct.toFixed(0)}%`;
12789
13917
  }
12790
- requestAnimationFrame(loop);
13918
+ idleAudio(ts);
13919
+ if (loopBusy(ts)) requestAnimationFrame(loop);
13920
+ else loopSleep = setTimeout(() => { loopSleep = 0; lastTs = null; requestAnimationFrame(loop); }, IDLE_MS); // a nap is not playback time
12791
13921
  }
12792
13922
 
12793
13923
  /* ═══════════════════════════ EXPORT ═══════════════════════════ */
@@ -12855,28 +13985,308 @@ function updateExportProfileNote(id) {
12855
13985
  } else if (getSetting("encodeProfile")) {
12856
13986
  els.exportProfileHint.textContent = "Browser default — saved when you change this dropdown.";
12857
13987
  } else {
12858
- els.exportProfileHint.textContent = "Using server default from encoding-profiles.json.";
13988
+ els.exportProfileHint.textContent = "Raw ffmpeg arguments from encoding-profiles.json.";
12859
13989
  }
12860
13990
  }
12861
13991
  }
13992
+
13993
+ /* ── Delivery presets (delivery.js) ──
13994
+ The Preset dropdown lists the delivery presets (YouTube, Reels, Broadcast…,
13995
+ saved ones, Custom) and, last, the raw encoding profiles. A delivery preset
13996
+ fills the controls below it; any change makes it "modified" and is kept as
13997
+ the browser's last settings. Values: "d:<preset id>" / "p:<profile id>". */
13998
+ const Delivery = window.FableCutDelivery;
13999
+ let deliveryInfo = { encoders: null, saved: {} };
14000
+ let dlvSpec = null;
14001
+ let dlvNotes = [];
14002
+ async function fetchDeliveryInfo() {
14003
+ if (!state.connected) return;
14004
+ try {
14005
+ const r = await fetch("/api/export/presets", { cache: "no-store" });
14006
+ if (!r.ok) return;
14007
+ const d = await r.json();
14008
+ deliveryInfo = { encoders: Array.isArray(d.encoders) ? d.encoders : null, saved: d.saved && typeof d.saved === "object" ? d.saved : {} };
14009
+ } catch { }
14010
+ }
14011
+ function deliveryOpts() {
14012
+ return { available: deliveryInfo.encoders ? new Set(deliveryInfo.encoders) : null, presets: deliveryInfo.saved };
14013
+ }
14014
+ function normalizeDelivery(input) { return Delivery.normalize(input, deliveryOpts()); }
14015
+ /** What an export uses when nobody picks: the project's pin, else this browser's last choice. */
14016
+ function exportChoice() {
14017
+ if (project.delivery) return { kind: "delivery", spec: project.delivery, source: "project" };
14018
+ if (project.encodeProfile) return { kind: "profile", id: project.encodeProfile, source: "project" };
14019
+ const kind = getSetting("exportKind") || (getSetting("encodeProfile") ? "profile" : "delivery");
14020
+ if (kind === "profile") return { kind, id: effectiveEncodeProfileId(), source: "browser" };
14021
+ const last = getSetting("delivery");
14022
+ return { kind: "delivery", spec: last && typeof last === "object" ? last : Delivery.DEFAULT_PRESET, source: last ? "browser" : "default" };
14023
+ }
14024
+ function selectedExportChoice() {
14025
+ const v = els.exportProfileSel?.value || "";
14026
+ return v.startsWith("p:") ? { kind: "profile", id: v.slice(2) } : { kind: "delivery", id: v.slice(2) || Delivery.DEFAULT_PRESET };
14027
+ }
12862
14028
  function populateExportProfileSelect() {
12863
- if (!els.exportProfileSel) return;
14029
+ const sel = els.exportProfileSel;
14030
+ if (!sel) return;
14031
+ const choice = exportChoice();
14032
+ let cur;
14033
+ if (choice.kind === "delivery") {
14034
+ const n = normalizeDelivery(choice.spec);
14035
+ dlvSpec = n.spec; dlvNotes = n.notes;
14036
+ cur = "d:" + dlvSpec.preset;
14037
+ } else cur = "p:" + choice.id;
14038
+ const opt = (v, label) => `<option value="${escapeHtml(v)}">${escapeHtml(label)}</option>`;
14039
+ let html = Delivery.presetGroups(deliveryInfo.saved).map((g) =>
14040
+ `<optgroup label="${escapeHtml(g.group)}">${g.items.map((it) => opt("d:" + it.id, it.label)).join("")}</optgroup>`).join("");
12864
14041
  const ids = Object.keys(encodeProfiles.profiles);
12865
- const cur = effectiveEncodeProfileId();
12866
14042
  // a project/browser default naming a deleted profile must stay visible rather
12867
14043
  // than silently falling through to whichever option happens to be first
12868
- if (cur && !ids.includes(cur)) ids.unshift(cur);
12869
- els.exportProfileSel.innerHTML = ids.map((id) => {
14044
+ if (choice.kind === "profile" && !ids.includes(choice.id)) ids.unshift(choice.id);
14045
+ html += `<optgroup label="Encoding profiles (encoding-profiles.json)">${ids.map((id) => {
12870
14046
  const p = encodeProfiles.profiles[id];
12871
- const sel = id === cur ? " selected" : "";
12872
- const label = p ? (p.label || id) : `${id} (not defined on the server)`;
12873
- return `<option value="${escapeHtml(id)}"${sel}>${escapeHtml(label)}</option>`;
12874
- }).join("");
12875
- updateExportProfileNote(els.exportProfileSel.value || cur || "delivery");
14047
+ return opt("p:" + id, p ? (p.label || id) : `${id} (not defined on the server)`);
14048
+ }).join("")}</optgroup>`;
14049
+ sel.innerHTML = html;
14050
+ sel.value = cur;
14051
+ if (sel.value !== cur) sel.value = "d:" + Delivery.DEFAULT_PRESET;
14052
+ syncExportChoiceUi(choice.source);
14053
+ }
14054
+ /** source: where the current settings came from, for the hint line. */
14055
+ function syncExportChoiceUi(source) {
14056
+ const c = selectedExportChoice();
14057
+ const isD = c.kind === "delivery";
14058
+ $("dlvBody")?.classList.toggle("hidden", !isD);
14059
+ $("dlvSaveBtn")?.classList.toggle("hidden", !isD);
14060
+ $("dlvDeleteBtn")?.classList.toggle("hidden", !(isD && deliveryInfo.saved[c.id]));
14061
+ $("dlvSaveRow")?.classList.add("hidden");
14062
+ if (!isD) {
14063
+ updateExportProfileNote(c.id);
14064
+ const w = $("dlvWarn"); if (w) { w.textContent = ""; w.classList.add("hidden"); }
14065
+ return;
14066
+ }
14067
+ if (!dlvSpec || dlvSpec.preset !== c.id) {
14068
+ const n = normalizeDelivery(c.id);
14069
+ dlvSpec = n.spec; dlvNotes = n.notes;
14070
+ source = "preset";
14071
+ }
14072
+ dlvSource = source || dlvSource;
14073
+ fillDeliveryControls();
14074
+ refreshDeliveryNotes();
14075
+ }
14076
+ let dlvSource = "default";
14077
+ function deliveryCanvas() {
14078
+ return { w: els.preview?.width || project.width, h: els.preview?.height || project.height, region: getExportFrame() };
14079
+ }
14080
+ function deliveryFrame(spec) {
14081
+ const cv = deliveryCanvas();
14082
+ return Delivery.reframe(spec, cv.w, cv.h, cv.region);
14083
+ }
14084
+ function deliveryFileName(spec, fps) {
14085
+ const fr = deliveryFrame(spec);
14086
+ return Delivery.renderFilename(spec.filename, spec, {
14087
+ project: project.name.replace(/[^\w\- ]+/g, "").trim() || "export",
14088
+ width: fr.outW, height: fr.outH, fps: fps || spec.fps || projectFps(), range: exportRangeMode(),
14089
+ });
14090
+ }
14091
+ const dlvVal = (id) => $(id)?.value ?? "";
14092
+ function setOptions(sel, items, value) {
14093
+ if (!sel) return;
14094
+ sel.innerHTML = items.map(([v, label, disabled]) =>
14095
+ `<option value="${escapeHtml(String(v))}"${disabled ? " disabled" : ""}>${escapeHtml(label)}</option>`).join("");
14096
+ sel.value = String(value);
14097
+ }
14098
+ function showField(id, on) { $(id)?.classList.toggle("hidden", !on); }
14099
+ function fillDeliveryControls() {
14100
+ const sp = dlvSpec, v = sp.video, a = sp.audio, C = Delivery.CODECS[v.codec];
14101
+ const fr = deliveryFrame({ ...sp, width: null, height: null });
14102
+ const avail = deliveryOpts().available;
14103
+ // resolution
14104
+ const res = [["", `Picture size (${fr.outW} × ${fr.outH})`]];
14105
+ for (const [w, h, label] of Delivery.RESOLUTIONS) res.push([`${w}x${h}`, `${w} × ${h} · ${label}`]);
14106
+ res.push(["custom", "Custom…"]);
14107
+ let rv = sp.width ? `${sp.width}x${sp.height}` : "";
14108
+ if (rv && !res.some((r) => r[0] === rv)) rv = "custom";
14109
+ if (dlvCustomRes && sp.width) rv = "custom";
14110
+ setOptions($("dlvRes"), res, rv);
14111
+ showField("dlvWhField", rv === "custom");
14112
+ if (rv === "custom") { $("dlvW").value = sp.width; $("dlvH").value = sp.height; }
14113
+ showField("dlvReframeField", !!sp.width);
14114
+ $("dlvReframe").value = sp.reframe;
14115
+ setOptions($("dlvFps"), [["", `Project (${projectFps()} fps)`], ...Delivery.FPS_CHOICES.map((f) => [f, `${f} fps`])], sp.fps ?? "");
14116
+ setOptions($("dlvCodec"), Object.entries(Delivery.CODECS).map(([k, c]) => {
14117
+ const none = avail && !Delivery.encodersFor(k, avail).length;
14118
+ return [k, c.label + (none ? " — not in this ffmpeg" : ""), none && k !== v.codec];
14119
+ }), v.codec);
14120
+ const encs = Delivery.encodersFor(v.codec, avail);
14121
+ setOptions($("dlvEncoder"), (encs.length ? encs : [v.encoder]).map((e) => [e, Delivery.ENCODERS[e]?.label || e]), v.encoder);
14122
+ showField("dlvEncoderField", encs.length > 1);
14123
+ showField("dlvProfileField", !!C.profiles);
14124
+ if (C.profiles) setOptions($("dlvProfile"), Object.entries(C.profiles), v.profile);
14125
+ const fixed = v.rate === "fixed";
14126
+ showField("dlvRateField", !fixed);
14127
+ if (!fixed) setOptions($("dlvRate"), C.rate.map((r) => [r, { crf: "CRF (constant quality)", vbr: "VBR (variable bitrate)", cbr: "CBR (constant bitrate)" }[r]]), v.rate);
14128
+ showField("dlvCrfField", v.rate === "crf");
14129
+ showField("dlvBitrateField", v.rate === "vbr" || v.rate === "cbr");
14130
+ showField("dlvMaxrateField", v.rate === "crf" || v.rate === "vbr");
14131
+ for (const f of ["dlvKeyintField", "dlvSpeedField"]) showField(f, !fixed);
14132
+ if (!fixed) {
14133
+ const crf = $("dlvCrf"); crf.min = C.crf[0]; crf.max = C.crf[1];
14134
+ if (document.activeElement !== crf) crf.value = v.crf;
14135
+ if (document.activeElement !== $("dlvBitrate")) $("dlvBitrate").value = +(v.bitrate / 1000).toFixed(2);
14136
+ if (document.activeElement !== $("dlvMaxrate")) $("dlvMaxrate").value = v.maxrate ? +(v.maxrate / 1000).toFixed(2) : "";
14137
+ if (document.activeElement !== $("dlvKeyint")) $("dlvKeyint").value = v.keyint;
14138
+ $("dlvSpeed").value = v.speed;
14139
+ }
14140
+ const E = Delivery.ENCODERS[v.encoder] || {};
14141
+ const pix = C.pixFmts.filter((f) => !E.pix || E.pix.includes(f));
14142
+ const PIX = { yuv420p: "4:2:0 8-bit", yuv422p: "4:2:2 8-bit", yuv444p: "4:4:4 8-bit", yuv420p10le: "4:2:0 10-bit", yuv422p10le: "4:2:2 10-bit", yuv444p10le: "4:4:4 10-bit" };
14143
+ setOptions($("dlvPixFmt"), (pix.includes(v.pixFmt) ? pix : [v.pixFmt, ...pix]).map((f) => [f, `${PIX[f] || f} (${f})`]), v.pixFmt);
14144
+ $("dlvPixFmt").disabled = v.codec === "prores" || v.codec === "dnxhr" || pix.length < 2;
14145
+ setOptions($("dlvColor"), Object.entries(Delivery.COLORS).map(([k, c]) => [k, c.label]), sp.color);
14146
+ setOptions($("dlvContainer"), C.containers.map((k) => [k, Delivery.CONTAINERS[k].label]), sp.container);
14147
+ // audio
14148
+ setOptions($("dlvACodec"), Object.entries(Delivery.AUDIO).filter(([, A]) => A.containers.includes(sp.container)).map(([k, A]) => [k, A.label]), a.codec);
14149
+ const A = Delivery.AUDIO[a.codec];
14150
+ showField("dlvABitrateField", !!A.bitrate);
14151
+ if (A.bitrate) {
14152
+ const steps = [64, 96, 128, 160, 192, 256, 320, 384, 448, 512].filter((k) => k >= A.bitrate[0] && k <= A.bitrate[1]);
14153
+ if (!steps.includes(a.bitrate)) steps.push(a.bitrate), steps.sort((x, y) => x - y);
14154
+ setOptions($("dlvABitrate"), steps.map((k) => [k, `${k} kb/s`]), a.bitrate);
14155
+ }
14156
+ showField("dlvARateField", a.codec !== "none" && a.codec !== "opus");
14157
+ showField("dlvAChField", a.codec !== "none");
14158
+ if (a.codec !== "none") { $("dlvARate").value = a.sampleRate; $("dlvACh").value = a.channels; }
14159
+ showField("dlvLoudField", a.codec !== "none");
14160
+ showField("dlvTpField", a.codec !== "none" && a.loudness != null);
14161
+ if (a.codec !== "none") {
14162
+ const opts = [["off", "Off — as mixed"], ...Delivery.LOUDNESS_TARGETS];
14163
+ if (a.loudness != null && !opts.some((o) => +o[0] === a.loudness)) opts.push([a.loudness, `${a.loudness} LUFS`]);
14164
+ setOptions($("dlvLoud"), opts, a.loudness == null ? "off" : a.loudness);
14165
+ if (a.loudness != null && document.activeElement !== $("dlvTp")) $("dlvTp").value = a.truePeak;
14166
+ }
14167
+ // file name + metadata (left alone while typing)
14168
+ const fn = $("dlvFilename");
14169
+ if (document.activeElement !== fn) fn.value = sp.filename;
14170
+ for (const k of Delivery.META_KEYS) {
14171
+ const el = $("dlvMeta" + k[0].toUpperCase() + k.slice(1));
14172
+ if (el && document.activeElement !== el) el.value = sp.metadata[k] || "";
14173
+ }
14174
+ $("dlvTokens").textContent = "Tokens: " + Delivery.FILENAME_TOKENS.map((t) => `{${t}}`).join(" ");
14175
+ }
14176
+ let dlvCustomRes = false;
14177
+ /** The controls → a spec. `changed`: the control the user touched. */
14178
+ function readDeliveryControls(changed) {
14179
+ const sp = dlvSpec;
14180
+ const raw = { preset: sp.preset, video: { ...sp.video }, audio: { ...sp.audio }, metadata: {} };
14181
+ const r = dlvVal("dlvRes");
14182
+ dlvCustomRes = r === "custom";
14183
+ if (r === "") { raw.width = null; raw.height = null; }
14184
+ else if (r === "custom") {
14185
+ if (changed === "dlvRes") { const fr = deliveryFrame(sp); raw.width = fr.outW; raw.height = fr.outH; }
14186
+ else { raw.width = +dlvVal("dlvW") || null; raw.height = +dlvVal("dlvH") || null; }
14187
+ } else { const [w, h] = r.split("x").map(Number); raw.width = w; raw.height = h; }
14188
+ raw.reframe = dlvVal("dlvReframe");
14189
+ raw.fps = dlvVal("dlvFps") === "" ? null : +dlvVal("dlvFps");
14190
+ raw.container = dlvVal("dlvContainer");
14191
+ raw.color = dlvVal("dlvColor");
14192
+ if (changed === "dlvCodec") raw.video = { codec: dlvVal("dlvCodec"), speed: sp.video.speed, keyint: sp.video.keyint, maxrate: sp.video.maxrate, bitrate: sp.video.bitrate };
14193
+ else {
14194
+ const v = raw.video;
14195
+ v.encoder = dlvVal("dlvEncoder") || v.encoder;
14196
+ if (!$("dlvProfileField").classList.contains("hidden")) v.profile = dlvVal("dlvProfile");
14197
+ if (v.rate !== "fixed") {
14198
+ v.rate = dlvVal("dlvRate") || v.rate;
14199
+ v.crf = +dlvVal("dlvCrf");
14200
+ v.bitrate = Math.round(+dlvVal("dlvBitrate") * 1000) || v.bitrate;
14201
+ v.maxrate = dlvVal("dlvMaxrate") === "" ? null : Math.round(+dlvVal("dlvMaxrate") * 1000);
14202
+ v.keyint = +dlvVal("dlvKeyint");
14203
+ v.speed = dlvVal("dlvSpeed");
14204
+ }
14205
+ v.pixFmt = dlvVal("dlvPixFmt");
14206
+ }
14207
+ const a = raw.audio;
14208
+ a.codec = dlvVal("dlvACodec") || a.codec;
14209
+ if (changed !== "dlvACodec") {
14210
+ if (!$("dlvABitrateField").classList.contains("hidden")) a.bitrate = +dlvVal("dlvABitrate");
14211
+ a.sampleRate = +dlvVal("dlvARate") || a.sampleRate;
14212
+ a.channels = +dlvVal("dlvACh") || a.channels;
14213
+ a.loudness = dlvVal("dlvLoud") === "off" || dlvVal("dlvLoud") === "" ? null : +dlvVal("dlvLoud");
14214
+ if (dlvVal("dlvTp") !== "") a.truePeak = +dlvVal("dlvTp");
14215
+ } else delete a.bitrate;
14216
+ raw.filename = dlvVal("dlvFilename");
14217
+ for (const k of Delivery.META_KEYS) raw.metadata[k] = dlvVal("dlvMeta" + k[0].toUpperCase() + k.slice(1));
14218
+ return raw;
14219
+ }
14220
+ function onDeliveryControl(e) {
14221
+ const id = e.target?.id || "";
14222
+ if (!id.startsWith("dlv") || id === "dlvSaveName") return;
14223
+ const n = normalizeDelivery(readDeliveryControls(id));
14224
+ dlvSpec = n.spec; dlvNotes = n.notes;
14225
+ dlvSource = "edited";
14226
+ // text fields refresh the preview only; everything else may move other controls
14227
+ if (e.type === "change") fillDeliveryControls();
14228
+ refreshDeliveryNotes();
14229
+ }
14230
+ function deliveryModified() {
14231
+ if (!dlvSpec) return false;
14232
+ const base = normalizeDelivery(dlvSpec.preset).spec;
14233
+ const strip = (x) => JSON.stringify({ ...x, metadata: null });
14234
+ return strip(base) !== strip(dlvSpec);
14235
+ }
14236
+ function refreshDeliveryNotes() {
14237
+ if (!dlvSpec) return;
14238
+ const sp = dlvSpec, fr = deliveryFrame(sp), fps = sp.fps || projectFps();
14239
+ const preset = Delivery.PRESETS[sp.preset] || deliveryInfo.saved[sp.preset];
14240
+ const modified = deliveryModified();
14241
+ if (els.exportProfileNote) els.exportProfileNote.textContent = preset?.note || "";
14242
+ if (els.exportProfileHint) {
14243
+ els.exportProfileHint.textContent = dlvSource === "project"
14244
+ ? "Project default (delivery in project.json) — agents export with it too."
14245
+ : modified ? `${preset?.label || "Preset"} · modified — Save preset… keeps these settings for every project.`
14246
+ : dlvSource === "browser" ? "Your last settings in this browser." : "";
14247
+ }
14248
+ const range = exportRange(fps);
14249
+ const size = Delivery.estimateSize(sp, { duration: range.dur, width: fr.outW, height: fr.outH, fps });
14250
+ $("dlvSummary").textContent = `${Delivery.describe(sp, { width: fr.outW, height: fr.outH, fps })}` +
14251
+ (size ? ` · about ${Delivery.fmtBytes(size)}` : "");
14252
+ $("dlvFilePreview").textContent = "→ exports/" + deliveryFileName(sp, fps) + Delivery.CONTAINERS[sp.container].ext;
14253
+ const cv = deliveryCanvas();
14254
+ const warn = [...dlvNotes.map((n) => n[0].toUpperCase() + n.slice(1)),
14255
+ ...Delivery.checks(sp, { duration: range.dur, fps, canvasW: cv.w, canvasH: cv.h, region: cv.region })];
14256
+ const w = $("dlvWarn");
14257
+ if (w) { w.textContent = warn.join(" · "); w.classList.toggle("hidden", !warn.length); }
14258
+ }
14259
+ async function saveDeliveryPreset() {
14260
+ const name = $("dlvSaveName").value.trim();
14261
+ if (!name) { $("dlvSaveName").focus(); return; }
14262
+ const r = await fetch("/api/export/presets", { method: "POST", body: JSON.stringify({ name, spec: dlvSpec }) })
14263
+ .then((x) => x.json()).catch((e) => ({ error: String(e) }));
14264
+ if (!r.ok) { alert("Could not save the preset: " + (r.error || "unknown error")); return; }
14265
+ await fetchDeliveryInfo();
14266
+ setSetting("exportKind", "delivery");
14267
+ setSetting("delivery", { ...dlvSpec, preset: r.id });
14268
+ if (!project.delivery) populateExportProfileSelect();
14269
+ else { els.exportProfileSel.value = "d:" + r.id; dlvSpec = null; syncExportChoiceUi("preset"); }
14270
+ }
14271
+ async function deleteDeliveryPreset() {
14272
+ const c = selectedExportChoice();
14273
+ if (c.kind !== "delivery" || !deliveryInfo.saved[c.id]) return;
14274
+ const r = await fetch("/api/export/presets/delete", { method: "POST", body: JSON.stringify({ id: c.id }) }).then((x) => x.json()).catch(() => ({}));
14275
+ if (!r.ok) { alert("Could not delete the preset: " + (r.error || "unknown error")); return; }
14276
+ await fetchDeliveryInfo();
14277
+ els.exportProfileSel.value = "d:" + Delivery.DEFAULT_PRESET;
14278
+ dlvSpec = null;
14279
+ populateExportProfileSelect();
14280
+ els.exportProfileSel.value = "d:" + Delivery.DEFAULT_PRESET;
14281
+ syncExportChoiceUi("preset");
12876
14282
  }
12877
14283
  function syncExportProfileVisibility() {
12878
14284
  const show = els.engineFast.checked && !els.engineFast.disabled;
12879
14285
  els.exportProfileRow?.classList.toggle("hidden", !show);
14286
+ // the batch list and the queue are Fast export (ffmpeg) only
14287
+ for (const id of ["dlvBatch", "btnQueueExport", "btnBatchExport"]) $(id)?.classList.toggle("hidden", !show);
14288
+ if (!show) { const w = $("dlvWarn"); if (w) w.classList.add("hidden"); }
14289
+ else if (selectedExportChoice().kind === "delivery") refreshDeliveryNotes();
12880
14290
  }
12881
14291
 
12882
14292
  function syncExportWcOpts() {
@@ -12975,14 +14385,30 @@ async function openExportSetup() {
12975
14385
  warn.textContent = "";
12976
14386
  warn.classList.add("hidden");
12977
14387
  }
14388
+ // missing files export as their "Media offline" slate — say so before the render
14389
+ const offUsed = project.media.filter((m) => isMediaOffline(m.id) && MediaLib.placements(usageDoc(), m.id).length);
14390
+ if (offUsed.length && warn) {
14391
+ warn.textContent = (warn.textContent ? warn.textContent + " " : "") +
14392
+ `${offUsed.length === 1 ? `“${offUsed[0].name}” is` : offUsed.length + " files are"} offline and will export as “Media offline” — relink first (right-click in the bin → Relink…).`;
14393
+ warn.classList.remove("hidden");
14394
+ }
12978
14395
  fillExportRangeSelect();
12979
14396
  syncExportRangeUi();
12980
14397
  syncExportProfileVisibility();
12981
- fetchEncodeProfiles().then(() => {
14398
+ Promise.all([fetchEncodeProfiles(), fetchDeliveryInfo()]).then(() => {
12982
14399
  populateExportProfileSelect();
14400
+ syncExportProfileVisibility();
14401
+ renderBatchList();
14402
+ renderQueue();
12983
14403
  els.exportSetup.classList.remove("hidden");
14404
+ // aria-modal hides what is behind — focus has to come inside
14405
+ if (!els.exportSetup.contains(document.activeElement)) $("exportRangeSel")?.focus();
12984
14406
  });
12985
14407
  }
14408
+ function closeExportSetup() {
14409
+ els.exportSetup.classList.add("hidden");
14410
+ $("btnExport")?.focus();
14411
+ }
12986
14412
  function startChosenExport() {
12987
14413
  persistExportWcOpts();
12988
14414
  const useFast = els.engineFast.checked && !els.engineFast.disabled;
@@ -13000,8 +14426,15 @@ function startChosenExport() {
13000
14426
  alert("Export range is empty — IN/OUT is at or past the end of the timeline. Choose Entire timeline, or move the markers.");
13001
14427
  return;
13002
14428
  }
13003
- els.exportSetup.classList.add("hidden");
13004
- if (useFast) fastExport();
14429
+ closeExportSetup();
14430
+ if (useFast) {
14431
+ const c = selectedExportChoice();
14432
+ setSetting("exportKind", c.kind);
14433
+ if (c.kind === "delivery") {
14434
+ setSetting("delivery", dlvSpec);
14435
+ fastExport({ delivery: dlvSpec });
14436
+ } else fastExport({ profile: c.id });
14437
+ }
13005
14438
  else if (useSecond && state.connected && state.ffmpeg && state.webCodecs && !getExportFrame()) webCodecsExport();
13006
14439
  else startExport();
13007
14440
  }
@@ -13043,8 +14476,8 @@ function syncExportRangeUi() {
13043
14476
  /* ── Fast export ── */
13044
14477
  let renderCancelled = false;
13045
14478
  let exportAbort = null;
13046
- function beginExportWindow() {
13047
- exportWindow = exportRange();
14479
+ function beginExportWindow(fps) {
14480
+ exportWindow = exportRange(fps);
13048
14481
  return exportWindow;
13049
14482
  }
13050
14483
  function endExportWindow() {
@@ -13052,6 +14485,9 @@ function endExportWindow() {
13052
14485
  }
13053
14486
  let exportCropCanvas = null;
13054
14487
  let exportCropCtx = null;
14488
+ /* A delivery export sends the whole canvas; ffmpeg crops the export frame
14489
+ (and reframes) itself, so one stream can feed any output shape. */
14490
+ let exportFullCanvas = false;
13055
14491
  function canvasTaintError(e) {
13056
14492
  const tainted = e && (e.name === "SecurityError" || /taint/i.test(String(e.message || e)));
13057
14493
  return tainted
@@ -13060,7 +14496,7 @@ function canvasTaintError(e) {
13060
14496
  }
13061
14497
  function exportSourceCanvas() {
13062
14498
  const ef = getExportFrame();
13063
- if (!ef) return els.preview;
14499
+ if (!ef || exportFullCanvas) return els.preview;
13064
14500
  if (!exportCropCanvas) exportCropCanvas = document.createElement("canvas");
13065
14501
  if (exportCropCanvas.width !== ef.w) exportCropCanvas.width = ef.w;
13066
14502
  if (exportCropCanvas.height !== ef.h) exportCropCanvas.height = ef.h;
@@ -13087,6 +14523,22 @@ function snapshotExportFrame() {
13087
14523
  return { kind: "rgba", data: img.data, w: src.width, h: src.height };
13088
14524
  } catch (e) { throw canvasTaintError(e); }
13089
14525
  }
14526
+ let exportRgbaOff = null, exportRgbaCtx = null;
14527
+ /** Raw RGBA of the frame, for outputs that keep more than JPEG's 4:2:0 8-bit
14528
+ * (ProRes, DNxHR, FFV1, 10-bit, 4:2:2 / 4:4:4). */
14529
+ function snapshotExportRgba() {
14530
+ const src = exportSourceCanvas();
14531
+ try {
14532
+ if (!exportRgbaOff || exportRgbaOff.width !== src.width || exportRgbaOff.height !== src.height) {
14533
+ exportRgbaOff = typeof OffscreenCanvas === "function" ? new OffscreenCanvas(src.width, src.height)
14534
+ : Object.assign(document.createElement("canvas"), { width: src.width, height: src.height });
14535
+ exportRgbaCtx = exportRgbaOff.getContext("2d", { alpha: false, willReadFrequently: true });
14536
+ }
14537
+ exportRgbaCtx.drawImage(src, 0, 0);
14538
+ const img = exportRgbaCtx.getImageData(0, 0, src.width, src.height);
14539
+ return new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
14540
+ } catch (e) { throw canvasTaintError(e); }
14541
+ }
13090
14542
  const JPEG_WORKER_SRC = `"use strict";
13091
14543
  self.onmessage = async (e) => {
13092
14544
  const { id, bmp, quality } = e.data;
@@ -13171,6 +14623,8 @@ function resetExportCanvases() {
13171
14623
  }
13172
14624
  adjScratch.width = adjScratch.width;
13173
14625
  scratch.width = scratch.width;
14626
+ if (pixGL.stage) pixGL.stage.width = pixGL.stage.width; // the GPU pixel pass keeps these between frames
14627
+ pixGL.outs.clear();
13174
14628
  exportSnapOff = null;
13175
14629
  exportSnapCtx = null;
13176
14630
  }
@@ -13731,20 +15185,27 @@ async function prepareFrameAssets(t) {
13731
15185
  }
13732
15186
  /** opts.job: an agent's export job id — progress and the result go back to
13733
15187
  * the server instead of an alert / download (nobody may be watching).
13734
- * opts.profile: encoding profile id for this run. */
15188
+ * opts.profile: encoding profile id for this run.
15189
+ * opts.delivery: a delivery spec (delivery.js) instead of a profile.
15190
+ * opts.deliveries: [{delivery, name?}] — several files from one render (same fps).
15191
+ * opts.silent: no overlay, alert or download (the export queue shows it);
15192
+ * opts.onProgress(0…1). Resolves {outputs:[{file, ok, src?, error?}]} or {error, cancelled}. */
13735
15193
  async function fastExport(opts = {}) {
13736
- if (state.exporting) return;
15194
+ if (state.exporting) return { error: "an export is already running" };
13737
15195
  const job = opts.job || null;
13738
15196
  pause();
13739
15197
  state.exporting = true; state.rendering = true; renderCancelled = false;
13740
15198
  exportAbort = new AbortController();
13741
15199
  const signal = exportAbort.signal;
13742
- els.exportOverlay.classList.remove("hidden");
15200
+ if (!opts.silent) els.exportOverlay.classList.remove("hidden");
13743
15201
  els.exportProgress.style.width = "0%";
13744
15202
  els.exportNote.textContent = "Rendering frames → ffmpeg. You can switch tabs; export continues.";
13745
15203
  restoreExportVideoState();
13746
- const { start: t0, end: t1, frames } = beginExportWindow();
13747
- const fps = projectFps();
15204
+ const deliveries = opts.deliveries || (opts.delivery ? [{ delivery: opts.delivery }] : null);
15205
+ const delivery = deliveries ? deliveries[0].delivery : null;
15206
+ const fps = (delivery && delivery.fps) || projectFps();
15207
+ let result = null;
15208
+ const { start: t0, end: t1, frames } = beginExportWindow(fps);
13748
15209
  let sessId = null;
13749
15210
  let uploadError = null;
13750
15211
  const setError = (err) => { if (!uploadError) uploadError = err; };
@@ -13752,12 +15213,15 @@ async function fastExport(opts = {}) {
13752
15213
  els.exportTitle.textContent = "Mixing audio…";
13753
15214
  const wav = await renderAudioMix(t0, t1);
13754
15215
  if (renderCancelled) throw new Error("cancelled");
13755
- const profileId = job ? (opts.profile || effectiveEncodeProfileId())
13756
- : els.exportProfileSel?.value || effectiveEncodeProfileId();
13757
- const jpegQ = exportProfileMeta(profileId).jpegQuality ?? 0.95;
15216
+ const profileId = opts.profile || effectiveEncodeProfileId();
15217
+ const cv = deliveryCanvas();
13758
15218
  const begin = await fetch("/api/export/begin", {
13759
15219
  method: "POST",
13760
- body: JSON.stringify({
15220
+ body: JSON.stringify(delivery ? {
15221
+ fps, duration: frames / fps,
15222
+ deliveries: deliveries.map((d) => ({ delivery: d.delivery, name: d.name || deliveryFileName(d.delivery, fps) })),
15223
+ canvasW: cv.w, canvasH: cv.h, region: cv.region, hasAudio: !!wav,
15224
+ } : {
13761
15225
  fps,
13762
15226
  name: project.name.replace(/[^\w\- ]+/g, "") || "export",
13763
15227
  profile: profileId,
@@ -13767,6 +15231,9 @@ async function fastExport(opts = {}) {
13767
15231
  }).then((r) => r.json());
13768
15232
  if (!begin.id) throw new Error(begin.error || "export begin failed");
13769
15233
  sessId = begin.id;
15234
+ const jpegQ = delivery ? begin.jpegQuality ?? 0.95 : exportProfileMeta(profileId).jpegQuality ?? 0.95;
15235
+ const rgba = begin.input === "rgba";
15236
+ exportFullCanvas = !!delivery;
13770
15237
  if (wav) {
13771
15238
  const r = await fetch("/api/export/audio?id=" + sessId, { method: "POST", body: wav, signal });
13772
15239
  if (!r.ok) throw new Error("audio upload failed");
@@ -13774,6 +15241,7 @@ async function fastExport(opts = {}) {
13774
15241
  await loadProjectFonts();
13775
15242
  resetExportCanvases();
13776
15243
  await armExportCors();
15244
+ await useOriginalPlayers(); // proxies are for editing — export reads the originals
13777
15245
  // JPEG off the compositor thread: snapshot is sync, encode/upload run ahead
13778
15246
  // under backpressure. Awaiting toBlob every frame was slower than ffmpeg.
13779
15247
  const up = createExportUploader(sessId, {
@@ -13807,10 +15275,11 @@ async function fastExport(opts = {}) {
13807
15275
  await seekVideosTo(t, { queueBusy });
13808
15276
  await prepareFrameAssets(t);
13809
15277
  drawFrame(t);
13810
- const snap = snapshotExportFrame();
15278
+ const snap = rgba ? { kind: "raw", px: snapshotExportRgba() } : snapshotExportFrame();
13811
15279
  pixelsInflight++;
13812
15280
  let jpegP;
13813
- if (snap.kind === "bmp") {
15281
+ if (snap.kind === "raw") jpegP = Promise.resolve(snap.px);
15282
+ else if (snap.kind === "bmp") {
13814
15283
  jpegP = workers ? workers.encode(snap.bmp, jpegQ) : jpegFromBitmap(snap.bmp, jpegQ);
13815
15284
  } else {
13816
15285
  const c = document.createElement("canvas");
@@ -13835,6 +15304,7 @@ async function fastExport(opts = {}) {
13835
15304
  const pct = ((f + 1) / frames) * 100;
13836
15305
  els.exportProgress.style.width = pct.toFixed(1) + "%";
13837
15306
  els.exportTitle.textContent = `Rendering… ${pct.toFixed(0)}%`;
15307
+ if (opts.onProgress) opts.onProgress(0.97 * (f + 1) / frames);
13838
15308
  if (job) reportExportJob(job, { progress: 0.97 * (f + 1) / frames });
13839
15309
  }
13840
15310
  await pixelChain;
@@ -13847,7 +15317,23 @@ async function fastExport(opts = {}) {
13847
15317
  }
13848
15318
  const end = await fetch("/api/export/end?id=" + sessId, { method: "POST", signal }).then((r) => r.json());
13849
15319
  if (!end.src) throw new Error(end.error || "encode failed");
13850
- if (job) await reportExportJob(job, { status: "done", src: end.src }, true);
15320
+ const outs = end.outputs || [{ file: decodeURIComponent(end.src.split("/").pop()), ok: true, src: end.src }];
15321
+ // what each output was asked to be, beside what came out
15322
+ result = { outputs: outs.map((o, i) => ({ ...o, ...(begin.outputs && begin.outputs[i] ? { summary: begin.outputs[i].summary, notes: begin.outputs[i].notes } : {}) })) };
15323
+ if (job) await reportExportJob(job, { status: "done", src: end.src, ...(delivery ? { result: { summary: begin.summary, notes: begin.notes || [], outputs: result.outputs } } : {}) }, true);
15324
+ else if (opts.silent) { /* the queue lists the files */ }
15325
+ else if (delivery && result.outputs[0]) {
15326
+ const o = result.outputs[0];
15327
+ exportQueue.push({ id: "q" + Math.random().toString(36).slice(2, 8), label: queueLabel(delivery), spec: delivery,
15328
+ name: String(o.file || "").replace(/\.[^.]+$/, ""), range: exportRangeMode(), status: "done", progress: 1,
15329
+ src: o.src, file: o.file, report: o.report || null });
15330
+ renderQueue();
15331
+ toast(o.report && !o.report.ok ? `Exported ${o.file} — check: ${o.report.checks.filter((c) => !c.ok).map((c) => c.text).join("; ")} (Export → Queue)` : `Exported ${o.file}${o.report ? " — checks passed" : ""}`);
15332
+ const a = document.createElement("a");
15333
+ a.href = end.src;
15334
+ a.download = decodeURIComponent(end.src.split("/").pop());
15335
+ a.click();
15336
+ }
13851
15337
  else {
13852
15338
  const a = document.createElement("a");
13853
15339
  a.href = end.src;
@@ -13857,17 +15343,198 @@ async function fastExport(opts = {}) {
13857
15343
  } catch (e) {
13858
15344
  if (sessId) fetch("/api/export/end?id=" + sessId + "&discard=1", { method: "POST" }).catch(() => { });
13859
15345
  const msg = e?.name === "AbortError" ? "cancelled" : String(e.message || e);
15346
+ result = { error: msg, cancelled: msg === "cancelled" };
13860
15347
  if (job) await reportExportJob(job, { status: "failed", error: msg }, true);
13861
- else if (msg !== "cancelled") alert("Export failed: " + msg);
15348
+ else if (msg !== "cancelled" && !opts.silent) alert("Export failed: " + msg);
13862
15349
  } finally {
13863
15350
  exportAbort = null;
15351
+ exportFullCanvas = false;
13864
15352
  restoreExportVideoState();
13865
15353
  endExportWindow();
13866
15354
  state.exporting = false; state.rendering = false;
15355
+ releaseOriginalPlayers();
13867
15356
  els.exportOverlay.classList.add("hidden");
13868
15357
  els.exportNote.textContent = "Rendering your sequence in real time. Keep this tab focused.";
13869
15358
  if (runtime.pendingSync) syncFromServer();
13870
15359
  }
15360
+ return result;
15361
+ }
15362
+
15363
+ /* ── Export queue ──
15364
+ Renders waiting outputs back to back. Outputs that share a frame rate and a
15365
+ range go out in ONE render: the frames are drawn once and every output's
15366
+ ffmpeg encodes them (up to 8 at a time). Lives in this tab only. */
15367
+ const exportQueue = [];
15368
+ let queueRunning = false, queueStop = false;
15369
+ function queueLabel(spec) {
15370
+ const p = Delivery.PRESETS[spec.preset] || deliveryInfo.saved[spec.preset];
15371
+ return p ? p.label : "Custom";
15372
+ }
15373
+ function queueAdd(spec, name) {
15374
+ const fps = spec.fps || projectFps();
15375
+ exportQueue.push({
15376
+ id: "q" + Math.random().toString(36).slice(2, 8), label: queueLabel(spec), spec,
15377
+ name: name || deliveryFileName(spec, fps), range: exportRangeMode(), status: "pending", progress: 0,
15378
+ });
15379
+ renderQueue();
15380
+ }
15381
+ function openQueue() {
15382
+ renderQueue();
15383
+ $("exportQueue").classList.remove("hidden");
15384
+ ($("btnQueueStart").disabled ? $("btnQueueClose") : $("btnQueueStart")).focus();
15385
+ }
15386
+ function closeQueue() {
15387
+ if (queueRunning) return;
15388
+ $("exportQueue").classList.add("hidden");
15389
+ $("btnExport")?.focus();
15390
+ }
15391
+ function renderQueue() {
15392
+ const list = $("exportQueueList");
15393
+ if (!list) return;
15394
+ const pending = exportQueue.filter((q) => q.status === "pending").length;
15395
+ const done = exportQueue.filter((q) => q.status === "done").length;
15396
+ $("exportQueueNote").textContent = queueRunning
15397
+ ? "Exporting — outputs with the same frame rate and range share one render."
15398
+ : exportQueue.length ? `${pending} waiting · ${done} done` : "Nothing queued — pick settings in Export and press Add to queue.";
15399
+ const btnOpen = $("btnOpenQueue");
15400
+ if (btnOpen) btnOpen.textContent = exportQueue.length ? `Queue · ${pending || exportQueue.length}` : "Queue";
15401
+ const start = $("btnQueueStart");
15402
+ start.textContent = queueRunning ? "Stop" : "Start";
15403
+ start.disabled = !queueRunning && !pending;
15404
+ $("btnQueueClose").disabled = queueRunning;
15405
+ $("btnQueueClear").disabled = queueRunning || !exportQueue.some((q) => q.status !== "pending" && q.status !== "running");
15406
+ if (!exportQueue.length) { list.innerHTML = `<li class="xq-empty">The queue is empty</li>`; return; }
15407
+ list.innerHTML = exportQueue.map((q) => {
15408
+ const ext = Delivery.CONTAINERS[q.spec.container].ext;
15409
+ const fr = deliveryFrame(q.spec), fps = q.spec.fps || projectFps();
15410
+ const status = q.status === "running" ? `Rendering… ${Math.round(q.progress * 100)}%`
15411
+ : q.status === "done" ? `Done → exports/${q.file || q.name + ext}`
15412
+ : q.status === "failed" ? `Failed: ${q.error || "unknown error"}`
15413
+ : q.status === "cancelled" ? "Stopped" : "Waiting";
15414
+ const actions = [
15415
+ q.status === "done" && q.src ? `<a class="btn tiny" href="${escapeHtml(q.src)}" target="_blank" rel="noopener" download>Open</a>` : "",
15416
+ q.status === "failed" || q.status === "cancelled" ? `<button type="button" class="btn tiny" data-q-retry="${q.id}">Retry</button>` : "",
15417
+ q.status !== "running" ? `<button type="button" class="btn tiny" data-q-remove="${q.id}" title="Remove from the queue">✕</button>` : "",
15418
+ ].join("");
15419
+ return `<li class="${q.status}" data-q="${q.id}"><div class="xq-main"><b>${escapeHtml(q.label)} — ${escapeHtml(q.name + ext)}</b>` +
15420
+ `<span class="xq-sub">${escapeHtml(Delivery.describe(q.spec, { width: fr.outW, height: fr.outH, fps }))} · ${q.range === "in-out" ? "IN–OUT" : "entire timeline"}</span>` +
15421
+ (q.status === "running" ? `<div class="progress"><div class="progress-fill" style="width:${(q.progress * 100).toFixed(1)}%"></div></div>` : "") +
15422
+ `<span class="xq-status">${escapeHtml(status)}</span>` +
15423
+ (q.report ? `<span class="xq-report${q.report.ok ? "" : " warn"}">${q.report.ok ? "✓ " : ""}${escapeHtml(q.report.line)}</span>` : "") +
15424
+ `</div><div class="xq-actions">${actions}</div></li>`;
15425
+ }).join("");
15426
+ }
15427
+ function renderQueueProgress() {
15428
+ for (const q of exportQueue) {
15429
+ if (q.status !== "running") continue;
15430
+ const li = document.querySelector(`#exportQueueList li[data-q="${q.id}"]`);
15431
+ if (!li) continue;
15432
+ const fill = li.querySelector(".progress-fill"), st = li.querySelector(".xq-status");
15433
+ if (fill) fill.style.width = (q.progress * 100).toFixed(1) + "%";
15434
+ if (st) st.textContent = `Rendering… ${Math.round(q.progress * 100)}%`;
15435
+ }
15436
+ }
15437
+ async function runQueue() {
15438
+ if (queueRunning || state.exporting) return;
15439
+ if (!(state.connected && state.ffmpeg)) { alert("The export queue needs the server and ffmpeg (Fast export)."); return; }
15440
+ queueRunning = true; queueStop = false;
15441
+ renderQueue();
15442
+ try {
15443
+ while (!queueStop) {
15444
+ const first = exportQueue.find((q) => q.status === "pending");
15445
+ if (!first) break;
15446
+ const fps = first.spec.fps || projectFps();
15447
+ const group = exportQueue.filter((q) => q.status === "pending" && (q.spec.fps || projectFps()) === fps && q.range === first.range).slice(0, 8);
15448
+ for (const q of group) { q.status = "running"; q.progress = 0; q.error = null; }
15449
+ renderQueue();
15450
+ exportRangeForced = first.range === "in-out" && hasWorkArea() ? "in-out" : "entire";
15451
+ let res;
15452
+ try {
15453
+ res = await fastExport({
15454
+ deliveries: group.map((q) => ({ delivery: q.spec, name: q.name })), silent: true,
15455
+ onProgress: (f) => { for (const q of group) q.progress = f; renderQueueProgress(); },
15456
+ });
15457
+ } finally { exportRangeForced = null; }
15458
+ group.forEach((q, i) => {
15459
+ const o = res && res.outputs && res.outputs[i];
15460
+ if (o && o.ok) Object.assign(q, { status: "done", src: o.src, file: o.file, progress: 1, report: o.report || null });
15461
+ else if (res && res.cancelled) q.status = "cancelled";
15462
+ else Object.assign(q, { status: "failed", error: (o && o.error) || (res && res.error) || "export failed" });
15463
+ });
15464
+ renderQueue();
15465
+ }
15466
+ } finally {
15467
+ queueRunning = false;
15468
+ renderQueue();
15469
+ }
15470
+ const done = exportQueue.filter((q) => q.status === "done").length, failed = exportQueue.filter((q) => q.status === "failed").length;
15471
+ const warned = exportQueue.filter((q) => q.status === "done" && q.report && !q.report.ok).length;
15472
+ if (done || failed) toast(`Export queue: ${done} done${failed ? `, ${failed} failed` : ""}${warned ? ` — ${warned} with warnings` : ""}`);
15473
+ }
15474
+ function stopQueue() {
15475
+ queueStop = true;
15476
+ renderCancelled = true;
15477
+ try { exportAbort?.abort(); } catch { }
15478
+ }
15479
+
15480
+ /* ── Batch outputs (project.outputs) ──
15481
+ The project's list of files to make from one timeline. Each entry is a
15482
+ delivery spec plus {id, on}; "Export checked" queues the checked ones. */
15483
+ function projectOutputs() { return Array.isArray(project.outputs) ? project.outputs : []; }
15484
+ function setProjectOutputs(list) {
15485
+ project.outputs = list.length ? list : null;
15486
+ scheduleSave();
15487
+ renderBatchList();
15488
+ }
15489
+ function addBatchOutput(spec) {
15490
+ const list = projectOutputs().slice();
15491
+ if (list.length >= 16) { toast("At most 16 batch outputs"); return; }
15492
+ list.push({ id: "o_" + Math.random().toString(36).slice(2, 8), on: true, ...spec });
15493
+ setProjectOutputs(list);
15494
+ }
15495
+ const TYPICAL_SET = [
15496
+ { preset: "master", filename: "{project}_master_{res}" },
15497
+ { preset: "youtube", filename: "{project}_youtube_{res}" },
15498
+ { preset: "instagram-reels", filename: "{project}_reel_{res}" },
15499
+ { preset: "custom", width: 1080, height: 1080, filename: "{project}_square_{res}" },
15500
+ { preset: "web", filename: "{project}_preview_{res}" },
15501
+ ];
15502
+ function renderBatchList() {
15503
+ const ul = $("dlvBatchList");
15504
+ if (!ul) return;
15505
+ const list = projectOutputs();
15506
+ const on = list.filter((o) => o.on !== false).length;
15507
+ $("dlvBatchCount").textContent = list.length ? `· ${on} of ${list.length} checked` : "";
15508
+ const be = $("btnBatchExport");
15509
+ if (be) { be.disabled = !on; be.textContent = on ? `Export checked (${on})` : "Export checked"; }
15510
+ if (!list.length) { ul.innerHTML = `<li class="bo-empty">No batch outputs yet — add the settings above, or a typical set</li>`; return; }
15511
+ ul.innerHTML = list.map((o) => {
15512
+ const sp = normalizeDelivery(o).spec, fr = deliveryFrame(sp), fps = sp.fps || projectFps();
15513
+ return `<li class="${o.on === false ? "off" : ""}" data-o="${escapeHtml(o.id)}">` +
15514
+ `<input type="checkbox" data-o-on="${escapeHtml(o.id)}"${o.on === false ? "" : " checked"} aria-label="Include ${escapeHtml(queueLabel(sp))}">` +
15515
+ `<div class="bo-main"><b>${escapeHtml(deliveryFileName(sp, fps) + Delivery.CONTAINERS[sp.container].ext)}</b>` +
15516
+ `<span>${escapeHtml(queueLabel(sp))} · ${escapeHtml(Delivery.describe(sp, { width: fr.outW, height: fr.outH, fps }))}</span></div>` +
15517
+ `<button type="button" class="btn tiny" data-o-load="${escapeHtml(o.id)}" title="Show these settings above">Load</button>` +
15518
+ `<button type="button" class="btn tiny" data-o-remove="${escapeHtml(o.id)}" title="Remove">✕</button></li>`;
15519
+ }).join("");
15520
+ }
15521
+ function onBatchListClick(e) {
15522
+ const t = e.target;
15523
+ const id = t.dataset.oOn || t.dataset.oLoad || t.dataset.oRemove;
15524
+ if (!id) return;
15525
+ const list = projectOutputs().slice();
15526
+ const i = list.findIndex((o) => o.id === id);
15527
+ if (i < 0) return;
15528
+ if (t.dataset.oOn) { list[i] = { ...list[i], on: t.checked }; setProjectOutputs(list); }
15529
+ else if (t.dataset.oRemove) { list.splice(i, 1); setProjectOutputs(list); }
15530
+ else if (t.dataset.oLoad) {
15531
+ const { id: _id, on: _on, ...spec } = list[i];
15532
+ const n = normalizeDelivery(spec);
15533
+ els.exportProfileSel.value = "d:" + n.spec.preset;
15534
+ if (els.exportProfileSel.value !== "d:" + n.spec.preset) els.exportProfileSel.value = "d:custom";
15535
+ dlvSpec = n.spec; dlvNotes = n.notes;
15536
+ syncExportChoiceUi("edited");
15537
+ }
13871
15538
  }
13872
15539
 
13873
15540
  /* ── Agent-requested export (fablecut_export · POST /api/export/request) ──
@@ -13914,7 +15581,31 @@ async function runExportJob(ticket) {
13914
15581
  try {
13915
15582
  if (exportRange().frames < 1) return fail("the export range is empty — IN/OUT is at or past the end of the timeline");
13916
15583
  reportExportJob(ticket.id, { progress: 0 }, true);
13917
- await fastExport({ job: ticket.id, profile: ticket.profile });
15584
+ // an explicit profile wins; then the job's delivery spec, the project's, else the raw default
15585
+ const want = ticket.profile || ticket.deliveries ? null : ticket.delivery || project.delivery || null;
15586
+ if (want || ticket.deliveries) await fetchDeliveryInfo();
15587
+ if (ticket.deliveries) {
15588
+ // one render per frame rate; the job reports every file at the end
15589
+ const specs = ticket.deliveries.map((d) => normalizeDelivery(d).spec);
15590
+ const groups = new Map();
15591
+ for (const sp of specs) { const k = sp.fps || projectFps(); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(sp); }
15592
+ if (groups.size === 1) await fastExport({ job: ticket.id, deliveries: specs.map((delivery) => ({ delivery })) });
15593
+ else {
15594
+ const all = [];
15595
+ let i = 0;
15596
+ for (const group of groups.values()) {
15597
+ const res = await fastExport({ deliveries: group.map((delivery) => ({ delivery })), silent: true,
15598
+ onProgress: (f) => reportExportJob(ticket.id, { progress: (i + f) / groups.size }) });
15599
+ i++;
15600
+ if (res && res.cancelled) return reportExportJob(ticket.id, { status: "failed", error: "cancelled" }, true);
15601
+ if (res && res.outputs) all.push(...res.outputs);
15602
+ else all.push(...group.map((sp) => ({ file: deliveryFileName(sp), ok: false, error: (res && res.error) || "export failed" })));
15603
+ }
15604
+ const ok = all.find((o) => o.ok);
15605
+ if (!ok) return fail(all.map((o) => o.error).join(" · "));
15606
+ await reportExportJob(ticket.id, { status: "done", src: ok.src, result: { outputs: all } }, true);
15607
+ }
15608
+ } else await fastExport(want ? { job: ticket.id, delivery: normalizeDelivery(want).spec } : { job: ticket.id, profile: ticket.profile });
13918
15609
  } finally {
13919
15610
  exportRangeForced = null;
13920
15611
  }
@@ -14027,6 +15718,10 @@ async function runTrackJob(ticket, fail) {
14027
15718
  tracks), then measure it with color.js scopeStats. The user's playhead
14028
15719
  stays where it is. */
14029
15720
  async function renderStillAt(t) {
15721
+ if (!runtime.forceOriginals) { // measured and shown to agents as export renders it: the originals
15722
+ await useOriginalPlayers();
15723
+ try { return await renderStillAt(t); } finally { releaseOriginalPlayers(); }
15724
+ }
14030
15725
  const waits = [];
14031
15726
  for (const c of mediaClips()) {
14032
15727
  if (!clipRenders(c) || !activeAt(c, t)) continue;
@@ -14047,6 +15742,7 @@ async function renderStillAt(t) {
14047
15742
  }
14048
15743
  async function runScopesJob(ticket, fail) {
14049
15744
  const wasPlaying = state.playing;
15745
+ let heldOriginals = false;
14050
15746
  try {
14051
15747
  if (wasPlaying) pause();
14052
15748
  await loadProjectFonts();
@@ -14060,6 +15756,8 @@ async function runScopesJob(ticket, fail) {
14060
15756
  const kClip = ticket.mask && getClip(ticket.mask.clip), view = runtime.maskView;
14061
15757
  runtime.maskCapture = kClip ? { id: kClip.id } : null;
14062
15758
  runtime.maskView = null;
15759
+ // the matte below re-grades the captured original — keep the originals loaded until then
15760
+ if (!runtime.forceOriginals) { await useOriginalPlayers(); heldOriginals = true; }
14063
15761
  try { await renderStillAt(t); } finally { runtime.matte = shown; runtime.maskView = view; }
14064
15762
  const kcap = runtime.maskCapture;
14065
15763
  runtime.maskCapture = null;
@@ -14114,6 +15812,14 @@ async function runScopesJob(ticket, fail) {
14114
15812
  else if (!kcap || kcap.coverage == null) mask.error = "the clip is not on screen at that time (or its masks are all off)";
14115
15813
  else Object.assign(mask, { masks: Mask.describe(clipMasks(kClip)), coverage: kcap.coverage, frameCoverage: kcap.frameCoverage });
14116
15814
  }
15815
+ /* fablecut_frame {time} with no src: hand back the same graded still as a
15816
+ JPEG so the agent can see what it is measuring. toDataURL can throw on a
15817
+ canvas tainted by a cross-origin src — the numbers still stand. */
15818
+ let image = null;
15819
+ if (ticket.image) {
15820
+ try { image = cv.toDataURL("image/jpeg", 0.85); }
15821
+ catch (err) { image = null; }
15822
+ }
14117
15823
  const onScreen = visibleClipsAt(t), mattes = matteTracksAt(onScreen);
14118
15824
  const clips = onScreen.map((c) => {
14119
15825
  const tm = clipMatte(c);
@@ -14126,13 +15832,14 @@ async function runScopesJob(ticket, fail) {
14126
15832
  ...(c.props.blend && c.props.blend !== "normal" ? { blend: c.props.blend } : {}),
14127
15833
  };
14128
15834
  });
14129
- await reportExportJob(ticket.id, { status: "done", result: { time: +t.toFixed(3), frame: { w: Math.round(r.w), h: Math.round(r.h) }, stats, clips, matte, mask } }, true);
15835
+ await reportExportJob(ticket.id, { status: "done", result: { time: +t.toFixed(3), frame: { w: Math.round(r.w), h: Math.round(r.h) }, stats, clips, matte, mask, image } }, true);
14130
15836
  } catch (err) {
14131
15837
  fail("could not measure the frame: " + (err && err.message || err));
14132
15838
  } finally {
14133
15839
  runtime.gradeCapture = null;
14134
15840
  runtime.maskCapture = null;
14135
15841
  state.rendering = false;
15842
+ if (heldOriginals) releaseOriginalPlayers();
14136
15843
  seekMediaWhilePaused(); // the videos sat on the job's frame — back to the playhead
14137
15844
  if (wasPlaying) play();
14138
15845
  }
@@ -14248,6 +15955,7 @@ async function webCodecsExport() {
14248
15955
  await loadProjectFonts();
14249
15956
  resetExportCanvases();
14250
15957
  await armExportCors();
15958
+ await useOriginalPlayers(); // proxies are for editing — export reads the originals
14251
15959
 
14252
15960
  for (let f = 0; f < frames; f++) {
14253
15961
  if (renderCancelled || signal.aborted) throw new Error("cancelled");
@@ -14306,6 +16014,7 @@ async function webCodecsExport() {
14306
16014
  restoreExportVideoState();
14307
16015
  endExportWindow();
14308
16016
  state.exporting = false; state.rendering = false;
16017
+ releaseOriginalPlayers();
14309
16018
  els.exportOverlay.classList.add("hidden");
14310
16019
  els.exportNote.textContent = "Rendering your sequence in real time. Keep this tab focused.";
14311
16020
  if (runtime.pendingSync) syncFromServer();
@@ -14331,6 +16040,7 @@ async function startExport() {
14331
16040
  pause();
14332
16041
  const { start } = beginExportWindow();
14333
16042
  state.time = start;
16043
+ await useOriginalPlayers(); // proxies are for editing — export reads the originals
14334
16044
  seekMediaWhilePaused();
14335
16045
  await new Promise((r) => setTimeout(r, 350)); // let first frames decode
14336
16046
  await loadProjectFonts();
@@ -14362,6 +16072,7 @@ async function startExport() {
14362
16072
  function finishExport(keep) {
14363
16073
  if (!state.exporting) return;
14364
16074
  state.exporting = false;
16075
+ releaseOriginalPlayers();
14365
16076
  endExportWindow();
14366
16077
  if (runtime.pendingSync) syncFromServer();
14367
16078
  recDiscard = !keep;
@@ -14411,14 +16122,80 @@ els.exportSetup?.addEventListener("change", (e) => {
14411
16122
  }
14412
16123
  if (e.target.id === "exportRangeSel") syncExportRangeUi();
14413
16124
  });
14414
- els.exportProfileSel?.addEventListener("change", (e) => {
14415
- const id = e.target.value;
14416
- if (!project.encodeProfile) {
14417
- setSetting("encodeProfile", id === encodeProfiles.default ? null : id);
16125
+ els.exportProfileSel?.addEventListener("change", () => {
16126
+ const c = selectedExportChoice();
16127
+ if (c.kind === "profile" && !project.encodeProfile)
16128
+ setSetting("encodeProfile", c.id === encodeProfiles.default ? null : c.id);
16129
+ syncExportChoiceUi("preset");
16130
+ });
16131
+ $("dlvBody")?.addEventListener("change", onDeliveryControl);
16132
+ $("dlvBody")?.addEventListener("input", onDeliveryControl);
16133
+ $("dlvSaveBtn")?.addEventListener("click", () => {
16134
+ $("dlvSaveRow").classList.remove("hidden");
16135
+ const p = Delivery.PRESETS[dlvSpec?.preset] || deliveryInfo.saved[dlvSpec?.preset];
16136
+ $("dlvSaveName").value = p && !Delivery.PRESETS[dlvSpec.preset] ? p.label : "";
16137
+ $("dlvSaveName").focus();
16138
+ });
16139
+ $("dlvSaveOk")?.addEventListener("click", saveDeliveryPreset);
16140
+ $("dlvSaveName")?.addEventListener("keydown", (e) => {
16141
+ if (e.key === "Enter") { e.preventDefault(); saveDeliveryPreset(); }
16142
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); $("dlvSaveRow").classList.add("hidden"); }
16143
+ });
16144
+ $("dlvSaveCancel")?.addEventListener("click", () => $("dlvSaveRow").classList.add("hidden"));
16145
+ $("dlvDeleteBtn")?.addEventListener("click", deleteDeliveryPreset);
16146
+ $("dlvBatchList")?.addEventListener("click", onBatchListClick);
16147
+ $("dlvBatchList")?.addEventListener("change", onBatchListClick);
16148
+ $("dlvBatchAdd")?.addEventListener("click", () => { if (dlvSpec) addBatchOutput(dlvSpec); });
16149
+ $("dlvBatchTypical")?.addEventListener("click", () => {
16150
+ const have = new Set(projectOutputs().map((o) => o.filename));
16151
+ for (const t of TYPICAL_SET) if (!have.has(t.filename)) addBatchOutput(normalizeDelivery(t).spec);
16152
+ });
16153
+ function exportPreflight() {
16154
+ if (exportRange().frames < 1) {
16155
+ alert("Export range is empty — IN/OUT is at or past the end of the timeline. Choose Entire timeline, or move the markers.");
16156
+ return false;
14418
16157
  }
14419
- updateExportProfileNote(id);
16158
+ return true;
16159
+ }
16160
+ $("btnQueueExport")?.addEventListener("click", () => {
16161
+ if (selectedExportChoice().kind !== "delivery" || !dlvSpec) { alert("The queue takes delivery presets — pick one above (encoding profiles export one at a time)."); return; }
16162
+ if (!exportPreflight()) return;
16163
+ setSetting("exportKind", "delivery");
16164
+ setSetting("delivery", dlvSpec);
16165
+ queueAdd(dlvSpec);
16166
+ els.exportSetup.classList.add("hidden");
16167
+ openQueue();
16168
+ });
16169
+ $("btnBatchExport")?.addEventListener("click", () => {
16170
+ const on = projectOutputs().filter((o) => o.on !== false);
16171
+ if (!on.length || !exportPreflight()) return;
16172
+ for (const o of on) { const { id: _id, on: _on, ...spec } = o; queueAdd(normalizeDelivery(spec).spec); }
16173
+ els.exportSetup.classList.add("hidden");
16174
+ openQueue();
16175
+ runQueue();
16176
+ });
16177
+ $("btnOpenQueue")?.addEventListener("click", () => { els.exportSetup.classList.add("hidden"); openQueue(); });
16178
+ $("btnQueueStart")?.addEventListener("click", () => (queueRunning ? stopQueue() : runQueue()));
16179
+ $("btnQueueClose")?.addEventListener("click", closeQueue);
16180
+ $("exportQueue")?.addEventListener("keydown", (e) => {
16181
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeQueue(); }
16182
+ });
16183
+ $("btnQueueClear")?.addEventListener("click", () => {
16184
+ for (let i = exportQueue.length - 1; i >= 0; i--) if (exportQueue[i].status !== "pending" && exportQueue[i].status !== "running") exportQueue.splice(i, 1);
16185
+ renderQueue();
16186
+ });
16187
+ $("exportQueueList")?.addEventListener("click", (e) => {
16188
+ const r = e.target.dataset.qRetry, x = e.target.dataset.qRemove;
16189
+ const q = exportQueue.find((v) => v.id === (r || x));
16190
+ if (!q) return;
16191
+ if (r) { q.status = "pending"; q.error = null; q.progress = 0; }
16192
+ else exportQueue.splice(exportQueue.indexOf(q), 1);
16193
+ renderQueue();
16194
+ });
16195
+ $("btnCancelSetup").addEventListener("click", closeExportSetup);
16196
+ els.exportSetup?.addEventListener("keydown", (e) => {
16197
+ if (e.key === "Escape" && !e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); closeExportSetup(); }
14420
16198
  });
14421
- $("btnCancelSetup").addEventListener("click", () => els.exportSetup.classList.add("hidden"));
14422
16199
  els.engineFast?.addEventListener("change", syncExportWcOpts);
14423
16200
  els.engineRealtime?.addEventListener("change", syncExportWcOpts);
14424
16201
  $("exportWcBitrate")?.addEventListener("change", persistExportWcOpts);
@@ -14605,6 +16382,14 @@ function openProjectTabMenu(clientX, clientY) {
14605
16382
  enterComp(comp.id);
14606
16383
  });
14607
16384
  menu.appendChild(compItem);
16385
+ const vids = project.media.filter((m) => m.kind === "video" && !proxyOf(m) && !isMediaOffline(m.id));
16386
+ if (vids.length && state.connected && state.ffmpeg) {
16387
+ const px = document.createElement("div");
16388
+ px.className = "ctx-opt";
16389
+ px.textContent = `Create proxies for ${vids.length} video${vids.length === 1 ? "" : "s"}`;
16390
+ px.addEventListener("click", () => { closeBinCtxMenu(); createProxies(vids, 540); });
16391
+ menu.appendChild(px);
16392
+ }
14608
16393
  document.body.appendChild(menu);
14609
16394
  const pad = 6;
14610
16395
  const w = menu.offsetWidth, h = menu.offsetHeight;