@pixerate/editor 0.89.0 → 0.91.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.
@@ -852,12 +852,33 @@ function parseVideoAsset(v, path = "asset") {
852
852
  height: num(v.height, `${path}.height`, { optional: true, positive: true }),
853
853
  hasAudio: bool(v.hasAudio, `${path}.hasAudio`),
854
854
  poster: str(v.poster, `${path}.poster`, true),
855
- transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`)
855
+ transcript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`),
856
+ proxies: parseVideoAssetProxies(v.proxies, `${path}.proxies`)
856
857
  });
858
+ if (kind !== "video" && asset.proxies) fail(`${path}.proxies`, "are only for video assets");
857
859
  if (kind === "image" && asset.transcript) fail(`${path}.transcript`, "is only for video and audio assets");
858
860
  if (kind === "image" && asset.duration !== void 0) fail(`${path}.duration`, "is only for video and audio assets");
859
861
  return asset;
860
862
  }
863
+ function parseVideoAssetProxies(v, path) {
864
+ if (v === void 0) return void 0;
865
+ if (!Array.isArray(v)) return fail(path, "must be a list of { scale, src }");
866
+ const out = v.map((p, i) => {
867
+ const at = `${path}[${i}]`;
868
+ if (!isObject(p)) return fail(at, "must be an object { scale, src }");
869
+ const unknown = Object.keys(p).find((k) => !["scale", "src", "width", "height"].includes(k));
870
+ if (unknown) fail(`${at}.${unknown}`, "is not a proxy setting; use scale, src, width, height");
871
+ const scale = num(p.scale, `${at}.scale`, { positive: true });
872
+ if (scale >= 1) fail(`${at}.scale`, "must be less than 1 (0.5 is half size)");
873
+ return compact({ scale, src: str(p.src, `${at}.src`), width: num(p.width, `${at}.width`, { optional: true, positive: true }), height: num(p.height, `${at}.height`, { optional: true, positive: true }) });
874
+ });
875
+ const scales = /* @__PURE__ */ new Set();
876
+ for (const [i, p] of out.entries()) {
877
+ if (scales.has(p.scale)) fail(`${path}[${i}].scale`, `repeats ${p.scale}; give each proxy its own scale`);
878
+ scales.add(p.scale);
879
+ }
880
+ return out.length ? out : void 0;
881
+ }
861
882
  function parseVideoClip(v, path = "clip") {
862
883
  if (!isObject(v)) return fail(path, "must be an object");
863
884
  const type = oneOf(v.type ?? "media", VIDEO_CLIP_TYPES, `${path}.type`, false);
@@ -3219,7 +3240,7 @@ var color = (c) => {
3219
3240
  const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
3220
3241
  return `0x${h.slice(0, 6)}${h.length === 8 ? `@${(parseInt(h.slice(6), 16) / 255).toFixed(3)}` : ""}`;
3221
3242
  };
3222
- var shellQuote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
3243
+ var videoShellQuote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
3223
3244
  function atempo(speed) {
3224
3245
  const out = [];
3225
3246
  let s = speed;
@@ -3515,7 +3536,7 @@ function buildFfmpeg(project, options, measure) {
3515
3536
  const filterComplex = graph.join(";");
3516
3537
  if (measure) {
3517
3538
  const args2 = ["-hide_banner", "-nostats", ...inputs.flat(), "-filter_complex", filterComplex, "-map", "[aout]", "-f", "null", "-"];
3518
- return { args: args2, command: ["ffmpeg", ...args2].map(shellQuote).join(" "), filterComplex, duration: D, notes };
3539
+ return { args: args2, command: ["ffmpeg", ...args2].map(videoShellQuote).join(" "), filterComplex, duration: D, notes };
3519
3540
  }
3520
3541
  const args = [
3521
3542
  "-y",
@@ -3536,7 +3557,333 @@ function buildFfmpeg(project, options, measure) {
3536
3557
  sec(D),
3537
3558
  options.output ?? "output.mp4"
3538
3559
  ];
3539
- return { args, command: ["ffmpeg", ...args].map(shellQuote).join(" "), filterComplex, duration: D, notes };
3560
+ return { args, command: ["ffmpeg", ...args].map(videoShellQuote).join(" "), filterComplex, duration: D, notes };
3561
+ }
3562
+
3563
+ // src/video-editor/proxy.ts
3564
+ var VIDEO_PROXY_SCALES = [0.5, 0.25];
3565
+ function videoAssetProxy(asset, scale) {
3566
+ if (!asset?.proxies?.length || !(scale > 0) || scale >= 1) return null;
3567
+ let best = null;
3568
+ for (const p of asset.proxies) if (p.scale >= scale - 1e-9 && p.scale < 1 && (!best || p.scale < best.scale)) best = p;
3569
+ return best;
3570
+ }
3571
+ var videoPreviewSrc = (asset, scale, fallback = asset?.src ?? "") => videoAssetProxy(asset, scale)?.src ?? fallback;
3572
+ function resolveVideoPreviewScale(scale, shown) {
3573
+ if (scale === void 0 || scale === "auto") return Math.min(1, Math.max(0, shown));
3574
+ if (scale === "full") return 1;
3575
+ return Math.min(1, Math.max(0, scale));
3576
+ }
3577
+ function videoProxyName(src, scale = 0.5) {
3578
+ const clean = src.replace(/[?#].*$/, "");
3579
+ const base = clean.replace(/\.[^./\\]+$/, "");
3580
+ return `${base}.proxy-${Math.round(scale * 100)}.mp4`;
3581
+ }
3582
+ function videoProxyFfmpeg(asset, options = {}) {
3583
+ if (asset.kind !== "video") throw new Error(`Asset "${asset.id}" is ${asset.kind === "image" ? "an image" : "audio"}; only video assets have proxies.`);
3584
+ const scale = options.scale ?? 0.5;
3585
+ if (!(scale > 0 && scale < 1)) throw new Error(`A proxy's scale must be between 0 and 1 (not including either), not ${scale}; 0.5 is half size.`);
3586
+ const gop = Math.max(1, Math.round(options.gop ?? 12));
3587
+ const output = options.output ?? videoProxyName(asset.src, scale);
3588
+ const src = options.resolve ? options.resolve(asset) : asset.src;
3589
+ const size = `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`;
3590
+ const args = [
3591
+ "-y",
3592
+ "-i",
3593
+ src,
3594
+ "-vf",
3595
+ size,
3596
+ "-c:v",
3597
+ "libx264",
3598
+ "-preset",
3599
+ "veryfast",
3600
+ "-tune",
3601
+ "fastdecode",
3602
+ "-crf",
3603
+ String(options.crf ?? 28),
3604
+ "-g",
3605
+ String(gop),
3606
+ "-keyint_min",
3607
+ String(gop),
3608
+ "-sc_threshold",
3609
+ "0",
3610
+ "-bf",
3611
+ "0",
3612
+ "-pix_fmt",
3613
+ "yuv420p",
3614
+ ...asset.hasAudio === false ? ["-an"] : ["-c:a", "aac", "-b:a", "128k"],
3615
+ "-movflags",
3616
+ "+faststart",
3617
+ output
3618
+ ];
3619
+ const proxy = { scale, src: output, ...asset.width && asset.height ? { width: Math.trunc(asset.width * scale / 2) * 2, height: Math.trunc(asset.height * scale / 2) * 2 } : {} };
3620
+ return { asset: asset.id, args, command: ["ffmpeg", ...args].map(videoShellQuote).join(" "), proxy };
3621
+ }
3622
+ function videoProxyPlan(project, options = {}) {
3623
+ const scale = options.scale ?? 0.5;
3624
+ const wanted = options.assets ? new Set(options.assets) : null;
3625
+ if (options.assets) for (const id of options.assets) {
3626
+ const a = project.assets.find((x) => x.id === id);
3627
+ if (!a) throw new Error(`There is no asset "${id}"; the assets are ${project.assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
3628
+ if (a.kind !== "video") throw new Error(`Asset "${id}" is ${a.kind === "image" ? "an image" : "audio"}; only video assets have proxies.`);
3629
+ }
3630
+ return project.assets.filter((a) => a.kind === "video" && (!wanted || wanted.has(a.id)) && !a.proxies?.some((p) => Math.abs(p.scale - scale) < 1e-9)).map((a) => videoProxyFfmpeg(a, { ...options, scale }));
3631
+ }
3632
+ var VIDEO_PREVIEW_SCALE_OPTIONS = [
3633
+ { value: "auto", label: "Auto", scale: "auto" },
3634
+ { value: "full", label: "Full", scale: "full" },
3635
+ ...VIDEO_PROXY_SCALES.map((s) => ({ value: String(s), label: s === 0.5 ? "\xBD" : s === 0.25 ? "\xBC" : `${s * 100}%`, scale: s }))
3636
+ ];
3637
+ var parseVideoPreviewScale = (value) => value === "full" ? "full" : Number(value) > 0 && Number(value) <= 1 ? Number(value) : "auto";
3638
+ var videoProjectHasProxies = (project) => project.assets.some((a) => !!a.proxies?.length);
3639
+
3640
+ // src/video-editor/frame-cache.ts
3641
+ var videoSourceFrame = (sourceTime, fps) => Math.max(0, Math.floor(sourceTime * fps / 1e3 + 1e-6));
3642
+ var videoSourceFrameTime = (frame, fps) => (frame + 0.5) * 1e3 / fps;
3643
+ var videoFrameCacheKey = (src, frame) => `${frame}@${src}`;
3644
+ function videoFrameRequestsAt(project, time, srcFor = (l) => l.src) {
3645
+ const out = [];
3646
+ for (const layer of videoFrameAt(project, time).layers) {
3647
+ if (layer.type !== "media" || layer.assetKind !== "video") continue;
3648
+ const src = srcFor(layer);
3649
+ if (!src) continue;
3650
+ const frame = videoSourceFrame(layer.sourceTime ?? 0, project.fps);
3651
+ out.push({ key: videoFrameCacheKey(src, frame), src, frame, sourceTime: videoSourceFrameTime(frame, project.fps), time });
3652
+ }
3653
+ return out;
3654
+ }
3655
+ function planVideoPrefetch(project, time, options = {}) {
3656
+ const window2 = options.window ?? 2e3;
3657
+ const dir = options.direction ?? 1;
3658
+ const step = 1e3 / project.fps;
3659
+ const seen = /* @__PURE__ */ new Set();
3660
+ const out = [];
3661
+ for (let i = 0; i * step <= window2 + 1e-6; i++) {
3662
+ const t = time + dir * i * step;
3663
+ if (t < 0) break;
3664
+ for (const r of videoFrameRequestsAt(project, t, options.srcFor)) {
3665
+ if (seen.has(r.key)) continue;
3666
+ seen.add(r.key);
3667
+ out.push(r);
3668
+ }
3669
+ }
3670
+ return out;
3671
+ }
3672
+ function videoCacheEvictions(entries, options) {
3673
+ let total = entries.reduce((n2, e) => n2 + e.bytes, 0);
3674
+ if (total <= options.maxBytes) return [];
3675
+ const dir = options.direction ?? 1;
3676
+ const behind = options.behind ?? 2;
3677
+ const cost = (e) => {
3678
+ const d = (e.time - options.playhead) * dir;
3679
+ return d >= 0 ? d : -d * behind;
3680
+ };
3681
+ const out = [];
3682
+ for (const e of entries.filter((x) => !options.protect?.has(x.key)).sort((a, b) => cost(b) - cost(a))) {
3683
+ if (total <= options.maxBytes) break;
3684
+ out.push(e.key);
3685
+ total -= e.bytes;
3686
+ }
3687
+ return out;
3688
+ }
3689
+ var defaultCapture = async (source) => {
3690
+ if (typeof createImageBitmap !== "function") return null;
3691
+ const image = await createImageBitmap(source);
3692
+ return { image, width: image.width, height: image.height };
3693
+ };
3694
+ var VideoFrameCache = class {
3695
+ constructor(options = {}) {
3696
+ this.options = options;
3697
+ this.maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
3698
+ this.prefetch = options.prefetch ?? 2e3;
3699
+ this.decode = options.decode !== void 0 ? options.decode : typeof document !== "undefined" ? createVideoElementDecoder() : null;
3700
+ }
3701
+ options;
3702
+ entries = /* @__PURE__ */ new Map();
3703
+ inFlight = /* @__PURE__ */ new Set();
3704
+ queue = [];
3705
+ protect = /* @__PURE__ */ new Set();
3706
+ listeners = /* @__PURE__ */ new Set();
3707
+ playhead = 0;
3708
+ direction = 1;
3709
+ hits = 0;
3710
+ misses = 0;
3711
+ bytes = 0;
3712
+ generation = 0;
3713
+ destroyed = false;
3714
+ srcFor;
3715
+ maxBytes;
3716
+ prefetch;
3717
+ decode;
3718
+ /** The cached frame of `src` showing at a source time (ms), on the project's `fps` grid. Counts a hit or a miss. */
3719
+ get(src, sourceTime, fps) {
3720
+ const entry = this.entries.get(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));
3721
+ if (entry) this.hits++;
3722
+ else this.misses++;
3723
+ return entry?.frame.image;
3724
+ }
3725
+ /** Whether a frame is cached, without counting it. */
3726
+ has(src, sourceTime, fps) {
3727
+ return this.entries.has(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));
3728
+ }
3729
+ /** Keeps a decoded frame (replacing any with the same key), then evicts to the budget. */
3730
+ put(request, frame) {
3731
+ if (this.destroyed) return void frame.image.close?.();
3732
+ const old = this.entries.get(request.key);
3733
+ if (old) this.drop(old);
3734
+ const bytes = Math.max(1, frame.width * frame.height * 4);
3735
+ this.entries.set(request.key, { key: request.key, time: request.time, bytes, frame });
3736
+ this.bytes += bytes;
3737
+ this.evict();
3738
+ this.emit();
3739
+ }
3740
+ /** Keeps what a ready media element shows now as the frame of `src` at `sourceTime` (async; does nothing if it is cached). */
3741
+ capture(src, sourceTime, fps, time, source) {
3742
+ const frame = videoSourceFrame(sourceTime, fps);
3743
+ const key = videoFrameCacheKey(src, frame);
3744
+ if (this.entries.has(key) || this.inFlight.has(key)) return;
3745
+ this.inFlight.add(key);
3746
+ void (this.options.capture ?? defaultCapture)(source).then((f) => {
3747
+ if (f) this.put({ key, time }, f);
3748
+ }).catch(() => {
3749
+ }).finally(() => this.inFlight.delete(key));
3750
+ }
3751
+ /**
3752
+ * Follows the playhead: protects the prefetch window, evicts to the budget, and queues the
3753
+ * window's missing frames for decoding (nearest first). `srcFor` gives the file a layer plays.
3754
+ */
3755
+ update(project, time, options = {}) {
3756
+ if (this.destroyed) return;
3757
+ this.direction = options.direction ?? (time < this.playhead && !options.playing ? -1 : 1);
3758
+ this.playhead = time;
3759
+ this.srcFor = options.srcFor;
3760
+ const plan = this.prefetch > 0 ? planVideoPrefetch(project, time, { window: this.prefetch, direction: this.direction, srcFor: options.srcFor }) : videoFrameRequestsAt(project, time, options.srcFor);
3761
+ this.protect = new Set(plan.map((r) => r.key));
3762
+ for (const r of plan) {
3763
+ const e = this.entries.get(r.key);
3764
+ if (e) e.time = r.time;
3765
+ }
3766
+ this.evict();
3767
+ this.generation++;
3768
+ this.queue = this.decode ? plan.filter((r) => !this.entries.has(r.key) && !this.inFlight.has(r.key)) : [];
3769
+ this.pump();
3770
+ }
3771
+ /**
3772
+ * The stretches of the timeline whose video frames are all cached, between `from` and `to` (ms),
3773
+ * for the files the preview last played (its `srcFor`, e.g. proxies) unless `srcFor` is given.
3774
+ * `step` (ms, at least a frame) samples more coarsely.
3775
+ */
3776
+ cachedRanges(project, options = {}) {
3777
+ const step = Math.max(1e3 / project.fps, options.step ?? 0);
3778
+ const from = Math.max(0, options.from ?? 0);
3779
+ const to = options.to ?? Math.max(...project.tracks.flatMap((t) => t.clips.map((c) => c.start + c.duration)), 0);
3780
+ const out = [];
3781
+ for (let t = from; t < to - 1e-6; t += step) {
3782
+ const requests = videoFrameRequestsAt(project, t, options.srcFor ?? this.srcFor);
3783
+ const cached = requests.length > 0 && requests.every((r) => this.entries.has(r.key));
3784
+ const last = out[out.length - 1];
3785
+ if (!cached) continue;
3786
+ const end = Math.min(to, t + step);
3787
+ if (last && Math.abs(last.end - t) < 1e-6) last.end = end;
3788
+ else out.push({ start: t, end });
3789
+ }
3790
+ return out.map((r) => ({ start: Math.round(r.start * 10) / 10, end: Math.round(r.end * 10) / 10 }));
3791
+ }
3792
+ stats() {
3793
+ return { frames: this.entries.size, bytes: this.bytes, maxBytes: this.maxBytes, hits: this.hits, misses: this.misses, pending: this.inFlight.size + this.queue.length, prefetch: this.prefetch };
3794
+ }
3795
+ /** Called when frames arrive or leave. */
3796
+ subscribe(listener) {
3797
+ this.listeners.add(listener);
3798
+ return () => {
3799
+ this.listeners.delete(listener);
3800
+ };
3801
+ }
3802
+ /** Drops every frame (closing them) and the queue. */
3803
+ clear() {
3804
+ for (const e of [...this.entries.values()]) this.drop(e);
3805
+ this.queue = [];
3806
+ this.generation++;
3807
+ this.emit();
3808
+ }
3809
+ /** Clears and stops decoding for good. */
3810
+ destroy() {
3811
+ this.clear();
3812
+ this.destroyed = true;
3813
+ this.listeners.clear();
3814
+ }
3815
+ pump() {
3816
+ const limit = Math.max(1, this.options.concurrency ?? 2);
3817
+ while (this.decode && this.inFlight.size < limit && this.queue.length) {
3818
+ const r = this.queue.shift();
3819
+ if (this.entries.has(r.key) || this.inFlight.has(r.key)) continue;
3820
+ if (this.bytes >= this.maxBytes && [...this.entries.keys()].every((k) => this.protect.has(k))) {
3821
+ this.queue = [];
3822
+ break;
3823
+ }
3824
+ const generation = this.generation;
3825
+ this.inFlight.add(r.key);
3826
+ this.decode(r.src, r.sourceTime).then(
3827
+ (frame) => {
3828
+ this.inFlight.delete(r.key);
3829
+ if (generation === this.generation || this.bytes + frame.width * frame.height * 4 <= this.maxBytes) this.put(r, frame);
3830
+ else frame.image.close?.();
3831
+ },
3832
+ () => this.inFlight.delete(r.key)
3833
+ ).finally(() => this.pump());
3834
+ }
3835
+ }
3836
+ evict() {
3837
+ const drop = videoCacheEvictions([...this.entries.values()], { playhead: this.playhead, maxBytes: this.maxBytes, direction: this.direction, behind: this.options.behind, protect: this.protect });
3838
+ for (const key of drop) this.drop(this.entries.get(key));
3839
+ }
3840
+ drop(e) {
3841
+ this.entries.delete(e.key);
3842
+ this.bytes -= e.bytes;
3843
+ e.frame.image.close?.();
3844
+ }
3845
+ emit() {
3846
+ for (const l of [...this.listeners]) l();
3847
+ }
3848
+ };
3849
+ function createVideoElementDecoder(options = {}) {
3850
+ const videos = /* @__PURE__ */ new Map();
3851
+ const make = options.createVideo ?? (() => {
3852
+ const el = document.createElement("video");
3853
+ el.muted = true;
3854
+ el.preload = "auto";
3855
+ el.crossOrigin = "anonymous";
3856
+ el.playsInline = true;
3857
+ return el;
3858
+ });
3859
+ const timeout = options.timeout ?? 5e3;
3860
+ const once = (el, type) => new Promise((resolve, reject) => {
3861
+ const timer = setTimeout(() => reject(new Error(`The video did not ${type} within ${timeout} ms.`)), timeout);
3862
+ el.addEventListener(type, () => (clearTimeout(timer), resolve()), { once: true });
3863
+ el.addEventListener("error", () => (clearTimeout(timer), reject(new Error("The video could not be loaded."))), { once: true });
3864
+ });
3865
+ return (src, sourceTime) => {
3866
+ let v = videos.get(src);
3867
+ if (!v) {
3868
+ const el2 = make();
3869
+ el2.src = src;
3870
+ v = { el: el2, chain: el2.readyState >= 1 ? Promise.resolve() : once(el2, "loadedmetadata") };
3871
+ videos.set(src, v);
3872
+ }
3873
+ const { el } = v;
3874
+ const next = v.chain.then(async () => {
3875
+ const presented = "requestVideoFrameCallback" in el ? new Promise((r) => el.requestVideoFrameCallback(() => r())) : null;
3876
+ const seeked = once(el, "seeked");
3877
+ el.currentTime = sourceTime / 1e3;
3878
+ await seeked;
3879
+ if (presented) await Promise.race([presented, new Promise((r) => setTimeout(r, 100))]);
3880
+ const image = await createImageBitmap(el);
3881
+ return { image, width: image.width, height: image.height };
3882
+ });
3883
+ v.chain = next.catch(() => {
3884
+ });
3885
+ return next;
3886
+ };
3540
3887
  }
3541
3888
 
3542
3889
  // src/video-editor/preview.ts
@@ -3550,6 +3897,9 @@ var VideoPreviewEngine = class {
3550
3897
  this.lutCache = options.luts !== void 0 ? options.luts : source.luts ?? null;
3551
3898
  this.unsubscribeLuts = this.lutCache ? this.lutCache.subscribe(() => this.redraw()) : () => {
3552
3899
  };
3900
+ this.frameCache = options.frameCache !== void 0 ? options.frameCache : source.frameCache ?? null;
3901
+ this.unsubscribeFrames = this.frameCache ? this.frameCache.subscribe(() => this.redraw()) : () => {
3902
+ };
3553
3903
  const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
3554
3904
  this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
3555
3905
  };
@@ -3572,6 +3922,11 @@ var VideoPreviewEngine = class {
3572
3922
  drawn = null;
3573
3923
  complete = true;
3574
3924
  lutCache;
3925
+ frameCache;
3926
+ unsubscribeFrames;
3927
+ srcFor = (layer) => layer.src;
3928
+ playing = false;
3929
+ fps = 30;
3575
3930
  unsubscribeLuts;
3576
3931
  scratchCanvas;
3577
3932
  /** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
@@ -3622,8 +3977,15 @@ var VideoPreviewEngine = class {
3622
3977
  /** Brings the media elements in step with the editor and draws the frame. */
3623
3978
  update() {
3624
3979
  if (this.destroyed) return;
3625
- const { project, time, playing } = this.source.getState();
3980
+ const state = this.source.getState();
3981
+ const { project, time, playing } = state;
3626
3982
  const frame = videoFrameAt(project, time);
3983
+ const scale = resolveVideoPreviewScale(this.options.previewScale ?? state.previewScale, this.resolutionFor(project.width));
3984
+ const assets = new Map(project.assets.map((a) => [a.id, a]));
3985
+ this.srcFor = (layer) => videoPreviewSrc(assets.get(layer.asset ?? ""), scale, layer.src);
3986
+ this.playing = playing;
3987
+ this.fps = project.fps;
3988
+ this.frameCache?.update(project, time, { playing, srcFor: this.srcFor });
3627
3989
  const tolerance = (this.options.tolerance ?? 200) / 1e3;
3628
3990
  const active = /* @__PURE__ */ new Set();
3629
3991
  const sync = (clip, src, kind, sourceTime, speed, gain) => {
@@ -3650,7 +4012,7 @@ var VideoPreviewEngine = class {
3650
4012
  for (const layer of frame.layers) {
3651
4013
  if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
3652
4014
  const sound = sounding.get(layer.clip);
3653
- sync(layer.clip, layer.src, "video", layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);
4015
+ sync(layer.clip, this.srcFor(layer), "video", layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);
3654
4016
  sounding.delete(layer.clip);
3655
4017
  }
3656
4018
  for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
@@ -3671,15 +4033,29 @@ var VideoPreviewEngine = class {
3671
4033
  return img.complete ? img : null;
3672
4034
  }
3673
4035
  const entry = this.media.get(layer.clip);
3674
- if (!entry || entry.media.readyState < 2) return null;
4036
+ const ready = !!entry && entry.media.readyState >= 2;
4037
+ const cache = this.frameCache;
4038
+ const src = entry?.src ?? this.srcFor(layer);
4039
+ const sourceTime = layer.sourceTime ?? 0;
4040
+ if (cache && src && (!this.playing || !ready || entry.media.seeking)) {
4041
+ const hit = cache.get(src, sourceTime, this.fps);
4042
+ if (hit) return hit;
4043
+ }
4044
+ if (!ready) return null;
3675
4045
  if (entry.media.seeking) this.complete = false;
4046
+ else if (cache && !this.playing) cache.capture(entry.src, sourceTime, this.fps, this.source.getState().time, entry.media);
3676
4047
  return entry.media;
3677
4048
  };
4049
+ /** Canvas pixels per frame pixel: the option, else the displayed width at the device pixel ratio (at most 1). */
4050
+ resolutionFor(frameWidth) {
4051
+ if (this.options.resolution !== void 0) return this.options.resolution;
4052
+ const shown = this.canvas.clientWidth || frameWidth;
4053
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
4054
+ return Math.min(1, shown * dpr / frameWidth);
4055
+ }
3678
4056
  draw(frame) {
3679
4057
  if (!this.ctx) return;
3680
- const shown = this.canvas.clientWidth || frame.width;
3681
- const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
3682
- const resolution = this.options.resolution ?? Math.min(1, shown * dpr / frame.width);
4058
+ const resolution = this.resolutionFor(frame.width);
3683
4059
  const w = Math.max(1, Math.round(frame.width * resolution));
3684
4060
  const h = Math.max(1, Math.round(frame.height * resolution));
3685
4061
  if (this.canvas.width !== w) this.canvas.width = w;
@@ -3738,6 +4114,7 @@ var VideoPreviewEngine = class {
3738
4114
  this.destroyed = true;
3739
4115
  this.detachPixels();
3740
4116
  this.unsubscribeLuts();
4117
+ this.unsubscribeFrames();
3741
4118
  this.filterHost?.remove();
3742
4119
  this.filterHost = null;
3743
4120
  this.filters.clear();
@@ -3757,10 +4134,13 @@ var VideoEditorController = class {
3757
4134
  previewPixels = new VideoPreviewPixels();
3758
4135
  /** Grades' LUTs, loaded on demand for the preview, SVG frames and agents. */
3759
4136
  luts;
4137
+ /** The host's frame cache, if it gave one (the preview fills and reads it). */
4138
+ frameCache;
3760
4139
  project;
3761
4140
  issues;
3762
4141
  selection = [];
3763
4142
  readOnly;
4143
+ previewScale;
3764
4144
  error = null;
3765
4145
  revision = 0;
3766
4146
  applying = false;
@@ -3772,9 +4152,11 @@ var VideoEditorController = class {
3772
4152
  this.history = options.history ?? new HistoryManager();
3773
4153
  this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
3774
4154
  this.luts = options.luts instanceof VideoLutCache ? options.luts : new VideoLutCache(options.luts);
4155
+ this.frameCache = options.frameCache ?? null;
3775
4156
  this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
3776
4157
  this.issues = checkVideoProject(this.project);
3777
4158
  this.readOnly = options.readOnly ?? false;
4159
+ this.previewScale = checkPreviewScale(options.previewScale ?? "auto");
3778
4160
  this.onChange = options.onChange;
3779
4161
  this.player = new AnimationPlayer({ clock: options.clock, duration: () => videoProjectDuration(this.project), loop: "none" });
3780
4162
  this.state = this.computeState();
@@ -3824,6 +4206,7 @@ var VideoEditorController = class {
3824
4206
  canRedo: h.canRedo,
3825
4207
  undoLabel: h.undoName,
3826
4208
  redoLabel: h.redoName,
4209
+ previewScale: this.previewScale,
3827
4210
  revision: this.revision
3828
4211
  };
3829
4212
  }
@@ -3913,6 +4296,17 @@ var VideoEditorController = class {
3913
4296
  this.readOnly = readOnly;
3914
4297
  this.notify();
3915
4298
  }
4299
+ /**
4300
+ * Sets how large a picture the preview plays: `full` (the originals), a fraction such as 0.5
4301
+ * (the smallest proxy at least that large, else the original) or `auto` (as large as the canvas
4302
+ * shows). A view setting, not part of the project: no undo step.
4303
+ */
4304
+ setPreviewScale(scale) {
4305
+ const next = checkPreviewScale(scale);
4306
+ if (next === this.previewScale) return;
4307
+ this.previewScale = next;
4308
+ this.notify();
4309
+ }
3916
4310
  /** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
3917
4311
  select(clips, options = {}) {
3918
4312
  const known = clips?.filter((id) => findVideoClip(this.project, id)) ?? [];
@@ -3997,6 +4391,11 @@ var VideoEditorController = class {
3997
4391
  return toVideoEdl(this.project, options);
3998
4392
  }
3999
4393
  };
4394
+ function checkPreviewScale(scale) {
4395
+ if (scale === "auto" || scale === "full") return scale;
4396
+ if (typeof scale !== "number" || !(scale > 0 && scale <= 1)) throw new Error(`A preview scale is "auto", "full" or a fraction above 0 up to 1 (0.5 is half size), not ${JSON.stringify(scale)}.`);
4397
+ return scale === 1 ? "full" : scale;
4398
+ }
4000
4399
 
4001
4400
  // src/video-editor/ui.ts
4002
4401
  var STEPS = [40, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5];
@@ -4466,7 +4865,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
4466
4865
  handler: () => {
4467
4866
  const state = controller.getState();
4468
4867
  const reason = readOnlyReason();
4469
- return { ...describeVideoProject(state.project), playhead: state.time, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...reason ? { readOnlyReason: reason } : {} };
4868
+ return { ...describeVideoProject(state.project), playhead: state.time, previewScale: state.previewScale, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...reason ? { readOnlyReason: reason } : {} };
4470
4869
  }
4471
4870
  };
4472
4871
  const getFrame = {
@@ -4634,7 +5033,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
4634
5033
  name: "update_video_asset",
4635
5034
  title: "Change media details",
4636
5035
  description: "Changes an asset's src, name, duration, size, sound or poster (e.g. relinking moved media). Clips that now run past its end are refused. Returns the asset.",
4637
- inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript"] }, description: "Fields to clear (transcript removes its words)." } }, ["asset"]),
5036
+ inputSchema: object({ asset: ASSET_ID, set: object(ASSET_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "duration", "width", "height", "hasAudio", "poster", "transcript", "proxies"] }, description: "Fields to clear (transcript removes its words, proxies its proxies)." } }, ["asset"]),
4638
5037
  annotations: { title: "Change media details", ...WRITE_IDEMPOTENT },
4639
5038
  handler: ({ asset, set, remove }) => apply({ op: "update_asset", asset, set, remove }, "Change media", (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)) }))
4640
5039
  };
@@ -4832,6 +5231,79 @@ function createVideoEditorToolProvider(controller, options = {}) {
4832
5231
  return apply(videoLutEdits(p, withLut, null), "Remove LUT", (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
4833
5232
  }
4834
5233
  };
5234
+ const PROXY_SCALE = { type: "number", exclusiveMinimum: 0, exclusiveMaximum: 1, description: "Proxy size as a fraction of the original: 0.5 (half width and height, the default) or 0.25 for very large footage." };
5235
+ const planProxies = {
5236
+ name: "plan_video_proxies",
5237
+ title: "Plan proxy files",
5238
+ description: 'Returns the ffmpeg commands that make smaller proxy copies of video assets for smooth preview playback, without changing anything: one per video asset (or each of "assets") that has no proxy at that scale yet, each with { asset, args, command, proxy }. Proxies are scaled, H.264 with a keyframe every few frames so scrubbing is quick, and keep the sound. After the host runs a command, attach its "proxy" with set_video_proxies. The export always uses the originals.',
5239
+ inputSchema: object({
5240
+ scale: PROXY_SCALE,
5241
+ assets: { type: "array", items: ASSET_ID, minItems: 1, uniqueItems: true, description: "Only these video assets (default: every video asset without a proxy at this scale)." },
5242
+ gop: { type: "integer", minimum: 1, maximum: 300, description: "Frames between keyframes (default 12): fewer makes seeking cheaper and files larger." },
5243
+ crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 28: proxies are for editing)." }
5244
+ }),
5245
+ annotations: { title: "Plan proxy files", ...READ },
5246
+ handler: ({ scale, assets, gop, crf }) => {
5247
+ const commands = videoProxyPlan(project(), { scale, assets, gop, crf });
5248
+ return { scale: scale ?? 0.5, commands, ...commands.length ? {} : { note: "Every video asset already has a proxy at this scale." } };
5249
+ }
5250
+ };
5251
+ const setProxies = {
5252
+ name: "set_video_proxies",
5253
+ title: "Attach proxies",
5254
+ description: "Sets a video asset's proxies, the smaller copies the preview plays instead of the original (one undo step); an empty list removes them. Each proxy must match the original's length and timing (plan_video_proxies makes such files). The export, EDL and source times always use the original. Returns the asset.",
5255
+ inputSchema: object(
5256
+ {
5257
+ asset: { ...ASSET_ID, description: "A video asset id from get_video_project." },
5258
+ proxies: {
5259
+ type: "array",
5260
+ maxItems: 8,
5261
+ description: "Its proxies, one per scale (empty removes them all).",
5262
+ items: object({ scale: PROXY_SCALE, src: { type: "string", minLength: 1, description: "Where the preview loads the proxy from (URL or path)." }, width: { type: "number", exclusiveMinimum: 0, description: "Its width in pixels." }, height: { type: "number", exclusiveMinimum: 0, description: "Its height in pixels." } }, ["scale", "src"], "One proxy.")
5263
+ }
5264
+ },
5265
+ ["asset", "proxies"]
5266
+ ),
5267
+ annotations: { title: "Attach proxies", ...WRITE_IDEMPOTENT },
5268
+ handler: ({ asset, proxies }) => {
5269
+ const a = project().assets.find((x) => x.id === asset);
5270
+ if (!a) throw new Error(`There is no asset "${asset}"; the assets are ${project().assets.map((x) => `"${x.id}"`).join(", ") || "none"}.`);
5271
+ if (a.kind !== "video") throw new Error(`Asset "${asset}" is ${a.kind === "image" ? "an image" : "audio"}; only video assets have proxies.`);
5272
+ const edit = proxies.length ? { op: "update_asset", asset, set: { proxies } } : { op: "update_asset", asset, remove: ["proxies"] };
5273
+ return apply(edit, proxies.length ? "Attach proxies" : "Remove proxies", (r) => ({ asset: videoAssetOut(r.project.assets.find((x) => x.id === asset)) }));
5274
+ }
5275
+ };
5276
+ const setPreviewScale = {
5277
+ name: "set_video_preview_scale",
5278
+ title: "Set the preview size",
5279
+ description: 'Sets how large a picture the preview plays, without changing the project (no undo step): "full" plays the originals, a fraction (0.5, 0.25) plays the smallest proxy at least that large (or the original where there is none), "auto" (the default) picks by the size the preview is shown at. Use a proxy scale when playback stutters on large footage. Returns the setting and, for each video asset, what the preview plays at a fixed scale.',
5280
+ inputSchema: object(
5281
+ { scale: { anyOf: [{ type: "string", enum: ["auto", "full"] }, { type: "number", exclusiveMinimum: 0, maximum: 1 }], description: '"auto", "full", or a fraction above 0 up to 1 (0.5 is half size).' } },
5282
+ ["scale"]
5283
+ ),
5284
+ annotations: { title: "Set the preview size", ...WRITE_IDEMPOTENT },
5285
+ handler: ({ scale }) => {
5286
+ controller.setPreviewScale(scale);
5287
+ const s = controller.getState().previewScale;
5288
+ const fixed = s === "full" ? 1 : typeof s === "number" ? s : null;
5289
+ const plays = fixed === null ? void 0 : project().assets.filter((a) => a.kind === "video").map((a) => ({ asset: a.id, src: videoAssetProxy(a, fixed)?.src ?? a.src, proxy: !!videoAssetProxy(a, fixed) }));
5290
+ return { previewScale: s, ...plays ? { plays } : { note: "auto picks the smallest proxy at least as large as the preview canvas shows, so it depends on the preview's size." } };
5291
+ }
5292
+ };
5293
+ const previewCache = {
5294
+ name: "get_video_preview_cache",
5295
+ title: "Read the preview's frame cache",
5296
+ description: `Reports the preview's cache of decoded frames, without changing anything: whether the host has one, how many frames and bytes it holds against its budget, hits and misses, frames still decoding, how far ahead it prefetches (ms), and the stretches of the timeline between "from" and "to" whose frames are all cached (these play and scrub without waiting). Use it to tell whether stuttering is the cache filling, and before asking the person to review a passage. With no cache, says so.`,
5297
+ inputSchema: object({ from: MS("Start of the stretch to report cached ranges for (default 0)"), to: MS("End of it (default: the end of the project)") }),
5298
+ annotations: { title: "Read the preview's frame cache", ...READ },
5299
+ handler: ({ from, to }) => {
5300
+ const cache = controller.frameCache;
5301
+ if (!cache) return { enabled: false, reason: "The host has not given this editor a frame cache (VideoEditorController frameCache), so the preview decodes each frame as it plays." };
5302
+ if (from !== void 0 && to !== void 0 && !(to > from)) throw new Error(`"to" (${to} ms) must be after "from" (${from} ms).`);
5303
+ const ranges = cache.cachedRanges(project(), { from, to });
5304
+ return { enabled: true, ...cache.stats(), cachedRanges: ranges };
5305
+ }
5306
+ };
4835
5307
  const TRANSCRIPT_TRACK = { ...TRACK_ID, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." };
4836
5308
  const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
4837
5309
  const timelineWords = (track) => {
@@ -5273,8 +5745,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
5273
5745
  return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
5274
5746
  }
5275
5747
  };
5276
- const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
5277
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setLut, removeLut, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
5748
+ const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];
5749
+ const writes = [setSettings, addAsset, updateAsset, removeAsset, setProxies, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setLut, removeLut, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
5278
5750
  return {
5279
5751
  id: options.id ?? "video",
5280
5752
  namespace: options.namespace,
@@ -5315,6 +5787,8 @@ export {
5315
5787
  VIDEO_LUT_MAX_SIZE,
5316
5788
  VIDEO_PEAK_BLOCK,
5317
5789
  VIDEO_PLACEMENT_MODES,
5790
+ VIDEO_PREVIEW_SCALE_OPTIONS,
5791
+ VIDEO_PROXY_SCALES,
5318
5792
  VIDEO_SCOPE_KINDS,
5319
5793
  VIDEO_TIME_EPSILON,
5320
5794
  VIDEO_TRANSFORM_PROPERTIES,
@@ -5324,6 +5798,7 @@ export {
5324
5798
  VIDEO_WIPE_DIRECTIONS,
5325
5799
  VideoAudioPeaksCache,
5326
5800
  VideoEditorController,
5801
+ VideoFrameCache,
5327
5802
  VideoLutCache,
5328
5803
  VideoPreviewEngine,
5329
5804
  VideoPreviewPixels,
@@ -5336,6 +5811,7 @@ export {
5336
5811
  checkVideoProject,
5337
5812
  checkVideoTimeRemap,
5338
5813
  createVideoEditorToolProvider,
5814
+ createVideoElementDecoder,
5339
5815
  createVideoProject,
5340
5816
  decodeVideoAudioPeaks,
5341
5817
  describeVideoProject,
@@ -5359,6 +5835,7 @@ export {
5359
5835
  normalizeVideoWord,
5360
5836
  parseVideoAnimatable,
5361
5837
  parseVideoAsset,
5838
+ parseVideoAssetProxies,
5362
5839
  parseVideoClip,
5363
5840
  parseVideoClipAnimation,
5364
5841
  parseVideoCubeLut,
@@ -5367,6 +5844,7 @@ export {
5367
5844
  parseVideoKeyframes,
5368
5845
  parseVideoLoudnessMeasurement,
5369
5846
  parseVideoMarker,
5847
+ parseVideoPreviewScale,
5370
5848
  parseVideoProject,
5371
5849
  parseVideoTimeRemap,
5372
5850
  parseVideoTrack,
@@ -5376,9 +5854,11 @@ export {
5376
5854
  parseVideoTransition,
5377
5855
  placeVideoRect,
5378
5856
  planVideoClipDrag,
5857
+ planVideoPrefetch,
5379
5858
  probeVideoMedia,
5380
5859
  readVideoScopesPanel,
5381
5860
  removeVideoKey,
5861
+ resolveVideoPreviewScale,
5382
5862
  retimeVideoAnimatable,
5383
5863
  setVideoKey,
5384
5864
  snapToVideoFrame,
@@ -5394,10 +5874,12 @@ export {
5394
5874
  videoAssetHasSound,
5395
5875
  videoAssetKindOf,
5396
5876
  videoAssetMap,
5877
+ videoAssetProxy,
5397
5878
  videoAudioLevels,
5398
5879
  videoAudioPeakRange,
5399
5880
  videoAudioPeakSpans,
5400
5881
  videoAudioPeaksDuration,
5882
+ videoCacheEvictions,
5401
5883
  videoChroma,
5402
5884
  videoClipAfter,
5403
5885
  videoClipAnimated,
@@ -5428,7 +5910,9 @@ export {
5428
5910
  videoExposureStats,
5429
5911
  videoFadeCurveGain,
5430
5912
  videoFrameAt,
5913
+ videoFrameCacheKey,
5431
5914
  videoFrameDuration,
5915
+ videoFrameRequestsAt,
5432
5916
  videoFrameToSvg,
5433
5917
  videoFrames,
5434
5918
  videoFreezeFrameEdit,
@@ -5462,13 +5946,21 @@ export {
5462
5946
  videoLutToCube,
5463
5947
  videoOverlap,
5464
5948
  videoPeakDb,
5949
+ videoPreviewSrc,
5465
5950
  videoProjectDuration,
5951
+ videoProjectHasProxies,
5466
5952
  videoProjectSvg,
5953
+ videoProxyFfmpeg,
5954
+ videoProxyName,
5955
+ videoProxyPlan,
5467
5956
  videoRemoveRangesEdits,
5468
5957
  videoRulerTicks,
5469
5958
  videoScopeImage,
5959
+ videoShellQuote,
5470
5960
  videoSlowMotionRamp,
5471
5961
  videoSnapPoints,
5962
+ videoSourceFrame,
5963
+ videoSourceFrameTime,
5472
5964
  videoSpeedRampEdit,
5473
5965
  videoSpeedRampKeys,
5474
5966
  videoTextStyle,