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/CLAUDE.md +373 -11
- package/README.md +48 -5
- package/analyze.js +161 -9
- package/app.js +1894 -109
- package/delivery.js +779 -0
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +136 -4
- package/mcp-server.js +318 -1070
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/package.json +1 -1
- package/paths.js +9 -2
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +498 -15
- package/style.css +361 -0
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
|
-
|
|
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
|
-
.
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
</
|
|
1972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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 {
|
|
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.
|
|
2258
|
-
|
|
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.
|
|
2265
|
-
|
|
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
|
-
|
|
3081
|
-
|
|
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
|
-
|
|
5750
|
-
|
|
5751
|
-
el.
|
|
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
|
-
//
|
|
6320
|
-
|
|
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 =
|
|
6325
|
-
el._fcSrc =
|
|
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
|
-
|
|
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
|
-
|
|
12774
|
-
if (
|
|
12775
|
-
|
|
12776
|
-
|
|
12777
|
-
|
|
12778
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
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 (
|
|
12869
|
-
|
|
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
|
-
|
|
12872
|
-
|
|
12873
|
-
|
|
12874
|
-
|
|
12875
|
-
|
|
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
|
-
|
|
13004
|
-
if (useFast)
|
|
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
|
|
13747
|
-
const
|
|
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 =
|
|
13756
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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", (
|
|
14415
|
-
const
|
|
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
|
-
|
|
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;
|