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/export-jobs.js CHANGED
@@ -49,7 +49,7 @@ function createExportJobs(opts) {
49
49
  let timer = null;
50
50
  const isEnded = (j) => j.status === "done" || j.status === "failed" || j.status === "cancelled";
51
51
  const view = (j) => ({
52
- id: j.id, kind: j.kind, status: j.status, via: j.via, range: j.range, profile: j.profile || null,
52
+ id: j.id, kind: j.kind, status: j.status, via: j.via, range: j.range, profile: j.profile || null, preset: j.delivery ? j.delivery.preset || "custom" : null,
53
53
  time: j.time ?? null, progress: j.progress, src: j.src || null, result: j.result || null, error: j.error || null,
54
54
  created: j.created, updated: j.updated,
55
55
  });
@@ -104,8 +104,10 @@ function createExportJobs(opts) {
104
104
  return {
105
105
  /** Queue an export. where: auto (open tab, else headless) · tab · headless.
106
106
  * kind "scopes" measures one still at `time` instead (fablecut_scopes);
107
- * kind "track" follows a region of a clip and saves it (fablecut_track). */
108
- request({ kind = "export", range, profile, time, revision, where = "auto", matte = null, mask = null, track = null }) {
107
+ * kind "track" follows a region of a clip and saves it (fablecut_track).
108
+ * A scopes job's `image` flag also returns that still as a JPEG data URL,
109
+ * which is how fablecut_frame shows an agent the composed program picture. */
110
+ request({ kind = "export", range, profile, delivery = null, deliveries = null, time, revision, where = "auto", matte = null, mask = null, track = null, image = false }) {
109
111
  if (!["auto", "tab", "headless"].includes(where)) throw Object.assign(new Error("where must be auto, tab or headless"), { code: 400 });
110
112
  if (!["export", "scopes", "track"].includes(kind)) throw Object.assign(new Error("kind must be export, scopes or track"), { code: 400 });
111
113
  if (time != null && !(Number.isFinite(time) && time >= 0)) throw Object.assign(new Error("time must be seconds ≥ 0"), { code: 400 });
@@ -117,10 +119,12 @@ function createExportJobs(opts) {
117
119
  const now = Date.now();
118
120
  const j = {
119
121
  id: "x_" + Math.random().toString(36).slice(2, 10), kind, status: "pending", progress: 0,
120
- range: range || null, profile: profile || null, time: time ?? null, revision: Number.isFinite(revision) ? revision : null,
122
+ range: range || null, profile: profile || null, delivery: kind === "export" && delivery ? delivery : null,
123
+ deliveries: kind === "export" && Array.isArray(deliveries) && deliveries.length ? deliveries : null, time: time ?? null, revision: Number.isFinite(revision) ? revision : null,
121
124
  matte: kind === "scopes" && matte ? { clip: String(matte.clip), layer: matte.layer } : null,
122
125
  mask: kind === "scopes" && mask ? { clip: String(mask.clip) } : null,
123
126
  track: kind === "track" && track ? track : null,
127
+ image: kind === "scopes" && !!image,
124
128
  via: where === "headless" || (where === "auto" && !tabs) ? "headless" : "tab",
125
129
  created: now, updated: now,
126
130
  };
@@ -132,7 +136,7 @@ function createExportJobs(opts) {
132
136
  },
133
137
  get(id) { const j = jobs.get(id); return j ? view(j) : null; },
134
138
  /** What a tab needs to run the job (headless pages fetch it by id). */
135
- ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, time: j.time, revision: j.revision, matte: j.matte || null, mask: j.mask || null, track: j.track || null, status: j.status } : null; },
139
+ ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, delivery: j.delivery || null, deliveries: j.deliveries || null, time: j.time, revision: j.revision, matte: j.matte || null, mask: j.mask || null, track: j.track || null, image: !!j.image, status: j.status } : null; },
136
140
  /** First tab to claim a pending job runs it. */
137
141
  claim(id) {
138
142
  const j = jobs.get(id);
package/frame-cache.js ADDED
@@ -0,0 +1,49 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ The /api/frame render cache (analysis/frames/) — bounded, oldest-first.
3
+
4
+ Every distinct combination of source + arguments renders its own JPEG, so an
5
+ unbounded cache is a disk-fill primitive: varying w, q or t mints a new file
6
+ each time, and a hostile page could do exactly that through the endpoint.
7
+ (The header gate on ?path= keeps strangers out; this keeps friends honest.)
8
+ Each entry is a .jpg plus its .json sidecar; pruning removes both, oldest
9
+ mtime first, until the directory is back under both limits.
10
+
11
+ Pure filesystem logic, no ffmpeg — unit-tested directly in frame-cache.test.js.
12
+ ═══════════════════════════════════════════════════════════════════════════ */
13
+ "use strict";
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ /* Generous enough that a real editing session never notices: a few hundred
18
+ grabs at ~100 KB each is tens of megabytes. */
19
+ const DEFAULT_MAX_FILES = 500;
20
+ const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
21
+
22
+ /** Delete oldest entries until dir holds ≤ maxFiles JPEGs and ≤ maxBytes.
23
+ * Returns {files, bytes, pruned}. Never throws — a prune failure must not
24
+ * fail the request that triggered it. */
25
+ function pruneCache(dir, { maxFiles = DEFAULT_MAX_FILES, maxBytes = DEFAULT_MAX_BYTES } = {}) {
26
+ let names;
27
+ try { names = fs.readdirSync(dir); } catch { return { files: 0, bytes: 0, pruned: 0 }; }
28
+ const entries = [];
29
+ for (const name of names) {
30
+ if (!name.endsWith(".jpg")) continue;
31
+ try {
32
+ const st = fs.statSync(path.join(dir, name));
33
+ if (st.isFile()) entries.push({ name, size: st.size, mtime: st.mtimeMs });
34
+ } catch { /* vanished mid-sweep — someone else pruned it */ }
35
+ }
36
+ entries.sort((a, b) => a.mtime - b.mtime); // oldest first
37
+ let bytes = entries.reduce((s, e) => s + e.size, 0);
38
+ let pruned = 0;
39
+ while ((entries.length > maxFiles || bytes > maxBytes) && entries.length) {
40
+ const victim = entries.shift();
41
+ bytes -= victim.size;
42
+ pruned++;
43
+ try { fs.rmSync(path.join(dir, victim.name)); } catch {}
44
+ try { fs.rmSync(path.join(dir, victim.name.replace(/\.jpg$/, ".json"))); } catch {}
45
+ }
46
+ return { files: entries.length, bytes, pruned };
47
+ }
48
+
49
+ module.exports = { pruneCache, DEFAULT_MAX_FILES, DEFAULT_MAX_BYTES };
package/frame-stats.js ADDED
@@ -0,0 +1,267 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ Frame statistics — describe a frame in words, for anyone who can't see it.
3
+
4
+ `fablecut_frame` hands a multimodal model an actual image, which is the point.
5
+ But not every MCP client, and not every model behind it, can read one: a
6
+ text-only agent gets the coordinates of the picture and nothing else, so it
7
+ learns nothing about the footage it was asked to judge.
8
+
9
+ This closes that gap with measurements instead of pixels — brightness, the
10
+ dominant colours, and how much a moment differs from the one before it — the
11
+ same numbers `fablecut_scopes` reports for the composed picture, applied to a
12
+ source frame. Deliberately NOT a caption: no zero-dependency way to describe
13
+ what is in the picture honestly. What it gives instead is enough to rank and
14
+ route footage ("bright outdoor, high motion, dominant green") without a
15
+ picture at all.
16
+
17
+ Pure functions over a raw RGB buffer. No ffmpeg, no canvas, no dependencies,
18
+ so the whole thing is testable on its own. ═══════════════════════════════════ */
19
+ "use strict";
20
+
21
+ /* Rec. 709 luma weights — the same coefficients the browser uses, so a number
22
+ here means the same thing as a number from fablecut_scopes. */
23
+ /* Every luma field below is 0–1, rounded to 3 decimals — the same convention
24
+ as color.js scopeStats, so the two readings can sit side by side without a
25
+ conversion nobody remembers to do. */
26
+ const WR = 0.2126, WG = 0.7152, WB = 0.0722;
27
+ const r3 = (v) => Math.round(v * 1000) / 1000;
28
+
29
+ /* Perceptual buckets. A 4-bit-per-channel histogram is 4096 entries: fine
30
+ enough to separate a lime from a green, coarse enough that JPEG noise in a
31
+ gradient lands in one bucket instead of smearing across a hundred. */
32
+ const BITS = 4, SHIFT = 8 - BITS, BUCKETS = 1 << (BITS * 3);
33
+
34
+ /** Rec. 709 luma of one pixel, 0–255. */
35
+ function luma(r, g, b) { return WR * r + WG * g + WB * b; }
36
+
37
+ /* Named colours a model can reason about, matched against the histogram's top
38
+ buckets. These are the useful coarse categories — "sky", "foliage", "skin",
39
+ "night" — rather than 4096 hex codes nobody can picture. Nearest match by
40
+ weighted RGB distance, so a slightly-off bucket still lands sensibly. */
41
+ const NAMED = [
42
+ { name: "black", rgb: [10, 10, 12] },
43
+ { name: "deep blue", rgb: [16, 26, 74] },
44
+ { name: "blue", rgb: [40, 84, 190] },
45
+ { name: "sky blue", rgb: [128, 186, 235] },
46
+ { name: "cyan", rgb: [56, 190, 200] },
47
+ { name: "green", rgb: [56, 142, 62] },
48
+ { name: "lime", rgb: [140, 205, 60] },
49
+ { name: "olive", rgb: [120, 118, 52] },
50
+ { name: "yellow", rgb: [226, 200, 62] },
51
+ { name: "orange", rgb: [222, 122, 44] },
52
+ { name: "red", rgb: [196, 52, 44] },
53
+ { name: "dark red", rgb: [104, 26, 26] },
54
+ { name: "magenta", rgb: [176, 58, 168] },
55
+ { name: "skin", rgb: [214, 168, 132] },
56
+ { name: "grey", rgb: [128, 128, 130] },
57
+ { name: "light grey", rgb: [206, 206, 208] },
58
+ { name: "white", rgb: [242, 242, 244] },
59
+ ];
60
+ function nameColor(r, g, b) {
61
+ let best = NAMED[0], bestD = Infinity;
62
+ for (const c of NAMED) {
63
+ /* Plain Euclidean RGB distance. Luma weighting looks principled and is
64
+ wrong here: Rec.709 gives blue a weight of 0.07, so a bucket that differs
65
+ from "blue" mostly in blue reads as "deep blue" instead. For naming a
66
+ colour, hue is the thing being asked about. */
67
+ const d = (r - c.rgb[0]) ** 2 + (g - c.rgb[1]) ** 2 + (b - c.rgb[2]) ** 2;
68
+ if (d < bestD) { bestD = d; best = c; }
69
+ }
70
+ return best.name;
71
+ }
72
+
73
+ /** Bucket a pixel into the 4-bit-per-channel histogram. */
74
+ const bucketOf = (r, g, b) => ((r >> SHIFT) << (BITS * 2)) | ((g >> SHIFT) << BITS) | (b >> SHIFT);
75
+ function bucketRgb(i) {
76
+ return [((i >> (BITS * 2)) & 0xf) << SHIFT, ((i >> BITS) & 0xf) << SHIFT, (i & 0xf) << SHIFT];
77
+ }
78
+
79
+ /**
80
+ * Describe one frame.
81
+ *
82
+ * @param rgb raw RGB24 bytes (w*h*3)
83
+ * @param w,h its dimensions
84
+ * @param opts {stride} — bytes per row, when rgb is a window into a bigger
85
+ * buffer (the contact sheet slices cells out of one grid image)
86
+ * @returns {{luma:object, brightness:string, colours:Array, saturation:number,
87
+ * meanRgb:number[], texture:number, samples:number}}
88
+ */
89
+ function describe(rgb, w, h, { stride = w * 3, offset = 0 } = {}) {
90
+ const n = w * h;
91
+ const hist = new Uint32Array(BUCKETS);
92
+ const lums = new Float64Array(n);
93
+ let sr = 0, sg = 0, sb = 0, k = 0;
94
+ for (let y = 0; y < h; y++) {
95
+ let i = offset + y * stride;
96
+ for (let x = 0; x < w; x++, i += 3, k++) {
97
+ const r = rgb[i], g = rgb[i + 1], b = rgb[i + 2];
98
+ sr += r; sg += g; sb += b;
99
+ lums[k] = luma(r, g, b);
100
+ hist[bucketOf(r, g, b)]++;
101
+ }
102
+ }
103
+ // Percentiles need a sort; a frame is at most a few hundred thousand samples,
104
+ // and this runs once per call, not per frame of a render.
105
+ const sorted = Float64Array.prototype.slice.call(lums).sort();
106
+ const q = (p) => sorted[Math.min(n - 1, Math.max(0, Math.round(p * (n - 1))))];
107
+ let sum = 0;
108
+ for (let i = 0; i < n; i++) sum += lums[i];
109
+ const mean = sum / n / 255;
110
+ const meanRgb = [sr / n, sg / n, sb / n];
111
+
112
+ // Saturation 0–1, from mean chroma against mean luma (the cheap version —
113
+ // no per-pixel HSV pass).
114
+ const chroma = (Math.max(...meanRgb) - Math.min(...meanRgb)) / 255;
115
+ const lum01 = mean;
116
+ const saturation = lum01 < 0.005 ? 0 : Math.min(1, chroma / (lum01 * 2));
117
+
118
+ /* "Texture": the mean absolute luma step between horizontally adjacent
119
+ samples, as a percentage of full range. Smooth sky or a locked-off tripod
120
+ shot scores near 0; foliage, crowds or a busy frame score clearly higher.
121
+ Reported on the same 0–100 scale as the analyzer's per-shot `energy`, so
122
+ the two can be compared directly. It measures detail *within* the frame,
123
+ not change over time — motionScore() is the change-over-time one. */
124
+ let diff = 0, dN = 0;
125
+ for (let y = 0; y < h; y++) {
126
+ // Start one pixel in: the step reads the pixel before the cursor, and on
127
+ // the first row that would be rgb[-3] — undefined, and silently NaN.
128
+ let i = offset + y * stride + 3;
129
+ for (let x = 1; x < w; x++, i += 3) {
130
+ diff += Math.abs(luma(rgb[i], rgb[i + 1], rgb[i + 2]) - luma(rgb[i - 3], rgb[i - 2], rgb[i - 1]));
131
+ dN++;
132
+ }
133
+ }
134
+ // A single-pixel-wide sample has no horizontal neighbours; report no texture
135
+ // rather than NaN.
136
+ const detail = dN ? Math.round(diff / dN / 255 * 1000) / 10 : 0;
137
+
138
+ // Top colour buckets, merged when they are neighbours so a sky reads as one
139
+ // entry rather than four.
140
+ const top = [];
141
+ for (let i = 0; i < BUCKETS; i++) {
142
+ if (hist[i]) top.push([i, hist[i]]);
143
+ }
144
+ top.sort((a, b) => b[1] - a[1]);
145
+ const colours = [];
146
+ for (const [idx, count] of top) {
147
+ const [r, g, b] = bucketRgb(idx);
148
+ const near = colours.find((c) =>
149
+ (c.rgb[0] - r) ** 2 + (c.rgb[1] - g) ** 2 + (c.rgb[2] - b) ** 2 < 900);
150
+ if (near) {
151
+ // Weighted mean, so the merged entry points at the colour actually seen.
152
+ const tot = near.count + count;
153
+ near.rgb = near.rgb.map((v, j) => Math.round((v * near.count + [r, g, b][j] * count) / tot));
154
+ near.count = tot;
155
+ near.pct = tot;
156
+ } else {
157
+ colours.push({ rgb: [r, g, b], count, pct: count });
158
+ }
159
+ }
160
+ colours.sort((a, b) => b.count - a.count);
161
+ const shown = colours.slice(0, 3);
162
+ for (const c of shown) {
163
+ c.pct = Math.round(c.pct / n * 100);
164
+ c.hex = "#" + c.rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
165
+ c.name = nameColor(c.rgb[0], c.rgb[1], c.rgb[2]);
166
+ }
167
+
168
+ const l = mean;
169
+ return {
170
+ luma: {
171
+ min: r3(q(0) / 255), p1: r3(q(0.01) / 255), median: r3(q(0.5) / 255),
172
+ mean: r3(l), p99: r3(q(0.99) / 255), max: r3(q(1) / 255),
173
+ },
174
+ brightness: l < 0.12 ? "very dark" : l < 0.35 ? "dark" : l < 0.65 ? "mid" : l < 0.85 ? "bright" : "very bright",
175
+ colours: shown,
176
+ meanRgb: meanRgb.map((v) => Math.round(v)),
177
+ saturation: Math.round(saturation * 100) / 100,
178
+ texture: detail,
179
+ samples: n,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Mean absolute per-channel difference between two equally sized regions,
185
+ * 0–1. High means the picture changed a lot between them — a cut, or fast
186
+ * motion. This is the "is this a different shot" signal a contact sheet's cells
187
+ * can be ranked by.
188
+ */
189
+ function motionScore(rgbA, rgbB, w, h) {
190
+ const n = w * h * 3;
191
+ if (!n || !rgbA || !rgbB || rgbA.length < n || rgbB.length < n) return 0;
192
+ let d = 0;
193
+ for (let i = 0; i < n; i++) d += Math.abs(rgbA[i] - rgbB[i]);
194
+ return Math.round(d / n / 255 * 1000) / 1000;
195
+ }
196
+
197
+ /**
198
+ * Describe a tiled contact sheet cell by cell. `rgb` is the whole grid image;
199
+ * cells are read left→right then top→bottom, so cell i is the frame at times[i].
200
+ *
201
+ * Each cell reports its own brightness/colours, plus motion against the previous
202
+ * populated cell — which is what lets a text-only agent find the cut points and
203
+ * pick the high-motion moments without seeing anything.
204
+ *
205
+ * @param rgb raw RGB24 of the whole grid
206
+ * @param w,h grid dimensions
207
+ * @param cols,rows the tile layout
208
+ * @param times one timestamp per cell, in cell order
209
+ */
210
+ function describeGrid(rgb, w, h, cols, rows, times = []) {
211
+ // tile lays cells out edge to edge, so the cell size is the grid divided by
212
+ // the layout. Guard the rounding: a width that doesn't divide evenly would
213
+ // drift a pixel per row and smear the last column.
214
+ const cw = Math.floor(w / cols), ch = Math.floor(h / rows);
215
+ const cells = [];
216
+ let prev = null, prevIdx = -1;
217
+ for (let r = 0; r < rows; r++) {
218
+ for (let c = 0; c < cols; c++) {
219
+ const index = r * cols + c;
220
+ const time = times[index];
221
+ // Fewer timestamps than cells means tile padded the last row with black —
222
+ // say so rather than describing a padding cell as a black frame.
223
+ if (time == null) { cells.push({ index, padded: true }); continue; }
224
+ const ox = c * cw, oy = r * ch;
225
+ const view = new Uint8Array(cw * ch * 3);
226
+ for (let y = 0; y < ch; y++) {
227
+ const from = ((oy + y) * w + ox) * 3;
228
+ view.set(rgb.subarray(from, from + cw * 3), y * cw * 3);
229
+ }
230
+ const stats = describe(view, cw, ch);
231
+ const cell = { index, time, ...stats };
232
+ // Compare against the previous *real* cell, skipping padding, so the last
233
+ // filled cell of a short row is still measured against its neighbour.
234
+ cell.motion = prev ? motionScore(view, prev, cw, ch) : 0;
235
+ cells.push(cell);
236
+ prev = view; prevIdx = index;
237
+ }
238
+ }
239
+ return { cols, rows, cellWidth: cw, cellHeight: ch, cells };
240
+ }
241
+
242
+ /** One line per thing worth saying, for a single frame. These lines are the whole
243
+ * point of the text fallback: read by a model that never sees the pixels.
244
+ * Keep them short — this is what an agent gets instead of the image. */
245
+ function frameLines(label, stats) {
246
+ const L = stats.luma;
247
+ const cols = stats.colours.map((c) => `${c.name} ${c.hex} ${c.pct}%`).join(", ");
248
+ return [
249
+ `${label}: ${stats.brightness} (luma mean ${L.mean}, median ${L.median}, range ${L.min}–${L.max})`,
250
+ ` dominant: ${cols || "flat"}`,
251
+ ` texture ${stats.texture}/100 · saturation ${stats.saturation} · mean rgb [${stats.meanRgb.join(", ")}]`,
252
+ ];
253
+ }
254
+
255
+ /** One line per cell, for a contact sheet. `motion` is the change since the
256
+ * previous cell, so it spikes at a cut *and* during fast movement — it ranks
257
+ * the most active moments, it does not prove there was a cut. */
258
+ function gridLines(grid) {
259
+ const lines = grid.cells.map((c) => {
260
+ if (c.padded) return ` cell ${c.index + 1}: (padding — not a frame in this clip)`;
261
+ return ` cell ${c.index + 1} @ ${c.time}s: ${c.brightness}, ${c.colours.map((x) => `${x.name} ${x.pct}%`).join(" / ") || "flat"}`
262
+ + ` · texture ${c.texture}/100 · change ${c.motion}`;
263
+ });
264
+ return [`Contact sheet ${grid.cols}x${grid.rows}, ${grid.cells.filter((c) => !c.padded).length} frames:`, ...lines];
265
+ }
266
+
267
+ module.exports = { describe, describeGrid, motionScore, luma, nameColor, frameLines, gridLines };
package/index.html CHANGED
@@ -60,6 +60,17 @@
60
60
  <button type="button" data-tab="shapes" title="Shape layers and null objects">Shapes</button>
61
61
  <button type="button" data-tab="templates" title="Lower thirds, logo animations and social layouts — with fields you edit in the Inspector">Templates</button>
62
62
  </div>
63
+ <div class="bin-tools" id="binTools">
64
+ <input type="search" id="binSearch" class="bin-search" placeholder="Search name, #tag, codec, 4k…" aria-label="Search the Project bin" spellcheck="false" autocomplete="off">
65
+ <select id="binFilter" class="bin-filter" title="Show only…" aria-label="Show"></select>
66
+ <select id="binSort" class="bin-sort" title="Sort by" aria-label="Sort by">
67
+ <option value="name">Name</option><option value="kind">Kind</option><option value="duration">Duration</option>
68
+ <option value="resolution">Resolution</option><option value="fps">Frame rate</option><option value="codec">Codec</option>
69
+ <option value="audio">Audio channels</option><option value="size">File size</option><option value="usage">Times used</option>
70
+ </select>
71
+ <button type="button" class="btn tiny" id="binSortDir" title="Ascending — click for descending">↑</button>
72
+ <button type="button" class="btn tiny" id="binViewBtn" title="List view: metadata columns">☰</button>
73
+ </div>
63
74
  <input type="file" id="fileInput" class="sr-only" multiple accept="video/*,audio/*,image/*,.mp4,.mov,.webm,.mkv,.mp3,.wav,.m4a,.png,.jpg,.jpeg,.gif,.webp,.svg">
64
75
  <div class="bin-list" id="binList">
65
76
  <div class="bin-empty" id="binEmpty">
@@ -95,6 +106,7 @@
95
106
  <button class="btn tiny toggle" id="btnExportFrame" title="Toggle export frame overlay (drag handle to reframe)">⬒ Frame</button>
96
107
  <button type="button" class="btn tiny toggle hidden" id="btnExportFrameDim" aria-pressed="true" title="Lights off — crop to the export frame"><svg class="ico" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><g class="ef-bulb-rays" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 .4v1.05M1.7 3.45l.85.65M14.3 3.45l-.85.65"/></g><path class="ef-bulb-glass" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linejoin="round" d="M8 1.7A4.1 4.1 0 0 0 3.9 5.8c0 1.62.86 2.72 1.8 3.5.38.32.62.68.62 1.15v.35h4.36v-.35c0-.47.24-.83.62-1.15.94-.78 1.8-1.88 1.8-3.5A4.1 4.1 0 0 0 8 1.7z"/><path fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="square" d="M6.25 12.85h3.5M6.7 14.35h2.6"/></svg></button>
97
108
  <button class="btn tiny toggle" id="btnGuides" title="Toggle safe-area guides">▦ Safe</button>
109
+ <button class="btn tiny toggle proxy-btn" id="btnProxy" title="Play proxies while editing (export always uses the originals)">Proxy</button>
98
110
  <span class="dim" id="monitorRes"></span>
99
111
  </span>
100
112
  </div>
@@ -258,8 +270,9 @@
258
270
 
259
271
  <!-- ══════════ EXPORT SETUP ══════════ -->
260
272
  <div class="overlay hidden" id="exportSetup">
261
- <div class="dialog">
262
- <h2>Export</h2>
273
+ <div class="dialog export-dialog" role="dialog" aria-modal="true" aria-labelledby="exportSetupTitle">
274
+ <h2 id="exportSetupTitle">Export</h2>
275
+ <div class="export-body">
263
276
  <div class="export-profile-row" id="exportRangeRow">
264
277
  <label class="export-profile-label" for="exportRangeSel">Range</label>
265
278
  <select class="export-profile-sel" id="exportRangeSel" aria-describedby="exportRangeNote">
@@ -276,11 +289,102 @@
276
289
  <input type="radio" name="engine" id="engineRealtime">
277
290
  <span><b>WebCodecs (HW encode)</b><br><span class="dim" id="engineWebCodecsNote">Frame-accurate. Browser encodes H.264; server muxes. Needs a modern Chromium and ffmpeg.</span></span>
278
291
  </label>
279
- <div class="export-profile-row" id="exportProfileRow">
280
- <label class="export-profile-label" for="exportProfileSel">Encoding profile</label>
292
+ <div class="export-profile-row dlv" id="exportProfileRow">
293
+ <div class="dlv-preset-line">
294
+ <label class="export-profile-label" for="exportProfileSel">Preset</label>
295
+ <span class="dlv-preset-actions">
296
+ <button type="button" class="btn tiny" id="dlvSaveBtn" title="Save these settings as a preset for every project">Save preset…</button>
297
+ <button type="button" class="btn tiny hidden" id="dlvDeleteBtn" title="Delete this saved preset">Delete</button>
298
+ </span>
299
+ </div>
281
300
  <select class="export-profile-sel" id="exportProfileSel" aria-describedby="exportProfileNote exportProfileHint"></select>
301
+ <div class="dlv-save-row hidden" id="dlvSaveRow">
302
+ <input type="text" id="dlvSaveName" maxlength="60" placeholder="Preset name, e.g. Client 4K" aria-label="Preset name">
303
+ <button type="button" class="btn tiny primary" id="dlvSaveOk">Save</button>
304
+ <button type="button" class="btn tiny" id="dlvSaveCancel">Cancel</button>
305
+ </div>
282
306
  <p class="dim export-profile-note" id="exportProfileNote"></p>
283
307
  <p class="dim export-profile-hint" id="exportProfileHint"></p>
308
+ <div class="dlv-body" id="dlvBody">
309
+ <details class="dlv-sec" id="dlvVideoSec" open>
310
+ <summary>Video</summary>
311
+ <div class="dlv-grid">
312
+ <label class="dlv-f">Resolution<select id="dlvRes"></select></label>
313
+ <label class="dlv-f dlv-wh hidden" id="dlvWhField">Width × height
314
+ <span class="dlv-wh-in"><input type="number" id="dlvW" min="16" max="8192" step="2" aria-label="Width"><span>×</span><input type="number" id="dlvH" min="16" max="8192" step="2" aria-label="Height"></span></label>
315
+ <label class="dlv-f" id="dlvReframeField">Reframe<select id="dlvReframe">
316
+ <option value="fill">Fill (crop to fit)</option>
317
+ <option value="fit">Fit (black bars)</option>
318
+ <option value="stretch">Stretch</option>
319
+ </select></label>
320
+ <label class="dlv-f">Frame rate<select id="dlvFps"></select></label>
321
+ <label class="dlv-f">Codec<select id="dlvCodec"></select></label>
322
+ <label class="dlv-f" id="dlvEncoderField">Encoder<select id="dlvEncoder"></select></label>
323
+ <label class="dlv-f" id="dlvProfileField">Profile<select id="dlvProfile"></select></label>
324
+ <label class="dlv-f" id="dlvRateField">Rate control<select id="dlvRate">
325
+ <option value="crf">CRF (constant quality)</option>
326
+ <option value="vbr">VBR (variable bitrate)</option>
327
+ <option value="cbr">CBR (constant bitrate)</option>
328
+ </select></label>
329
+ <label class="dlv-f" id="dlvCrfField">CRF<input type="number" id="dlvCrf" min="0" max="63" step="1" title="Lower = better quality, bigger file"></label>
330
+ <label class="dlv-f" id="dlvBitrateField">Bitrate (Mb/s)<input type="number" id="dlvBitrate" min="0.1" max="400" step="0.5"></label>
331
+ <label class="dlv-f" id="dlvMaxrateField">Max rate (Mb/s)<input type="number" id="dlvMaxrate" min="0.1" max="800" step="0.5" placeholder="none"></label>
332
+ <label class="dlv-f" id="dlvKeyintField">Keyframe every (s)<input type="number" id="dlvKeyint" min="0" max="20" step="0.5" title="0 = the encoder's default"></label>
333
+ <label class="dlv-f" id="dlvSpeedField">Encoder speed<select id="dlvSpeed">
334
+ <option value="fast">Fast</option>
335
+ <option value="balanced">Balanced</option>
336
+ <option value="best">Best quality (slow)</option>
337
+ </select></label>
338
+ <label class="dlv-f">Pixel format<select id="dlvPixFmt"></select></label>
339
+ <label class="dlv-f">Color<select id="dlvColor"></select></label>
340
+ <label class="dlv-f">Container<select id="dlvContainer"></select></label>
341
+ </div>
342
+ </details>
343
+ <details class="dlv-sec" id="dlvAudioSec" open>
344
+ <summary>Audio</summary>
345
+ <div class="dlv-grid">
346
+ <label class="dlv-f">Codec<select id="dlvACodec"></select></label>
347
+ <label class="dlv-f" id="dlvABitrateField">Bitrate (kb/s)<select id="dlvABitrate"></select></label>
348
+ <label class="dlv-f" id="dlvARateField">Sample rate<select id="dlvARate">
349
+ <option value="48000">48 kHz</option>
350
+ <option value="44100">44.1 kHz</option>
351
+ </select></label>
352
+ <label class="dlv-f" id="dlvAChField">Channels<select id="dlvACh">
353
+ <option value="2">Stereo</option>
354
+ <option value="1">Mono</option>
355
+ </select></label>
356
+ <label class="dlv-f" id="dlvLoudField">Loudness<select id="dlvLoud" title="Turn the whole mix up or down to an integrated loudness, with a true-peak limiter"></select></label>
357
+ <label class="dlv-f" id="dlvTpField">True peak (dBTP)<input type="number" id="dlvTp" min="-9" max="0" step="0.5"></label>
358
+ </div>
359
+ </details>
360
+ <details class="dlv-sec" id="dlvFileSec">
361
+ <summary>File name and metadata</summary>
362
+ <label class="dlv-f dlv-wide">File name
363
+ <input type="text" id="dlvFilename" maxlength="160" spellcheck="false" aria-describedby="dlvFilePreview dlvTokens"></label>
364
+ <p class="dim dlv-file-preview" id="dlvFilePreview"></p>
365
+ <p class="dim dlv-tokens" id="dlvTokens"></p>
366
+ <div class="dlv-grid dlv-meta">
367
+ <label class="dlv-f">Title<input type="text" id="dlvMetaTitle" maxlength="300"></label>
368
+ <label class="dlv-f">Artist / author<input type="text" id="dlvMetaArtist" maxlength="300"></label>
369
+ <label class="dlv-f">Copyright<input type="text" id="dlvMetaCopyright" maxlength="300"></label>
370
+ <label class="dlv-f">Date<input type="text" id="dlvMetaDate" maxlength="40" placeholder="2026-10-18"></label>
371
+ <label class="dlv-f dlv-wide">Description<textarea id="dlvMetaDescription" rows="2" maxlength="2000"></textarea></label>
372
+ <label class="dlv-f dlv-wide">Keywords<input type="text" id="dlvMetaKeywords" maxlength="300" placeholder="comma, separated"></label>
373
+ </div>
374
+ </details>
375
+ <p class="dim dlv-summary" id="dlvSummary"></p>
376
+ </div>
377
+ </div>
378
+ <div class="export-profile-row dlv-batch" id="dlvBatch">
379
+ <div class="dlv-preset-line">
380
+ <span class="export-profile-label">Batch outputs <span class="dim" id="dlvBatchCount"></span></span>
381
+ <span class="dlv-preset-actions">
382
+ <button type="button" class="btn tiny" id="dlvBatchAdd" title="Add the settings above to this project's outputs">+ Add these settings</button>
383
+ <button type="button" class="btn tiny" id="dlvBatchTypical" title="Master, YouTube 1080p, Reel 1080×1920, Square 1080 and a 720p preview">+ Typical set</button>
384
+ </span>
385
+ </div>
386
+ <p class="dim export-profile-hint" id="dlvBatchHint">One render, several files: every checked output is encoded from the same frames. Saved with the project.</p>
387
+ <ul class="dlv-batch-list" id="dlvBatchList"></ul>
284
388
  </div>
285
389
  <div class="export-wc-opts hidden" id="exportWcOpts">
286
390
  <label class="export-wc-field" for="exportWcBitrate">Bitrate
@@ -305,14 +409,33 @@
305
409
  </label>
306
410
  <p class="dim export-wc-hint">WebCodecs has no CRF — quality is driven by bitrate. VBR spends bits on hard frames; CBR holds a steady rate.</p>
307
411
  </div>
412
+ <div class="export-track-warn hidden" id="dlvWarn" role="status"></div>
308
413
  <div class="export-track-warn hidden" id="exportTrackWarn" role="status"></div>
414
+ </div>
309
415
  <div class="dialog-actions">
416
+ <button class="btn dlv-queue-open" id="btnOpenQueue" title="Show the export queue">Queue</button>
310
417
  <button class="btn" id="btnCancelSetup">Cancel</button>
418
+ <button class="btn" id="btnBatchExport" title="Queue every checked batch output and start">Export checked</button>
419
+ <button class="btn" id="btnQueueExport" title="Add these settings to the export queue">Add to queue</button>
311
420
  <button class="btn primary" id="btnStartExport">Export</button>
312
421
  </div>
313
422
  </div>
314
423
  </div>
315
424
 
425
+ <!-- ══════════ EXPORT QUEUE ══════════ -->
426
+ <div class="overlay hidden" id="exportQueue">
427
+ <div class="dialog export-dialog" role="dialog" aria-modal="true" aria-labelledby="exportQueueTitle">
428
+ <h2 id="exportQueueTitle">Export queue</h2>
429
+ <p class="dim" id="exportQueueNote"></p>
430
+ <div class="export-body"><ol class="xq-list" id="exportQueueList"></ol></div>
431
+ <div class="dialog-actions">
432
+ <button class="btn" id="btnQueueClear">Clear finished</button>
433
+ <button class="btn" id="btnQueueClose">Close</button>
434
+ <button class="btn primary" id="btnQueueStart">Start</button>
435
+ </div>
436
+ </div>
437
+ </div>
438
+
316
439
  <!-- ══════════ EXPORT OVERLAY ══════════ -->
317
440
  <div class="overlay hidden" id="exportOverlay">
318
441
  <div class="dialog">
@@ -445,6 +568,13 @@
445
568
  <table class="keys">
446
569
  <tr><td><kbd>Ctrl</kbd>+click bin</td><td>Import media files</td></tr>
447
570
  <tr><td>Right-click <b>Project</b> tab</td><td>New folder · New composition</td></tr>
571
+ <tr><td>Bin search</td><td>Name, kind, codec, <code>2160p</code> or a tag; <code>#tag</code> matches tags only · <kbd>Esc</kbd> clears</td></tr>
572
+ <tr><td>Right-click a bin file</td><td>Find timeline instances · Media info · favorites · Tags · collections</td></tr>
573
+ <tr><td>☆ / uses badge on a bin file</td><td>Star it · list where it is used (click one to jump there)</td></tr>
574
+ <tr><td>☰ in the bin</td><td>List view with metadata columns · click a heading to sort</td></tr>
575
+ <tr><td>Right-click a clip</td><td><b>Reveal in project</b> — show its file in the bin</td></tr>
576
+ <tr><td>Right-click a bin file</td><td><b>Relink…</b> — point it at the footage where it is now · <b>Replace file…</b> — a new file for every clip that uses it</td></tr>
577
+ <tr><td>Right-click a bin video</td><td><b>Create proxy</b> — a light copy to edit with (right-click the Project tab for all of them) · <b>Proxy</b> above the monitor plays proxies on / off; export always uses the originals</td></tr>
448
578
  <tr><td>Comp clip / bin comp</td><td>Double-click to open it on the timeline · drag a bin comp onto the timeline to place it · the breadcrumb above the timeline steps back out</td></tr>
449
579
  <tr><td>Right-click clips</td><td><b>Precompose</b> — move the selection into a new comp</td></tr>
450
580
  <tr><td><kbd>Esc</kbd> inside a comp</td><td>Back out one level (once nothing is selected)</td></tr>
@@ -501,6 +631,8 @@
501
631
  <script src="edit-ops.js"></script>
502
632
  <script src="comp.js"></script>
503
633
  <script src="templates.js"></script>
634
+ <script src="media-lib.js"></script>
635
+ <script src="delivery.js"></script>
504
636
  <script src="app.js"></script>
505
637
  </body>
506
638
  </html>