@pixerate/editor 0.86.0 → 0.87.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.
@@ -2124,6 +2124,379 @@ var VideoAudioPeaksCache = class {
2124
2124
  }
2125
2125
  };
2126
2126
 
2127
+ // src/video-editor/scopes.ts
2128
+ var VIDEO_SCOPE_KINDS = ["histogram", "waveform", "vectorscope"];
2129
+ var videoLuma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
2130
+ function videoChroma(r, g, b) {
2131
+ const y = videoLuma(r, g, b);
2132
+ return [(b - y) / 1.8556, (r - y) / 1.5748];
2133
+ }
2134
+ function eachPixel(pixels, maxPixels, visit) {
2135
+ const { width, height, data } = pixels;
2136
+ if (!(width > 0 && height > 0) || data.length < width * height * 4) throw new Error(`The pixels are not ${width} \xD7 ${height} RGBA: there are ${data.length} values, and ${width * height * 4} are needed.`);
2137
+ const step = Math.max(1, Math.sqrt(width * height / Math.max(1, maxPixels ?? 262144)));
2138
+ let count = 0;
2139
+ for (let fy = step / 2; fy < height; fy += step) {
2140
+ const y = Math.floor(fy);
2141
+ for (let fx = step / 2; fx < width; fx += step) {
2142
+ const x = Math.floor(fx);
2143
+ const i = (y * width + x) * 4;
2144
+ visit(data[i], data[i + 1], data[i + 2], x, y);
2145
+ count++;
2146
+ }
2147
+ }
2148
+ return count;
2149
+ }
2150
+ var binOf = (v, bins) => Math.min(bins - 1, Math.floor((v + 1e-6) / 256 * bins));
2151
+ function videoHistogram(pixels, options = {}) {
2152
+ const bins = Math.max(1, Math.min(256, Math.round(options.bins ?? 256)));
2153
+ const out = { bins, r: Array(bins).fill(0), g: Array(bins).fill(0), b: Array(bins).fill(0), luma: Array(bins).fill(0), total: 0 };
2154
+ out.total = eachPixel(pixels, options.maxPixels, (r, g, b) => {
2155
+ out.r[binOf(r, bins)]++;
2156
+ out.g[binOf(g, bins)]++;
2157
+ out.b[binOf(b, bins)]++;
2158
+ out.luma[binOf(videoLuma(r, g, b), bins)]++;
2159
+ });
2160
+ return out;
2161
+ }
2162
+ function videoLumaWaveform(pixels, options = {}) {
2163
+ const columns = Math.max(1, Math.min(pixels.width, Math.round(options.columns ?? Math.min(pixels.width, 256))));
2164
+ const levels = Math.max(2, Math.min(256, Math.round(options.levels ?? 128)));
2165
+ const data = new Uint32Array(columns * levels);
2166
+ let peak = 0;
2167
+ eachPixel(pixels, options.maxPixels, (r, g, b, x) => {
2168
+ const c = Math.min(columns - 1, Math.floor(x / pixels.width * columns));
2169
+ const i = c * levels + binOf(videoLuma(r, g, b), levels);
2170
+ if (++data[i] > peak) peak = data[i];
2171
+ });
2172
+ return { columns, levels, data, peak };
2173
+ }
2174
+ var videoVectorscopePoint = (cb, cr, size) => [(cb + 0.5) * (size - 1), (0.5 - cr) * (size - 1)];
2175
+ function videoVectorscope(pixels, options = {}) {
2176
+ const size = Math.max(16, Math.min(512, Math.round(options.size ?? 128)));
2177
+ const data = new Uint32Array(size * size);
2178
+ let peak = 0;
2179
+ eachPixel(pixels, options.maxPixels, (r, g, b) => {
2180
+ const [cb, cr] = videoChroma(r / 255, g / 255, b / 255);
2181
+ const [x, y] = videoVectorscopePoint(cb, cr, size);
2182
+ const i = Math.round(y) * size + Math.round(x);
2183
+ if (++data[i] > peak) peak = data[i];
2184
+ });
2185
+ return { size, data, peak };
2186
+ }
2187
+ var round3 = (v) => Math.round(v * 1e3) / 1e3;
2188
+ var hex2 = (v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
2189
+ function videoExposureStats(pixels, options = {}) {
2190
+ const low = (options.shadowLevel ?? 0.02) * 255;
2191
+ const high = (options.highlightLevel ?? 0.98) * 255;
2192
+ const lumaBins = new Uint32Array(256);
2193
+ let sumY = 0;
2194
+ let sumR = 0;
2195
+ let sumG = 0;
2196
+ let sumB = 0;
2197
+ let sumSat = 0;
2198
+ let maxSat = 0;
2199
+ let shadows = 0;
2200
+ let highlights = 0;
2201
+ let cr = 0;
2202
+ let cg = 0;
2203
+ let cbl = 0;
2204
+ const total = eachPixel(pixels, options.maxPixels, (r, g, b) => {
2205
+ const y = videoLuma(r, g, b);
2206
+ lumaBins[Math.min(255, Math.round(y))]++;
2207
+ sumY += y;
2208
+ sumR += r;
2209
+ sumG += g;
2210
+ sumB += b;
2211
+ const [u, v] = videoChroma(r / 255, g / 255, b / 255);
2212
+ const sat = Math.min(1, Math.hypot(u, v) / 0.5);
2213
+ sumSat += sat;
2214
+ if (sat > maxSat) maxSat = sat;
2215
+ if (y <= low) shadows++;
2216
+ if (y >= high) highlights++;
2217
+ if (r >= high) cr++;
2218
+ if (g >= high) cg++;
2219
+ if (b >= high) cbl++;
2220
+ });
2221
+ const percentile = (p) => {
2222
+ const target = p * total;
2223
+ let seen = 0;
2224
+ for (let i = 0; i < 256; i++) {
2225
+ seen += lumaBins[i];
2226
+ if (seen >= target && seen > 0) return i / 255;
2227
+ }
2228
+ return 1;
2229
+ };
2230
+ let min = 0;
2231
+ while (min < 255 && !lumaBins[min]) min++;
2232
+ let max = 255;
2233
+ while (max > 0 && !lumaBins[max]) max--;
2234
+ return {
2235
+ pixels: total,
2236
+ luma: { mean: round3(sumY / total / 255), median: round3(percentile(0.5)), min: round3(min / 255), max: round3(max / 255), p1: round3(percentile(0.01)), p99: round3(percentile(0.99)) },
2237
+ clipped: { shadows: round3(shadows / total), highlights: round3(highlights / total), r: round3(cr / total), g: round3(cg / total), b: round3(cbl / total) },
2238
+ saturation: { mean: round3(sumSat / total), max: round3(maxSat) },
2239
+ average: `#${hex2(sumR / total)}${hex2(sumG / total)}${hex2(sumB / total)}`
2240
+ };
2241
+ }
2242
+ function videoExposureNotes(stats) {
2243
+ const notes = [];
2244
+ const pct = (v2) => `${Math.round(v2 * 1e3) / 10}%`;
2245
+ if (stats.clipped.shadows >= 0.05) notes.push(`${pct(stats.clipped.shadows)} of the picture is crushed to black; raise exposure or lower contrast.`);
2246
+ if (stats.clipped.highlights >= 0.02) notes.push(`${pct(stats.clipped.highlights)} of the picture is blown to white; lower exposure.`);
2247
+ else {
2248
+ const channels = ["r", "g", "b"].filter((k) => stats.clipped[k] >= 0.05);
2249
+ if (channels.length) notes.push(`The ${channels.map((k) => ({ r: "red", g: "green", b: "blue" })[k]).join(" and ")} channel${channels.length > 1 ? "s clip" : " clips"} in ${pct(Math.max(...channels.map((k) => stats.clipped[k])))} of the picture; lower exposure or saturation.`);
2250
+ }
2251
+ if (stats.luma.mean < 0.2 && stats.clipped.shadows < 0.05) notes.push(`The picture is dark (mean luma ${stats.luma.mean}); raise exposure if it is not meant to be.`);
2252
+ if (stats.luma.mean > 0.75 && stats.clipped.highlights < 0.02) notes.push(`The picture is bright (mean luma ${stats.luma.mean}); lower exposure if it is not meant to be.`);
2253
+ if (stats.luma.p99 - stats.luma.p1 < 0.3 && stats.pixels > 1) notes.push(`The picture is flat (most of it lies between luma ${stats.luma.p1} and ${stats.luma.p99}); raise contrast.`);
2254
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(stats.average.slice(i, i + 2), 16) / 255);
2255
+ const [u, v] = videoChroma(r, g, b);
2256
+ if (Math.hypot(u, v) > 0.04 && stats.saturation.mean < 0.35) {
2257
+ const warm = v - u;
2258
+ notes.push(`The picture has a ${Math.abs(warm) > Math.abs(v + u) * 0.5 ? warm > 0 ? "warm (orange)" : "cool (blue)" : v > 0 ? "magenta" : "green"} cast (average ${stats.average}); adjust temperature and tint.`);
2259
+ }
2260
+ return notes;
2261
+ }
2262
+ var videoVectorscopeAngle = (cb, cr) => (Math.atan2(cr, cb) * 180 / Math.PI % 360 + 360) % 360;
2263
+ function summarizeVideoScopes(pixels, options = {}) {
2264
+ const stats = videoExposureStats(pixels, options);
2265
+ const bins = Math.max(2, Math.min(64, Math.round(options.bins ?? 16)));
2266
+ const h = videoHistogram(pixels, { ...options, bins });
2267
+ const share = (a) => a.map((v) => round3(v / h.total));
2268
+ const columns = Math.max(1, Math.min(32, Math.round(options.columns ?? 8)));
2269
+ const w = videoLumaWaveform(pixels, { ...options, columns, levels: 256 });
2270
+ const waveform = Array.from({ length: w.columns }, (_, c) => {
2271
+ const counts = w.data.subarray(c * 256, c * 256 + 256);
2272
+ const n2 = counts.reduce((a, b) => a + b, 0);
2273
+ let lo = 0;
2274
+ while (lo < 255 && !counts[lo]) lo++;
2275
+ let hi = 255;
2276
+ while (hi > 0 && !counts[hi]) hi--;
2277
+ let seen = 0;
2278
+ let median = 0;
2279
+ for (let i = 0; i < 256; i++) {
2280
+ seen += counts[i];
2281
+ if (seen >= n2 / 2 && n2) {
2282
+ median = i;
2283
+ break;
2284
+ }
2285
+ }
2286
+ return { start: round3(c / w.columns), end: round3((c + 1) / w.columns), min: round3(lo / 255), median: round3(median / 255), max: round3(hi / 255) };
2287
+ }).filter((_, c) => w.data.subarray(c * 256, c * 256 + 256).some((v) => v > 0));
2288
+ const chroma = [];
2289
+ eachPixel(pixels, Math.min(options.maxPixels ?? 65536, 65536), (r, g, b) => {
2290
+ const [u, v] = videoChroma(r / 255, g / 255, b / 255);
2291
+ chroma.push([u, v, Math.hypot(u, v)]);
2292
+ });
2293
+ chroma.sort((a, b) => b[2] - a[2]);
2294
+ const top = chroma.slice(0, Math.max(1, Math.ceil(chroma.length / 10)));
2295
+ const mu = top.reduce((s, c) => s + c[0], 0) / top.length;
2296
+ const mv = top.reduce((s, c) => s + c[1], 0) / top.length;
2297
+ const strength = Math.min(1, top.reduce((s, c) => s + c[2], 0) / top.length / 0.5);
2298
+ const coherence = Math.hypot(mu, mv) / Math.max(1e-9, top.reduce((s, c) => s + c[2], 0) / top.length);
2299
+ return {
2300
+ stats,
2301
+ notes: videoExposureNotes(stats),
2302
+ histogram: { bins, luma: share(h.luma), r: share(h.r), g: share(h.g), b: share(h.b) },
2303
+ waveform,
2304
+ vectorscope: { hue: strength < 0.02 ? null : Math.round(videoVectorscopeAngle(mu, mv)), strength: round3(strength), spread: round3(1 - coherence) }
2305
+ };
2306
+ }
2307
+ var VIDEO_VECTORSCOPE_TARGETS = [
2308
+ ["R", [0.75, 0, 0]],
2309
+ ["Mg", [0.75, 0, 0.75]],
2310
+ ["B", [0, 0, 0.75]],
2311
+ ["Cy", [0, 0.75, 0.75]],
2312
+ ["G", [0, 0.75, 0]],
2313
+ ["Yl", [0.75, 0.75, 0]]
2314
+ ].map(([name, rgb]) => {
2315
+ const [cb, cr] = videoChroma(rgb[0], rgb[1], rgb[2]);
2316
+ return { name, rgb: [...rgb], cb, cr };
2317
+ });
2318
+ function canvas(width, height) {
2319
+ const data = new Uint8ClampedArray(width * height * 4);
2320
+ for (let i = 0; i < data.length; i += 4) {
2321
+ data[i] = 12;
2322
+ data[i + 1] = 14;
2323
+ data[i + 2] = 18;
2324
+ data[i + 3] = 255;
2325
+ }
2326
+ return { width, height, data };
2327
+ }
2328
+ function plot(img, x, y, rgb, alpha = 1, add = false) {
2329
+ const xi = Math.round(x);
2330
+ const yi = Math.round(y);
2331
+ if (xi < 0 || yi < 0 || xi >= img.width || yi >= img.height) return;
2332
+ const i = (yi * img.width + xi) * 4;
2333
+ for (let k = 0; k < 3; k++) img.data[i + k] = add ? img.data[i + k] + rgb[k] * alpha : img.data[i + k] * (1 - alpha) + rgb[k] * alpha;
2334
+ }
2335
+ var GRID = [70, 76, 88];
2336
+ var glow = (count, peak) => count ? 0.25 + 0.75 * (Math.log1p(count) / Math.log1p(Math.max(1, peak))) : 0;
2337
+ function videoHistogramImage(h, width = 256, height = 128) {
2338
+ const img = canvas(width, height);
2339
+ for (const q of [0.25, 0.5, 0.75]) for (let y = 0; y < height; y++) plot(img, q * (width - 1), y, GRID);
2340
+ const inner = (a) => a.length > 2 ? a.slice(1, -1) : a;
2341
+ const peak = Math.max(1, ...inner(h.r), ...inner(h.g), ...inner(h.b), ...inner(h.luma));
2342
+ const height01 = (v) => Math.min(1, Math.sqrt(v / peak));
2343
+ for (let x = 0; x < width; x++) {
2344
+ const bin = Math.min(h.bins - 1, Math.floor(x / width * h.bins));
2345
+ const channels = [
2346
+ [h.r, [200, 40, 40]],
2347
+ [h.g, [40, 180, 40]],
2348
+ [h.b, [50, 80, 220]]
2349
+ ];
2350
+ for (const [a, rgb] of channels) {
2351
+ const top = height - height01(a[bin]) * height;
2352
+ for (let y = Math.ceil(top); y < height; y++) plot(img, x, y, rgb, 0.8, true);
2353
+ }
2354
+ plot(img, x, height - 1 - height01(h.luma[bin]) * (height - 1), [255, 255, 255]);
2355
+ }
2356
+ return img;
2357
+ }
2358
+ function videoWaveformImage(w, width = 256, height = 128) {
2359
+ const img = canvas(width, height);
2360
+ for (const q of [0, 0.25, 0.5, 0.75, 1]) for (let x = 0; x < width; x++) plot(img, x, (1 - q) * (height - 1), GRID);
2361
+ for (let x = 0; x < width; x++) {
2362
+ const c = Math.min(w.columns - 1, Math.floor(x / width * w.columns));
2363
+ for (let y = 0; y < height; y++) {
2364
+ const level = Math.min(w.levels - 1, Math.floor((height - 1 - y) / height * w.levels));
2365
+ const a = glow(w.data[c * w.levels + level], w.peak);
2366
+ if (a) plot(img, x, y, [120, 255, 140], a);
2367
+ }
2368
+ }
2369
+ return img;
2370
+ }
2371
+ function videoVectorscopeImage(v, size = v.size) {
2372
+ const img = canvas(size, size);
2373
+ const c = (size - 1) / 2;
2374
+ for (let a = 0; a < 720; a++) {
2375
+ const t = a / 720 * 2 * Math.PI;
2376
+ plot(img, c + Math.cos(t) * c, c - Math.sin(t) * c, GRID);
2377
+ }
2378
+ const skin = 123 * Math.PI / 180;
2379
+ for (let r = 0; r < c; r++) plot(img, c + Math.cos(skin) * r, c - Math.sin(skin) * r, [140, 110, 90]);
2380
+ for (const t of VIDEO_VECTORSCOPE_TARGETS) {
2381
+ const [x, y] = videoVectorscopePoint(t.cb, t.cr, size);
2382
+ const rgb = t.rgb.map((k) => 80 + k * 200);
2383
+ for (let k = -3; k <= 3; k++) {
2384
+ plot(img, x + k, y - 3, rgb);
2385
+ plot(img, x + k, y + 3, rgb);
2386
+ plot(img, x - 3, y + k, rgb);
2387
+ plot(img, x + 3, y + k, rgb);
2388
+ }
2389
+ }
2390
+ for (let y = 0; y < size; y++)
2391
+ for (let x = 0; x < size; x++) {
2392
+ const sy = Math.min(v.size - 1, Math.floor(y / size * v.size));
2393
+ const sx = Math.min(v.size - 1, Math.floor(x / size * v.size));
2394
+ const a = glow(v.data[sy * v.size + sx], v.peak);
2395
+ if (!a) continue;
2396
+ const cb = sx / (v.size - 1) - 0.5;
2397
+ const cr = 0.5 - sy / (v.size - 1);
2398
+ const r = Math.min(1, Math.max(0, 0.6 + 1.5748 * cr));
2399
+ const g = Math.min(1, Math.max(0, 0.6 - 0.1873 * cb - 0.4681 * cr));
2400
+ const b = Math.min(1, Math.max(0, 0.6 + 1.8556 * cb));
2401
+ plot(img, x, y, [r * 255, g * 255, b * 255], a);
2402
+ }
2403
+ return img;
2404
+ }
2405
+ function videoScopeImage(kind, pixels, width = 256, height = 128, options = {}) {
2406
+ if (kind === "histogram") return videoHistogramImage(videoHistogram(pixels, { ...options, bins: Math.min(256, width) }), width, height);
2407
+ if (kind === "waveform") return videoWaveformImage(videoLumaWaveform(pixels, { ...options, columns: Math.min(pixels.width, width), levels: Math.min(256, height) }), width, height);
2408
+ const size = Math.min(width, height);
2409
+ return videoVectorscopeImage(videoVectorscope(pixels, { ...options, size }), size);
2410
+ }
2411
+ var VideoPreviewPixels = class {
2412
+ reader = null;
2413
+ drawnAt = null;
2414
+ listeners = /* @__PURE__ */ new Set();
2415
+ /** Gives the canvas to read (the latest preview wins). Returns a function that takes it away again. */
2416
+ attach(reader) {
2417
+ this.reader = reader;
2418
+ this.drawnAt = null;
2419
+ return () => {
2420
+ if (this.reader !== reader) return;
2421
+ this.reader = null;
2422
+ this.drawnAt = null;
2423
+ this.emit();
2424
+ };
2425
+ }
2426
+ /** Whether a preview is attached. */
2427
+ get attached() {
2428
+ return this.reader !== null;
2429
+ }
2430
+ /** The preview drew the frame at `time`; `complete` is false while a picture was missing or stale. */
2431
+ drawn(time, complete) {
2432
+ this.drawnAt = { time, complete };
2433
+ this.emit();
2434
+ }
2435
+ /** The last frame drawn, with its pixels; `null` without a preview. Throws when the canvas cannot be read. */
2436
+ read() {
2437
+ if (!this.reader) return null;
2438
+ try {
2439
+ return this.reader();
2440
+ } catch (e) {
2441
+ const reason = e instanceof Error ? e.message : String(e);
2442
+ throw new Error(`The preview's pixels cannot be read (${reason}). Media from another origin must be served with CORS headers (Access-Control-Allow-Origin) for its pixels to be measured.`);
2443
+ }
2444
+ }
2445
+ /** Called after each frame the preview draws. */
2446
+ subscribe(listener) {
2447
+ this.listeners.add(listener);
2448
+ return () => {
2449
+ this.listeners.delete(listener);
2450
+ };
2451
+ }
2452
+ /**
2453
+ * The frame at `time` once it is complete: now, if the last frame drawn was it, else the next
2454
+ * complete drawing of it. Fails after `timeout` ms (default 5000).
2455
+ */
2456
+ next(time, timeout = 5e3) {
2457
+ const matches = () => this.drawnAt && this.drawnAt.complete && Math.abs(this.drawnAt.time - time) < 0.5;
2458
+ return new Promise((resolve, reject) => {
2459
+ const done = (fn) => {
2460
+ clearTimeout(timer);
2461
+ off();
2462
+ fn();
2463
+ };
2464
+ const check = () => {
2465
+ if (!this.reader) return done(() => reject(new Error("The preview was closed before it drew the frame.")));
2466
+ if (!matches()) return;
2467
+ try {
2468
+ const snap = this.read();
2469
+ done(() => snap ? resolve(snap) : reject(new Error("The preview has no pixels to read.")));
2470
+ } catch (e) {
2471
+ done(() => reject(e));
2472
+ }
2473
+ };
2474
+ const off = this.subscribe(check);
2475
+ const timer = setTimeout(() => done(() => reject(new Error(`The preview did not finish drawing ${Math.round(time)} ms within ${timeout} ms; its media may still be loading or may have failed to load. Try again, or check the asset sources.`))), timeout);
2476
+ check();
2477
+ });
2478
+ }
2479
+ emit() {
2480
+ for (const l of [...this.listeners]) l();
2481
+ }
2482
+ };
2483
+ function readVideoScopesPanel(feed, kinds = VIDEO_SCOPE_KINDS, options = {}) {
2484
+ let snap;
2485
+ try {
2486
+ snap = feed.read();
2487
+ } catch (e) {
2488
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
2489
+ }
2490
+ if (!snap) return { ok: false, message: feed.attached ? "The preview has not drawn a frame yet." : "Show the preview to see its scopes." };
2491
+ const width = options.width ?? 256;
2492
+ const height = options.height ?? 128;
2493
+ const opts = { maxPixels: options.maxPixels ?? 65536 };
2494
+ const images = {};
2495
+ for (const kind of kinds) images[kind] = videoScopeImage(kind, snap.pixels, width, height, opts);
2496
+ const stats = videoExposureStats(snap.pixels, opts);
2497
+ return { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };
2498
+ }
2499
+
2127
2500
  // src/video-editor/transcript.ts
