fablecut 1.13.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 +802 -16
- package/README.md +71 -7
- package/analyze.js +161 -9
- package/app.js +3905 -258
- package/comp.js +195 -0
- package/delivery.js +779 -0
- package/edit-ops.js +50 -18
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +282 -72
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mcp-server.js +324 -656
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +33 -3
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +517 -15
- package/style.css +534 -20
- package/templates.js +576 -0
package/media-lib.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/* FableCut media management — the Project bin's metadata, search, filters,
|
|
2
|
+
sorting, tags, collections and usage. Zero dependencies. Loaded by the
|
|
3
|
+
editor as a plain script (global `FableCutMedia`) and required by the
|
|
4
|
+
server, the MCP server and the tests.
|
|
5
|
+
|
|
6
|
+
A media entry may carry, beside id/name/kind/src/duration/width/height:
|
|
7
|
+
size bytes on disk
|
|
8
|
+
fps video frame rate (29.97, 30, 59.94 …)
|
|
9
|
+
codec video codec (h264, hevc, vp9, prores …) — or the image format
|
|
10
|
+
audioCodec aac, mp3, pcm_s16le, opus …
|
|
11
|
+
audioChannels channel count of the first audio stream, 0 = no audio
|
|
12
|
+
sampleRate Hz
|
|
13
|
+
bitrate bits per second (whole file)
|
|
14
|
+
favorite true = starred in the bin
|
|
15
|
+
tags ["interview", "b-roll"]
|
|
16
|
+
Metadata comes from ffprobe (parseProbe) — the server reads it, nobody
|
|
17
|
+
types it. Collections are project-level: project.collections =
|
|
18
|
+
[{id, name, media:[mediaId…]}], a media entry can be in any number of them. */
|
|
19
|
+
(function (root, factory) {
|
|
20
|
+
const node = typeof module === "object" && module.exports;
|
|
21
|
+
const api = factory();
|
|
22
|
+
if (node) module.exports = api;
|
|
23
|
+
else root.FableCutMedia = api;
|
|
24
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
25
|
+
"use strict";
|
|
26
|
+
|
|
27
|
+
const KINDS = ["video", "audio", "image", "svg"];
|
|
28
|
+
/** Fields the server probes. Written back to the entry; never user-set. */
|
|
29
|
+
const INFO_KEYS = ["size", "fps", "codec", "audioCodec", "audioChannels", "sampleRate", "bitrate"];
|
|
30
|
+
const MAX_TAGS = 24, MAX_TAG_LEN = 32;
|
|
31
|
+
const SORT_KEYS = ["name", "kind", "duration", "resolution", "fps", "codec", "audio", "size", "usage"];
|
|
32
|
+
|
|
33
|
+
const ratio = (s) => {
|
|
34
|
+
const [a, b] = String(s || "").split("/").map(Number);
|
|
35
|
+
return b > 0 && a > 0 ? a / b : (a > 0 && !b && !String(s).includes("/") ? a : 0);
|
|
36
|
+
};
|
|
37
|
+
const round = (v, n) => Math.round(v * 10 ** n) / 10 ** n;
|
|
38
|
+
|
|
39
|
+
/** ffprobe -show_format -show_streams -of json output → the INFO_KEYS (+ duration,
|
|
40
|
+
* width, height). `size` falls back to the stat size the caller passes. */
|
|
41
|
+
function parseProbe(json, statSize) {
|
|
42
|
+
const out = {};
|
|
43
|
+
const streams = Array.isArray(json && json.streams) ? json.streams : [];
|
|
44
|
+
const fmt = (json && json.format) || {};
|
|
45
|
+
const v = streams.find((s) => s.codec_type === "video" && !(s.disposition && s.disposition.attached_pic));
|
|
46
|
+
const a = streams.find((s) => s.codec_type === "audio");
|
|
47
|
+
const size = Number(fmt.size) || Number(statSize) || 0;
|
|
48
|
+
if (size > 0) out.size = size;
|
|
49
|
+
const dur = Number(fmt.duration);
|
|
50
|
+
if (dur > 0) out.duration = round(dur, 3);
|
|
51
|
+
const br = Number(fmt.bit_rate);
|
|
52
|
+
if (br > 0) out.bitrate = Math.round(br);
|
|
53
|
+
// stills (png/jpg/webp, gif) report 25/1 — not a frame rate anyone shot at
|
|
54
|
+
const still = !!v && /^(png|mjpeg|jpeg|webp|bmp|tiff|gif)$/.test(String(v.codec_name || "")) && !(dur > 0 && Number(v.nb_frames) > 1);
|
|
55
|
+
if (v) {
|
|
56
|
+
if (v.codec_name) out.codec = String(v.codec_name);
|
|
57
|
+
let w = Number(v.width) || 0, h = Number(v.height) || 0;
|
|
58
|
+
const rot = Number(((v.side_data_list || []).find((d) => d.rotation != null) || {}).rotation ?? (v.tags && v.tags.rotate)) || 0;
|
|
59
|
+
if (Math.abs(rot) % 180 === 90) [w, h] = [h, w];
|
|
60
|
+
if (w > 0 && h > 0) { out.width = w; out.height = h; }
|
|
61
|
+
const fps = ratio(v.avg_frame_rate) || ratio(v.r_frame_rate);
|
|
62
|
+
if (!still && fps > 0 && fps < 1000) out.fps = round(fps, 3);
|
|
63
|
+
}
|
|
64
|
+
if (a) {
|
|
65
|
+
if (a.codec_name) out.audioCodec = String(a.codec_name);
|
|
66
|
+
const ch = Number(a.channels);
|
|
67
|
+
if (ch > 0) out.audioChannels = ch;
|
|
68
|
+
const sr = Number(a.sample_rate);
|
|
69
|
+
if (sr > 0) out.sampleRate = sr;
|
|
70
|
+
} else if (v && !still) out.audioChannels = 0; // a video without sound says so; a picture has no audio to speak of
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── formatting ── */
|
|
75
|
+
function fmtSize(b) {
|
|
76
|
+
if (!(b > 0)) return "";
|
|
77
|
+
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
78
|
+
let i = 0, n = b;
|
|
79
|
+
while (n >= 1000 && i < u.length - 1) { n /= 1000; i++; }
|
|
80
|
+
return (i === 0 ? n : n < 10 ? n.toFixed(1) : Math.round(n)) + " " + u[i];
|
|
81
|
+
}
|
|
82
|
+
function fmtFps(f) {
|
|
83
|
+
if (!(f > 0)) return "";
|
|
84
|
+
return String(round(f, 2));
|
|
85
|
+
}
|
|
86
|
+
function fmtRes(m) { return m && m.width > 0 && m.height > 0 ? `${m.width}×${m.height}` : ""; }
|
|
87
|
+
function fmtAudio(m) {
|
|
88
|
+
if (!m || m.audioChannels == null) return "";
|
|
89
|
+
if (m.audioChannels === 0) return "no audio";
|
|
90
|
+
const ch = m.audioChannels === 1 ? "mono" : m.audioChannels === 2 ? "stereo" : m.audioChannels === 6 ? "5.1" : m.audioChannels === 8 ? "7.1" : m.audioChannels + " ch";
|
|
91
|
+
return ch + (m.sampleRate ? ` ${round(m.sampleRate / 1000, 1)} kHz` : "");
|
|
92
|
+
}
|
|
93
|
+
function fmtDur(t) {
|
|
94
|
+
if (!(t > 0)) return "";
|
|
95
|
+
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
|
|
96
|
+
const ss = s.toFixed(2).padStart(5, "0");
|
|
97
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
|
|
98
|
+
}
|
|
99
|
+
/** One-line summary: "1920×1080 · 29.97 fps · h264 · stereo 48 kHz · 45 MB". */
|
|
100
|
+
function describe(m) {
|
|
101
|
+
return [fmtRes(m), m.fps ? fmtFps(m.fps) + " fps" : "", m.codec || "", fmtAudio(m), fmtSize(m.size)]
|
|
102
|
+
.filter(Boolean).join(" · ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ── tags and collections ── */
|
|
106
|
+
function normalizeTags(v) {
|
|
107
|
+
const list = Array.isArray(v) ? v : typeof v === "string" ? v.split(",") : [];
|
|
108
|
+
const out = [], seen = new Set();
|
|
109
|
+
for (const t of list) {
|
|
110
|
+
const s = String(t == null ? "" : t).replace(/^#+/, "").replace(/\s+/g, " ").trim().slice(0, MAX_TAG_LEN);
|
|
111
|
+
if (!s || seen.has(s.toLowerCase())) continue;
|
|
112
|
+
seen.add(s.toLowerCase());
|
|
113
|
+
out.push(s);
|
|
114
|
+
if (out.length >= MAX_TAGS) break;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Every tag used in the project, sorted, each once (first spelling wins). */
|
|
119
|
+
function allTags(media) {
|
|
120
|
+
const seen = new Map();
|
|
121
|
+
for (const m of media || []) for (const t of m.tags || []) if (!seen.has(t.toLowerCase())) seen.set(t.toLowerCase(), t);
|
|
122
|
+
return [...seen.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
123
|
+
}
|
|
124
|
+
function normalizeCollections(list, media) {
|
|
125
|
+
if (!Array.isArray(list)) return [];
|
|
126
|
+
const ids = media ? new Set(media.map((m) => m.id)) : null;
|
|
127
|
+
const out = [], seen = new Set();
|
|
128
|
+
for (const c of list) {
|
|
129
|
+
if (!c || !c.id || seen.has(String(c.id))) continue;
|
|
130
|
+
seen.add(String(c.id));
|
|
131
|
+
const mem = [...new Set((Array.isArray(c.media) ? c.media : []).map(String))].filter((id) => !ids || ids.has(id));
|
|
132
|
+
out.push({ id: String(c.id), name: String(c.name || "Collection").trim().slice(0, 80) || "Collection", media: mem });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ── usage ── */
|
|
138
|
+
/** Where a media entry is placed: one entry per placement (a video and its
|
|
139
|
+
* linked audio stems count once), on the main timeline (comp: null) and
|
|
140
|
+
* inside every composition. Sorted main timeline first, then by time. */
|
|
141
|
+
function placements(doc, mediaId) {
|
|
142
|
+
const out = [];
|
|
143
|
+
const scan = (clips, comp) => {
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
for (const c of clips || []) {
|
|
146
|
+
if (c.mediaId !== mediaId) continue;
|
|
147
|
+
const key = c.linkGroup ? "g:" + c.linkGroup : "c:" + c.id;
|
|
148
|
+
const prev = groups.get(key);
|
|
149
|
+
// the picture represents a placement; its stems fold into it
|
|
150
|
+
if (!prev || (prev.kind !== "video" && c.kind === "video")) groups.set(key, c);
|
|
151
|
+
}
|
|
152
|
+
for (const c of groups.values())
|
|
153
|
+
out.push({ clipId: c.id, comp: comp ? comp.id : null, compName: comp ? comp.name : null, track: c.track, start: +c.start || 0, duration: +c.duration || 0, kind: c.kind });
|
|
154
|
+
};
|
|
155
|
+
scan(doc && doc.clips, null);
|
|
156
|
+
for (const comp of (doc && doc.comps) || []) scan(comp.clips, comp);
|
|
157
|
+
return out.sort((a, b) => (a.comp ? 1 : 0) - (b.comp ? 1 : 0) || String(a.comp || "").localeCompare(String(b.comp || "")) || a.start - b.start);
|
|
158
|
+
}
|
|
159
|
+
/** mediaId → number of placements (see placements()). */
|
|
160
|
+
function usageCounts(doc) {
|
|
161
|
+
const n = new Map();
|
|
162
|
+
for (const m of (doc && doc.media) || []) n.set(m.id, 0);
|
|
163
|
+
const scan = (clips) => {
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
for (const c of clips || []) {
|
|
166
|
+
if (!c.mediaId) continue;
|
|
167
|
+
// per file: a denoised stem shares its video's linkGroup but plays another file
|
|
168
|
+
const key = (c.linkGroup ? "g:" + c.linkGroup : "c:" + c.id) + "|" + c.mediaId;
|
|
169
|
+
if (seen.has(key)) continue;
|
|
170
|
+
seen.add(key);
|
|
171
|
+
n.set(c.mediaId, (n.get(c.mediaId) || 0) + 1);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
scan(doc && doc.clips);
|
|
175
|
+
for (const comp of (doc && doc.comps) || []) scan(comp.clips);
|
|
176
|
+
return n;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── search, filter, sort ── */
|
|
180
|
+
const ext = (m) => (/\.([a-z0-9]+)$/i.exec(String(m.name || m.src || "")) || [])[1] || "";
|
|
181
|
+
/** Search text: every word must match the name, kind, extension, codecs,
|
|
182
|
+
* resolution or a tag; "#word" matches tags only. */
|
|
183
|
+
function matchesQuery(m, q) {
|
|
184
|
+
const words = String(q || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
185
|
+
if (!words.length) return true;
|
|
186
|
+
const tags = (m.tags || []).map((t) => t.toLowerCase());
|
|
187
|
+
const hay = [m.name, m.kind, ext(m), m.codec, m.audioCodec, fmtRes(m).replace("×", "x"), m.height ? m.height + "p" : ""]
|
|
188
|
+
.filter(Boolean).join(" ").toLowerCase();
|
|
189
|
+
return words.every((w) => w.startsWith("#")
|
|
190
|
+
? tags.some((t) => t.includes(w.slice(1)))
|
|
191
|
+
: hay.includes(w) || tags.some((t) => t.includes(w)));
|
|
192
|
+
}
|
|
193
|
+
/** filter: "" | "fav" | "used" | "unused" | "kind:<kind>" | "tag:<tag>" | "col:<collectionId>". */
|
|
194
|
+
function matchesFilter(m, filter, ctx) {
|
|
195
|
+
const f = String(filter || "");
|
|
196
|
+
if (!f || f === "all") return true;
|
|
197
|
+
const uses = ctx && ctx.usage ? ctx.usage.get(m.id) || 0 : 0;
|
|
198
|
+
if (f === "fav") return m.favorite === true;
|
|
199
|
+
if (f === "used") return uses > 0;
|
|
200
|
+
if (f === "unused") return uses === 0;
|
|
201
|
+
if (f.startsWith("kind:")) return m.kind === f.slice(5);
|
|
202
|
+
if (f.startsWith("tag:")) { const t = f.slice(4).toLowerCase(); return (m.tags || []).some((x) => x.toLowerCase() === t); }
|
|
203
|
+
if (f.startsWith("col:")) {
|
|
204
|
+
const c = ((ctx && ctx.collections) || []).find((x) => x.id === f.slice(4));
|
|
205
|
+
return !!c && c.media.includes(m.id);
|
|
206
|
+
}
|
|
207
|
+
if (ctx && typeof ctx.extraFilter === "function") return ctx.extraFilter(m, f);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
/** Sort value for a key; null sorts last whatever the direction. */
|
|
211
|
+
function sortValue(m, key, ctx) {
|
|
212
|
+
switch (key) {
|
|
213
|
+
case "kind": return KINDS.indexOf(m.kind);
|
|
214
|
+
case "duration": return m.duration > 0 ? m.duration : null;
|
|
215
|
+
case "resolution": return m.width > 0 && m.height > 0 ? m.width * m.height : null;
|
|
216
|
+
case "fps": return m.fps > 0 ? m.fps : null;
|
|
217
|
+
case "codec": return m.codec || null;
|
|
218
|
+
case "audio": return m.audioChannels != null ? m.audioChannels : null;
|
|
219
|
+
case "size": return m.size > 0 ? m.size : null;
|
|
220
|
+
case "usage": return ctx && ctx.usage ? ctx.usage.get(m.id) || 0 : 0;
|
|
221
|
+
default: return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function sortMedia(list, key, dir, ctx) {
|
|
225
|
+
const sign = dir === "desc" ? -1 : 1;
|
|
226
|
+
const byName = (a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: "base", numeric: true });
|
|
227
|
+
return list.slice().sort((a, b) => {
|
|
228
|
+
if (!key || key === "name") return sign * byName(a, b);
|
|
229
|
+
const va = sortValue(a, key, ctx), vb = sortValue(b, key, ctx);
|
|
230
|
+
if (va == null || vb == null) return va == null && vb == null ? byName(a, b) : va == null ? 1 : -1;
|
|
231
|
+
const d = typeof va === "string" ? va.localeCompare(vb) : va - vb;
|
|
232
|
+
return d ? sign * d : byName(a, b);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** search + filter + sort in one go. Favorites float to the top when asked. */
|
|
236
|
+
function query(media, opts, ctx) {
|
|
237
|
+
const o = opts || {};
|
|
238
|
+
const hit = (media || []).filter((m) => matchesQuery(m, o.q) && matchesFilter(m, o.filter, ctx));
|
|
239
|
+
return sortMedia(hit, o.sort || "name", o.dir || "asc", ctx);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const KIND_BY_EXT = {
|
|
243
|
+
mp4: "video", webm: "video", mov: "video", mkv: "video", m4v: "video", avi: "video",
|
|
244
|
+
mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio", aac: "audio", flac: "audio", opus: "audio",
|
|
245
|
+
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "svg",
|
|
246
|
+
};
|
|
247
|
+
/** video | audio | image | svg from a file name or src, or null. */
|
|
248
|
+
function kindFromName(n) {
|
|
249
|
+
const ext = (/\.([a-z0-9]+)$/i.exec(String(n || "").split("?")[0]) || [])[1];
|
|
250
|
+
return (ext && KIND_BY_EXT[ext.toLowerCase()]) || null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ── relink ── */
|
|
254
|
+
const stem = (n) => String(n || "").replace(/\.[a-z0-9]{1,5}$/i, "").toLowerCase();
|
|
255
|
+
/** Name of a /media/… or /library/… src, decoded. */
|
|
256
|
+
function srcName(src) {
|
|
257
|
+
const s = String(src || "").split("?")[0].split("/").pop() || "";
|
|
258
|
+
try { return decodeURIComponent(s); } catch { return s; }
|
|
259
|
+
}
|
|
260
|
+
/** How likely `file` ({name, size}) is the missing media `m` ({name, src, size}).
|
|
261
|
+
* → {score, why}; 0 = not a candidate. Same file name beats same name with
|
|
262
|
+
* another extension beats a name containing the other; a matching size adds.
|
|
263
|
+
* With m.kind, files of another kind never qualify. */
|
|
264
|
+
function relinkScore(m, file) {
|
|
265
|
+
// only files that can stand in for this kind (an audio entry may sit in a video container)
|
|
266
|
+
const fk = kindFromName(file.name);
|
|
267
|
+
if (m.kind && fk && fk !== m.kind && !(m.kind === "audio" && fk === "video")) return { score: 0, why: "" };
|
|
268
|
+
const names = [m.name, srcName(m.src)].filter(Boolean).map((n) => n.toLowerCase());
|
|
269
|
+
const fn = String(file.name || "").toLowerCase(), fs = stem(fn);
|
|
270
|
+
let score = 0, why = "";
|
|
271
|
+
if (names.includes(fn)) { score = 100; why = "same name"; }
|
|
272
|
+
else if (names.some((n) => stem(n) === fs && fs)) { score = 60; why = "same name, other type"; }
|
|
273
|
+
else if (names.some((n) => { const a = stem(n); return a.length >= 3 && fs.length >= 3 && (fs.includes(a) || a.includes(fs)); })) { score = 30; why = "similar name"; }
|
|
274
|
+
const sameSize = m.size > 0 && file.size > 0 && m.size === file.size;
|
|
275
|
+
if (sameSize) { score += score ? 50 : 40; why = why ? why + " and size" : "same size"; }
|
|
276
|
+
return { score, why };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
KINDS, INFO_KEYS, SORT_KEYS, MAX_TAGS,
|
|
281
|
+
srcName, relinkScore, kindFromName,
|
|
282
|
+
parseProbe, fmtSize, fmtFps, fmtRes, fmtAudio, fmtDur, describe,
|
|
283
|
+
normalizeTags, allTags, normalizeCollections,
|
|
284
|
+
placements, usageCounts,
|
|
285
|
+
matchesQuery, matchesFilter, sortValue, sortMedia, query,
|
|
286
|
+
};
|
|
287
|
+
});
|
package/media-probe.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* Read a media file's metadata with ffprobe (Node only — the server and the
|
|
2
|
+
MCP server). The parsing is media-lib.js's parseProbe, shared with the
|
|
3
|
+
tests; this file only runs ffprobe and caches the answer per file version.
|
|
4
|
+
|
|
5
|
+
probeFile(file) → Promise of {size, fps?, codec?, audioCodec?, audioChannels?,
|
|
6
|
+
sampleRate?, bitrate?, duration?, width?, height?, probe:"ffprobe"|"stat"}
|
|
7
|
+
or null when the file is not there.
|
|
8
|
+
Without ffprobe on PATH only `size` comes back (probe:"stat"). ffprobe runs
|
|
9
|
+
asynchronously, at most PROBE_CONCURRENCY at a time, so a project full of
|
|
10
|
+
unprobed files never stalls the server's other requests. */
|
|
11
|
+
"use strict";
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const { execFile, spawnSync } = require("child_process");
|
|
14
|
+
const MediaLib = require("./media-lib");
|
|
15
|
+
|
|
16
|
+
const PROBE_CONCURRENCY = 4;
|
|
17
|
+
|
|
18
|
+
let hasProbe = null;
|
|
19
|
+
function ffprobeAvailable() {
|
|
20
|
+
if (hasProbe == null) {
|
|
21
|
+
try { hasProbe = spawnSync("ffprobe", ["-version"], { stdio: "ignore" }).status === 0; } catch { hasProbe = false; }
|
|
22
|
+
}
|
|
23
|
+
return hasProbe;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cache = new Map(); // file → {key, info}
|
|
27
|
+
const inflight = new Map(); // file + key → Promise
|
|
28
|
+
const CACHE_MAX = 2000;
|
|
29
|
+
let active = 0;
|
|
30
|
+
const waiting = [];
|
|
31
|
+
function slot() {
|
|
32
|
+
if (active < PROBE_CONCURRENCY) { active++; return Promise.resolve(); }
|
|
33
|
+
return new Promise((r) => waiting.push(r));
|
|
34
|
+
}
|
|
35
|
+
function release() {
|
|
36
|
+
const next = waiting.shift();
|
|
37
|
+
if (next) next(); else active--;
|
|
38
|
+
}
|
|
39
|
+
function runProbe(file) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
execFile("ffprobe", ["-v", "error", "-show_format", "-show_streams", "-of", "json", file],
|
|
42
|
+
{ encoding: "utf8", timeout: 15_000, maxBuffer: 1 << 23, windowsHide: true },
|
|
43
|
+
(err, stdout) => {
|
|
44
|
+
if (err || !stdout) return resolve(null);
|
|
45
|
+
try { resolve(JSON.parse(stdout)); } catch { resolve(null); }
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function probeFile(file) {
|
|
51
|
+
let st;
|
|
52
|
+
try { st = await fs.promises.stat(file); } catch { return null; }
|
|
53
|
+
if (!st.isFile()) return null;
|
|
54
|
+
const key = st.size + ":" + st.mtimeMs;
|
|
55
|
+
const hit = cache.get(file);
|
|
56
|
+
if (hit && hit.key === key) return { ...hit.info };
|
|
57
|
+
const id = file + "\0" + key;
|
|
58
|
+
if (!inflight.has(id)) {
|
|
59
|
+
inflight.set(id, (async () => {
|
|
60
|
+
let info = { size: st.size, probe: "stat" };
|
|
61
|
+
if (ffprobeAvailable()) {
|
|
62
|
+
await slot();
|
|
63
|
+
try {
|
|
64
|
+
const json = await runProbe(file);
|
|
65
|
+
if (json) info = { ...MediaLib.parseProbe(json, st.size), probe: "ffprobe" };
|
|
66
|
+
} finally { release(); }
|
|
67
|
+
}
|
|
68
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
69
|
+
cache.set(file, { key, info });
|
|
70
|
+
return info;
|
|
71
|
+
})().finally(() => inflight.delete(id)));
|
|
72
|
+
}
|
|
73
|
+
return { ...(await inflight.get(id)) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The INFO_KEYS of probeFile's answer, ready to merge into a media entry
|
|
77
|
+
* (duration / width / height only when the entry lacks them). */
|
|
78
|
+
function infoForEntry(info, entry) {
|
|
79
|
+
const out = {};
|
|
80
|
+
if (!info) return out;
|
|
81
|
+
for (const k of MediaLib.INFO_KEYS) if (info[k] != null) out[k] = info[k];
|
|
82
|
+
const kind = entry && entry.kind;
|
|
83
|
+
if (kind !== "image" && kind !== "svg" && info.duration > 0 && !(entry && entry.duration > 0)) out.duration = info.duration;
|
|
84
|
+
if (info.width > 0 && !(entry && entry.width > 0)) { out.width = info.width; out.height = info.height; }
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { probeFile, infoForEntry, ffprobeAvailable, PROBE_CONCURRENCY };
|