@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.
- package/dist/video-editor/index.cjs +505 -48
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +243 -1
- package/dist/video-editor/index.d.ts +243 -1
- package/dist/video-editor/index.js +463 -6
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +12 -0
|
@@ -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,
|
|
3222
|
+
constructor(source, canvas2, options = {}) {
|
|
2850
3223
|
this.source = source;
|
|
2851
3224
|
this.options = options;
|
|
2852
|
-
this.canvas =
|
|
2853
|
-
this.ctx = options.context !== void 0 ? options.context :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|