2128
2501
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2129
2502
  function cueTime(s) {
@@ -2846,12 +3219,16 @@ function buildFfmpeg(project, options, measure) {
2846
3219
 
2847
3220
  // src/video-editor/preview.ts
2848
3221
  var VideoPreviewEngine = class {
2849
- constructor(source, canvas, options = {}) {
3222
+ constructor(source, canvas2, options = {}) {
2850
3223
  this.source = source;
2851
3224
  this.options = options;
2852
- this.canvas = canvas;
2853
- this.ctx = options.context !== void 0 ? options.context : canvas.getContext?.("2d") ?? null;
3225
+ this.canvas = canvas2;
3226
+ this.ctx = options.context !== void 0 ? options.context : canvas2.getContext?.("2d") ?? null;
2854
3227
  this.unsubscribe = source.subscribe(() => this.update());
3228
+ const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
3229
+ this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
3230
+ };
3231
+ this.pixelsTarget = pixels ?? null;
2855
3232
  this.update();
2856
3233
  }
2857
3234
  source;
@@ -2865,6 +3242,16 @@ var VideoPreviewEngine = class {
2865
3242
  destroyed = false;
2866
3243
  filterHost = null;
2867
3244
  filters = /* @__PURE__ */ new Set();
3245
+ detachPixels;
3246
+ pixelsTarget;
3247
+ drawn = null;
3248
+ complete = true;
3249
+ /** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
3250
+ readPixels() {
3251
+ const ctx = this.ctx;
3252
+ if (!ctx?.getImageData || !this.drawn) return null;
3253
+ return { ...this.drawn, pixels: ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) };
3254
+ }
2868
3255
  createMedia(kind) {
2869
3256
  if (this.options.createMedia) return this.options.createMedia(kind);
2870
3257
  const el = document.createElement(kind);
@@ -2956,7 +3343,9 @@ var VideoPreviewEngine = class {
2956
3343
  return img.complete ? img : null;
2957
3344
  }
2958
3345
  const entry = this.media.get(layer.clip);
2959
- return entry && entry.media.readyState >= 2 ? entry.media : null;
3346
+ if (!entry || entry.media.readyState < 2) return null;
3347
+ if (entry.media.seeking) this.complete = false;
3348
+ return entry.media;
2960
3349
  };
2961
3350
  draw(frame) {
2962
3351
  if (!this.ctx) return;
@@ -2967,8 +3356,16 @@ var VideoPreviewEngine = class {
2967
3356
  const h = Math.max(1, Math.round(frame.height * resolution));
2968
3357
  if (this.canvas.width !== w) this.canvas.width = w;
2969
3358
  if (this.canvas.height !== h) this.canvas.height = h;
2970
- drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
3359
+ this.complete = true;
3360
+ const sourceFor = (layer) => {
3361
+ const source = this.sourceFor(layer);
3362
+ if (!source) this.complete = false;
3363
+ return source;
3364
+ };
3365
+ drawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
3366
+ this.drawn = { time: frame.time, complete: this.complete };
2971
3367
  this.options.onFrame?.(frame);
3368
+ this.pixelsTarget?.drawn(frame.time, this.complete);
2972
3369
  }
2973
3370
  defineSvgFilter = (id, markup) => {
2974
3371
  if (this.options.defineSvgFilter === null) return;
@@ -2989,6 +3386,7 @@ var VideoPreviewEngine = class {
2989
3386
  /** Stops following the editor and releases every media element. */
2990
3387
  destroy() {
2991
3388
  this.destroyed = true;
3389
+ this.detachPixels();
2992
3390
  this.filterHost?.remove();
2993
3391
  this.filterHost = null;
2994
3392
  this.filters.clear();
@@ -3004,6 +3402,8 @@ var VideoEditorController = class {
3004
3402
  player;
3005
3403
  /** Sound levels per asset, loaded on demand for waveforms and agents. */
3006
3404
  audioPeaks;
3405
+ /** The pixels the preview last drew, for scopes and `get_video_scopes` (a `VideoPreviewEngine` fills it). */
3406
+ previewPixels = new VideoPreviewPixels();
3007
3407
  project;
3008
3408
  issues;
3009
3409
  selection = [];
@@ -4199,7 +4599,46 @@ function createVideoEditorToolProvider(controller, options = {}) {
4199
4599
  return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
4200
4600
  }
4201
4601
  };
4202
- const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
4602
+ const pixelsAt = async (time) => {
4603
+ if (options.framePixels) {
4604
+ const px = await options.framePixels(time);
4605
+ if (!px) throw new Error(`The host could not render the frame at ${Math.round(time)} ms; use render_video_frame to look at it instead.`);
4606
+ return px;
4607
+ }
4608
+ const feed = controller.previewPixels;
4609
+ if (!feed.attached) throw new Error("No preview is showing this project, so there are no pixels to measure. The host must show a <VideoPreview> for this editor (or pass framePixels to the tool provider); until then use get_video_frame and render_video_frame.");
4610
+ const state = controller.getState();
4611
+ if (Math.abs(time - state.time) < 0.5) return (await feed.next(state.time, options.framePixelsTimeout)).pixels;
4612
+ const { time: before, playing } = state;
4613
+ if (playing) controller.pause();
4614
+ controller.seek(time);
4615
+ try {
4616
+ return (await feed.next(controller.getState().time, options.framePixelsTimeout)).pixels;
4617
+ } finally {
4618
+ controller.seek(before);
4619
+ if (playing) controller.play();
4620
+ }
4621
+ };
4622
+ const getScopes = {
4623
+ name: "get_video_scopes",
4624
+ title: "Measure a frame with scopes",
4625
+ description: "Measures the rendered picture at a moment as colour scopes would, without changing the project: luma (0 = black, 1 = white) mean, median, min, max and 1st / 99th percentiles; the share of pixels crushed to black or blown to white (and per channel); saturation; the average colour (a cast shows as a tint); a coarse luma and RGB histogram; the luma spread of vertical strips left to right (a waveform); the main hue and strength on a vectorscope; and notes on what to fix. Use it before and after grading (update_video_clip grade, apply_video_grade_preset) to check exposure and colour. Needs the pixels the host draws: fails with a reason when no preview is showing.",
4626
+ inputSchema: object({
4627
+ time: MS("Time from the start to measure (default: the playhead). Another time is shown briefly and the playhead put back"),
4628
+ bins: { type: "integer", minimum: 2, maximum: 64, description: "Histogram steps, darkest first (default 16)." },
4629
+ columns: { type: "integer", minimum: 1, maximum: 32, description: "Waveform strips across the picture, left to right (default 8)." }
4630
+ }),
4631
+ annotations: { title: "Measure a frame with scopes", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4632
+ handler: async ({ time, bins, columns }) => {
4633
+ const p = project();
4634
+ const end = videoProjectDuration(p);
4635
+ const t = time ?? controller.getState().time;
4636
+ if (time !== void 0 && time > end) throw new Error(`Time ${time} ms is past the end of the project (${end} ms).`);
4637
+ const pixels = await pixelsAt(t);
4638
+ return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
4639
+ }
4640
+ };
4641
+ const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
4203
4642
  const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
4204
4643
  return {
4205
4644
  id: options.id ?? "video",
@@ -4461,14 +4900,17 @@ export {
4461
4900
  VIDEO_KEYFRAME_PROPERTIES,
4462
4901
  VIDEO_PEAK_BLOCK,
4463
4902
  VIDEO_PLACEMENT_MODES,
4903
+ VIDEO_SCOPE_KINDS,
4464
4904
  VIDEO_TIME_EPSILON,
4465
4905
  VIDEO_TRANSFORM_PROPERTIES,
4466
4906
  VIDEO_TRANSITION_LABELS,
4467
4907
  VIDEO_TRANSITION_TYPES,
4908
+ VIDEO_VECTORSCOPE_TARGETS,
4468
4909
  VIDEO_WIPE_DIRECTIONS,
4469
4910
  VideoAudioPeaksCache,
4470
4911
  VideoEditorController,
4471
4912
  VideoPreviewEngine,
4913
+ VideoPreviewPixels,
4472
4914
  allVideoClipIds,
4473
4915
  applyVideoEdits,
4474
4916
  applyVideoGrade,
@@ -4514,6 +4956,7 @@ export {
4514
4956
  placeVideoRect,
4515
4957
  planVideoClipDrag,
4516
4958
  probeVideoMedia,
4959
+ readVideoScopesPanel,
4517
4960
  removeVideoKey,
4518
4961
  retimeVideoAnimatable,
4519
4962
  setVideoKey,
@@ -4522,6 +4965,7 @@ export {
4522
4965
  sortVideoKeys,
4523
4966
  sortedVideoClips,
4524
4967
  splitVideoAnimatable,
4968
+ summarizeVideoScopes,
4525
4969
  toVideoEdl,
4526
4970
  toVideoFfmpeg,
4527
4971
  toVideoLoudnessMeasure,
@@ -4533,6 +4977,7 @@ export {
4533
4977
  videoAudioPeakRange,
4534
4978
  videoAudioPeakSpans,
4535
4979
  videoAudioPeaksDuration,
4980
+ videoChroma,
4536
4981
  videoClipAfter,
4537
4982
  videoClipAnimated,
4538
4983
  videoClipAnimationProperties,
@@ -4558,6 +5003,8 @@ export {
4558
5003
  videoDropTracks,
4559
5004
  videoEaseBezier,
4560
5005
  videoEaseProgress,
5006
+ videoExposureNotes,
5007
+ videoExposureStats,
4561
5008
  videoFadeCurveGain,
4562
5009
  videoFrameAt,
4563
5010
  videoFrameDuration,
@@ -4573,6 +5020,8 @@ export {
4573
5020
  videoGradeSteps,
4574
5021
  videoGradeSvgDefs,
4575
5022
  videoGradeSvgFilter,
5023
+ videoHistogram,
5024
+ videoHistogramImage,
4576
5025
  videoHourFrames,
4577
5026
  videoIrisRadius,
4578
5027
  videoIssueKey,
@@ -4582,12 +5031,15 @@ export {
4582
5031
  videoKeyframeToggleEdit,
4583
5032
  videoKeyframeValueEdit,
4584
5033
  videoKeysOf,
5034
+ videoLuma,
5035
+ videoLumaWaveform,
4585
5036
  videoOverlap,
4586
5037
  videoPeakDb,
4587
5038
  videoProjectDuration,
4588
5039
  videoProjectSvg,
4589
5040
  videoRemoveRangesEdits,
4590
5041
  videoRulerTicks,
5042
+ videoScopeImage,
4591
5043
  videoSlowMotionRamp,
4592
5044
  videoSnapPoints,
4593
5045
  videoSpeedRampEdit,
@@ -4606,6 +5058,11 @@ export {
4606
5058
  videoTransitionEase,
4607
5059
  videoTransitionHasDirection,
4608
5060
  videoValueAt,
5061
+ videoVectorscope,
5062
+ videoVectorscopeAngle,
5063
+ videoVectorscopeImage,
5064
+ videoVectorscopePoint,
5065
+ videoWaveformImage,
4609
5066
  videoWaveformPath,
4610
5067
  videoWipeRect,
4611
5068
  videoWordAt,