@pixerate/editor 0.123.0 → 0.124.1
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/agent/index.cjs +2 -2
- package/dist/agent/index.cjs.map +1 -1
- package/dist/agent/index.js +3 -3
- package/dist/animation/index.d.cts +2 -1
- package/dist/animation/index.d.ts +2 -1
- package/dist/animation-editor/index.cjs.map +1 -1
- package/dist/animation-editor/index.d.cts +11 -733
- package/dist/animation-editor/index.d.ts +11 -733
- package/dist/animation-editor/index.js.map +1 -1
- package/dist/{bezier-C-W9LFyD.d.cts → bezier-CBzKKDjl.d.cts} +2 -66
- package/dist/{bezier-DQZE5dxA.d.ts → bezier-CWv6LQal.d.ts} +2 -66
- package/dist/{brushstrokeRender-M4S65DHT.cjs → brushstrokeRender-EELTV7U4.cjs} +8 -8
- package/dist/{brushstrokeRender-M4S65DHT.cjs.map → brushstrokeRender-EELTV7U4.cjs.map} +1 -1
- package/dist/{brushstrokeRender-CDDRSV2W.js → brushstrokeRender-LRV7GXGD.js} +3 -3
- package/dist/canvas/index.cjs +6 -6
- package/dist/canvas/index.d.cts +10 -6
- package/dist/canvas/index.d.ts +10 -6
- package/dist/canvas/index.js +1 -1
- package/dist/{chunk-SMJGE7HM.cjs → chunk-4QQZV5Y5.cjs} +512 -39
- package/dist/chunk-4QQZV5Y5.cjs.map +1 -0
- package/dist/{chunk-TVXFHE4T.js → chunk-64PYNEHJ.js} +45 -12
- package/dist/chunk-64PYNEHJ.js.map +1 -0
- package/dist/{chunk-2IW3BWRT.cjs → chunk-7LQO6OHH.cjs} +2 -2
- package/dist/{chunk-2IW3BWRT.cjs.map → chunk-7LQO6OHH.cjs.map} +1 -1
- package/dist/chunk-CN4PJHUG.cjs +234 -0
- package/dist/chunk-CN4PJHUG.cjs.map +1 -0
- package/dist/chunk-FHYDQOPC.cjs +215 -0
- package/dist/chunk-FHYDQOPC.cjs.map +1 -0
- package/dist/{chunk-KMBC4HNV.js → chunk-J67OLQUQ.js} +2 -2
- package/dist/chunk-JRXJTPES.js +215 -0
- package/dist/chunk-JRXJTPES.js.map +1 -0
- package/dist/{chunk-IPFRWILU.js → chunk-ORHMVUR6.js} +488 -15
- package/dist/chunk-ORHMVUR6.js.map +1 -0
- package/dist/{chunk-6CJRDJCU.cjs → chunk-PEHYWUVV.cjs} +176 -111
- package/dist/chunk-PEHYWUVV.cjs.map +1 -0
- package/dist/chunk-QEG3GRN2.js +234 -0
- package/dist/chunk-QEG3GRN2.js.map +1 -0
- package/dist/{chunk-6ZBR4J62.js → chunk-WATAQ2Z2.js} +79 -14
- package/dist/chunk-WATAQ2Z2.js.map +1 -0
- package/dist/{chunk-RLRUAPBJ.cjs → chunk-Z6OUCS2K.cjs} +47 -14
- package/dist/chunk-Z6OUCS2K.cjs.map +1 -0
- package/dist/effects/index.cjs +605 -0
- package/dist/effects/index.cjs.map +1 -0
- package/dist/effects/index.d.cts +163 -0
- package/dist/effects/index.d.ts +163 -0
- package/dist/effects/index.js +605 -0
- package/dist/effects/index.js.map +1 -0
- package/dist/evaluate-2S3vSPLy.d.cts +55 -0
- package/dist/evaluate-CfmT6xA3.d.ts +55 -0
- package/dist/{export-DfjzHAJI.d.ts → export-BGtXgMJ3.d.ts} +536 -128
- package/dist/{export-Dv0-ZVrV.d.cts → export-BdfY_lH0.d.ts} +392 -5
- package/dist/{export-Dv0-ZVrV.d.ts → export-CbpjwxaI.d.cts} +392 -5
- package/dist/{export-lU5qSpHE.d.cts → export-WmYoWmLM.d.cts} +536 -128
- package/dist/flair-CxB3fOcU.d.ts +739 -0
- package/dist/flair-D7BxyBwL.d.cts +739 -0
- package/dist/gradient-KDfCvhuj.d.cts +66 -0
- package/dist/gradient-KDfCvhuj.d.ts +66 -0
- package/dist/helpers-777mC6dc.d.cts +253 -0
- package/dist/helpers-DX2_7L5t.d.ts +253 -0
- package/dist/image-editor/index.cjs +1296 -805
- package/dist/image-editor/index.cjs.map +1 -1
- package/dist/image-editor/index.d.cts +39 -182
- package/dist/image-editor/index.d.ts +39 -182
- package/dist/image-editor/index.js +497 -6
- package/dist/image-editor/index.js.map +1 -1
- package/dist/{opentype-p8Xxf-Wh.d.ts → opentype-14PDI5D2.d.ts} +1 -1
- package/dist/{opentype-BV7wWatY.d.cts → opentype-D80AVmMu.d.cts} +1 -1
- package/dist/paint/index.cjs +2 -2
- package/dist/paint/index.d.cts +32 -10
- package/dist/paint/index.d.ts +32 -10
- package/dist/paint/index.js +1 -1
- package/dist/paint-editor/index.cjs +10 -8
- package/dist/paint-editor/index.cjs.map +1 -1
- package/dist/paint-editor/index.d.cts +11 -5
- package/dist/paint-editor/index.d.ts +11 -5
- package/dist/paint-editor/index.js +4 -2
- package/dist/paint-editor/index.js.map +1 -1
- package/dist/parameters/index.d.cts +4 -184
- package/dist/parameters/index.d.ts +4 -184
- package/dist/{pathfinder-IxcyhO00.d.cts → pathfinder-C0UoFypx.d.cts} +1 -1
- package/dist/{pathfinder-StVChJOT.d.ts → pathfinder-DjISxkUv.d.ts} +1 -1
- package/dist/{player-DrcP2xqj.d.cts → player-CBtEoU2x.d.cts} +2 -54
- package/dist/{player-DV4Jujdq.d.ts → player-cGLrngL_.d.ts} +2 -54
- package/dist/{svg-BtND4TJu.d.cts → svg-BDrjRnL1.d.cts} +3 -2
- package/dist/{svg-BfVXG7hL.d.ts → svg-C-vZ3q50.d.ts} +3 -2
- package/dist/tools/index.cjs +2 -2
- package/dist/tools/index.cjs.map +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools-C7JtLYzo.d.ts +126 -0
- package/dist/tools-DHKGHiSK.d.cts +126 -0
- package/dist/{types-fuXEme9j.d.ts → types-BcX7sCsn.d.ts} +2 -2
- package/dist/{types-DJF3Ghn5.d.cts → types-CznabG7m.d.cts} +2 -2
- package/dist/types-D3-J3TFf.d.ts +185 -0
- package/dist/types-DMcf_z4J.d.cts +185 -0
- package/dist/vector/index.d.cts +10 -10
- package/dist/vector/index.d.ts +10 -10
- package/dist/video-editor/index.cjs +749 -277
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +265 -138
- package/dist/video-editor/index.d.ts +265 -138
- package/dist/video-editor/index.js +490 -18
- package/dist/video-editor/index.js.map +1 -1
- package/dist/{warp-BjmAgQwq.d.cts → warp-CH9MM435.d.cts} +1 -1
- package/dist/{warp-Ca5fedzS.d.ts → warp-k_PdtXOG.d.ts} +1 -1
- package/package.json +6 -1
- package/skills/pixerate-image-editor/SKILL.md +7 -0
- package/skills/pixerate-image-editor/references/provider-effects.md +35 -0
- package/skills/pixerate-video-editor/SKILL.md +39 -1
- package/dist/chunk-6CJRDJCU.cjs.map +0 -1
- package/dist/chunk-6ZBR4J62.js.map +0 -1
- package/dist/chunk-IPFRWILU.js.map +0 -1
- package/dist/chunk-RLRUAPBJ.cjs.map +0 -1
- package/dist/chunk-SMJGE7HM.cjs.map +0 -1
- package/dist/chunk-TVXFHE4T.js.map +0 -1
- /package/dist/{brushstrokeRender-CDDRSV2W.js.map → brushstrokeRender-LRV7GXGD.js.map} +0 -0
- /package/dist/{chunk-KMBC4HNV.js.map → chunk-J67OLQUQ.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/editor/editor/packages/core/dist/video-editor/index.cjs","../../src/video-editor/parse.ts","../../src/video-editor/check.ts","../../src/video-editor/edit.ts","../../src/video-editor/audio.ts","../../src/video-editor/scopes.ts","../../src/video-editor/transcript.ts","../../src/video-editor/proxy.ts","../../src/video-editor/frame-cache.ts","../../src/video-editor/preview.ts","../../src/video-editor/controller.ts","../../src/video-editor/ui.ts","../../src/video-editor/tools.ts"],"names":["v","words","el","canvas","changed","outcome"],"mappings":"AAAA;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B,iCAA8B;AAC9B,iCAA8B;AAC9B,iCAA8B;AAC9B;AACA;AClIA,IAAM,SAAA,EAAW,CAAC,CAAA,EAAA,GAAyC,OAAO,EAAA,IAAM,SAAA,GAAY,EAAA,IAAM,KAAA,GAAQ,CAAC,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA;AAClH,IAAM,KAAA,EAAO,CAAC,IAAA,EAAc,IAAA,EAAA,GAAwB;AACnD,EAAA,MAAM,IAAI,KAAA,CAAM,CAAA,EAAA;AACjB;AACyB;AACd,EAAA;AACO,EAAA;AACV,EAAA;AACR;AACyB;AACd,EAAA;AACO,EAAA;AACD,EAAA;AACJ,EAAA;AACA,EAAA;AACL,EAAA;AACR;AAC0B;AACJ,EAAA;AACJ,EAAA;AACV,EAAA;AACR;AAC6C;AAClC,EAAA;AACG,EAAA;AACN,EAAA;AACR;AAEmC;AAE1B;AACa,EAAA;AACX,EAAA;AACH,IAAA;AACK,IAAA;AACG,IAAA;AACf,EAAA;AACgB,EAAA;AACjB;AAMgB;AACG,EAAA;AACP,EAAA;AACA,EAAA;AACK,EAAA;AACD,IAAA;AACC,IAAA;AACJ,IAAA;AACI,IAAA;AACf,EAAA;AACe,EAAA;AACF,EAAA;AACf;AAGgB;AACL,EAAA;AACO,EAAA;AACA,EAAA;AACH,EAAA;AACf;AAEgB;AACM,EAAA;AACH,EAAA;AACP,EAAA;AACI,EAAA;AACX,IAAA;AACA,IAAA;AACI,IAAA;AACG,IAAA;AACV,EAAA;AACF;AAEgB;AACM,EAAA;AACP,EAAA;AACH,EAAA;AACG,EAAA;AACV,EAAA;AACH,IAAA;AACW,EAAA;AACD,IAAA;AACX,EAAA;AACO,EAAA;AACR;AAEgB;AACM,EAAA;AACH,EAAA;AACH,EAAA;AACA,IAAA;AACA,IAAA;AACd,EAAA;AACF;AAEgB;AACM,EAAA;AACH,EAAA;AACJ,EAAA;AACE,EAAA;AACH,EAAA;AACI,EAAA;AAClB;AAEgB;AACM,EAAA;AACH,EAAA;AACF,EAAA;AACH,EAAA;AACI,EAAA;AAClB;AAEgB;AACM,EAAA;AACH,EAAA;AACH,EAAA;AACA,IAAA;AACA,IAAA;AACD,IAAA;AACF,IAAA;AACC,IAAA;AACZ,EAAA;AACF;AAEgB;AACM,EAAA;AACV,EAAA;AACK,EAAA;AACA,IAAA;AACF,IAAA;AACC,IAAA;AACF,IAAA;AACG,IAAA;AACf,EAAA;AACY,EAAA;AACd;AAEgB;AACG,EAAA;AACL,EAAA;AACa,EAAA;AACX,IAAA;AACd,IAAA;AACW,IAAA;AACC,IAAA;AACE,IAAA;AACD,IAAA;AACC,IAAA;AACC,IAAA;AACD,IAAA;AACF,IAAA;AACH,IAAA;AACT,EAAA;AACY,EAAA;AACA,EAAA;AACA,EAAA;AACN,EAAA;AACR;AAEgB;AACM,EAAA;AACV,EAAA;AACG,EAAA;AACC,IAAA;AACC,IAAA;AACT,IAAA;AACO,IAAA;AACC,IAAA;AACE,IAAA;AACD,IAAA;AACf,EAAA;AACc,EAAA;AACC,EAAA;AACA,IAAA;AACF,IAAA;AACd,EAAA;AACW,EAAA;AACZ;AAEgB;AACG,EAAA;AACL,EAAA;AACA,EAAA;AACE,IAAA;AACF,IAAA;AACC,IAAA;AACC,IAAA;AACL,IAAA;AACE,IAAA;AACE,IAAA;AACE,IAAA;AACR,IAAA;AACI,IAAA;AACC,IAAA;AACE,IAAA;AACC,IAAA;AACH,IAAA;AACb,EAAA;AACa,EAAA;AACG,IAAA;AACd,MAAA;AACG,MAAA;AACU,MAAA;AACH,MAAA;AACG,MAAA;AACL,MAAA;AACI,MAAA;AACD,MAAA;AACE,MAAA;AACb,MAAA;AACA,IAAA;AACW,EAAA;AAED,IAAA;AACD,MAAA;AACK,IAAA;AACd,MAAA;AACG,MAAA;AACO,MAAA;AACA,MAAA;AACF,MAAA;AACI,MAAA;AACC,MAAA;AACb,MAAA;AACA,IAAA;AACF,EAAA;AACa,EAAA;AACG,IAAA;AACd,MAAA;AACG,MAAA;AACS,MAAA;AACC,MAAA;AACH,MAAA;AACE,MAAA;AACA,MAAA;AACA,MAAA;AACZ,IAAA;AACe,EAAA;AAClB;AAEgB;AACG,EAAA;AACF,EAAA;AACD,EAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AACE,IAAA;AACC,IAAA;AACA,IAAA;AACH,IAAA;AACE,IAAA;AACH,IAAA;AACX,EAAA;AACF;AAGgB;AACG,EAAA;AACD,EAAA;AACe,EAAA;AACjB,IAAA;AACF,IAAA;AACA,IAAA;AACZ,EAAA;AACY,EAAA;AACG,EAAA;AACA,IAAA;AACF,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAEgB;AACG,EAAA;AACD,EAAA;AAClB;AASgB;AACP,EAAA;AACS,EAAA;AACZ,IAAA;AACM,MAAA;AACD,IAAA;AACE,MAAA;AACX,IAAA;AACD,EAAA;AACkB,EAAA;AACZ,EAAA;AACK,EAAA;AACmB,EAAA;AACpB,IAAA;AACG,IAAA;AACC,IAAA;AACC,IAAA;AACH,IAAA;AACC,IAAA;AACA,IAAA;AACA,IAAA;AACC,IAAA;AACF,IAAA;AACX,EAAA;AACe,EAAA;AACF,IAAA;AACF,IAAA;AACG,MAAA;AACF,MAAA;AACZ,IAAA;AACD,EAAA;AACe,EAAA;AACA,EAAA;AACC,EAAA;AAEL,EAAA;AACK,EAAA;AACT,EAAA;AACR;AAcgB;AACA,EAAA;AACL,IAAA;AACK,IAAA;AACC,IAAA;AACP,IAAA;AACK,IAAA;AACD,IAAA;AACH,IAAA;AACD,IAAA;AACK,MAAA;AACA,MAAA;AACb,IAAA;AACA,EAAA;AACF;ADsFmB;AACA;AE1bH;AACH,EAAA;AACC,IAAA;AACA,IAAA;AACT,IAAA;AACJ,EAAA;AACW,EAAA;AACZ;AAIa;AACZ,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACD;AAiBa;AASG;AACe,EAAA;AACf,EAAA;AACT,EAAA;AACO,EAAA;AACP,EAAA;AACS,EAAA;AACJ,EAAA;AACC,IAAA;AACE,MAAA;AACR,EAAA;AACS,IAAA;AACA,IAAA;AACD,IAAA;AACC,MAAA;AACA,MAAA;AACJ,MAAA;AACR,QAAA;AACM,QAAA;AACI,QAAA;AACL,QAAA;AACC,UAAA;AACC,UAAA;AACF,UAAA;AACE,UAAA;AACA,UAAA;AACF,UAAA;AACI,YAAA;AACT,QAAA;AACU,MAAA;AACD,QAAA;AACH,QAAA;AACM,QAAA;AACP,QAAA;AACA,UAAA;AACA,UAAA;AACE,UAAA;AACF,UAAA;AACI,YAAA;AACT,QAAA;AACU,MAAA;AACE,MAAA;AACD,MAAA;AACJ,QAAA;AACE,MAAA;AACF,QAAA;AACF,MAAA;AACK,MAAA;AACH,QAAA;AACG,MAAA;AACA,QAAA;AACF,QAAA;AACF,QAAA;AACI,QAAA;AACF,UAAA;AACT,MAAA;AACa,MAAA;AACN,QAAA;AACF,QAAA;AACI,UAAA;AACA,YAAA;AACN,YAAA;AACG,YAAA;AACH,YAAA;AACA,UAAA;AACH,MAAA;AACS,MAAA;AACE,QAAA;AACJ,QAAA;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,QAAA;AACH,QAAA;AACK,QAAA;AACD,UAAA;AACH,YAAA;AACC,YAAA;AACP,UAAA;AACK,UAAA;AACN,QAAA;AACO,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACA,QAAA;AACA,QAAA;AACE,QAAA;AACA,QAAA;AACD,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACI,QAAA;AACC,QAAA;AACL,QAAA;AACA,QAAA;AACK,QAAA;AACF,QAAA;AACD,QAAA;AACD,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACD,QAAA;AACE,QAAA;AACG,QAAA;AACH,QAAA;AACR,MAAA;AACK,MAAA;AACK,QAAA;AACF,QAAA;AACR,MAAA;AACA,MAAA;AACQ,QAAA;AACT,IAAA;AACD,EAAA;AACD;AAQgB;AACF,EAAA;AACK,EAAA;AACH,EAAA;AACC,EAAA;AACF,IAAA;AACT,IAAA;AACQ,MAAA;AACH,IAAA;AACF,MAAA;AACO,MAAA;AACd,IAAA;AACD,EAAA;AACI,EAAA;AACA,EAAA;AACI,IAAA;AACQ,EAAA;AACF,IAAA;AACd,EAAA;AACe,EAAA;AACA,EAAA;AACD,EAAA;AACH,EAAA;AACF,EAAA;AACI,EAAA;AACd;AH4amB;AACA;AIxzCN;AAGG;AACT,EAAA;AACS,EAAA;AACA,EAAA;AACA,EAAA;AACC,EAAA;AACN,IAAA;AACA,IAAA;AACG,IAAA;AACD,IAAA;AACG,MAAA;AACF,QAAA;AACL,QAAA;AACO,QAAA;AACA,QAAA;AACb,MAAA;AACD,IAAA;AACY,IAAA;AACG,IAAA;AAChB,EAAA;AACgB,EAAA;AACF,EAAA;AACC,IAAA;AACJ,IAAA;AACK,IAAA;AACF,IAAA;AACH,MAAA;AACA,MAAA;AACI,MAAA;AACF,QAAA;AACA,QAAA;AACX,MAAA;AACY,MAAA;AACA,MAAA;AACb,IAAA;AACc,IAAA;AACf,EAAA;AACS,EAAA;AACV;AAGa;AAMG;AACA,EAAA;AACE,EAAA;AACA,EAAA;AACL,EAAA;AAEF,IAAA;AACD,IAAA;AACT,EAAA;AACU,EAAA;AACA,EAAA;AACI,EAAA;AACD,IAAA;AACG,IAAA;AACD,IAAA;AACf,EAAA;AAEgB,EAAA;AACD,IAAA;AACJ,IAAA;AACI,MAAA;AACb,MAAA;AACD,IAAA;AACW,IAAA;AACA,IAAA;AACN,IAAA;AACA,IAAA;AACN,EAAA;AACe,EAAA;AAChB;AAGgB;AACA,EAAA;AACC,EAAA;AACF,EAAA;AACE,EAAA;AACF,IAAA;AACA,IAAA;AACA,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAOgB;AACA,EAAA;AACC,EAAA;AACF,EAAA;AACA,EAAA;AACD,EAAA;AACG,EAAA;AACF,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAMgB;AACC,EAAA;AACN,EAAA;AACE,EAAA;AACD,EAAA;AACA,EAAA;AAEG,EAAA;AACS,EAAA;AACG,EAAA;AACV,EAAA;AACC,EAAA;AACH,EAAA;AACf;AAMgB;AACC,EAAA;AACT,EAAA;AACR;AAG4B;AAWZ;AACD,EAAA;AACA,EAAA;AACD,EAAA;AACd;AAYgB;AACT,EAAA;AACA,EAAA;AACA,EAAA;AACU,EAAA;AAC+B,EAAA;AACtB,EAAA;AACV,EAAA;AACC,EAAA;AACD,IAAA;AACD,IAAA;AACE,IAAA;AACF,MAAA;AACF,MAAA;AACJ,MAAA;AACP,IAAA;AACA,EAAA;AACM,EAAA;AACR;AAGsB;AACJ,EAAA;AACD,EAAA;AACC,EAAA;AACC,EAAA;AACL,EAAA;AACT,EAAA;AACA,EAAA;AACY,IAAA;AACR,EAAA;AACG,IAAA;AACX,EAAA;AACO,EAAA;AACR;AASa;AAMiB,EAAA;AAAA,IAAA;AAA8E,EAAA;AAA9E,EAAA;AALJ,kBAAA;AACE,kBAAA;AACD,kBAAA;AACG,kBAAA;AAAoB;AAKI,EAAA;AACxC,IAAA;AACb,EAAA;AAAA;AAGoD,EAAA;AACrC,IAAA;AACC,IAAA;AAAgE,IAAA;AACxE,IAAA;AACR,EAAA;AAAA;AAG6C,EAAA;AAChC,IAAA;AACb,EAAA;AAAA;AAGkD,EAAA;AACnC,IAAA;AACH,IAAA;AACI,IAAA;AACH,IAAA;AACE,IAAA;AACA,IAAA;AACH,MAAA;AACE,QAAA;AACL,UAAA;AACA,UAAA;AACA,UAAA;AACE,UAAA;AACR,QAAA;AACgB,QAAA;AACV,UAAA;AACC,UAAA;AACD,UAAA;AACA,UAAA;AACC,UAAA;AACP,QAAA;AACD,MAAA;AACa,MAAA;AACd,IAAA;AACO,IAAA;AACR,EAAA;AAAA;AAG+C,EAAA;AAC/B,IAAA;AACH,IAAA;AACF,IAAA;AACX,EAAA;AAEsC,EAAA;AACtB,IAAA;AACF,IAAA;AACd,EAAA;AAEe,EAAA;AACH,IAAA;AACZ,EAAA;AACD;AJuvCmB;AACA;AKthDN;AAGa;AAGV;AACL,EAAA;AACG,EAAA;AACd;AAWmB;AACH,EAAA;AACD,EAAA;AACD,EAAA;AACD,EAAA;AACE,EAAA;AACE,IAAA;AACD,IAAA;AACH,MAAA;AACC,MAAA;AACC,MAAA;AACZ,MAAA;AACD,IAAA;AACD,EAAA;AACO,EAAA;AACR;AAc0B;AAEV;AACF,EAAA;AACiB,EAAA;AAClB,EAAA;AACI,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACf,EAAA;AACM,EAAA;AACR;AAcgB;AACC,EAAA;AACD,EAAA;AACE,EAAA;AACN,EAAA;AACD,EAAA;AACM,IAAA;AACD,IAAA;AACF,IAAA;AACZ,EAAA;AACQ,EAAA;AACV;AAca;AAEG;AACF,EAAA;AACI,EAAA;AACN,EAAA;AACD,EAAA;AACI,IAAA;AACE,IAAA;AACA,IAAA;AACH,IAAA;AACZ,EAAA;AACc,EAAA;AAChB;AAsBgB;AACY;AAEZ;AACF,EAAA;AACC,EAAA;AACG,EAAA;AACN,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACE,EAAA;AACA,EAAA;AACC,EAAA;AACG,EAAA;AACR,EAAA;AACA,EAAA;AACC,EAAA;AACI,EAAA;AACH,IAAA;AACI,IAAA;AACN,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACO,IAAA;AACH,IAAA;AACF,IAAA;AACA,IAAA;AACI,IAAA;AACC,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACf,EAAA;AACK,EAAA;AACU,IAAA;AACJ,IAAA;AACE,IAAA;AACJ,MAAA;AACI,MAAA;AACb,IAAA;AACO,IAAA;AACR,EAAA;AACU,EAAA;AACG,EAAA;AACH,EAAA;AACG,EAAA;AACN,EAAA;AACE,IAAA;AACM,IAAA;AACH,IAAA;AACG,IAAA;AACD,IAAA;AACd,EAAA;AACD;AAGgB;AACU,EAAA;AACZA,EAAAA;AACH,EAAA;AACA,EAAA;AACL,EAAA;AACE,IAAA;AACO,IAAA;AACd,EAAA;AACe,EAAA;AACA,EAAA;AACA,EAAA;AACD,EAAA;AACC,EAAA;AACA,EAAA;AACD,IAAA;AACF,IAAA;AACZ,EAAA;AACO,EAAA;AACR;AAea;AAEG;AACD,EAAA;AACD,EAAA;AACH,EAAA;AACK,EAAA;AACC,EAAA;AACN,EAAA;AACO,EAAA;AACD,IAAA;AACL,IAAA;AACD,IAAA;AACG,IAAA;AACH,IAAA;AACG,IAAA;AACD,IAAA;AACE,IAAA;AACA,IAAA;AACJ,MAAA;AACI,MAAA;AACF,QAAA;AACT,QAAA;AACD,MAAA;AACD,IAAA;AACS,IAAA;AACI,EAAA;AAE8B,EAAA;AAClC,EAAA;AACM,IAAA;AACF,IAAA;AACb,EAAA;AACe,EAAA;AACJ,EAAA;AACG,EAAA;AACA,EAAA;AACE,EAAA;AACX,EAAA;AACC,EAAA;AACN,IAAA;AACO,IAAA;AACM,IAAA;AACb,IAAA;AACe,IAAA;AAChB,EAAA;AACD;AAGa;AAEM,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACH,EAAA;AAEC;AACC,EAAA;AACF,EAAA;AACf;AASe;AACE,EAAA;AACD,EAAA;AACL,IAAA;AACI,IAAA;AACA,IAAA;AACA,IAAA;AACf,EAAA;AACgB,EAAA;AACjB;AAEoC;AACnB,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACA,EAAA;AACjB;AAE4C;AAEf;AAGb;AACH,EAAA;AACK,EAAA;AAEF,EAAA;AACF,EAAA;AACI,EAAA;AACD,EAAA;AACH,IAAA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AA;AACR;AAGa;AAGG;AACC,EAAA;AAIH,EAAA;AACwB,EAAA;AACrB,EAAA;AACH,IAAA;AACH,IAAA;AACC,MAAA;AACA,MAAA;AACH,IAAA;AACR,EAAA;AACO,EAAA;AACR;AAYgB;AACH,EAAA;AACC,EAAA;AACwB,EAAA;AACrB,EAAA;AACL,IAAA;AACA,IAAA;AACK,IAAA;AACF,IAAA;AACF,IAAA;AACC,IAAA;AAED,MAAA;AACH,MAAA;AACF,MAAA;AACN,IAAA;AACS,IAAA;AACV,EAAA;AACO,EAAA;AACR;AAGgB;AACA,EAAA;AAC8B,EAAA;AAC7B,EAAA;AACF,IAAA;AACC,IAAA;AACC,IAAA;AAChB,EAAA;AACO,EAAA;AACR;AAMgB;AACR,EAAA;AAGR;AAGgB;AACA,EAAA;AACA,EAAA;AAChB;AAGgB;AACF,EAAA;AACd;AAGgB;AACN,EAAA;AACM,EAAA;AACE,EAAA;AACH,IAAA;AACE,IAAA;AACA,IAAA;AACH,IAAA;AACb,EAAA;AACO,EAAA;AACR;ANgzDmB;AACA;AOnjEN;AAGG;AACH,EAAA;AACuB,EAAA;AACnB,EAAA;AACT,EAAA;AACR;AAGa;AAGG;AACD,EAAA;AACA,EAAA;AACE,EAAA;AACjB;AAgBgB;AACD,EAAA;AACD,EAAA;AACC,EAAA;AACf;AAiBgB;AACL,EAAA;AACI,EAAA;AACA,EAAA;AACG,EAAA;AACF,EAAA;AACH,EAAA;AACC,EAAA;AACA,EAAA;AACZ,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACe,IAAA;AACf,IAAA;AACU,IAAA;AACV,IAAA;AACU,IAAA;AACV,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACU,IAAA;AACV,IAAA;AACA,IAAA;AACA,IAAA;AACD,EAAA;AACiC,EAAA;AACjB,EAAA;AACjB;AAGgB;AACD,EAAA;AACC,EAAA;AACH,EAAA;AACD,IAAA;AACI,IAAA;AA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AAA;AACH,IAAA;AACZ,EAAA;AACO,EAAA;AACR;AXsvFmB;AACA;AYhmGJ;AAqBA;AACC;AACV;AACc;AAEJ;AACT,EAAA;AACY,EAAA;AAClB,EAAA;AACa,EAAA;AACb,EAAA;AACD;AAEY;AACM;AACC;AACA;AACJ;AACA;AACR,EAAA;AAEL,EAAA;AACF;AACa;AACN,EAAA;AACI,EAAA;AAET,EAAA;AACF;AACe;AACM;AAEH;AACjB,EAAA;AACY,IAAA;AACA,IAAA;AACI,IAAA;AACH,IAAA;AACb,EAAA;AACC,EAAA;AACD,EAAA;AACD;AACmB;AAClB,EAAA;AACe,IAAA;AACF,IAAA;AACC,IAAA;AACA,IAAA;AACC,IAAA;AACf,EAAA;AACS,EAAA;AACT,EAAA;AACD;AACkB;AACG;AACP;AACb,EAAA;AACW,IAAA;AACI,IAAA;AACf,EAAA;AACC,EAAA;AACD,EAAA;AACD;AACmB;AAEjB,EAAA;AACW,IAAA;AACE,IAAA;AACb,EAAA;AACS,EAAA;AACT,EAAA;AACD;AAC+C;AACjC,EAAA;AACG,EAAA;AACN,EAAA;AACE,EAAA;AACN,EAAA;AACI,EAAA;AACC,EAAA;AACG,EAAA;AACJ,EAAA;AACC,EAAA;AACb;AACiD;AACzC,EAAA;AACQ,EAAA;AACC,EAAA;AACD,EAAA;AACA,EAAA;AACC,EAAA;AACjB;AACgD;AACjC,EAAA;AACD,EAAA;AACD,EAAA;AACE,EAAA;AACA,EAAA;AACF,EAAA;AACb;AAC8C;AACjC,EAAA;AACF,EAAA;AACE,EAAA;AACb;AAC6B;AACf,EAAA;AACN,EAAA;AACS,IAAA;AACA,IAAA;AACA,IAAA;AAChB,EAAA;AACD;AAC6C;AAC9B,EAAA;AACC,EAAA;AACC,EAAA;AACH,EAAA;AACD,EAAA;AACb;AACiD;AAClC,EAAA;AACC,EAAA;AACC,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACjB;AACiD;AACnC,EAAA;AACC,EAAA;AACF,EAAA;AACG,EAAA;AACC,EAAA;AACJ,EAAA;AACI,EAAA;AACjB;AAEqB;AACd,EAAA;AACI,EAAA;AACG,EAAA;AACd;AACyB;AAClB,EAAA;AACI,EAAA;AACA,EAAA;AACG,EAAA;AACN,EAAA;AACN,IAAA;AACY,MAAA;AACA,MAAA;AACL,MAAA;AACL,QAAA;AACO,QAAA;AACE,UAAA;AACA,UAAA;AACT,QAAA;AACD,MAAA;AACD,IAAA;AACS,IAAA;AACT,IAAA;AACD,EAAA;AACD;AAEqB;AACL,EAAA;AACE,EAAA;AACH,EAAA;AACA,EAAA;AACf;AAGM;AACG,EAAA;AACD,EAAA;AACR;AAGM;AAEE,EAAA;AACE,EAAA;AACR,EAAA;AACe,EAAA;AACC,EAAA;AACC,EAAA;AACA,EAAA;AACX,EAAA;AACW,EAAA;AACV,EAAA;AACN;AAGG;AACwC,EAAA;AAE7C;AAGe;AACD,EAAA;AACP,EAAA;AACO,IAAA;AACA,IAAA;AACH,IAAA;AACC,IAAA;AACJ,IAAA;AACC,IAAA;AACM,IAAA;AACC,IAAA;AAChB,EAAA;AACD;AAGgB;AACR,EAAA;AACM,IAAA;AACG,IAAA;AACP,IAAA;AACK,IAAA;AACH,IAAA;AACE,IAAA;AACJ,IAAA;AACA,IAAA;AACI,IAAA;AACH,IAAA;AACV,EAAA;AACD;AAWgB;AACT,EAAA;AACK,IAAA;AACI,IAAA;AACP,IAAA;AACR,EAAA;AACgB,EAAA;AACC,EAAA;AACA,IAAA;AACJ,IAAA;AACb,EAAA;AACe,EAAA;AACC,IAAA;AACA,IAAA;AAChB,EAAA;AACiB,EAAA;AACL,IAAA;AACE,MAAA;AACL,MAAA;AACR,IAAA;AACO,IAAA;AACR,EAAA;AAGgB,EAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACC,IAAA;AACjB,EAAA;AACiB,EAAA;AACN,IAAA;AACkC,IAAA;AAC9B,IAAA;AACf,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACA,IAAA;AACR,MAAA;AACA,MAAA;AACM,MAAA;AACb,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACL,MAAA;AACM,MAAA;AACb,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACD,MAAA;AACE,MAAA;AACR,MAAA;AACG,QAAA;AACM,QAAA;AACH,QAAA;AACV,MAAA;AACS,MAAA;AACV,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACP,MAAA;AACK,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACA,IAAA;AACR,MAAA;AACM,MAAA;AACb,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACH,MAAA;AACA,MAAA;AACE,MAAA;AACD,MAAA;AACV,IAAA;AACc,IAAA;AACN,IAAA;AACE,MAAA;AAEJ,MAAA;AACA,MAAA;AACA,MAAA;AACM,MAAA;AACN,MAAA;AACF,MAAA;AACE,QAAA;AACC,QAAA;AACA,QAAA;AACG,QAAA;AACV,MAAA;AACS,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACS,IAAA;AACF,IAAA;AACG,IAAA;AAChB,EAAA;AACM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACF,MAAA;AACG,MAAA;AACH,MAAA;AACT,QAAA;AACI,UAAA;AACH,UAAA;AACC,YAAA;AACC,cAAA;AACA,cAAA;AACA,cAAA;AACA,cAAA;AACA,cAAA;AACD,YAAA;AACC,YAAA;AACD,YAAA;AACD,UAAA;AACD,QAAA;AACC,QAAA;AACD,QAAA;AACD,MAAA;AACA,IAAA;AACc,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACL,IAAA;AACX,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACA,MAAA;AACH,MAAA;AACI,MAAA;AACb,IAAA;AACc,IAAA;AACH,IAAA;AACb,EAAA;AAEwB,EAAA;AACjB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACH,MAAA;AACE,MAAA;AACD,MAAA;AACF,MAAA;AACR,IAAA;AACc,IAAA;AACH,IAAA;AACD,MAAA;AACG,MAAA;AACF,QAAA;AACF,QAAA;AACG,QAAA;AACD,MAAA;AACC,QAAA;AACH,QAAA;AACG,QAAA;AACD,MAAA;AACE,MAAA;AACA,MAAA;AACP,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACL,IAAA;AACG,MAAA;AACC,MAAA;AACd,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACL,IAAA;AACX,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AAEF,MAAA;AACC,MAAA;AACT,IAAA;AACH,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE2B,EAAA;AACpB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACP,MAAA;AACS,MAAA;AACd,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACD,MAAA;AACH,MAAA;AACT,IAAA;AACe,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACO,QAAA;AACD,QAAA;AACK,QAAA;AACX,MAAA;AACO,MAAA;AACR,IAAA;AACe,IAAA;AACH,IAAA;AACL,MAAA;AACF,MAAA;AACG,QAAA;AACA,QAAA;AACI,QAAA;AACX,MAAA;AACa,MAAA;AACd,IAAA;AACD,EAAA;AAGM,EAAA;AACK,IAAA;AACC,IAAA;AACC,MAAA;AACF,MAAA;AACI,MAAA;AACA,MAAA;AACF,QAAA;AACZ,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACU,QAAA;AACC,QAAA;AACA,QAAA;AACX,MAAA;AACU,MAAA;AACX,IAAA;AACe,IAAA;AACH,IAAA;AACE,MAAA;AAEP,MAAA;AACC,QAAA;AACC,QAAA;AACP,MAAA;AACY,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACE,MAAA;AACP,MAAA;AACA,MAAA;AACO,MAAA;AACd,IAAA;AACD,EAAA;AAG0B,EAAA;AACnB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACU,QAAA;AACJ,QAAA;AACK,QAAA;AACF,QAAA;AACT,MAAA;AACU,MAAA;AACX,IAAA;AACe,IAAA;AACN,IAAA;AACJ,MAAA;AACS,MAAA;AACD,MAAA;AACN,MAAA;AACA,MAAA;AACO,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACE,MAAA;AACH,MAAA;AACJ,MAAA;AACO,MAAA;AACA,MAAA;AACd,IAAA;AACD,EAAA;AAMM,EAAA;AACA,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACL,MAAA;AACG,MAAA;AACG,MAAA;AACA,MAAA;AACb,IAAA;AACc,IAAA;AACH,IAAA;AACL,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACU,QAAA;AACA,QAAA;AACF,UAAA;AACN,UAAA;AACA,UAAA;AACO,UAAA;AACR,QAAA;AACD,MAAA;AACU,MAAA;AACX,IAAA;AACe,IAAA;AACH,IAAA;AACD,MAAA;AACF,MAAA;AACF,MAAA;AACkB,MAAA;AACX,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACD,MAAA;AACH,MAAA;AACT,IAAA;AACe,IAAA;AACH,IAAA;AACA,MAAA;AACD,MAAA;AACJ,MAAA;AACA,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACL,MAAA;AACM,MAAA;AACC,MAAA;AACP,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAMM,EAAA;AACA,EAAA;AACA,EAAA;AACS,IAAA;AACH,IAAA;AACJ,IAAA;AACR,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACU,QAAA;AACD,QAAA;AACC,QAAA;AACV,MAAA;AACQ,MAAA;AACT,IAAA;AACe,IAAA;AACH,IAAA;AACG,MAAA;AACJ,MAAA;AACF,MAAA;AACF,MAAA;AACA,MAAA;AACO,MAAA;AACA,MAAA;AACZ,QAAA;AACO,QAAA;AACP,QAAA;AACO,QAAA;AACI,QAAA;AACL,QAAA;AACL,MAAA;AACH,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACL,MAAA;AACE,MAAA;AACF,MAAA;AACE,MAAA;AACT,IAAA;AACc,IAAA;AACH,IAAA;AACC,MAAA;AACN,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACL,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACL,MAAA;AACM,MAAA;AACL,MAAA;AACR,IAAA;AACc,IAAA;AACH,IAAA;AACL,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACW,QAAA;AACA,QAAA;AACX,MAAA;AACS,MAAA;AACV,IAAA;AACe,IAAA;AACH,IAAA;AACE,MAAA;AACC,MAAA;AACR,MAAA;AACA,MAAA;AACO,MAAA;AACd,IAAA;AACD,EAAA;AAOM,EAAA;AACM,IAAA;AACI,IAAA;AACF,IAAA;AACJ,IAAA;AACA,IAAA;AACK,IAAA;AACP,IAAA;AACR,EAAA;AACiB,EAAA;AACVC,IAAAA;AACS,IAAA;AAChB,EAAA;AACM,EAAA;AAEA,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACO,QAAA;AACI,QAAA;AACH,UAAA;AACN,UAAA;AACA,UAAA;AACA,UAAA;AACO,UAAA;AACN,YAAA;AACC,cAAA;AACA,cAAA;AACA,cAAA;AACD,YAAA;AACC,YAAA;AACD,YAAA;AACD,UAAA;AACD,QAAA;AACQ,QAAA;AACT,MAAA;AACS,MAAA;AACV,IAAA;AACe,IAAA;AACH,IAAA;AAEE,MAAA;AACA,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACO,QAAA;AACC,QAAA;AACG,QAAA;AACF,QAAA;AACT,MAAA;AACS,MAAA;AACV,IAAA;AACe,IAAA;AACH,IAAA;AACD,MAAA;AACA,MAAA;AACG,MAAA;AACA,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACA,MAAA;AACE,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AAEJ,MAAA;AACG,MAAA;AACT,IAAA;AACH,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AAEE,MAAA;AACF,MAAA;AACD,MAAA;AACT,IAAA;AACH,EAAA;AAEM,EAAA;AACW,EAAA;AAEX,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE2B,EAAA;AACpB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AAEE,MAAA;AACH,MAAA;AACT,IAAA;AACH,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACL,IAAA;AACX,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACH,IAAA;AACb,EAAA;AAE4B,EAAA;AACrB,IAAA;AACO,IAAA;AACC,IAAA;AACC,IAAA;AAChB,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACH,IAAA;AACE,MAAA;AACP,MAAA;AACC,MAAA;AACR,IAAA;AACD,EAAA;AAEwB,EAAA;AACjB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACA,IAAA;AACR,MAAA;AACA,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEwB,EAAA;AACjB,IAAA;AACC,IAAA;AACM,IAAA;AACA,IAAA;AACE,IAAA;AACA,IAAA;AACR,MAAA;AACA,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAGM,EAAA;AACK,IAAA;AACA,IAAA;AACN,IAAA;AACW,IAAA;AACF,IAAA;AACT,IAAA;AACA,IAAA;AACK,IAAA;AACF,MAAA;AACM,MAAA;AACF,MAAA;AACJ,MAAA;AACA,MAAA;AACI,MAAA;AACJ,MAAA;AACM,MAAA;AACH,MAAA;AACG,MAAA;AACF,MAAA;AACJ,IAAA;AACI,MAAA;AACE,MAAA;AACA,MAAA;AACF,MAAA;AACX,IAAA;AACY,IAAA;AACF,IAAA;AACC,IAAA;AACF,IAAA;AACV,EAAA;AACM,EAAA;AACM,IAAA;AACC,IAAA;AACH,IAAA;AACF,IAAA;AACR,EAAA;AACM,EAAA;AAEA,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACN,IAAA;AACE,MAAA;AACJ,MAAA;AACA,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACT,MAAA;AACU,MAAA;AACA,MAAA;AACb,IAAA;AACc,IAAA;AACN,IAAA;AACE,MAAA;AACJ,MAAA;AACA,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAIiB,EAAA;AACJ,IAAA;AACA,MAAA;AACF,MAAA;AACF,MAAA;AACR,IAAA;AACa,IAAA;AACH,IAAA;AACI,IAAA;AACD,IAAA;AACC,IAAA;AACD,IAAA;AACF,IAAA;AACP,IAAA;AACK,MAAA;AACP,IAAA;AACU,MAAA;AACE,MAAA;AACd,IAAA;AACD,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACH,MAAA;AACD,MAAA;AACG,MAAA;AACX,IAAA;AACc,IAAA;AACN,IAAA;AACE,MAAA;AACE,MAAA;AACF,MAAA;AACG,MAAA;AACP,MAAA;AACG,MAAA;AACV,IAAA;AACD,EAAA;AAEe,EAAA;AACC,EAAA;AAET,EAAA;AACM,IAAA;AACD,IAAA;AACG,IAAA;AACF,IAAA;AACA,MAAA;AACJ,MAAA;AACA,QAAA;AACF,QAAA;AACI,UAAA;AACA,UAAA;AACR,QAAA;AACA,MAAA;AACF,IAAA;AACD,EAAA;AACD;AZ66FmB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/editor/editor/packages/core/dist/video-editor/index.cjs","sourcesContent":[null,"import { VIDEO_GRADE_CONTROLS } from './grade.js';\nimport { checkVideoTimeRemap } from './remap.js';\nimport { VIDEO_EASE_NAMES, type VideoAnimatable, type VideoEase, type VideoKey, type VideoKeyframes } from './keyframes.js';\nimport { VIDEO_BLEND_MODES, VIDEO_CLIP_ANIMATION_EFFECTS, VIDEO_CLIP_TYPES, VIDEO_FADE_CURVES, VIDEO_FITS, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoAsset, type VideoAssetProxy, type VideoClip, type VideoClipAnimation, type VideoGrade, type VideoGradeLut, type VideoMarker, type VideoProject, type VideoSequence, type VideoTrack, type VideoTranscriptWord, type VideoTransform, type VideoTransition } from './types.js';\n\nconst isObject = (v: unknown): v is Record<string, any> => typeof v === 'object' && v !== null && !Array.isArray(v);\nconst fail = (path: string, what: string): never => {\n\tthrow new Error(`${path} ${what}.`);\n};\nconst str = (v: unknown, path: string, optional = false): string | undefined => {\n\tif (v === undefined && optional) return undefined;\n\tif (typeof v !== 'string' || (!optional && !v.length)) fail(path, 'must be a non-empty string');\n\treturn v as string;\n};\nconst num = (v: unknown, path: string, { min, positive, optional, max }: { min?: number; positive?: boolean; optional?: boolean; max?: number } = {}): number | undefined => {\n\tif (v === undefined && optional) return undefined;\n\tif (typeof v !== 'number' || !Number.isFinite(v)) return fail(path, 'must be a number');\n\tif (positive && !(v > 0)) fail(path, 'must be greater than 0');\n\tif (min !== undefined && v < min) fail(path, `must be at least ${min}`);\n\tif (max !== undefined && v > max) fail(path, `must be at most ${max}`);\n\treturn v;\n};\nconst bool = (v: unknown, path: string): boolean | undefined => {\n\tif (v === undefined) return undefined;\n\tif (typeof v !== 'boolean') fail(path, 'must be true or false');\n\treturn v as boolean;\n};\nconst oneOf = <T extends string>(v: unknown, options: readonly T[], path: string, optional = true): T | undefined => {\n\tif (v === undefined && optional) return undefined;\n\tif (!options.includes(v as T)) fail(path, `must be one of ${options.map((o) => `\"${o}\"`).join(', ')}`);\n\treturn v as T;\n};\n/** Copies only the defined keys, so parsed objects carry no `undefined` fields. */\nconst compact = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;\n\nfunction parseVideoEase(v: unknown, path: string): VideoEase | undefined {\n\tif (v === undefined) return undefined;\n\tif (Array.isArray(v)) {\n\t\tif (v.length !== 4 || !v.every((n) => typeof n === 'number' && Number.isFinite(n))) fail(path, 'must be four numbers [x1, y1, x2, y2] (CSS cubic-bezier)');\n\t\tif (v[0] < 0 || v[0] > 1 || v[2] < 0 || v[2] > 1) fail(path, 'must have x1 and x2 between 0 and 1');\n\t\treturn [v[0], v[1], v[2], v[3]];\n\t}\n\treturn oneOf(v, VIDEO_EASE_NAMES, path)! as VideoEase;\n}\n\n/**\n * Keyframes `{ keys: [{ t, v, ease }] }`: at least one key, in strictly increasing time (clip-relative\n * ms), each value within `range`.\n */\nexport function parseVideoKeyframes(v: unknown, path: string, { min, max }: { min?: number; max?: number } = {}): VideoKeyframes {\n\tif (!isObject(v)) return fail(path, 'must be a number or keyframes { keys: [{ t, v, ease }] }');\n\tfor (const key of Object.keys(v)) if (key !== 'keys') fail(`${path}.${key}`, 'is not a keyframes field (keys)');\n\tif (!Array.isArray(v.keys) || !v.keys.length) fail(`${path}.keys`, 'must be an array of at least one key { t, v, ease }');\n\tconst keys = (v.keys as unknown[]).map((k, i): VideoKey => {\n\t\tconst at = `${path}.keys[${i}]`;\n\t\tif (!isObject(k)) return fail(at, 'must be an object { t, v, ease }');\n\t\tfor (const key of Object.keys(k)) if (!['t', 'v', 'ease'].includes(key)) fail(`${at}.${key}`, 'is not a key field (t, v, ease)');\n\t\treturn compact({ t: num(k.t, `${at}.t`)!, v: num(k.v, `${at}.v`, { min, max })!, ease: parseVideoEase(k.ease, `${at}.ease`) });\n\t});\n\tfor (let i = 1; i < keys.length; i++) if (!(keys[i].t > keys[i - 1].t)) fail(`${path}.keys[${i}].t`, `must be later than the key before (${keys[i - 1].t} ms); keys go in time order, one per time`);\n\treturn { keys };\n}\n\n/** A number within `range`, or keyframes for one. */\nexport function parseVideoAnimatable(v: unknown, path: string, range: { min?: number; max?: number; optional?: boolean } = {}): VideoAnimatable | undefined {\n\tif (v === undefined && range.optional) return undefined;\n\tconst { min, max } = range;\n\tif (isObject(v)) return parseVideoKeyframes(v, path, { min, max });\n\treturn num(v, path, { min, max });\n}\n\nexport function parseVideoTransform(v: unknown, path: string): VideoTransform | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { x, y, scale, rotation }');\n\tfor (const key of Object.keys(v)) if (!['x', 'y', 'scale', 'rotation'].includes(key)) fail(`${path}.${key}`, 'is not a transform field (x, y, scale, rotation)');\n\treturn compact({\n\t\tx: parseVideoAnimatable(v.x, `${path}.x`, { optional: true }),\n\t\ty: parseVideoAnimatable(v.y, `${path}.y`, { optional: true }),\n\t\tscale: parseVideoAnimatable(v.scale, `${path}.scale`, { optional: true, min: 0 }),\n\t\trotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })\n\t});\n}\n\nexport function parseVideoTimeRemap(v: unknown, speed: unknown, path: string): VideoKeyframes | undefined {\n\tif (v === undefined) return undefined;\n\tconst remap = parseVideoAnimatable(v, path, { optional: true, min: 0 });\n\tif (typeof remap === 'number' || remap === undefined) return fail(path, 'must be keyframes { keys: [{ t, v, ease }] } (v: ms of source after the in point)');\n\tif (speed !== undefined && speed !== 1) fail(path, 'cannot be used with a speed; a ramp sets the speed itself (remove \"speed\")');\n\ttry {\n\t\tcheckVideoTimeRemap(remap, path);\n\t} catch (e) {\n\t\tthrow new Error((e as Error).message.replace(/\\.?$/, '.'));\n\t}\n\treturn remap;\n}\n\nexport function parseVideoClipAnimation(v: unknown, path: string): VideoClipAnimation | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { effect, duration }');\n\treturn compact({\n\t\teffect: oneOf(v.effect, VIDEO_CLIP_ANIMATION_EFFECTS, `${path}.effect`, false)!,\n\t\tduration: num(v.duration, `${path}.duration`, { optional: true, positive: true })\n\t});\n}\n\nexport function parseVideoGrade(v: unknown, path: string): VideoGrade | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object such as { exposure, contrast, saturation }');\n\tconst known = new Set([...VIDEO_GRADE_CONTROLS.map((c) => c.key as string), 'lut']);\n\tconst unknown = Object.keys(v).find((k) => !known.has(k));\n\tif (unknown) fail(`${path}.${unknown}`, `is not a grade setting; use ${[...known].join(', ')}`);\n\treturn compact({ ...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])), lut: parseVideoGradeLut(v.lut, `${path}.lut`) }) as VideoGrade;\n}\n\nexport function parseVideoGradeLut(v: unknown, path: string): VideoGradeLut | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { src, strength } naming a .cube file');\n\tconst unknown = Object.keys(v).find((k) => !['src', 'strength', 'name'].includes(k));\n\tif (unknown) fail(`${path}.${unknown}`, 'is not a LUT setting; use src, strength, name');\n\treturn compact({ src: str(v.src, `${path}.src`)!, strength: num(v.strength, `${path}.strength`, { optional: true, min: 0, max: 1 }), name: str(v.name, `${path}.name`, true) });\n}\n\nexport function parseVideoTransition(v: unknown, path: string): VideoTransition | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { type, duration }');\n\treturn compact({\n\t\ttype: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false)!,\n\t\tduration: num(v.duration, `${path}.duration`, { positive: true })!,\n\t\tcolor: str(v.color, `${path}.color`, true),\n\t\tdirection: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`),\n\t\taudioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)\n\t});\n}\n\nexport function parseVideoTranscriptWords(v: unknown, path: string): VideoTranscriptWord[] | undefined {\n\tif (v === undefined) return undefined;\n\tif (!Array.isArray(v)) return fail(path, 'must be an array of { word, start, end } (ms in the source)');\n\tconst words = v.map((w, i) => {\n\t\tif (!isObject(w)) return fail(`${path}[${i}]`, 'must be an object { word, start, end }');\n\t\tconst word = str(w.word, `${path}[${i}].word`)!;\n\t\tconst start = num(w.start, `${path}[${i}].start`, { min: 0 })!;\n\t\tconst end = num(w.end, `${path}[${i}].end`, { min: start })!;\n\t\treturn { word, start, end };\n\t});\n\treturn words.sort((a, b) => a.start - b.start);\n}\n\nexport function parseVideoAsset(v: unknown, path = 'asset'): VideoAsset {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\tconst kind = oneOf(v.kind, ['video', 'audio', 'image'] as const, `${path}.kind`, false)!;\n\tconst asset: VideoAsset = compact({\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tkind,\n\t\tsrc: str(v.src, `${path}.src`)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\tduration: num(v.duration, `${path}.duration`, { optional: true, positive: true }),\n\t\twidth: num(v.width, `${path}.width`, { optional: true, positive: true }),\n\t\theight: num(v.height, `${path}.height`, { optional: true, positive: true }),\n\t\thasAudio: bool(v.hasAudio, `${path}.hasAudio`),\n\t\tposter: str(v.poster, `${path}.poster`, true),\n\t\ttranscript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`),\n\t\tproxies: parseVideoAssetProxies(v.proxies, `${path}.proxies`)\n\t});\n\tif (kind !== 'video' && asset.proxies) fail(`${path}.proxies`, 'are only for video assets');\n\tif (kind === 'image' && asset.transcript) fail(`${path}.transcript`, 'is only for video and audio assets');\n\tif (kind === 'image' && asset.duration !== undefined) fail(`${path}.duration`, 'is only for video and audio assets');\n\treturn asset;\n}\n\nexport function parseVideoAssetProxies(v: unknown, path: string): VideoAssetProxy[] | undefined {\n\tif (v === undefined) return undefined;\n\tif (!Array.isArray(v)) return fail(path, 'must be a list of { scale, src }');\n\tconst out = v.map((p, i) => {\n\t\tconst at = `${path}[${i}]`;\n\t\tif (!isObject(p)) return fail(at, 'must be an object { scale, src }');\n\t\tconst unknown = Object.keys(p).find((k) => !['scale', 'src', 'width', 'height'].includes(k));\n\t\tif (unknown) fail(`${at}.${unknown}`, 'is not a proxy setting; use scale, src, width, height');\n\t\tconst scale = num(p.scale, `${at}.scale`, { positive: true })!;\n\t\tif (scale >= 1) fail(`${at}.scale`, 'must be less than 1 (0.5 is half size)');\n\t\treturn 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 }) });\n\t});\n\tconst scales = new Set<number>();\n\tfor (const [i, p] of out.entries()) {\n\t\tif (scales.has(p.scale)) fail(`${path}[${i}].scale`, `repeats ${p.scale}; give each proxy its own scale`);\n\t\tscales.add(p.scale);\n\t}\n\treturn out.length ? out : undefined;\n}\n\nexport function parseVideoClip(v: unknown, path = 'clip'): VideoClip {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\tconst type = oneOf(v.type ?? 'media', VIDEO_CLIP_TYPES, `${path}.type`, false)!;\n\tconst base = {\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\tstart: num(v.start, `${path}.start`, { min: 0 })!,\n\t\tduration: num(v.duration, `${path}.duration`, { positive: true })!,\n\t\topacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),\n\t\ttransform: parseVideoTransform(v.transform, `${path}.transform`),\n\t\tfit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),\n\t\tblend: oneOf(v.blend, VIDEO_BLEND_MODES, `${path}.blend`),\n\t\tgrade: parseVideoGrade(v.grade, `${path}.grade`),\n\t\tanimateIn: parseVideoClipAnimation(v.animateIn, `${path}.animateIn`),\n\t\tanimateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),\n\t\tfadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),\n\t\tfadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),\n\t\ttransition: parseVideoTransition(v.transition, `${path}.transition`)\n\t};\n\tif (type === 'media')\n\t\treturn compact({\n\t\t\ttype,\n\t\t\t...base,\n\t\t\tasset: str(v.asset, `${path}.asset`)!,\n\t\t\tin: num(v.in, `${path}.in`, { optional: true, min: 0 }),\n\t\t\tspeed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),\n\t\t\tvolume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),\n\t\t\tmuted: bool(v.muted, `${path}.muted`),\n\t\t\ttimeRemap: parseVideoTimeRemap(v.timeRemap, v.speed, `${path}.timeRemap`),\n\t\t\tfadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),\n\t\t\tfadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)\n\t\t});\n\tif (type === 'sequence') {\n\t\t// The sound and pictures of a sequence are not re-timed yet, so a compound plays at normal speed.\n\t\tfor (const key of ['speed', 'timeRemap'])\n\t\t\tif (v[key] !== undefined && !(key === 'speed' && v[key] === 1)) fail(`${path}.${key}`, 'is not supported on a compound clip (type \"sequence\"), which plays its sequence at normal speed; remove it, or change the speed of the clips inside the sequence');\n\t\treturn compact({\n\t\t\ttype,\n\t\t\t...base,\n\t\t\tsequence: str(v.sequence, `${path}.sequence`)!,\n\t\t\tin: num(v.in, `${path}.in`, { optional: true, min: 0 }),\n\t\t\tvolume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),\n\t\t\tmuted: bool(v.muted, `${path}.muted`),\n\t\t\tfadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),\n\t\t\tfadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)\n\t\t});\n\t}\n\tif (type === 'text')\n\t\treturn compact({\n\t\t\ttype,\n\t\t\t...base,\n\t\t\ttext: str(v.text, `${path}.text`)!,\n\t\t\tcolor: str(v.color, `${path}.color`, true),\n\t\t\tfontSize: num(v.fontSize, `${path}.fontSize`, { optional: true, positive: true }),\n\t\t\tfontFamily: str(v.fontFamily, `${path}.fontFamily`, true),\n\t\t\tfontWeight: num(v.fontWeight, `${path}.fontWeight`, { optional: true, min: 100, max: 1000 }),\n\t\t\tbackground: str(v.background, `${path}.background`, true)\n\t\t});\n\treturn compact({ type, ...base, color: str(v.color, `${path}.color`)! });\n}\n\nexport function parseVideoTrack(v: unknown, path = 'track'): VideoTrack {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\tif (v.clips !== undefined && !Array.isArray(v.clips)) fail(`${path}.clips`, 'must be an array');\n\treturn compact({\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tkind: oneOf(v.kind, ['video', 'audio'] as const, `${path}.kind`, false)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\tmuted: bool(v.muted, `${path}.muted`),\n\t\thidden: bool(v.hidden, `${path}.hidden`),\n\t\tlocked: bool(v.locked, `${path}.locked`),\n\t\tsyncLocked: bool(v.syncLocked, `${path}.syncLocked`),\n\t\tvolume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),\n\t\tclips: ((v.clips ?? []) as unknown[]).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))\n\t});\n}\n\n/** A nested sequence `{ id, name, tracks }` (see `VideoSequence`). */\nexport function parseVideoSequence(v: unknown, path = 'sequence'): VideoSequence {\n\tif (!isObject(v)) return fail(path, 'must be an object { id, name, tracks }');\n\tif (v.tracks !== undefined && !Array.isArray(v.tracks)) fail(`${path}.tracks`, 'must be an array');\n\tconst sequence: VideoSequence = compact({\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\ttracks: ((v.tracks ?? []) as unknown[]).map((t, i) => parseVideoTrack(t, `${path}.tracks[${i}]`))\n\t});\n\tconst seen = new Set<string>();\n\tfor (const t of sequence.tracks) {\n\t\tif (seen.has(t.id)) throw new Error(`Two tracks in sequence \"${sequence.id}\" share the id \"${t.id}\"; ids must be unique.`);\n\t\tseen.add(t.id);\n\t}\n\treturn sequence;\n}\n\nexport function parseVideoMarker(v: unknown, path = 'marker'): VideoMarker {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\treturn compact({ id: str(v.id, `${path}.id`)!, time: num(v.time, `${path}.time`, { min: 0 })!, label: str(v.label, `${path}.label`, true), color: str(v.color, `${path}.color`, true) });\n}\n\n/**\n * Reads a project from JSON text or an object, checking every field. Throws an error naming the\n * first bad field (`tracks[0].clips[2].duration must be greater than 0.`). Ids must be unique\n * within assets, tracks, sequences, clips (across the project and its sequences) and markers.\n * Overlaps, missing media and sequences, and sequences that contain themselves are left to\n * `checkVideoProject`, so a project with problems can still be opened and fixed.\n */\nexport function parseVideoProject(input: unknown): VideoProject {\n\tlet v = input;\n\tif (typeof v === 'string') {\n\t\ttry {\n\t\t\tv = JSON.parse(v);\n\t\t} catch (error) {\n\t\t\tthrow new Error(`The project is not valid JSON: ${(error as Error).message}`);\n\t\t}\n\t}\n\tif (!isObject(v)) return fail('The project', 'must be an object');\n\tif (v.version !== undefined && v.version !== 1) fail('version', 'must be 1');\n\tfor (const key of ['assets', 'tracks', 'sequences', 'markers']) if (v[key] !== undefined && !Array.isArray(v[key])) fail(key, 'must be an array');\n\tconst project: VideoProject = compact({\n\t\tversion: 1 as const,\n\t\tname: str(v.name, 'name', true),\n\t\twidth: num(v.width, 'width', { positive: true })!,\n\t\theight: num(v.height, 'height', { positive: true })!,\n\t\tfps: num(v.fps ?? 30, 'fps', { positive: true, max: 240 })!,\n\t\tbackground: str(v.background, 'background', true),\n\t\tassets: ((v.assets ?? []) as unknown[]).map((a, i) => parseVideoAsset(a, `assets[${i}]`)),\n\t\ttracks: ((v.tracks ?? []) as unknown[]).map((t, i) => parseVideoTrack(t, `tracks[${i}]`)),\n\t\tsequences: v.sequences === undefined ? undefined : (v.sequences as unknown[]).map((q, i) => parseVideoSequence(q, `sequences[${i}]`)),\n\t\tmarkers: v.markers === undefined ? undefined : (v.markers as unknown[]).map((m, i) => parseVideoMarker(m, `markers[${i}]`))\n\t});\n\tconst unique = (ids: string[], what: string) => {\n\t\tconst seen = new Set<string>();\n\t\tfor (const id of ids) {\n\t\t\tif (seen.has(id)) throw new Error(`Two ${what} share the id \"${id}\"; ids must be unique.`);\n\t\t\tseen.add(id);\n\t\t}\n\t};\n\tunique(project.assets.map((a) => a.id), 'assets');\n\tunique(project.tracks.map((t) => t.id), 'tracks');\n\tunique((project.sequences ?? []).map((q) => q.id), 'sequences');\n\t// Clip ids are unique across the project and every sequence, so an id names one clip.\n\tunique([...project.tracks, ...(project.sequences ?? []).flatMap((q) => q.tracks)].flatMap((t) => t.clips.map((c) => c.id)), 'clips');\n\tunique((project.markers ?? []).map((m) => m.id), 'markers');\n\treturn project;\n}\n\nexport interface CreateVideoProjectOptions {\n\tname?: string;\n\t/** Default 1920. */\n\twidth?: number;\n\t/** Default 1080. */\n\theight?: number;\n\t/** Default 30. */\n\tfps?: number;\n\tbackground?: string;\n}\n\n/** An empty project with one video track (`v1`) and one audio track (`a1`). */\nexport function createVideoProject(options: CreateVideoProjectOptions = {}): VideoProject {\n\treturn compact({\n\t\tversion: 1 as const,\n\t\tname: options.name,\n\t\twidth: options.width ?? 1920,\n\t\theight: options.height ?? 1080,\n\t\tfps: options.fps ?? 30,\n\t\tbackground: options.background,\n\t\tassets: [],\n\t\ttracks: [\n\t\t\t{ id: 'v1', kind: 'video' as const, name: 'Video 1', clips: [] },\n\t\t\t{ id: 'a1', kind: 'audio' as const, name: 'Audio 1', clips: [] }\n\t\t]\n\t});\n}\n","import { findVideoSequenceCycle, formatVideoSeconds, sortedVideoClips, VIDEO_TIME_EPSILON, videoAssetMap, videoClipBefore, videoClipEnd, videoClipHandles, videoClipSourceRange, videoProjectDuration, videoSequenceDuration, videoSequenceMap } from './timeline.js';\nimport { VIDEO_CLIP_ANIMATION_DURATION, videoClipAnimationProperties } from './clip-animation.js';\nimport { VIDEO_TRANSFORM_PROPERTIES, videoKeysOf } from './keyframes.js';\nimport type { VideoClip, VideoProject, VideoTrack } from './types.js';\n\n/** A clip's keyframed properties (`opacity`, `volume`, `x`…) with their keys. */\nexport function videoClipKeyframes(clip: VideoClip): { property: string; keys: ReturnType<typeof videoKeysOf> }[] {\n\tconst out = [\n\t\t{ property: 'opacity', keys: videoKeysOf(clip.opacity) },\n\t\t{ property: 'volume', keys: clip.type === 'media' || clip.type === 'sequence' ? videoKeysOf(clip.volume) : [] },\n\t\t...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p as string, keys: videoKeysOf(clip.transform?.[p]) }))\n\t];\n\treturn out.filter((o) => o.keys.length);\n}\n\nexport type VideoIssueSeverity = 'error' | 'warning' | 'note';\n\nexport const VIDEO_ISSUE_CODES = [\n\t'missing-asset',\n\t'missing-sequence',\n\t'sequence-cycle',\n\t'wrong-track',\n\t'overlap',\n\t'source-overrun',\n\t'transition-without-cut',\n\t'transition-too-long',\n\t'transition-handles',\n\t'fades-too-long',\n\t'animations-too-long',\n\t'animation-keyed',\n\t'keyframes-outside-clip',\n\t'unknown-duration',\n\t'unused-asset',\n\t'unused-sequence',\n\t'marker-past-end',\n\t'empty'\n] as const;\nexport type VideoIssueCode = (typeof VIDEO_ISSUE_CODES)[number];\n\nexport interface VideoIssue {\n\tcode: VideoIssueCode;\n\tseverity: VideoIssueSeverity;\n\t/** One sentence saying what is wrong and how to fix it. */\n\tmessage: string;\n\t/** The sequence whose track it is about (absent for the project's own tracks). */\n\tsequence?: string;\n\ttrack?: string;\n\tclip?: string;\n\tasset?: string;\n\tmarker?: string;\n}\n\n/** A stable key for an issue (code and what it is about), to tell new problems from old ones. */\nexport const videoIssueKey = (i: VideoIssue): string => [i.code, i.sequence ?? '', i.track ?? '', i.clip ?? '', i.asset ?? '', i.marker ?? ''].join('|');\n\n/**\n * Everything wrong with a project, errors first: clips pointing at missing media or sitting on the\n * wrong kind of track, overlaps, clips running past the end of their source, transitions with no\n * cut to play over or not enough media either side of it, and softer notes (unused assets, markers\n * past the end). Sequences' tracks are checked the same way, and compound clips must play a\n * sequence that exists, does not contain itself and is long enough. Edits never add errors (see `applyVideoEdits`), so errors only come from a loaded file.\n */\nexport function checkVideoProject(project: VideoProject): VideoIssue[] {\n\tconst issues: VideoIssue[] = [];\n\tconst assets = videoAssetMap(project);\n\tconst sequences = videoSequenceMap(project);\n\tconst used = new Set<string>();\n\tconst usedSequences = new Set<string>();\n\tconst cycles = new Map<string, string[] | null>();\n\tfor (const asset of project.assets)\n\t\tif ((asset.kind === 'video' || asset.kind === 'audio') && asset.duration === undefined)\n\t\t\tissues.push({ code: 'unknown-duration', severity: 'warning', asset: asset.id, message: `Asset \"${asset.id}\" has no duration, so clips of it cannot be checked against its length; set \"duration\" in ms.` });\n\tconst checkTrack = (track: VideoTrack, sequence?: string) => {\n\t\tconst clips = sortedVideoClips(track);\n\t\tconst where = sequence ? ` in sequence \"${sequence}\"` : '';\n\t\tfor (let i = 0; i < clips.length; i++) {\n\t\t\tconst clip = clips[i];\n\t\t\tconst at = { ...(sequence ? { sequence } : {}), track: track.id, clip: clip.id };\n\t\t\tif (clip.type === 'sequence') {\n\t\t\t\tusedSequences.add(clip.sequence);\n\t\t\t\tconst seq = sequences.get(clip.sequence);\n\t\t\t\tif (!seq) issues.push({ code: 'missing-sequence', severity: 'error', ...at, message: `Compound clip \"${clip.id}\" plays sequence \"${clip.sequence}\", which is not in the project; add the sequence or delete the clip.` });\n\t\t\t\telse {\n\t\t\t\t\tif (!cycles.has(seq.id)) cycles.set(seq.id, findVideoSequenceCycle(project, seq.id));\n\t\t\t\t\tconst cycle = cycles.get(seq.id);\n\t\t\t\t\tif (cycle) issues.push({ code: 'sequence-cycle', severity: 'error', ...at, message: `Compound clip \"${clip.id}\" plays sequence \"${seq.id}\", which contains itself (${cycle.join(' → ')}), so it is left out of the picture and sound; remove the compound clip that closes the loop.` });\n\t\t\t\t\tconst length = videoSequenceDuration(seq);\n\t\t\t\t\tconst range = videoClipSourceRange(clip)!;\n\t\t\t\t\tif (range.out > length + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\tissues.push({ code: 'source-overrun', severity: 'error', ...at, message: `Compound clip \"${clip.id}\" plays to ${formatVideoSeconds(range.out)} of sequence \"${seq.id}\", which is only ${formatVideoSeconds(length)} long; shorten the clip or move its in point.` });\n\t\t\t\t}\n\t\t\t} else if (clip.type === 'media') {\n\t\t\t\tused.add(clip.asset);\n\t\t\t\tconst asset = assets.get(clip.asset);\n\t\t\t\tif (!asset) issues.push({ code: 'missing-asset', severity: 'error', ...at, asset: clip.asset, message: `Clip \"${clip.id}\" plays asset \"${clip.asset}\", which is not in the project; add the asset or delete the clip.` });\n\t\t\t\telse {\n\t\t\t\t\tif (track.kind === 'audio' && asset.kind === 'image') issues.push({ code: 'wrong-track', severity: 'error', ...at, message: `Clip \"${clip.id}\" is an image on audio track \"${track.id}\"; move it to a video track.` });\n\t\t\t\t\tif (track.kind === 'video' && asset.kind === 'audio') issues.push({ code: 'wrong-track', severity: 'error', ...at, message: `Clip \"${clip.id}\" is audio on video track \"${track.id}\"; move it to an audio track.` });\n\t\t\t\t\tconst range = videoClipSourceRange(clip)!;\n\t\t\t\t\tif (asset.duration !== undefined && range.out > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\tissues.push({ code: 'source-overrun', severity: 'error', ...at, message: `Clip \"${clip.id}\" plays to ${formatVideoSeconds(range.out)} of \"${asset.id}\", which is only ${formatVideoSeconds(asset.duration)} long; shorten the clip or move its in point.` });\n\t\t\t\t}\n\t\t\t} else if (track.kind === 'audio') issues.push({ code: 'wrong-track', severity: 'error', ...at, message: `Clip \"${clip.id}\" is a ${clip.type} clip on audio track \"${track.id}\"; move it to a video track.` });\n\t\t\tconst next = clips[i + 1];\n\t\t\tif (next && next.start < videoClipEnd(clip) - VIDEO_TIME_EPSILON)\n\t\t\t\tissues.push({ code: 'overlap', severity: 'error', ...at, message: `Clips \"${clip.id}\" and \"${next.id}\" overlap on track \"${track.id}\"${where} from ${formatVideoSeconds(next.start)}; move one of them.` });\n\t\t\tif ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)\n\t\t\t\tissues.push({ code: 'fades-too-long', severity: 'warning', ...at, message: `Clip \"${clip.id}\"'s fades (${clip.fadeIn ?? 0} + ${clip.fadeOut ?? 0} ms) are longer than the clip (${clip.duration} ms); shorten them.` });\n\t\t\tconst anim = (clip.animateIn ? (clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION) : 0) + (clip.animateOut ? (clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION) : 0);\n\t\t\tif (anim > clip.duration + VIDEO_TIME_EPSILON)\n\t\t\t\tissues.push({ code: 'animations-too-long', severity: 'warning', ...at, message: `Clip \"${clip.id}\"'s entrance and exit (${anim} ms together) are longer than the clip (${clip.duration} ms), so both are shortened; give them shorter durations.` });\n\t\t\tfor (const which of ['animateIn', 'animateOut'] as const) {\n\t\t\t\tconst a = clip[which];\n\t\t\t\tif (!a) continue;\n\t\t\t\tconst keyed = videoClipAnimationProperties(a).filter((p) => videoKeysOf(p === 'opacity' ? clip.opacity : clip.transform?.[p]).length);\n\t\t\t\tif (keyed.length)\n\t\t\t\t\tissues.push({ code: 'animation-keyed', severity: 'note', ...at, message: `Clip \"${clip.id}\"'s ${a.effect} ${which === 'animateIn' ? 'entrance' : 'exit'} leaves ${keyed.join(', ')} alone, because the clip keys ${keyed.length > 1 ? 'them' : 'it'} itself; remove those keyframes or the animation.` });\n\t\t\t}\n\t\t\tfor (const { property, keys } of videoClipKeyframes(clip)) {\n\t\t\t\tconst outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);\n\t\t\t\tif (outside.length)\n\t\t\t\t\tissues.push({\n\t\t\t\t\t\tcode: 'keyframes-outside-clip',\n\t\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\t\t...at,\n\t\t\t\t\t\tmessage: `Clip \"${clip.id}\" has ${property} key${outside.length > 1 ? 's' : ''} at ${outside.map((k) => `${Math.round(k.t)} ms`).join(', ')}, outside the clip (0–${Math.round(clip.duration)} ms in clip time), so ${outside.length > 1 ? 'they never play' : 'it never plays'} directly; move ${outside.length > 1 ? 'them' : 'it'} inside or remove ${outside.length > 1 ? 'them' : 'it'} (set_video_keyframes).`\n\t\t\t\t\t});\n\t\t\t}\n\t\t\tif (clip.transition) {\n\t\t\t\tconst d = clip.transition.duration;\n\t\t\t\tconst before = videoClipBefore(track, clip);\n\t\t\t\tif (!before) {\n\t\t\t\t\tissues.push({ code: 'transition-without-cut', severity: 'warning', ...at, message: `Clip \"${clip.id}\" has a transition but no clip ends where it starts on track \"${track.id}\", so it plays as nothing; butt a clip up against it or remove the transition.` });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (d > clip.duration + VIDEO_TIME_EPSILON || d > before.duration + VIDEO_TIME_EPSILON) {\n\t\t\t\t\tissues.push({ code: 'transition-too-long', severity: 'error', ...at, message: `The ${d} ms transition into \"${clip.id}\" is longer than \"${clip.id}\" (${clip.duration} ms) or \"${before.id}\" (${before.duration} ms); make it at most ${Math.min(clip.duration, before.duration)} ms.` });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t// A dip cuts under its colour, so it plays no media past the cut.\n\t\t\t\tif (clip.transition.type === 'dip') continue;\n\t\t\t\tconst outgoing = videoClipHandles(before, assets, sequences).after;\n\t\t\t\tconst incoming = videoClipHandles(clip, assets, sequences).before;\n\t\t\t\tconst half = d / 2;\n\t\t\t\tif (outgoing < half - VIDEO_TIME_EPSILON || incoming < half - VIDEO_TIME_EPSILON) {\n\t\t\t\t\tconst fits = 2 * Math.min(outgoing, incoming);\n\t\t\t\t\tissues.push({\n\t\t\t\t\t\tcode: 'transition-handles',\n\t\t\t\t\t\tseverity: 'error',\n\t\t\t\t\t\t...at,\n\t\t\t\t\t\tmessage: `The ${d} ms transition into \"${clip.id}\" needs ${half} ms of media past the cut on both sides, but \"${before.id}\" has ${Math.floor(outgoing)} ms after its out point and \"${clip.id}\" ${Math.floor(incoming)} ms before its in point; make it at most ${Math.floor(fits)} ms, or trim the clips to leave handles.`\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\tfor (const track of project.tracks) checkTrack(track);\n\tfor (const sequence of project.sequences ?? []) for (const track of sequence.tracks) checkTrack(track, sequence.id);\n\tfor (const sequence of project.sequences ?? [])\n\t\tif (!usedSequences.has(sequence.id)) issues.push({ code: 'unused-sequence', severity: 'note', sequence: sequence.id, message: `Sequence \"${sequence.id}\" is not played by any compound clip.` });\n\tfor (const asset of project.assets) if (!used.has(asset.id)) issues.push({ code: 'unused-asset', severity: 'note', asset: asset.id, message: `Asset \"${asset.id}\" is not used by any clip.` });\n\tconst end = videoProjectDuration(project);\n\tfor (const marker of project.markers ?? [])\n\t\tif (marker.time > end + VIDEO_TIME_EPSILON) issues.push({ code: 'marker-past-end', severity: 'note', marker: marker.id, message: `Marker \"${marker.id}\" at ${formatVideoSeconds(marker.time)} is past the end (${formatVideoSeconds(end)}).` });\n\tif (!project.tracks.some((t) => t.clips.length)) issues.push({ code: 'empty', severity: 'note', message: 'The project has no clips yet; add assets and then clips.' });\n\tconst rank = { error: 0, warning: 1, note: 2 } as const;\n\treturn issues.sort((a, b) => rank[a.severity] - rank[b.severity]);\n}\n","import { checkVideoProject, videoIssueKey, type VideoIssue } from './check.js';\nimport { isVideoKeyframes, retimeVideoAnimatable, sortVideoKeys, splitVideoAnimatable, VIDEO_KEYFRAME_LIMITS, VIDEO_KEYFRAME_PROPERTIES, VIDEO_TRANSFORM_PROPERTIES, videoValueAt, type VideoAnimatable, type VideoKey, type VideoKeyframeProperty } from './keyframes.js';\nimport { parseVideoAnimatable, parseVideoAsset, parseVideoClip, parseVideoMarker, parseVideoProject, parseVideoTimeRemap, parseVideoTransition } from './parse.js';\nimport { checkVideoTimeRemap, videoTimeRemapSpan } from './remap.js';\nimport {\n\tallVideoClipIds,\n\tallVideoTracks,\n\tfindVideoClip,\n\tfindVideoSequenceClip,\n\tformatVideoSeconds,\n\tnextFreeVideoSlot,\n\tsortedVideoClips,\n\tVIDEO_TIME_EPSILON,\n\tvideoAssetMap,\n\tvideoClipAfter,\n\tvideoClipBefore,\n\tvideoClipEnd,\n\tvideoClipHandles,\n\tvideoClipOverhang,\n\tvideoClipSpeed,\n\tvideoOverlap,\n\tvideoSequenceDuration,\n\tvideoSequenceMap,\n\tvideoTrackSyncLocked\n} from './timeline.js';\nimport { VIDEO_PLACEMENT_MODES, type VideoAsset, type VideoClip, type VideoMarker, type VideoMediaClip, type VideoPlacementMode, type VideoProject, type VideoTrack, type VideoTrackKind, type VideoTransition } from './types.js';\nimport { namedWithSuggestion } from '../animation-editor/issues.js';\n\ntype Settings = { name?: string; width?: number; height?: number; fps?: number; background?: string };\ntype TrackFields = { name?: string; muted?: boolean; hidden?: boolean; locked?: boolean; syncLocked?: boolean; volume?: number };\nconst TRACK_FIELD_KEYS = ['name', 'muted', 'hidden', 'locked', 'syncLocked', 'volume'];\n/** A clip to add: `id` and `start` are optional (default: a fresh id, the end of the track). */\nexport type NewVideoClip = { [K in VideoClip['type']]: Omit<Extract<VideoClip, { type: K }>, 'id' | 'start' | 'duration'> & { id?: string; start?: number; duration?: number } }[VideoClip['type']];\n\n/** One change to a project. `applyVideoEdits` applies a list of them all or nothing. */\nexport type VideoEdit =\n\t| ({ op: 'set_settings' } & Settings)\n\t| { op: 'add_asset'; asset: Omit<VideoAsset, 'id'> & { id?: string } }\n\t| { op: 'update_asset'; asset: string; set?: Partial<Omit<VideoAsset, 'id'>>; remove?: string[] }\n\t| { op: 'remove_asset'; asset: string }\n\t| { op: 'add_track'; track: TrackFields & { id?: string; kind: VideoTrackKind }; at?: number }\n\t| { op: 'update_track'; track: string; set?: TrackFields; remove?: string[] }\n\t| { op: 'remove_track'; track: string }\n\t| { op: 'move_track'; track: string; to: number }\n\t| { op: 'add_clip'; track: string; clip: NewVideoClip; mode?: VideoPlacementMode; ripple?: boolean }\n\t| { op: 'move_clip'; clip: string; start?: number; track?: string; mode?: VideoPlacementMode }\n\t| { op: 'trim_clip'; clip: string; edge: 'start' | 'end'; to: number; ripple?: boolean }\n\t| { op: 'split_clip'; clip: string; at: number; id?: string }\n\t| { op: 'delete_clips'; clips: string[]; ripple?: boolean }\n\t| { op: 'update_clip'; clip: string; set?: Record<string, unknown>; remove?: string[] }\n\t| { op: 'set_transition'; clip: string; transition: VideoTransition | null }\n\t| { op: 'slip_clip'; clip: string; delta: number }\n\t| { op: 'roll_edit'; left: string; right: string; delta: number }\n\t| { op: 'slide_clip'; clip: string; delta: number }\n\t| { op: 'lift_range'; from: number; to: number; tracks?: string[] }\n\t| { op: 'extract_range'; from: number; to: number; tracks?: string[] }\n\t| { op: 'split_at'; at: number; tracks?: string[] }\n\t/** Keys for one property (in any order; clip-relative ms), or null to stop animating it and keep its value at the clip's start. */\n\t| { op: 'set_keyframes'; clip: string; property: VideoKeyframeProperty; keys: VideoKey[] | null }\n\t/**\n\t * A speed ramp: keys mapping clip time to source ms after the in point (see `timeRemap`), or\n\t * null to play at normal speed from where the ramp started. `duration` sets the clip's new length\n\t * (default: unchanged); `ripple` moves later material with the change.\n\t */\n\t| { op: 'set_time_remap'; clip: string; keys: VideoKey[] | null; duration?: number; ripple?: boolean }\n\t| { op: 'close_gaps'; track: string; from?: number }\n\t| { op: 'add_marker'; marker: Omit<VideoMarker, 'id'> & { id?: string } }\n\t| { op: 'update_marker'; marker: string; set?: Partial<Omit<VideoMarker, 'id'>>; remove?: string[] }\n\t| { op: 'remove_marker'; marker: string }\n\t| { op: 'replace_project'; project: VideoProject };\n\nexport type VideoEditOp = VideoEdit['op'];\nexport const VIDEO_EDIT_OPS: readonly VideoEditOp[] = [\n\t'set_settings',\n\t'add_asset',\n\t'update_asset',\n\t'remove_asset',\n\t'add_track',\n\t'update_track',\n\t'remove_track',\n\t'move_track',\n\t'add_clip',\n\t'move_clip',\n\t'trim_clip',\n\t'split_clip',\n\t'delete_clips',\n\t'update_clip',\n\t'set_transition',\n\t'slip_clip',\n\t'roll_edit',\n\t'slide_clip',\n\t'lift_range',\n\t'extract_range',\n\t'split_at',\n\t'set_keyframes',\n\t'set_time_remap',\n\t'close_gaps',\n\t'add_marker',\n\t'update_marker',\n\t'remove_marker',\n\t'replace_project'\n];\n\n/** What one op made or changed, so the next step can refer to it. */\nexport interface VideoEditOutcome {\n\top: VideoEditOp;\n\tclip?: string;\n\tclips?: string[];\n\ttrack?: string;\n\tasset?: string;\n\tmarker?: string;\n\t/** Clips the op made by cutting others (the right-hand parts). */\n\tcreated?: string[];\n\t/** Clips it deleted to make room (overwrite, lift, extract). */\n\tremoved?: string[];\n\t/** Clips it shortened to make room. */\n\ttrimmed?: string[];\n\t/** Tracks a ripple moved (the edited ones and every sync-locked one with them). */\n\trippled?: string[];\n\t/** For slip, roll and slide: how far it actually moved (ms), after clamping to what the media allows. */\n\tdelta?: number;\n}\n\nexport type VideoEditResult =\n\t| { ok: true; project: VideoProject; issues: VideoIssue[]; outcomes: VideoEditOutcome[] }\n\t| { ok: false; error: string };\n\n/** Default lengths (ms) for clips added without one. */\nexport const VIDEO_DEFAULT_STILL_DURATION = 5000;\nexport const VIDEO_DEFAULT_TITLE_DURATION = 3000;\n\nclass EditError extends Error {}\nconst refuse = (message: string): never => {\n\tthrow new EditError(message);\n};\n\nfunction freshId(prefix: string, taken: Iterable<string>): string {\n\tconst used = new Set(taken);\n\tfor (let n = 1; ; n++) if (!used.has(`${prefix}${n}`)) return `${prefix}${n}`;\n}\n\nconst slug = (s: string) =>\n\ts\n\t\t.replace(/^.*[\\\\/]/, '')\n\t\t.replace(/\\.[a-z0-9]+(\\?.*)?$/i, '')\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, '-')\n\t\t.replace(/^-|-$/g, '')\n\t\t.slice(0, 32);\n\n/** Sorts `[from, to]` spans and merges the ones that overlap or touch. */\nfunction mergeSpans(spans: [number, number][]): [number, number][] {\n\tconst out: [number, number][] = [];\n\tfor (const [from, to] of [...spans].sort((a, b) => a[0] - b[0])) {\n\t\tconst last = out[out.length - 1];\n\t\tif (last && from <= last[1] + VIDEO_TIME_EPSILON) last[1] = Math.max(last[1], to);\n\t\telse out.push([from, to]);\n\t}\n\treturn out;\n}\n\n/** Whether a clip plays through a speed ramp. */\nconst isRamped = (clip: VideoClip): boolean => clip.type === 'media' && !!clip.timeRemap;\n\n/** Every keyframeable value of a clip, with a way to read and replace it. */\nfunction clipAnimatables(clip: VideoClip): { get: () => VideoAnimatable | undefined; set: (v: VideoAnimatable | undefined) => void }[] {\n\tconst c = clip as VideoClip & { volume?: VideoAnimatable };\n\tconst out = [\n\t\t{ get: () => c.opacity, set: (v: VideoAnimatable | undefined) => (c.opacity = v) },\n\t\t{ get: () => c.volume, set: (v: VideoAnimatable | undefined) => (c.volume = v) },\n\t\t// A speed ramp is keys in clip time too, so trims and splits keep it on the same frames of source.\n\t\t{ get: () => (c as { timeRemap?: VideoAnimatable }).timeRemap, set: (v: VideoAnimatable | undefined) => ((c as { timeRemap?: VideoAnimatable }).timeRemap = v) }\n\t];\n\tfor (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => c.transform?.[p], set: (v) => c.transform && (c.transform[p] = v) });\n\treturn out;\n}\n\n/** Applies `fn` to every keyframed value of a clip (plain numbers are left alone). */\nfunction retimeClipKeys(clip: VideoClip, fn: (v: VideoAnimatable) => VideoAnimatable) {\n\tfor (const a of clipAnimatables(clip)) {\n\t\tconst v = a.get();\n\t\tif (isVideoKeyframes(v)) a.set(fn(v));\n\t}\n}\n\nconst OPTIONAL_CLIP_FIELDS = ['name', 'opacity', 'transform', 'fit', 'blend', 'grade', 'animateIn', 'animateOut', 'fadeIn', 'fadeOut', 'fadeInCurve', 'fadeOutCurve', 'transition', 'in', 'speed', 'timeRemap', 'volume', 'muted', 'color', 'fontSize', 'fontFamily', 'fontWeight', 'background'];\n\n/** Times the new range and nudge ops require, checked before anything changes. */\nconst TIME_FIELDS: Partial<Record<VideoEditOp, string[]>> = { slip_clip: ['delta'], roll_edit: ['delta'], slide_clip: ['delta'], lift_range: ['from', 'to'], extract_range: ['from', 'to'], split_at: ['at'] };\n\n/** Applies edits to a mutable copy of a project. */\nclass Editor {\n\treadonly outcomes: VideoEditOutcome[] = [];\n\tconstructor(public p: VideoProject) {}\n\n\ttrack(id: string): VideoTrack {\n\t\tconst t = this.p.tracks.find((x) => x.id === id);\n\t\tif (!t) refuse(`There is no track ${namedWithSuggestion(id, this.p.tracks.map((x) => x.id))}; the tracks are ${this.p.tracks.map((x) => `\"${x.id}\" (${x.kind})`).join(', ') || 'none'}.`);\n\t\treturn t!;\n\t}\n\n\tclip(id: string) {\n\t\tconst at = findVideoClip(this.p, id);\n\t\tconst nested = at ? null : findVideoSequenceClip(this.p, id);\n\t\tif (nested) refuse(`Clip \"${id}\" is inside sequence \"${nested.sequence.id}\", and clips inside sequences cannot be edited yet; edit the compound clip that plays the sequence instead (list_video_sequences shows which).`);\n\t\tif (!at) {\n\t\t\tconst ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));\n\t\t\trefuse(`There is no clip ${namedWithSuggestion(id, ids)}${ids.length ? '' : '; the project has no clips yet'}.`);\n\t\t}\n\t\treturn at!;\n\t}\n\n\tunlocked(track: VideoTrack) {\n\t\tif (track.locked) refuse(`Track \"${track.id}\" is locked; unlock it (update_track with locked false) first.`);\n\t}\n\n\tasset(id: string): VideoAsset {\n\t\tconst a = this.p.assets.find((x) => x.id === id);\n\t\tif (!a) refuse(`There is no asset ${namedWithSuggestion(id, this.p.assets.map((x) => x.id))}; add it first (the assets are ${this.p.assets.map((x) => `\"${x.id}\"`).join(', ') || 'none yet'}).`);\n\t\treturn a!;\n\t}\n\n\tfits(track: VideoTrack, start: number, duration: number, ignore: Set<string>, what: string) {\n\t\tconst hit = videoOverlap(track, start, start + duration, ignore);\n\t\tif (hit) {\n\t\t\tconst free = nextFreeVideoSlot(track, start, duration, ignore);\n\t\t\trefuse(`${what} at ${formatVideoSeconds(start)}–${formatVideoSeconds(start + duration)} would overlap clip \"${hit.id}\" (${formatVideoSeconds(hit.start)}–${formatVideoSeconds(videoClipEnd(hit))}) on track \"${track.id}\"; the next free place is ${formatVideoSeconds(free)}, or use mode \"insert\" to push later clips along or \"overwrite\" to replace what is there.`);\n\t\t}\n\t}\n\n\tkindFits(track: VideoTrack, clip: { type: string; asset?: string; sequence?: string }) {\n\t\tconst asset = clip.type === 'media' ? this.asset(clip.asset!) : null;\n\t\tif (clip.type === 'sequence') {\n\t\t\tconst ids = (this.p.sequences ?? []).map((q) => q.id);\n\t\t\tif (typeof clip.sequence !== 'string' || !ids.includes(clip.sequence)) refuse(`There is no sequence ${namedWithSuggestion(String(clip.sequence), ids)}; the sequences are ${ids.map((q) => `\"${q}\"`).join(', ') || 'none'}.`);\n\t\t\t// On an audio track a compound plays only its sound.\n\t\t\treturn;\n\t\t}\n\t\tif (track.kind === 'audio' && (clip.type !== 'media' || asset!.kind === 'image')) refuse(`Track \"${track.id}\" is an audio track; ${clip.type === 'media' ? 'images' : `${clip.type} clips`} go on a video track.`);\n\t\tif (track.kind === 'video' && asset?.kind === 'audio') refuse(`Track \"${track.id}\" is a video track; audio asset \"${asset.id}\" goes on an audio track.`);\n\t}\n\n\t/** Moves every clip on `track` starting at or after `from` by `delta`. */\n\tshift(track: VideoTrack, from: number, delta: number, except: ReadonlySet<string> = new Set()) {\n\t\tif (!delta) return;\n\t\tfor (const c of track.clips) if (!except.has(c.id) && c.start >= from - VIDEO_TIME_EPSILON) c.start = Math.max(0, c.start + delta);\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t}\n\n\tget frame() {\n\t\treturn 1000 / this.p.fps;\n\t}\n\n\tids() {\n\t\treturn allVideoClipIds(this.p);\n\t}\n\n\t/**\n\t * The tracks a ripple on `sources` moves, top to bottom: the sources, and every unlocked\n\t * sync-locked track when one of the sources is sync-locked.\n\t */\n\trippleTracks(sources: Iterable<VideoTrack>): VideoTrack[] {\n\t\tconst set = new Set(sources);\n\t\tif ([...set].some(videoTrackSyncLocked)) for (const t of this.p.tracks) if (videoTrackSyncLocked(t) && !t.locked) set.add(t);\n\t\treturn this.p.tracks.filter((t) => set.has(t));\n\t}\n\n\t/** The tracks a range edit works on: the named ones (unlocked), or every unlocked track. */\n\trangeTracks(ids: readonly string[] | undefined): VideoTrack[] {\n\t\tif (ids) {\n\t\t\tif (!ids.length) refuse('\"tracks\" is empty; name at least one track, or leave it out for every unlocked track.');\n\t\t\tconst tracks = [...new Set(ids)].map((id) => this.track(id));\n\t\t\ttracks.forEach((t) => this.unlocked(t));\n\t\t\treturn this.p.tracks.filter((t) => tracks.includes(t));\n\t\t}\n\t\tconst open = this.p.tracks.filter((t) => !t.locked);\n\t\tif (!open.length) refuse('Every track is locked; unlock one (update_track with locked false) first.');\n\t\treturn open;\n\t}\n\n\trange(from: number, to: number) {\n\t\tif (!(to - from > VIDEO_TIME_EPSILON)) refuse(`\"to\" (${formatVideoSeconds(to)}) must be after \"from\" (${formatVideoSeconds(from)}).`);\n\t}\n\n\t/** Moves a media or compound clip's in point by `delta` of timeline time (stills, titles and colours have none). */\n\tmoveIn(clip: VideoClip, delta: number) {\n\t\tif (clip.type === 'sequence') {\n\t\t\tclip.in = Math.max(0, (clip.in ?? 0) + delta);\n\t\t\tif (clip.in < 1e-9) delete clip.in;\n\t\t\treturn;\n\t\t}\n\t\tif (clip.type !== 'media' || videoAssetMap(this.p).get(clip.asset)?.kind === 'image') return;\n\t\tclip.in = Math.max(0, (clip.in ?? 0) + delta * videoClipSpeed(clip));\n\t\tif (clip.in < 1e-9) delete clip.in;\n\t}\n\n\t/** Moves a clip's head `delta` later in time (start, in point and keys); the caller sets start and duration. */\n\ttrimHead(clip: VideoClip, delta: number) {\n\t\tif (!isRamped(clip)) this.moveIn(clip, delta);\n\t\t// Keys stay on the picture they were set on, so they move by the trim.\n\t\tretimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));\n\t}\n\n\t/** Cuts `clip` at `at` (inside it): the left part keeps the id, the right part is returned. */\n\tcut(track: VideoTrack, clip: VideoClip, at: number, id?: string): VideoClip {\n\t\tconst end = videoClipEnd(clip);\n\t\tconst right: any = { ...structuredClone(clip), id: id ?? freshId(`${clip.id}-`, this.ids()), start: at, duration: end - at };\n\t\tdelete right.transition;\n\t\tdelete right.fadeIn;\n\t\tdelete right.fadeInCurve;\n\t\tdelete right.animateIn;\n\t\t// Each part keeps the keys on its side, with a key at the cut so both play as the whole did.\n\t\tconst leftValues = clipAnimatables(clip);\n\t\tclipAnimatables(right).forEach((r, i) => {\n\t\t\tconst [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);\n\t\t\tif (isVideoKeyframes(l)) leftValues[i].set(l);\n\t\t\tif (isVideoKeyframes(rr)) r.set(rr);\n\t\t});\n\t\tif (!isRamped(right)) this.moveIn(right, at - clip.start);\n\t\tclip.duration = at - clip.start;\n\t\tdelete clip.fadeOut;\n\t\tdelete clip.animateOut;\n\t\tif (clip.type === 'media' || clip.type === 'sequence') delete clip.fadeOutCurve;\n\t\ttrack.clips.push(right);\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t\treturn right;\n\t}\n\n\t/** Cuts every clip on `tracks` that runs across `at`; returns the new right-hand parts. */\n\tcutAcross(tracks: readonly VideoTrack[], at: number): VideoClip[] {\n\t\tconst made: VideoClip[] = [];\n\t\tfor (const t of tracks) for (const c of [...t.clips]) if (c.start < at - VIDEO_TIME_EPSILON && videoClipEnd(c) > at + VIDEO_TIME_EPSILON) made.push(this.cut(t, c, at));\n\t\treturn made;\n\t}\n\n\t/**\n\t * Opens `amount` of empty time at `at` on `tracks`: clips across `at` are cut there and everything\n\t * from `at` on moves later. Returns the clips made by cutting.\n\t */\n\topen(tracks: readonly VideoTrack[], at: number, amount: number, except: ReadonlySet<string> = new Set()): string[] {\n\t\tconst made = this.cutAcross(tracks, at).map((c) => c.id);\n\t\tfor (const t of tracks) this.shift(t, at, amount, except);\n\t\treturn made;\n\t}\n\n\t/**\n\t * Closes `[from, to)` on `tracks`, pulling everything after it earlier. Refuses, before moving\n\t * anything, when a track still has a clip in that stretch (it would be pulled out of sync).\n\t */\n\tclose(tracks: readonly VideoTrack[], from: number, to: number) {\n\t\tfor (const t of tracks) {\n\t\t\tconst hit = videoOverlap(t, from, to);\n\t\t\tif (hit)\n\t\t\t\trefuse(\n\t\t\t\t\t`Rippling out ${formatVideoSeconds(from)}–${formatVideoSeconds(to)} would pull track \"${t.id}\" out of sync: clip \"${hit.id}\" (${formatVideoSeconds(hit.start)}–${formatVideoSeconds(videoClipEnd(hit))}) is in that stretch. Extract the range across \"${t.id}\" too (extract_range with its id in \"tracks\"), turn its sync lock off (update_track with syncLocked false) or lock it, or lift instead of rippling.`\n\t\t\t\t);\n\t\t}\n\t\tfor (const t of tracks) this.shift(t, to, from - to);\n\t}\n\n\t/**\n\t * Empties `[from, to)` on one track, leaving a gap: clips inside go, clips across an edge are\n\t * trimmed back to it, and a clip across both is cut in two around the gap.\n\t */\n\tclear(track: VideoTrack, from: number, to: number, report: { removed: string[]; trimmed: string[]; created: string[] }) {\n\t\tfor (const c of [...track.clips]) {\n\t\t\tconst start = c.start;\n\t\t\tconst end = videoClipEnd(c);\n\t\t\tif (end <= from + VIDEO_TIME_EPSILON || start >= to - VIDEO_TIME_EPSILON) continue;\n\t\t\tconst coversHead = start >= from - VIDEO_TIME_EPSILON;\n\t\t\tconst coversTail = end <= to + VIDEO_TIME_EPSILON;\n\t\t\tif (coversHead && coversTail) {\n\t\t\t\ttrack.clips = track.clips.filter((x) => x !== c);\n\t\t\t\treport.removed.push(c.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!coversHead && !coversTail) report.created.push(this.cut(track, c, to).id);\n\t\t\telse if (coversHead) {\n\t\t\t\t// The head goes: start later in the source; a transition in has no cut left to play over.\n\t\t\t\tthis.trimHead(c, to - start);\n\t\t\t\tc.start = to;\n\t\t\t\tc.duration = end - to;\n\t\t\t\tdelete c.transition;\n\t\t\t\treport.trimmed.push(c.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tc.duration = from - start;\n\t\t\tif (!report.trimmed.includes(c.id)) report.trimmed.push(c.id);\n\t\t}\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t}\n\n\t/** Clamps a slip, roll or slide to `[lo, hi]`, refusing when it cannot move that way at all. */\n\tclamp(delta: number, lo: number, hi: number, why: () => string): number {\n\t\tconst applied = Math.min(Math.max(delta, Math.min(0, lo)), Math.max(0, hi));\n\t\tif (Math.abs(delta) > VIDEO_TIME_EPSILON && Math.abs(applied) <= VIDEO_TIME_EPSILON) refuse(why());\n\t\treturn Math.abs(applied) <= VIDEO_TIME_EPSILON ? 0 : applied;\n\t}\n\n\t/** Places a clip on a track by `mode`, returning what it cut, trimmed, removed and rippled. */\n\tplace(track: VideoTrack, clip: VideoClip, mode: VideoPlacementMode, outcome: VideoEditOutcome) {\n\t\tif (mode === 'insert') {\n\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\toutcome.created = this.open(tracks, clip.start, clip.duration);\n\t\t\toutcome.rippled = tracks.map((t) => t.id);\n\t\t} else if (mode === 'overwrite') {\n\t\t\tconst report = { removed: [] as string[], trimmed: [] as string[], created: [] as string[] };\n\t\t\tthis.clear(track, clip.start, videoClipEnd(clip), report);\n\t\t\tObject.assign(outcome, report);\n\t\t} else this.fits(track, clip.start, clip.duration, new Set([clip.id]), `Clip \"${clip.id}\"`);\n\t\ttrack.clips.push(clip);\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t}\n\n\tplacement(mode: unknown, ripple?: boolean): VideoPlacementMode {\n\t\tif (mode !== undefined && !VIDEO_PLACEMENT_MODES.includes(mode as VideoPlacementMode)) refuse(`\"mode\" must be ${VIDEO_PLACEMENT_MODES.map((m) => `\"${m}\"`).join(', ')}.`);\n\t\tif (ripple && mode !== undefined && mode !== 'insert') refuse(`\"ripple\" means mode \"insert\", so it cannot go with mode \"${mode}\"; pass one or the other.`);\n\t\treturn (mode as VideoPlacementMode | undefined) ?? (ripple ? 'insert' : 'refuse');\n\t}\n\n\trun(e: VideoEdit) {\n\t\tfor (const k of TIME_FIELDS[e.op] ?? []) {\n\t\t\tconst v = (e as Record<string, unknown>)[k];\n\t\t\tif (typeof v !== 'number' || !Number.isFinite(v)) refuse(`\"${k}\" must be a number of milliseconds.`);\n\t\t\tif (k !== 'delta' && (v as number) < 0) refuse(`\"${k}\" must be a time of at least 0.`);\n\t\t}\n\t\tswitch (e.op) {\n\t\t\tcase 'set_settings': {\n\t\t\t\tconst { op: _op, ...settings } = e;\n\t\t\t\tfor (const [k, v] of Object.entries(settings)) {\n\t\t\t\t\tif (!['name', 'width', 'height', 'fps', 'background'].includes(k)) refuse(`\"${k}\" is not a setting; use name, width, height, fps or background.`);\n\t\t\t\t\tif (v !== undefined) (this.p as any)[k] = v;\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op });\n\t\t\t}\n\t\t\tcase 'add_asset': {\n\t\t\t\tconst ids = this.p.assets.map((a) => a.id);\n\t\t\t\tif (e.asset.id !== undefined && ids.includes(e.asset.id)) refuse(`There is already an asset \"${e.asset.id}\"; pick another id or use update_asset.`);\n\t\t\t\tconst base = slug(e.asset.name ?? e.asset.src ?? '') || e.asset.kind || 'asset';\n\t\t\t\tconst unique = e.asset.id ?? (ids.includes(base) ? freshId(`${base}-`, ids) : base);\n\t\t\t\tthis.p.assets.push(parseVideoAsset({ ...e.asset, id: unique }));\n\t\t\t\treturn this.outcomes.push({ op: e.op, asset: unique });\n\t\t\t}\n\t\t\tcase 'update_asset': {\n\t\t\t\tconst asset = this.asset(e.asset);\n\t\t\t\tif (e.set && 'id' in e.set) refuse('An asset\\'s id cannot be changed.');\n\t\t\t\tconst next: any = { ...asset, ...e.set };\n\t\t\t\tfor (const k of e.remove ?? []) delete next[k];\n\t\t\t\tthis.p.assets[this.p.assets.indexOf(asset)] = parseVideoAsset(next);\n\t\t\t\treturn this.outcomes.push({ op: e.op, asset: asset.id });\n\t\t\t}\n\t\t\tcase 'remove_asset': {\n\t\t\t\tconst asset = this.asset(e.asset);\n\t\t\t\tconst users = allVideoTracks(this.p).flatMap(({ track: t }) => t.clips.filter((c) => c.type === 'media' && c.asset === asset.id).map((c) => c.id));\n\t\t\t\tif (users.length) refuse(`Asset \"${asset.id}\" is used by clip${users.length > 1 ? 's' : ''} ${users.map((u) => `\"${u}\"`).join(', ')}; delete ${users.length > 1 ? 'them' : 'it'} first.`);\n\t\t\t\tthis.p.assets = this.p.assets.filter((a) => a !== asset);\n\t\t\t\treturn this.outcomes.push({ op: e.op, asset: asset.id });\n\t\t\t}\n\t\t\tcase 'add_track': {\n\t\t\t\tconst ids = this.p.tracks.map((t) => t.id);\n\t\t\t\tif (e.track.id && ids.includes(e.track.id)) refuse(`There is already a track \"${e.track.id}\".`);\n\t\t\t\tconst id = e.track.id ?? freshId(e.track.kind === 'video' ? 'v' : 'a', ids);\n\t\t\t\tconst n = this.p.tracks.filter((t) => t.kind === e.track.kind).length + 1;\n\t\t\t\tconst track: VideoTrack = { name: `${e.track.kind === 'video' ? 'Video' : 'Audio'} ${n}`, ...e.track, id, clips: [] };\n\t\t\t\t// By default a video track goes above the other video tracks, an audio track below everything.\n\t\t\t\tconst at = e.at ?? (e.track.kind === 'video' ? 0 : this.p.tracks.length);\n\t\t\t\tif (at > this.p.tracks.length) refuse(`\"at\" is ${at} but there are only ${this.p.tracks.length} tracks; use 0–${this.p.tracks.length}.`);\n\t\t\t\tthis.p.tracks.splice(at, 0, track);\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: id });\n\t\t\t}\n\t\t\tcase 'update_track': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tfor (const k of Object.keys(e.set ?? {})) if (!TRACK_FIELD_KEYS.includes(k)) refuse(`\"${k}\" cannot be set on a track; use ${TRACK_FIELD_KEYS.join(', ')}.`);\n\t\t\t\tObject.assign(track, e.set);\n\t\t\t\tfor (const k of e.remove ?? []) delete (track as any)[k];\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id });\n\t\t\t}\n\t\t\tcase 'remove_track': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tthis.p.tracks = this.p.tracks.filter((t) => t !== track);\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id, clips: track.clips.map((c) => c.id) });\n\t\t\t}\n\t\t\tcase 'move_track': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tif (e.to < 0 || e.to >= this.p.tracks.length) refuse(`\"to\" must be 0–${this.p.tracks.length - 1}.`);\n\t\t\t\tthis.p.tracks.splice(this.p.tracks.indexOf(track), 1);\n\t\t\t\tthis.p.tracks.splice(e.to, 0, track);\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id });\n\t\t\t}\n\t\t\tcase 'add_clip': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst mode = this.placement(e.mode, e.ripple);\n\t\t\t\tconst draft: any = { ...e.clip, type: e.clip.type ?? 'media' };\n\t\t\t\tthis.kindFits(track, draft);\n\t\t\t\tconst ids = this.ids();\n\t\t\t\tif (draft.id !== undefined && ids.includes(draft.id)) refuse(`There is already a clip \"${draft.id}\".`);\n\t\t\t\tdraft.id ??= freshId('c', ids);\n\t\t\t\tif (draft.duration === undefined) {\n\t\t\t\t\tif (draft.type === 'media') {\n\t\t\t\t\t\tconst asset = this.asset(draft.asset);\n\t\t\t\t\t\tif (asset.kind === 'image') draft.duration = VIDEO_DEFAULT_STILL_DURATION;\n\t\t\t\t\t\telse if (asset.duration === undefined) refuse(`Asset \"${asset.id}\" has no duration, so give the clip a \"duration\".`);\n\t\t\t\t\t\telse draft.duration = (asset.duration - (draft.in ?? 0)) / (draft.speed ?? 1);\n\t\t\t\t\t\tif (!(draft.duration > 0)) refuse(`\"in\" (${draft.in} ms) is at or past the end of \"${asset.id}\" (${asset.duration} ms).`);\n\t\t\t\t\t} else if (draft.type === 'sequence') {\n\t\t\t\t\t\tconst length = videoSequenceDuration(videoSequenceMap(this.p).get(draft.sequence)!) - (draft.in ?? 0);\n\t\t\t\t\t\tif (!(length > 0)) refuse(`\"in\" (${draft.in ?? 0} ms) is at or past the end of sequence \"${draft.sequence}\".`);\n\t\t\t\t\t\tdraft.duration = length;\n\t\t\t\t\t} else draft.duration = VIDEO_DEFAULT_TITLE_DURATION;\n\t\t\t\t}\n\t\t\t\tconst end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);\n\t\t\t\tdraft.start ??= end;\n\t\t\t\tconst clip = parseVideoClip(draft, 'clip');\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, clip: clip.id, track: track.id };\n\t\t\t\tthis.place(track, clip, mode, outcome);\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'move_clip': {\n\t\t\t\tconst from = this.clip(e.clip);\n\t\t\t\tthis.unlocked(from.track);\n\t\t\t\tconst to = e.track !== undefined ? this.track(e.track) : from.track;\n\t\t\t\tthis.unlocked(to);\n\t\t\t\tif (to !== from.track) this.kindFits(to, from.clip);\n\t\t\t\tconst mode = this.placement(e.mode);\n\t\t\t\tconst start = e.start ?? from.clip.start;\n\t\t\t\tif (start < 0) refuse('A clip cannot start before 0.');\n\t\t\t\t// Lift it out (leaving a gap where it was), then place it as if it were new.\n\t\t\t\tfrom.track.clips.splice(from.index, 1);\n\t\t\t\tfrom.clip.start = start;\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, clip: from.clip.id, track: to.id };\n\t\t\t\tthis.place(to, from.clip, mode, outcome);\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'trim_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst speed = videoClipSpeed(clip);\n\t\t\t\tconst end = videoClipEnd(clip);\n\t\t\t\tconst minimum = 1000 / this.p.fps;\n\t\t\t\tconst assets = videoAssetMap(this.p);\n\t\t\t\tif (e.edge === 'start') {\n\t\t\t\t\tconst delta = e.to - clip.start;\n\t\t\t\t\tif (clip.duration - delta < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the start to ${formatVideoSeconds(e.to)} would leave \"${clip.id}\" shorter than a frame; it ends at ${formatVideoSeconds(end)}.`);\n\t\t\t\t\t// A ramp's keys carry its source: trimming moves them (and holds the first frame past them).\n\t\t\t\t\tif ((clip.type === 'media' && !isRamped(clip)) || clip.type === 'sequence') {\n\t\t\t\t\t\tconst sourceIn = (clip.in ?? 0) + delta * speed;\n\t\t\t\t\t\tif (sourceIn < -VIDEO_TIME_EPSILON) refuse(`\"${clip.id}\" starts ${Math.round((clip.in ?? 0) / speed)} ms into its source, so its start can move at most that far earlier (to ${formatVideoSeconds(clip.start - (clip.in ?? 0) / speed)}).`);\n\t\t\t\t\t\tclip.in = Math.max(0, sourceIn);\n\t\t\t\t\t\tif (clip.in === 0) delete clip.in;\n\t\t\t\t\t}\n\t\t\t\t\t// Keys stay on the picture they were set on, so they move by the trim.\n\t\t\t\t\tretimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));\n\t\t\t\t\tif (e.ripple) {\n\t\t\t\t\t\t// Ripple at the edit point: the head comes out (or goes in) there, on every sync-locked track.\n\t\t\t\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\t\t\t\tif (delta > 0) {\n\t\t\t\t\t\t\tclip.start += delta;\n\t\t\t\t\t\t\tclip.duration -= delta;\n\t\t\t\t\t\t\tthis.close(tracks, clip.start - delta, clip.start);\n\t\t\t\t\t\t} else if (delta < 0) {\n\t\t\t\t\t\t\tconst created = this.open(tracks, clip.start, -delta, new Set([clip.id]));\n\t\t\t\t\t\t\tclip.duration -= delta;\n\t\t\t\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id), ...(created.length ? { created } : {}) });\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id) });\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (e.to < 0) refuse('A clip cannot start before 0.');\n\t\t\t\t\t\tif (delta < 0) this.fits(track, e.to, -delta, new Set([clip.id]), `Extending \"${clip.id}\"`);\n\t\t\t\t\t\tclip.start = e.to;\n\t\t\t\t\t\tclip.duration -= delta;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst duration = e.to - clip.start;\n\t\t\t\t\tif (duration < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the end to ${formatVideoSeconds(e.to)} would leave \"${clip.id}\" shorter than a frame; it starts at ${formatVideoSeconds(clip.start)}.`);\n\t\t\t\t\tif (clip.type === 'media' && !isRamped(clip)) {\n\t\t\t\t\t\tconst asset = assets.get(clip.asset);\n\t\t\t\t\t\tconst out = (clip.in ?? 0) + duration * speed;\n\t\t\t\t\t\tif (asset?.duration !== undefined && out > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\t\trefuse(`\"${clip.id}\" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where \"${asset.id}\" runs out.`);\n\t\t\t\t\t}\n\t\t\t\t\tif (clip.type === 'sequence') {\n\t\t\t\t\t\tconst seq = videoSequenceMap(this.p).get(clip.sequence);\n\t\t\t\t\t\tconst length = seq ? videoSequenceDuration(seq) : Infinity;\n\t\t\t\t\t\tif ((clip.in ?? 0) + duration > length + VIDEO_TIME_EPSILON) refuse(`\"${clip.id}\" can end at most at ${formatVideoSeconds(clip.start + length - (clip.in ?? 0))}, where sequence \"${clip.sequence}\" ends.`);\n\t\t\t\t\t}\n\t\t\t\t\tif (e.ripple) {\n\t\t\t\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\t\t\t\tlet created: string[] = [];\n\t\t\t\t\t\tif (e.to < end) {\n\t\t\t\t\t\t\tclip.duration = duration;\n\t\t\t\t\t\t\tthis.close(tracks, e.to, end);\n\t\t\t\t\t\t} else if (e.to > end) {\n\t\t\t\t\t\t\tcreated = this.open(tracks, end, e.to - end, new Set([clip.id]));\n\t\t\t\t\t\t\tclip.duration = duration;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id), ...(created.length ? { created } : {}) });\n\t\t\t\t\t}\n\t\t\t\t\tif (e.to > end) this.fits(track, end, e.to - end, new Set([clip.id]), `Extending \"${clip.id}\"`);\n\t\t\t\t\tclip.duration = duration;\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'split_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst end = videoClipEnd(clip);\n\t\t\t\tconst minimum = 1000 / this.p.fps;\n\t\t\t\tif (e.at < clip.start + minimum - VIDEO_TIME_EPSILON || e.at > end - minimum + VIDEO_TIME_EPSILON) refuse(`${formatVideoSeconds(e.at)} is not inside \"${clip.id}\" (${formatVideoSeconds(clip.start)}–${formatVideoSeconds(end)}), at least a frame from either end.`);\n\t\t\t\tif (e.id !== undefined && this.ids().includes(e.id)) refuse(`There is already a clip \"${e.id}\".`);\n\t\t\t\tconst right = this.cut(track, clip, e.at, e.id);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: [clip.id, right.id], clip: right.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'delete_clips': {\n\t\t\t\tconst found = [...new Set(e.clips)].map((id) => this.clip(id));\n\t\t\t\tfor (const f of found) this.unlocked(f.track);\n\t\t\t\tfor (const track of new Set(found.map((f) => f.track))) track.clips = track.clips.filter((c) => !found.some((f) => f.clip === c));\n\t\t\t\tif (!e.ripple) return this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id) });\n\t\t\t\t// A track out of sync lock closes only its own gaps; the gaps of sync-locked tracks are\n\t\t\t\t// merged into spans closed on every sync-locked track, right to left so earlier spans stay put.\n\t\t\t\tconst rippled = new Set<VideoTrack>();\n\t\t\t\tconst groups = [...new Set(found.map((f) => f.track))].filter((t) => !videoTrackSyncLocked(t)).map((t) => [t]);\n\t\t\t\tif (found.some((f) => videoTrackSyncLocked(f.track))) groups.push(found.filter((f) => videoTrackSyncLocked(f.track)).map((f) => f.track));\n\t\t\t\tfor (const sources of groups) {\n\t\t\t\t\tconst tracks = this.rippleTracks(sources);\n\t\t\t\t\ttracks.forEach((t) => rippled.add(t));\n\t\t\t\t\tconst spans = mergeSpans(found.filter((f) => sources.includes(f.track)).map((f) => [f.clip.start, videoClipEnd(f.clip)]));\n\t\t\t\t\tfor (const [from, to] of spans.reverse()) this.close(tracks, from, to);\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id), rippled: this.p.tracks.filter((t) => rippled.has(t)).map((t) => t.id) });\n\t\t\t}\n\t\t\tcase 'update_clip': {\n\t\t\t\tconst at = this.clip(e.clip);\n\t\t\t\tthis.unlocked(at.track);\n\t\t\t\tconst set = { ...(e.set ?? {}) };\n\t\t\t\tfor (const k of ['id', 'type']) if (k in set) refuse(`A clip's ${k} cannot be changed; delete it and add a new one.`);\n\t\t\t\tfor (const k of e.remove ?? []) if (!OPTIONAL_CLIP_FIELDS.includes(k)) refuse(`\"${k}\" cannot be removed; removable fields are ${OPTIONAL_CLIP_FIELDS.join(', ')}.`);\n\t\t\t\t// A new speed keeps the stretch of source and changes the length, as in an NLE's rate stretch.\n\t\t\t\tif ('speed' in set && (typeof set.speed !== 'number' || !(set.speed > 0))) refuse('\"speed\" must be a number greater than 0 (1 = normal speed).');\n\t\t\t\tif ('speed' in set && isRamped(at.clip)) refuse(`Clip \"${at.clip.id}\" has a speed ramp, which sets its speed; change the ramp, or remove it first.`);\n\t\t\t\tif ('timeRemap' in set) refuse('Set a speed ramp with set_time_remap, which checks it against the media and moves later clips if asked.');\n\t\t\t\tlet stretch = 1;\n\t\t\t\tif (at.clip.type === 'media' && !('duration' in set)) {\n\t\t\t\t\tconst was = videoClipSpeed(at.clip);\n\t\t\t\t\tconst now = 'speed' in set ? (set.speed as number) : e.remove?.includes('speed') ? 1 : was;\n\t\t\t\t\tif (now !== was) {\n\t\t\t\t\t\tset.duration = (at.clip.duration * was) / now;\n\t\t\t\t\t\tstretch = was / now;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst next: any = { ...structuredClone(at.clip), ...set };\n\t\t\t\t// A rate stretch keeps keys on the same frames of source.\n\t\t\t\tif (stretch !== 1) retimeClipKeys(next, (v) => retimeVideoAnimatable(v, 0, stretch));\n\t\t\t\tfor (const k of e.remove ?? []) delete next[k];\n\t\t\t\tconst clip = parseVideoClip(next, 'set');\n\t\t\t\tif (clip.type === 'media' && clip.asset !== (at.clip as any).asset) this.kindFits(at.track, clip);\n\t\t\t\tif (clip.start !== at.clip.start || clip.duration !== at.clip.duration) this.fits(at.track, clip.start, clip.duration, new Set([clip.id]), `Clip \"${clip.id}\"`);\n\t\t\t\tat.track.clips[at.index] = clip;\n\t\t\t\tat.track.clips.sort((a, b) => a.start - b.start);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: at.track.id });\n\t\t\t}\n\t\t\tcase 'set_time_remap': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (clip.type === 'sequence') refuse(`Clip \"${clip.id}\" is a compound clip, which plays its sequence at normal speed; ramp the clips inside the sequence instead.`);\n\t\t\t\tif (clip.type !== 'media') refuse(`Clip \"${clip.id}\" is a ${clip.type === 'text' ? 'title' : 'colour'}, so it has no source to ramp; only video and audio clips change speed.`);\n\t\t\t\tconst asset = this.asset((clip as VideoMediaClip).asset);\n\t\t\t\tif (asset.kind === 'image') refuse(`Clip \"${clip.id}\" is a still (\"${asset.id}\"), so it has no motion to ramp.`);\n\t\t\t\tconst media = clip as VideoMediaClip;\n\t\t\t\tconst end = videoClipEnd(media);\n\t\t\t\tconst duration = e.duration ?? media.duration;\n\t\t\t\tif (!(duration >= 1000 / this.p.fps - VIDEO_TIME_EPSILON)) refuse(`\"duration\" must be at least a frame (${Math.ceil(1000 / this.p.fps)} ms).`);\n\t\t\t\tif (e.keys === null) {\n\t\t\t\t\tif (!media.timeRemap) refuse(`Clip \"${clip.id}\" has no speed ramp to remove.`);\n\t\t\t\t\t// Play on at normal speed from where the ramp started.\n\t\t\t\t\tconst from = (media.in ?? 0) + media.timeRemap!.keys[0].v;\n\t\t\t\t\tif (asset.duration !== undefined && from + duration > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\trefuse(`At normal speed from ${Math.round(from)} ms, \"${clip.id}\" would run past the end of \"${asset.id}\" (${asset.duration} ms); give a duration of at most ${Math.floor(asset.duration - from)} ms.`);\n\t\t\t\t\tdelete media.timeRemap;\n\t\t\t\t\tmedia.in = from;\n\t\t\t\t\tif (media.in < 1e-9) delete media.in;\n\t\t\t\t} else {\n\t\t\t\t\tif (!Array.isArray(e.keys) || !e.keys.length) refuse('\"keys\" must be a list of at least one { t, v, ease } (or null to remove the ramp).');\n\t\t\t\t\tconst remap = parseVideoTimeRemap({ keys: e.keys }, undefined, 'keys')!;\n\t\t\t\t\tremap.keys = sortVideoKeys(remap.keys);\n\t\t\t\t\tcheckVideoTimeRemap(remap, 'keys');\n\t\t\t\t\tconst span = videoTimeRemapSpan(remap);\n\t\t\t\t\tif (asset.duration !== undefined && (media.in ?? 0) + span.to > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\trefuse(`The ramp plays ${Math.round(span.to)} ms of source from the in point (${media.in ?? 0} ms), past the end of \"${asset.id}\" (${asset.duration} ms); it can use at most ${Math.floor(asset.duration - (media.in ?? 0))} ms.`);\n\t\t\t\t\tmedia.timeRemap = remap;\n\t\t\t\t\tdelete media.speed;\n\t\t\t\t}\n\t\t\t\tconst newEnd = media.start + duration;\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, clip: clip.id, track: track.id };\n\t\t\t\tif (Math.abs(newEnd - end) > VIDEO_TIME_EPSILON) {\n\t\t\t\t\tif (e.ripple) {\n\t\t\t\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\t\t\t\tif (newEnd < end) {\n\t\t\t\t\t\t\tmedia.duration = duration;\n\t\t\t\t\t\t\tthis.close(tracks, newEnd, end);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconst created = this.open(tracks, end, newEnd - end, new Set([clip.id]));\n\t\t\t\t\t\t\tmedia.duration = duration;\n\t\t\t\t\t\t\tif (created.length) outcome.created = created;\n\t\t\t\t\t\t}\n\t\t\t\t\t\toutcome.rippled = tracks.map((t) => t.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (newEnd > end) this.fits(track, end, newEnd - end, new Set([clip.id]), `Lengthening \"${clip.id}\"`);\n\t\t\t\t\t\tmedia.duration = duration;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'set_transition': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (e.transition === null) delete clip.transition;\n\t\t\t\telse clip.transition = parseVideoTransition(e.transition, 'transition');\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'slip_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (clip.type !== 'media' && clip.type !== 'sequence') refuse(`Clip \"${clip.id}\" is a ${clip.type === 'text' ? 'title' : 'colour'}, so it has no source to slip; only video, audio and compound clips slip.`);\n\t\t\t\tconst source = clip.type === 'sequence' ? `sequence \"${clip.sequence}\"` : `\"${this.asset((clip as { asset: string }).asset).id}\"`;\n\t\t\t\tif (clip.type === 'media' && this.asset(clip.asset).kind === 'image') refuse(`Clip \"${clip.id}\" is a still (\"${clip.asset}\"), so it has no source to slip; only video and audio clips slip.`);\n\t\t\t\t// Keep the media a transition at either edge plays past the cut.\n\t\t\t\tconst handles = videoClipHandles(clip, videoAssetMap(this.p), videoSequenceMap(this.p));\n\t\t\t\tconst { lead, tail } = videoClipOverhang(track, clip);\n\t\t\t\tconst delta = this.clamp(e.delta, lead - handles.before, handles.after - tail, () =>\n\t\t\t\t\te.delta > 0\n\t\t\t\t\t\t? `\"${clip.id}\" already plays to the end of ${source}${tail ? ` (less the ${tail} ms the transition after it needs)` : ''}, so it cannot slip later; slip it earlier (a negative delta).`\n\t\t\t\t\t\t: `\"${clip.id}\" already starts at the beginning of ${source}${lead ? ` (plus the ${lead} ms its transition needs)` : ''}, so it cannot slip earlier; slip it later (a positive delta).`\n\t\t\t\t);\n\t\t\t\tthis.moveIn(clip, delta);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, delta });\n\t\t\t}\n\t\t\tcase 'roll_edit': {\n\t\t\t\tconst left = this.clip(e.left);\n\t\t\t\tconst right = this.clip(e.right);\n\t\t\t\tif (left.track !== right.track) refuse(`\"${e.left}\" is on track \"${left.track.id}\" and \"${e.right}\" on \"${right.track.id}\"; a roll moves the cut between two clips on one track.`);\n\t\t\t\tconst track = left.track;\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst L = left.clip;\n\t\t\t\tconst R = right.clip;\n\t\t\t\tif (Math.abs(videoClipEnd(L) - R.start) > VIDEO_TIME_EPSILON) {\n\t\t\t\t\tif (Math.abs(videoClipEnd(R) - L.start) <= VIDEO_TIME_EPSILON) refuse(`\"${R.id}\" comes before \"${L.id}\"; pass left \"${R.id}\" and right \"${L.id}\".`);\n\t\t\t\t\trefuse(`\"${L.id}\" ends at ${formatVideoSeconds(videoClipEnd(L))} but \"${R.id}\" starts at ${formatVideoSeconds(R.start)}; a roll needs two clips that meet at a cut (use trim_clip to move one edge).`);\n\t\t\t\t}\n\t\t\t\tconst assets = videoAssetMap(this.p);\n\t\t\t\tconst lo = Math.max(-(L.duration - this.frame), -(videoClipHandles(R, assets, videoSequenceMap(this.p)).before - videoClipOverhang(track, R).lead));\n\t\t\t\tconst hi = Math.min(R.duration - this.frame, videoClipHandles(L, assets, videoSequenceMap(this.p)).after - videoClipOverhang(track, L).tail);\n\t\t\t\tconst delta = this.clamp(e.delta, lo, hi, () =>\n\t\t\t\t\te.delta > 0\n\t\t\t\t\t\t? `The cut cannot move later: ${R.duration - this.frame < 1 ? `\"${R.id}\" is already a frame long` : `\"${L.id}\" has no media after its out point`}. Roll it earlier (a negative delta).`\n\t\t\t\t\t\t: `The cut cannot move earlier: ${L.duration - this.frame < 1 ? `\"${L.id}\" is already a frame long` : `\"${R.id}\" has no media before its in point`}. Roll it later (a positive delta).`\n\t\t\t\t);\n\t\t\t\tL.duration += delta;\n\t\t\t\tR.start += delta;\n\t\t\t\tR.duration -= delta;\n\t\t\t\tthis.trimHead(R, delta);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: R.id, clips: [L.id, R.id], track: track.id, delta });\n\t\t\t}\n\t\t\tcase 'slide_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst before = videoClipBefore(track, clip);\n\t\t\t\tconst after = videoClipAfter(track, clip);\n\t\t\t\tif (!before && !after) refuse(`\"${clip.id}\" has no clip right before or after it on track \"${track.id}\", so there is nothing to slide against; use move_clip.`);\n\t\t\t\tconst assets = videoAssetMap(this.p);\n\t\t\t\tconst end = videoClipEnd(clip);\n\t\t\t\tconst others = track.clips.filter((c) => c !== clip);\n\t\t\t\t// Without a neighbour on one side the clip moves through the gap there, up to the next clip.\n\t\t\t\tconst room = {\n\t\t\t\t\tearlier: before ? before.duration - this.frame : clip.start - others.filter((c) => videoClipEnd(c) <= clip.start + VIDEO_TIME_EPSILON).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0),\n\t\t\t\t\tlater: after ? after.duration - this.frame : others.filter((c) => c.start >= end - VIDEO_TIME_EPSILON).reduce((m, c) => Math.min(m, c.start - end), Infinity)\n\t\t\t\t};\n\t\t\t\tconst lo = -Math.min(room.earlier, after ? videoClipHandles(after, assets, videoSequenceMap(this.p)).before - videoClipOverhang(track, after).lead : Infinity);\n\t\t\t\tconst hi = Math.min(room.later, before ? videoClipHandles(before, assets, videoSequenceMap(this.p)).after - videoClipOverhang(track, before).tail : Infinity);\n\t\t\t\tconst delta = this.clamp(e.delta, lo, hi, () =>\n\t\t\t\t\te.delta > 0\n\t\t\t\t\t\t? `\"${clip.id}\" cannot slide later: ${room.later < 1 ? (after ? `\"${after.id}\" is already a frame long` : 'the next clip is right after it') : `\"${before!.id}\" has no media after its out point to fill in behind it`}. Slide it earlier (a negative delta).`\n\t\t\t\t\t\t: `\"${clip.id}\" cannot slide earlier: ${room.earlier < 1 ? (before ? `\"${before.id}\" is already a frame long` : 'it is against the clip before it or the start') : `\"${after!.id}\" has no media before its in point to fill in behind it`}. Slide it later (a positive delta).`\n\t\t\t\t);\n\t\t\t\tclip.start += delta;\n\t\t\t\tif (before) before.duration += delta;\n\t\t\t\tif (after) {\n\t\t\t\t\tafter.start += delta;\n\t\t\t\t\tafter.duration -= delta;\n\t\t\t\t\tthis.trimHead(after, delta);\n\t\t\t\t}\n\t\t\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, clips: [before?.id, clip.id, after?.id].filter((x): x is string => !!x), track: track.id, delta });\n\t\t\t}\n\t\t\tcase 'lift_range':\n\t\t\tcase 'extract_range': {\n\t\t\t\tthis.range(e.from, e.to);\n\t\t\t\tconst tracks = this.rangeTracks(e.tracks);\n\t\t\t\tconst report = { removed: [] as string[], trimmed: [] as string[], created: [] as string[] };\n\t\t\t\tfor (const t of tracks) this.clear(t, e.from, e.to, report);\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, ...report };\n\t\t\t\tif (e.op === 'extract_range') {\n\t\t\t\t\tconst rippled = this.rippleTracks(tracks);\n\t\t\t\t\tthis.close(rippled, e.from, e.to);\n\t\t\t\t\toutcome.rippled = rippled.map((t) => t.id);\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'split_at': {\n\t\t\t\tconst tracks = this.rangeTracks(e.tracks);\n\t\t\t\tconst lefts = tracks.flatMap((t) => t.clips.filter((c) => c.start < e.at - VIDEO_TIME_EPSILON && videoClipEnd(c) > e.at + VIDEO_TIME_EPSILON).map((c) => c.id));\n\t\t\t\tif (!lefts.length) refuse(`No clip runs across ${formatVideoSeconds(e.at)} on ${e.tracks ? `track${tracks.length > 1 ? 's' : ''} ${tracks.map((t) => `\"${t.id}\"`).join(', ')}` : 'the unlocked tracks'}, so there is nothing to split there.`);\n\t\t\t\tconst created = this.cutAcross(tracks, e.at).map((c) => c.id);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: lefts, created });\n\t\t\t}\n\t\t\tcase 'set_keyframes': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`\"${e.property}\" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(', ')}.`);\n\t\t\t\tif (e.property === 'volume' && clip.type !== 'media' && clip.type !== 'sequence') refuse(`Clip \"${clip.id}\" is a ${clip.type} clip and has no volume; volume keys go on media and compound clips.`);\n\t\t\t\tconst limits = VIDEO_KEYFRAME_LIMITS[e.property];\n\t\t\t\tconst transform = (VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(e.property);\n\t\t\t\tconst c = clip as any;\n\t\t\t\tconst current: VideoAnimatable | undefined = transform ? c.transform?.[e.property] : c[e.property];\n\t\t\t\tlet value: VideoAnimatable | undefined;\n\t\t\t\tif (e.keys === null) {\n\t\t\t\t\t// Stop animating: keep what it shows at the clip's start.\n\t\t\t\t\tconst v = videoValueAt(current, 0, limits.default);\n\t\t\t\t\tvalue = isVideoKeyframes(current) ? (v === limits.default ? undefined : v) : current;\n\t\t\t\t} else {\n\t\t\t\t\tif (!Array.isArray(e.keys) || !e.keys.length) refuse('\"keys\" must list at least one key { t, v }; pass null to remove the keyframes.');\n\t\t\t\t\tlet sorted: VideoKey[] = [];\n\t\t\t\t\ttry {\n\t\t\t\t\t\tsorted = sortVideoKeys(e.keys);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\trefuse((error as Error).message);\n\t\t\t\t\t}\n\t\t\t\t\tvalue = parseVideoAnimatable({ keys: sorted }, e.property, limits);\n\t\t\t\t}\n\t\t\t\tif (transform) {\n\t\t\t\t\tc.transform = { ...c.transform, [e.property]: value };\n\t\t\t\t\tif (value === undefined) delete c.transform[e.property];\n\t\t\t\t\tif (!Object.keys(c.transform).length) delete c.transform;\n\t\t\t\t} else if (value === undefined) delete c[e.property];\n\t\t\t\telse c[e.property] = value;\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'close_gaps': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tlet at = 0;\n\t\t\t\tconst moved: string[] = [];\n\t\t\t\tfor (const clip of sortedVideoClips(track)) {\n\t\t\t\t\tif (clip.start >= (e.from ?? 0) - VIDEO_TIME_EPSILON && clip.start > at + VIDEO_TIME_EPSILON) {\n\t\t\t\t\t\tclip.start = Math.max(at, e.from ?? 0);\n\t\t\t\t\t\tmoved.push(clip.id);\n\t\t\t\t\t}\n\t\t\t\t\tat = Math.max(at, videoClipEnd(clip));\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id, clips: moved });\n\t\t\t}\n\t\t\tcase 'add_marker': {\n\t\t\t\tconst markers = (this.p.markers ??= []);\n\t\t\t\tif (e.marker.id !== undefined && markers.some((m) => m.id === e.marker.id)) refuse(`There is already a marker \"${e.marker.id}\".`);\n\t\t\t\tconst marker = parseVideoMarker({ ...e.marker, id: e.marker.id ?? freshId('m', markers.map((m) => m.id)) });\n\t\t\t\tmarkers.push(marker);\n\t\t\t\tmarkers.sort((a, b) => a.time - b.time);\n\t\t\t\treturn this.outcomes.push({ op: e.op, marker: marker.id });\n\t\t\t}\n\t\t\tcase 'update_marker': {\n\t\t\t\tconst markers = this.p.markers ?? [];\n\t\t\t\tconst i = markers.findIndex((m) => m.id === e.marker);\n\t\t\t\tif (i < 0) refuse(`There is no marker ${namedWithSuggestion(e.marker, markers.map((m) => m.id))}.`);\n\t\t\t\tif (e.set && 'id' in e.set) refuse('A marker\\'s id cannot be changed.');\n\t\t\t\tconst next: any = { ...markers[i], ...e.set };\n\t\t\t\tfor (const k of e.remove ?? []) delete next[k];\n\t\t\t\tmarkers[i] = parseVideoMarker(next);\n\t\t\t\tmarkers.sort((a, b) => a.time - b.time);\n\t\t\t\treturn this.outcomes.push({ op: e.op, marker: e.marker });\n\t\t\t}\n\t\t\tcase 'remove_marker': {\n\t\t\t\tconst markers = this.p.markers ?? [];\n\t\t\t\tif (!markers.some((m) => m.id === e.marker)) refuse(`There is no marker ${namedWithSuggestion(e.marker, markers.map((m) => m.id))}.`);\n\t\t\t\tthis.p.markers = markers.filter((m) => m.id !== e.marker);\n\t\t\t\tif (!this.p.markers.length) delete this.p.markers;\n\t\t\t\treturn this.outcomes.push({ op: e.op, marker: e.marker });\n\t\t\t}\n\t\t\tcase 'replace_project': {\n\t\t\t\tthis.p = parseVideoProject(e.project);\n\t\t\t\treturn this.outcomes.push({ op: e.op });\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\trefuse(`Unknown op ${namedWithSuggestion(String((e as { op: unknown }).op), VIDEO_EDIT_OPS)}; the ops are ${VIDEO_EDIT_OPS.join(', ')}.`);\n\t\t}\n\t}\n}\n\n/**\n * Applies edits in order, all or nothing: the first that fails (an unknown id, an overlap, a trim\n * past the end of the source, a locked track) stops the lot and the project is unchanged. Edits\n * may not add errors (`checkVideoProject`); ones already there (from a loaded file) do not block.\n * With several edits, errors name the op (`op 2 (trim_clip): …`).\n */\nexport function applyVideoEdits(project: VideoProject, edits: VideoEdit | readonly VideoEdit[]): VideoEditResult {\n\tconst list = Array.isArray(edits) ? edits : [edits as VideoEdit];\n\tif (!list.length) return { ok: true, project, issues: checkVideoProject(project), outcomes: [] };\n\tconst editor = new Editor(structuredClone(project));\n\tfor (let i = 0; i < list.length; i++) {\n\t\tconst edit = list[i];\n\t\ttry {\n\t\t\teditor.run(edit);\n\t\t} catch (error) {\n\t\t\tconst message = (error as Error).message;\n\t\t\treturn { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${edit?.op}): ` : ''}${message}${/Nothing was changed\\.$/.test(message) ? '' : ' Nothing was changed.'}` };\n\t\t}\n\t}\n\tlet next: VideoProject;\n\ttry {\n\t\tnext = parseVideoProject(editor.p);\n\t} catch (error) {\n\t\treturn { ok: false, error: `${(error as Error).message} Nothing was changed.` };\n\t}\n\tconst before = new Set(checkVideoProject(project).filter((i) => i.severity === 'error').map(videoIssueKey));\n\tconst issues = checkVideoProject(next);\n\tconst added = issues.find((i) => i.severity === 'error' && !before.has(videoIssueKey(i)));\n\tif (added) return { ok: false, error: `That would leave an error: ${added.message} Nothing was changed.` };\n\tif (JSON.stringify(next) === JSON.stringify(project)) return { ok: true, project, issues, outcomes: editor.outcomes };\n\treturn { ok: true, project: next, issues, outcomes: editor.outcomes };\n}\n","import { videoClipSoundGain } from './evaluate.js';\nimport type { VideoAsset, VideoMediaClip } from './types.js';\n\n/**\n * Sound levels of a piece of audio, small enough to keep for every asset: a pyramid of min / max\n * sample values. Level 0 holds one min / max pair per `blockSize` samples; each level above merges\n * four blocks of the one below, so the min / max of any stretch costs a handful of reads at any\n * zoom. Channels are folded together (the loudest of them counts). Plain data: hosts can compute\n * it once (`buildVideoAudioPeaks`) and store it.\n */\nexport interface VideoAudioPeaks {\n\t/** Samples per second of the decoded audio. */\n\tsampleRate: number;\n\t/** Samples in the audio (per channel). */\n\tlength: number;\n\t/** Samples per level-0 block. */\n\tblockSize: number;\n\t/** `levels[k]` is `[min, max, min, max, …]`, one pair per block of `blockSize × 4^k` samples. */\n\tlevels: Float32Array[];\n}\n\n/** Samples per level-0 block (well under a millisecond at common rates). */\nexport const VIDEO_PEAK_BLOCK = 32;\n\n/** Builds the peak pyramid for decoded audio (one `Float32Array` of samples per channel). */\nexport function buildVideoAudioPeaks(channels: readonly Float32Array[], sampleRate: number, blockSize = VIDEO_PEAK_BLOCK): VideoAudioPeaks {\n\tif (!(sampleRate > 0)) throw new Error('The sample rate must be above 0.');\n\tconst length = channels.reduce((n, c) => Math.max(n, c.length), 0);\n\tconst blocks = Math.max(1, Math.ceil(length / blockSize));\n\tconst level0 = new Float32Array(blocks * 2);\n\tfor (let b = 0; b < blocks; b++) {\n\t\tlet lo = Infinity;\n\t\tlet hi = -Infinity;\n\t\tconst end = Math.min(length, (b + 1) * blockSize);\n\t\tfor (const channel of channels) {\n\t\t\tfor (let i = b * blockSize; i < Math.min(end, channel.length); i++) {\n\t\t\t\tconst v = channel[i];\n\t\t\t\tif (!Number.isFinite(v)) continue;\n\t\t\t\tif (v < lo) lo = v;\n\t\t\t\tif (v > hi) hi = v;\n\t\t\t}\n\t\t}\n\t\tlevel0[2 * b] = lo === Infinity ? 0 : lo;\n\t\tlevel0[2 * b + 1] = hi === -Infinity ? 0 : hi;\n\t}\n\tconst levels = [level0];\n\twhile (levels[levels.length - 1].length > 2) {\n\t\tconst below = levels[levels.length - 1];\n\t\tconst n = below.length / 2;\n\t\tconst up = new Float32Array(Math.ceil(n / 4) * 2);\n\t\tfor (let j = 0; j < up.length / 2; j++) {\n\t\t\tlet lo = Infinity;\n\t\t\tlet hi = -Infinity;\n\t\t\tfor (let i = 4 * j; i < Math.min(n, 4 * j + 4); i++) {\n\t\t\t\tlo = Math.min(lo, below[2 * i]);\n\t\t\t\thi = Math.max(hi, below[2 * i + 1]);\n\t\t\t}\n\t\t\tup[2 * j] = lo;\n\t\t\tup[2 * j + 1] = hi;\n\t\t}\n\t\tlevels.push(up);\n\t}\n\treturn { sampleRate, length, blockSize, levels };\n}\n\n/** Length of the audio (ms). */\nexport const videoAudioPeaksDuration = (peaks: VideoAudioPeaks): number => (peaks.length * 1000) / peaks.sampleRate;\n\n/**\n * The lowest and highest sample between two times (ms) in the audio, to the nearest block (so a\n * stretch can read up to a block wider). `{ min: 0, max: 0 }` outside the audio.\n */\nexport function videoAudioPeakRange(peaks: VideoAudioPeaks, from: number, to: number): { min: number; max: number } {\n\tconst blocks = peaks.levels[0].length / 2;\n\tlet a = Math.max(0, Math.floor((from * peaks.sampleRate) / 1000 / peaks.blockSize));\n\tlet b = Math.min(blocks, Math.ceil((to * peaks.sampleRate) / 1000 / peaks.blockSize));\n\tif (b <= a) {\n\t\t// Narrower than a block: the block it falls in.\n\t\tif (a >= blocks || to < 0) return { min: 0, max: 0 };\n\t\tb = a + 1;\n\t}\n\tlet min = Infinity;\n\tlet max = -Infinity;\n\tconst take = (level: Float32Array, i: number) => {\n\t\tif (2 * i + 1 >= level.length) return;\n\t\tif (level[2 * i] < min) min = level[2 * i];\n\t\tif (level[2 * i + 1] > max) max = level[2 * i + 1];\n\t};\n\t// Whole blocks at the edges of each level, then up a level for the aligned middle.\n\tfor (let k = 0; a < b; k++) {\n\t\tconst level = peaks.levels[k];\n\t\tif (k === peaks.levels.length - 1) {\n\t\t\tfor (let i = a; i < b; i++) take(level, i);\n\t\t\tbreak;\n\t\t}\n\t\twhile (a < b && a % 4) take(level, a++);\n\t\twhile (a < b && b % 4) take(level, --b);\n\t\ta /= 4;\n\t\tb /= 4;\n\t}\n\treturn min === Infinity ? { min: 0, max: 0 } : { min, max };\n}\n\n/** `columns` min / max pairs (`[min, max, …]`) evenly across `from`..`to` ms, e.g. one per pixel. */\nexport function videoAudioPeakSpans(peaks: VideoAudioPeaks, from: number, to: number, columns: number): Float32Array {\n\tconst n = Math.max(0, Math.floor(columns));\n\tconst out = new Float32Array(n * 2);\n\tconst step = (to - from) / Math.max(1, n);\n\tfor (let c = 0; c < n; c++) {\n\t\tconst { min, max } = videoAudioPeakRange(peaks, from + c * step, from + (c + 1) * step);\n\t\tout[2 * c] = min;\n\t\tout[2 * c + 1] = max;\n\t}\n\treturn out;\n}\n\n/**\n * What a media clip plays as `columns` min / max pairs across its length: each column reads the\n * stretch of source under it (through the in point and speed) and is scaled by the clip's volume and\n * fades, so the waveform shows what is heard.\n */\nexport function videoClipWaveform(clip: VideoMediaClip, peaks: VideoAudioPeaks, columns: number): Float32Array {\n\tconst n = Math.max(0, Math.floor(columns));\n\tconst out = new Float32Array(n * 2);\n\tconst speed = clip.speed ?? 1;\n\tconst start = clip.in ?? 0;\n\tconst step = clip.duration / Math.max(1, n);\n\tfor (let c = 0; c < n; c++) {\n\t\tconst { min, max } = videoAudioPeakRange(peaks, start + c * step * speed, start + (c + 1) * step * speed);\n\t\tconst gain = videoClipSoundGain(clip, (c + 0.5) * step);\n\t\tout[2 * c] = min * gain;\n\t\tout[2 * c + 1] = max * gain;\n\t}\n\treturn out;\n}\n\n/**\n * An SVG path (`d`) of a waveform filling `width` × `height`: the top edge along the maxima, back\n * along the minima. Values are clamped to ±1, so anything louder than full scale touches the edge.\n */\nexport function videoWaveformPath(spans: Float32Array, width: number, height: number): string {\n\tconst n = spans.length / 2;\n\tif (!n || width <= 0 || height <= 0) return '';\n\tconst mid = height / 2;\n\tconst y = (v: number) => (mid - Math.max(-1, Math.min(1, v)) * mid).toFixed(1);\n\tconst x = (i: number) => ((i + 0.5) * (width / n)).toFixed(1);\n\t// Keep at least a hairline where it is silent.\n\tconst floor = 0.5 / mid;\n\tconst top: string[] = [];\n\tconst bottom: string[] = [];\n\tfor (let i = 0; i < n; i++) top.push(`${i ? 'L' : 'M'}${x(i)} ${y(Math.max(spans[2 * i + 1], floor))}`);\n\tfor (let i = n - 1; i >= 0; i--) bottom.push(`L${x(i)} ${y(Math.min(spans[2 * i], -floor))}`);\n\treturn `${top.join('')}${bottom.join('')}Z`;\n}\n\n/**\n * The SVG path of a media clip's waveform drawn `width` × `height` pixels (one column per 2 px), as\n * `videoClipWaveform` reads it: what timelines draw inside an audio clip.\n */\nexport function videoClipWaveformPath(clip: VideoMediaClip, peaks: VideoAudioPeaks, width: number, height: number): string {\n\tconst columns = Math.max(1, Math.min(2000, Math.ceil(width / 2)));\n\treturn videoWaveformPath(videoClipWaveform(clip, peaks, columns), width, height);\n}\n\n/** A sample value as dBFS, floored at -100 (silence). */\nexport const videoPeakDb = (value: number): number => (Math.abs(value) > 1e-5 ? Math.max(-100, Math.round(200 * Math.log10(Math.abs(value))) / 10) : -100);\n\nexport interface VideoAudioLevel {\n\t/** Start of the bucket (ms, in the clock the caller asked in). */\n\tstart: number;\n\tend: number;\n\t/** Peak level in dBFS (0 = full scale, -100 = silence). */\n\tpeakDb: number;\n}\n\n/** Peak level in `buckets` equal stretches of `from`..`to` ms of the audio. */\nexport function videoAudioLevels(peaks: VideoAudioPeaks, from: number, to: number, buckets: number): VideoAudioLevel[] {\n\tconst spans = videoAudioPeakSpans(peaks, from, to, buckets);\n\tconst step = (to - from) / Math.max(1, buckets);\n\treturn Array.from({ length: spans.length / 2 }, (_, i) => ({ start: from + i * step, end: from + (i + 1) * step, peakDb: videoPeakDb(Math.max(Math.abs(spans[2 * i]), Math.abs(spans[2 * i + 1]))) }));\n}\n\nexport interface VideoSilenceOptions {\n\t/** Quieter than this peak level counts as silence (dBFS, default -40). */\n\tthresholdDb?: number;\n\t/** Shortest silence to report (ms, default 300). */\n\tminDuration?: number;\n\t/** Resolution of the scan (ms, default 10). */\n\tresolution?: number;\n}\n\n/** Stretches of `from`..`to` ms of the audio that stay below a level, at least `minDuration` long. */\nexport function findVideoAudioSilences(peaks: VideoAudioPeaks, from: number, to: number, options: VideoSilenceOptions = {}): { start: number; end: number }[] {\n\tconst threshold = options.thresholdDb ?? -40;\n\tconst minDuration = options.minDuration ?? 300;\n\tconst resolution = Math.max(1, options.resolution ?? 10);\n\tconst buckets = Math.max(1, Math.ceil((to - from) / resolution));\n\tconst out: { start: number; end: number }[] = [];\n\tlet run: number | null = null;\n\tconst levels = videoAudioLevels(peaks, from, to, buckets);\n\tlevels.forEach((l, i) => {\n\t\tconst quiet = l.peakDb < threshold;\n\t\tif (quiet && run === null) run = l.start;\n\t\tif ((!quiet || i === levels.length - 1) && run !== null) {\n\t\t\tconst end = quiet ? l.end : l.start;\n\t\t\tif (end - run >= minDuration) out.push({ start: run, end });\n\t\t\trun = null;\n\t\t}\n\t});\n\treturn out;\n}\n\n/** Fetches and decodes a media file's sound into peaks, in a browser (Web Audio). */\nexport async function decodeVideoAudioPeaks(src: string, options: { signal?: AbortSignal } = {}): Promise<VideoAudioPeaks> {\n\tconst Offline = (globalThis as { OfflineAudioContext?: typeof OfflineAudioContext }).OfflineAudioContext;\n\tif (!Offline || typeof fetch === 'undefined') throw new Error('Audio can only be decoded in a browser with Web Audio.');\n\tconst response = await fetch(src, options.signal ? { signal: options.signal } : {});\n\tif (!response.ok) throw new Error(`\"${src}\" could not be loaded (${response.status}).`);\n\tconst data = await response.arrayBuffer();\n\tlet buffer: AudioBuffer;\n\ttry {\n\t\tbuffer = await new Offline(1, 1, 44100).decodeAudioData(data);\n\t} catch {\n\t\tthrow new Error(`\"${src}\" has no sound this browser can decode.`);\n\t}\n\treturn buildVideoAudioPeaks(Array.from({ length: buffer.numberOfChannels }, (_, i) => buffer.getChannelData(i)), buffer.sampleRate);\n}\n\n/** Loads the peaks of an asset's sound. */\nexport type VideoAudioPeaksLoader = (asset: VideoAsset) => Promise<VideoAudioPeaks>;\n\n/**\n * Peaks per asset, loaded once each (by `src`) and shared by the timeline and agent tools.\n * `get` returns what is ready and starts loading the rest; `subscribe` hears when more is ready.\n */\nexport class VideoAudioPeaksCache {\n\tprivate readonly ready = new Map<string, VideoAudioPeaks>();\n\tprivate readonly pending = new Map<string, Promise<VideoAudioPeaks>>();\n\tprivate readonly failed = new Map<string, string>();\n\tprivate readonly listeners = new Set<() => void>();\n\n\tconstructor(private readonly loader: VideoAudioPeaksLoader = (asset) => decodeVideoAudioPeaks(asset.src)) {}\n\n\t/** The peaks if ready, without starting to load them. */\n\tpeek(asset: VideoAsset): VideoAudioPeaks | undefined {\n\t\treturn this.ready.get(asset.src);\n\t}\n\n\t/** The peaks if ready; otherwise `undefined`, and loading starts (once) unless it failed. */\n\tget(asset: VideoAsset): VideoAudioPeaks | undefined {\n\t\tconst peaks = this.ready.get(asset.src);\n\t\tif (!peaks && !this.failed.has(asset.src)) void this.load(asset).catch(() => {});\n\t\treturn peaks;\n\t}\n\n\t/** Why loading an asset's peaks failed, if it did. */\n\terror(asset: VideoAsset): string | undefined {\n\t\treturn this.failed.get(asset.src);\n\t}\n\n\t/** The peaks, loading them if needed. Rejects with a readable error. */\n\tload(asset: VideoAsset): Promise<VideoAudioPeaks> {\n\t\tconst ready = this.ready.get(asset.src);\n\t\tif (ready) return Promise.resolve(ready);\n\t\tconst failed = this.failed.get(asset.src);\n\t\tif (failed) return Promise.reject(new Error(failed));\n\t\tlet pending = this.pending.get(asset.src);\n\t\tif (!pending) {\n\t\t\tpending = this.loader(asset).then(\n\t\t\t\t(peaks) => {\n\t\t\t\t\tthis.pending.delete(asset.src);\n\t\t\t\t\tthis.ready.set(asset.src, peaks);\n\t\t\t\t\tthis.emit();\n\t\t\t\t\treturn peaks;\n\t\t\t\t},\n\t\t\t\t(e: unknown) => {\n\t\t\t\t\tthis.pending.delete(asset.src);\n\t\t\t\t\tconst message = `The sound of \"${asset.id}\" could not be read: ${e instanceof Error ? e.message : String(e)}`;\n\t\t\t\t\tthis.failed.set(asset.src, message);\n\t\t\t\t\tthis.emit();\n\t\t\t\t\tthrow new Error(message);\n\t\t\t\t}\n\t\t\t);\n\t\t\tthis.pending.set(asset.src, pending);\n\t\t}\n\t\treturn pending;\n\t}\n\n\t/** Uses peaks the host already has (computed earlier or stored). */\n\tset(src: string, peaks: VideoAudioPeaks): void {\n\t\tthis.ready.set(src, peaks);\n\t\tthis.failed.delete(src);\n\t\tthis.emit();\n\t}\n\n\tsubscribe(fn: () => void): () => void {\n\t\tthis.listeners.add(fn);\n\t\treturn () => this.listeners.delete(fn);\n\t}\n\n\tprivate emit() {\n\t\tfor (const fn of this.listeners) fn();\n\t}\n}\n","/**\n * Colour scopes: a histogram, a luma waveform and a vectorscope, worked out from a frame's pixels,\n * plus exposure and clipping figures. Pure functions on RGBA pixels as `ImageData` holds them, so\n * the same numbers come from a preview canvas, a decoded export frame or a test.\n *\n * Luma and chroma use the Rec. 709 weights on the gamma-encoded values (Y′, Cb, Cr), as broadcast\n * scopes do: luma 0 is black and 1 white; Cb and Cr run from −0.5 to 0.5.\n */\n\n/** RGBA pixels, 4 values (0–255) a pixel, rows top to bottom: an `ImageData` fits. */\nexport interface VideoPixels {\n\twidth: number;\n\theight: number;\n\tdata: ArrayLike<number>;\n}\n\nexport type VideoScopeKind = 'histogram' | 'waveform' | 'vectorscope';\nexport const VIDEO_SCOPE_KINDS = ['histogram', 'waveform', 'vectorscope'] as const;\n\n/** Rec. 709 luma of gamma-encoded RGB (each 0–1). */\nexport const videoLuma = (r: number, g: number, b: number): number => 0.2126 * r + 0.7152 * g + 0.0722 * b;\n\n/** Rec. 709 chroma (Cb, Cr), each −0.5 to 0.5, of gamma-encoded RGB (each 0–1). */\nexport function videoChroma(r: number, g: number, b: number): [number, number] {\n\tconst y = videoLuma(r, g, b);\n\treturn [(b - y) / 1.8556, (r - y) / 1.5748];\n}\n\nexport interface VideoScopeOptions {\n\t/**\n\t * Read at most about this many pixels, evenly spaced (default 262 144, a 512 × 512 picture), so\n\t * a large frame costs no more than a small one. Counts are of the pixels read.\n\t */\n\tmaxPixels?: number;\n}\n\n/** Calls `visit(r, g, b, x, y)` (0–255 values) for evenly spaced pixels, at most about `maxPixels`. */\nfunction eachPixel(pixels: VideoPixels, maxPixels: number | undefined, visit: (r: number, g: number, b: number, x: number, y: number) => void): number {\n\tconst { width, height, data } = pixels;\n\tif (!(width > 0 && height > 0) || data.length < width * height * 4) throw new Error(`The pixels are not ${width} × ${height} RGBA: there are ${data.length} values, and ${width * height * 4} are needed.`);\n\tconst step = Math.max(1, Math.sqrt((width * height) / Math.max(1, maxPixels ?? 262_144)));\n\tlet count = 0;\n\tfor (let fy = step / 2; fy < height; fy += step) {\n\t\tconst y = Math.floor(fy);\n\t\tfor (let fx = step / 2; fx < width; fx += step) {\n\t\t\tconst x = Math.floor(fx);\n\t\t\tconst i = (y * width + x) * 4;\n\t\t\tvisit(data[i], data[i + 1], data[i + 2], x, y);\n\t\t\tcount++;\n\t\t}\n\t}\n\treturn count;\n}\n\n/** How many pixels fall in each of `bins` equal steps of red, green, blue and luma (bin 0 is darkest). */\nexport interface VideoHistogram {\n\tbins: number;\n\tr: number[];\n\tg: number[];\n\tb: number[];\n\tluma: number[];\n\t/** Pixels counted. */\n\ttotal: number;\n}\n\n// The nudge keeps a grey whose luma comes out a hair under its value (39.99999…) in its own step.\nconst binOf = (v: number, bins: number) => Math.min(bins - 1, Math.floor(((v + 1e-6) / 256) * bins));\n\nexport function videoHistogram(pixels: VideoPixels, options: VideoScopeOptions & { bins?: number } = {}): VideoHistogram {\n\tconst bins = Math.max(1, Math.min(256, Math.round(options.bins ?? 256)));\n\tconst out: VideoHistogram = { bins, r: Array(bins).fill(0), g: Array(bins).fill(0), b: Array(bins).fill(0), luma: Array(bins).fill(0), total: 0 };\n\tout.total = eachPixel(pixels, options.maxPixels, (r, g, b) => {\n\t\tout.r[binOf(r, bins)]++;\n\t\tout.g[binOf(g, bins)]++;\n\t\tout.b[binOf(b, bins)]++;\n\t\tout.luma[binOf(videoLuma(r, g, b), bins)]++;\n\t});\n\treturn out;\n}\n\n/**\n * A luma waveform: the picture split into `columns` vertical strips, left to right, each counting\n * its pixels at each of `levels` luma steps. `data[column * levels + level]`; level 0 is black.\n */\nexport interface VideoWaveform {\n\tcolumns: number;\n\tlevels: number;\n\tdata: Uint32Array;\n\t/** The largest count, for scaling a drawing. */\n\tpeak: number;\n}\n\nexport function videoLumaWaveform(pixels: VideoPixels, options: VideoScopeOptions & { columns?: number; levels?: number } = {}): VideoWaveform {\n\tconst columns = Math.max(1, Math.min(pixels.width, Math.round(options.columns ?? Math.min(pixels.width, 256))));\n\tconst levels = Math.max(2, Math.min(256, Math.round(options.levels ?? 128)));\n\tconst data = new Uint32Array(columns * levels);\n\tlet peak = 0;\n\teachPixel(pixels, options.maxPixels, (r, g, b, x) => {\n\t\tconst c = Math.min(columns - 1, Math.floor((x / pixels.width) * columns));\n\t\tconst i = c * levels + binOf(videoLuma(r, g, b), levels);\n\t\tif (++data[i] > peak) peak = data[i];\n\t});\n\treturn { columns, levels, data, peak };\n}\n\n/**\n * A vectorscope: pixels counted on a `size` × `size` grid by chroma, Cb across (left −0.5, right\n * +0.5) and Cr up (top +0.5). The centre is grey; the further out, the more saturated; the angle\n * is the hue. `data[row * size + column]`.\n */\nexport interface VideoVectorscope {\n\tsize: number;\n\tdata: Uint32Array;\n\tpeak: number;\n}\n\n/** Where chroma (Cb, Cr) falls on a vectorscope of `size` pixels, as `[column, row]`. */\nexport const videoVectorscopePoint = (cb: number, cr: number, size: number): [number, number] => [(cb + 0.5) * (size - 1), (0.5 - cr) * (size - 1)];\n\nexport function videoVectorscope(pixels: VideoPixels, options: VideoScopeOptions & { size?: number } = {}): VideoVectorscope {\n\tconst size = Math.max(16, Math.min(512, Math.round(options.size ?? 128)));\n\tconst data = new Uint32Array(size * size);\n\tlet peak = 0;\n\teachPixel(pixels, options.maxPixels, (r, g, b) => {\n\t\tconst [cb, cr] = videoChroma(r / 255, g / 255, b / 255);\n\t\tconst [x, y] = videoVectorscopePoint(cb, cr, size);\n\t\tconst i = Math.round(y) * size + Math.round(x);\n\t\tif (++data[i] > peak) peak = data[i];\n\t});\n\treturn { size, data, peak };\n}\n\n/** Exposure and colour figures for a frame (see `videoExposureStats`). Luma and levels run 0–1. */\nexport interface VideoExposureStats {\n\t/** Pixels read. */\n\tpixels: number;\n\tluma: { mean: number; median: number; min: number; max: number; p1: number; p99: number };\n\t/** Share (0–1) of pixels crushed to black (luma at or below `shadowLevel`) or blown to white (at or above `highlightLevel`). */\n\tclipped: { shadows: number; highlights: number; r: number; g: number; b: number };\n\t/** Mean and highest chroma, 0 (grey) to 1 (a full primary). */\n\tsaturation: { mean: number; max: number };\n\t/** The mean colour as `#rrggbb`: far from grey means a colour cast. */\n\taverage: string;\n}\n\nexport interface VideoExposureOptions extends VideoScopeOptions {\n\t/** Luma (0–1) at or below which a pixel counts as crushed (default 0.02). */\n\tshadowLevel?: number;\n\t/** Luma or channel (0–1) at or above which a pixel counts as blown (default 0.98). */\n\thighlightLevel?: number;\n}\n\nconst round3 = (v: number) => Math.round(v * 1000) / 1000;\nconst hex2 = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');\n\nexport function videoExposureStats(pixels: VideoPixels, options: VideoExposureOptions = {}): VideoExposureStats {\n\tconst low = (options.shadowLevel ?? 0.02) * 255;\n\tconst high = (options.highlightLevel ?? 0.98) * 255;\n\tconst lumaBins = new Uint32Array(256);\n\tlet sumY = 0;\n\tlet sumR = 0;\n\tlet sumG = 0;\n\tlet sumB = 0;\n\tlet sumSat = 0;\n\tlet maxSat = 0;\n\tlet shadows = 0;\n\tlet highlights = 0;\n\tlet cr = 0;\n\tlet cg = 0;\n\tlet cbl = 0;\n\tconst total = eachPixel(pixels, options.maxPixels, (r, g, b) => {\n\t\tconst y = videoLuma(r, g, b);\n\t\tlumaBins[Math.min(255, Math.round(y))]++;\n\t\tsumY += y;\n\t\tsumR += r;\n\t\tsumG += g;\n\t\tsumB += b;\n\t\tconst [u, v] = videoChroma(r / 255, g / 255, b / 255);\n\t\tconst sat = Math.min(1, Math.hypot(u, v) / 0.5);\n\t\tsumSat += sat;\n\t\tif (sat > maxSat) maxSat = sat;\n\t\tif (y <= low) shadows++;\n\t\tif (y >= high) highlights++;\n\t\tif (r >= high) cr++;\n\t\tif (g >= high) cg++;\n\t\tif (b >= high) cbl++;\n\t});\n\tconst percentile = (p: number) => {\n\t\tconst target = p * total;\n\t\tlet seen = 0;\n\t\tfor (let i = 0; i < 256; i++) {\n\t\t\tseen += lumaBins[i];\n\t\t\tif (seen >= target && seen > 0) return i / 255;\n\t\t}\n\t\treturn 1;\n\t};\n\tlet min = 0;\n\twhile (min < 255 && !lumaBins[min]) min++;\n\tlet max = 255;\n\twhile (max > 0 && !lumaBins[max]) max--;\n\treturn {\n\t\tpixels: total,\n\t\tluma: { 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)) },\n\t\tclipped: { shadows: round3(shadows / total), highlights: round3(highlights / total), r: round3(cr / total), g: round3(cg / total), b: round3(cbl / total) },\n\t\tsaturation: { mean: round3(sumSat / total), max: round3(maxSat) },\n\t\taverage: `#${hex2(sumR / total)}${hex2(sumG / total)}${hex2(sumB / total)}`\n\t};\n}\n\n/** What a scope reading says in words: exposure, clipping and colour cast worth fixing. */\nexport function videoExposureNotes(stats: VideoExposureStats): string[] {\n\tconst notes: string[] = [];\n\tconst pct = (v: number) => `${Math.round(v * 1000) / 10}%`;\n\tif (stats.clipped.shadows >= 0.05) notes.push(`${pct(stats.clipped.shadows)} of the picture is crushed to black; raise exposure or lower contrast.`);\n\tif (stats.clipped.highlights >= 0.02) notes.push(`${pct(stats.clipped.highlights)} of the picture is blown to white; lower exposure.`);\n\telse {\n\t\tconst channels = (['r', 'g', 'b'] as const).filter((k) => stats.clipped[k] >= 0.05);\n\t\tif (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.`);\n\t}\n\tif (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.`);\n\tif (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.`);\n\tif (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.`);\n\tconst [r, g, b] = [1, 3, 5].map((i) => parseInt(stats.average.slice(i, i + 2), 16) / 255);\n\tconst [u, v] = videoChroma(r, g, b);\n\tif (Math.hypot(u, v) > 0.04 && stats.saturation.mean < 0.35) {\n\t\tconst warm = v - u;\n\t\tnotes.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.`);\n\t}\n\treturn notes;\n}\n\n/** A compact reading of a frame for agents: exposure figures, notes, and coarse histogram, waveform and vectorscope summaries. */\nexport interface VideoScopeSummary {\n\tstats: VideoExposureStats;\n\tnotes: string[];\n\t/** Share of pixels (0–1) in each of `bins` equal luma and channel steps, darkest first. */\n\thistogram: { bins: number; luma: number[]; r: number[]; g: number[]; b: number[] };\n\t/** The picture in vertical strips, left to right: each strip's luma spread (0–1). */\n\twaveform: { start: number; end: number; min: number; median: number; max: number }[];\n\t/** The chroma of the most saturated tenth of the picture: its main hue (degrees on the vectorscope, red ≈ 103°) and strength (0–1). */\n\tvectorscope: { hue: number | null; strength: number; spread: number };\n}\n\n/** Vectorscope angle (degrees, anticlockwise from +Cb) of chroma. */\nexport const videoVectorscopeAngle = (cb: number, cr: number): number => (((Math.atan2(cr, cb) * 180) / Math.PI) % 360 + 360) % 360;\n\nexport function summarizeVideoScopes(pixels: VideoPixels, options: VideoExposureOptions & { bins?: number; columns?: number } = {}): VideoScopeSummary {\n\tconst stats = videoExposureStats(pixels, options);\n\tconst bins = Math.max(2, Math.min(64, Math.round(options.bins ?? 16)));\n\tconst h = videoHistogram(pixels, { ...options, bins });\n\tconst share = (a: number[]) => a.map((v) => round3(v / h.total));\n\tconst columns = Math.max(1, Math.min(32, Math.round(options.columns ?? 8)));\n\tconst w = videoLumaWaveform(pixels, { ...options, columns, levels: 256 });\n\tconst waveform = Array.from({ length: w.columns }, (_, c) => {\n\t\tconst counts = w.data.subarray(c * 256, c * 256 + 256);\n\t\tconst n = counts.reduce((a, b) => a + b, 0);\n\t\tlet lo = 0;\n\t\twhile (lo < 255 && !counts[lo]) lo++;\n\t\tlet hi = 255;\n\t\twhile (hi > 0 && !counts[hi]) hi--;\n\t\tlet seen = 0;\n\t\tlet median = 0;\n\t\tfor (let i = 0; i < 256; i++) {\n\t\t\tseen += counts[i];\n\t\t\tif (seen >= n / 2 && n) {\n\t\t\t\tmedian = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn { start: round3(c / w.columns), end: round3((c + 1) / w.columns), min: round3(lo / 255), median: round3(median / 255), max: round3(hi / 255) };\n\t}).filter((_, c) => w.data.subarray(c * 256, c * 256 + 256).some((v) => v > 0));\n\t// The most saturated tenth: its mean direction is the picture's main hue.\n\tconst chroma: [number, number, number][] = [];\n\teachPixel(pixels, Math.min(options.maxPixels ?? 65_536, 65_536), (r, g, b) => {\n\t\tconst [u, v] = videoChroma(r / 255, g / 255, b / 255);\n\t\tchroma.push([u, v, Math.hypot(u, v)]);\n\t});\n\tchroma.sort((a, b) => b[2] - a[2]);\n\tconst top = chroma.slice(0, Math.max(1, Math.ceil(chroma.length / 10)));\n\tconst mu = top.reduce((s, c) => s + c[0], 0) / top.length;\n\tconst mv = top.reduce((s, c) => s + c[1], 0) / top.length;\n\tconst strength = Math.min(1, top.reduce((s, c) => s + c[2], 0) / top.length / 0.5);\n\tconst coherence = Math.hypot(mu, mv) / Math.max(1e-9, top.reduce((s, c) => s + c[2], 0) / top.length);\n\treturn {\n\t\tstats,\n\t\tnotes: videoExposureNotes(stats),\n\t\thistogram: { bins, luma: share(h.luma), r: share(h.r), g: share(h.g), b: share(h.b) },\n\t\twaveform,\n\t\tvectorscope: { hue: strength < 0.02 ? null : Math.round(videoVectorscopeAngle(mu, mv)), strength: round3(strength), spread: round3(1 - coherence) }\n\t};\n}\n\n/** Where the six 75% colour-bar targets sit on a vectorscope: name, Cb and Cr. */\nexport const VIDEO_VECTORSCOPE_TARGETS: readonly { name: string; rgb: [number, number, number]; cb: number; cr: number }[] = (\n\t[\n\t\t['R', [0.75, 0, 0]],\n\t\t['Mg', [0.75, 0, 0.75]],\n\t\t['B', [0, 0, 0.75]],\n\t\t['Cy', [0, 0.75, 0.75]],\n\t\t['G', [0, 0.75, 0]],\n\t\t['Yl', [0.75, 0.75, 0]]\n\t] as const\n).map(([name, rgb]) => {\n\tconst [cb, cr] = videoChroma(rgb[0], rgb[1], rgb[2]);\n\treturn { name, rgb: [...rgb] as [number, number, number], cb, cr };\n});\n\n/** RGBA pixels of a drawn scope, ready for `new ImageData(data, width, height)`. */\nexport interface VideoScopeImage {\n\twidth: number;\n\theight: number;\n\tdata: Uint8ClampedArray<ArrayBuffer>;\n}\n\nfunction canvas(width: number, height: number): VideoScopeImage {\n\tconst data = new Uint8ClampedArray(width * height * 4);\n\tfor (let i = 0; i < data.length; i += 4) {\n\t\tdata[i] = 12;\n\t\tdata[i + 1] = 14;\n\t\tdata[i + 2] = 18;\n\t\tdata[i + 3] = 255;\n\t}\n\treturn { width, height, data };\n}\n\nfunction plot(img: VideoScopeImage, x: number, y: number, rgb: readonly [number, number, number], alpha = 1, add = false) {\n\tconst xi = Math.round(x);\n\tconst yi = Math.round(y);\n\tif (xi < 0 || yi < 0 || xi >= img.width || yi >= img.height) return;\n\tconst i = (yi * img.width + xi) * 4;\n\tfor (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;\n}\n\nconst GRID: [number, number, number] = [70, 76, 88];\n/** Brightness for a count: log-scaled so a few pixels still show next to a large flat area. */\nconst glow = (count: number, peak: number) => (count ? 0.25 + 0.75 * (Math.log1p(count) / Math.log1p(Math.max(1, peak))) : 0);\n\n/** A histogram drawn `width` × `height`: red, green and blue added together, with luma as a white outline. */\nexport function videoHistogramImage(h: VideoHistogram, width = 256, height = 128): VideoScopeImage {\n\tconst img = canvas(width, height);\n\tfor (const q of [0.25, 0.5, 0.75]) for (let y = 0; y < height; y++) plot(img, q * (width - 1), y, GRID);\n\t// Scale to the tallest bin away from the ends, so a clipped spike does not flatten everything else.\n\tconst inner = (a: number[]) => (a.length > 2 ? a.slice(1, -1) : a);\n\tconst peak = Math.max(1, ...inner(h.r), ...inner(h.g), ...inner(h.b), ...inner(h.luma));\n\tconst height01 = (v: number) => Math.min(1, Math.sqrt(v / peak));\n\tfor (let x = 0; x < width; x++) {\n\t\tconst bin = Math.min(h.bins - 1, Math.floor((x / width) * h.bins));\n\t\tconst channels: [number[], [number, number, number]][] = [\n\t\t\t[h.r, [200, 40, 40]],\n\t\t\t[h.g, [40, 180, 40]],\n\t\t\t[h.b, [50, 80, 220]]\n\t\t];\n\t\tfor (const [a, rgb] of channels) {\n\t\t\tconst top = height - height01(a[bin]) * height;\n\t\t\tfor (let y = Math.ceil(top); y < height; y++) plot(img, x, y, rgb, 0.8, true);\n\t\t}\n\t\tplot(img, x, height - 1 - height01(h.luma[bin]) * (height - 1), [255, 255, 255]);\n\t}\n\treturn img;\n}\n\n/** A luma waveform drawn `width` × `height`: white at the top, black at the bottom, lines at each quarter. */\nexport function videoWaveformImage(w: VideoWaveform, width = 256, height = 128): VideoScopeImage {\n\tconst img = canvas(width, height);\n\tfor (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);\n\tfor (let x = 0; x < width; x++) {\n\t\tconst c = Math.min(w.columns - 1, Math.floor((x / width) * w.columns));\n\t\tfor (let y = 0; y < height; y++) {\n\t\t\tconst level = Math.min(w.levels - 1, Math.floor(((height - 1 - y) / height) * w.levels));\n\t\t\tconst a = glow(w.data[c * w.levels + level], w.peak);\n\t\t\tif (a) plot(img, x, y, [120, 255, 140], a);\n\t\t}\n\t}\n\treturn img;\n}\n\n/** A vectorscope drawn `size` × `size`, with its outer circle, the skin-tone line and the colour-bar targets. */\nexport function videoVectorscopeImage(v: VideoVectorscope, size = v.size): VideoScopeImage {\n\tconst img = canvas(size, size);\n\tconst c = (size - 1) / 2;\n\tfor (let a = 0; a < 720; a++) {\n\t\tconst t = (a / 720) * 2 * Math.PI;\n\t\tplot(img, c + Math.cos(t) * c, c - Math.sin(t) * c, GRID);\n\t}\n\t// The skin-tone (I) line, at 123° on a vectorscope.\n\tconst skin = (123 * Math.PI) / 180;\n\tfor (let r = 0; r < c; r++) plot(img, c + Math.cos(skin) * r, c - Math.sin(skin) * r, [140, 110, 90]);\n\tfor (const t of VIDEO_VECTORSCOPE_TARGETS) {\n\t\tconst [x, y] = videoVectorscopePoint(t.cb, t.cr, size);\n\t\tconst rgb = t.rgb.map((k) => 80 + k * 200) as [number, number, number];\n\t\tfor (let k = -3; k <= 3; k++) {\n\t\t\tplot(img, x + k, y - 3, rgb);\n\t\t\tplot(img, x + k, y + 3, rgb);\n\t\t\tplot(img, x - 3, y + k, rgb);\n\t\t\tplot(img, x + 3, y + k, rgb);\n\t\t}\n\t}\n\tfor (let y = 0; y < size; y++)\n\t\tfor (let x = 0; x < size; x++) {\n\t\t\tconst sy = Math.min(v.size - 1, Math.floor((y / size) * v.size));\n\t\t\tconst sx = Math.min(v.size - 1, Math.floor((x / size) * v.size));\n\t\t\tconst a = glow(v.data[sy * v.size + sx], v.peak);\n\t\t\tif (!a) continue;\n\t\t\t// Each point takes the hue it stands for, so the scope reads like a colour wheel.\n\t\t\tconst cb = sx / (v.size - 1) - 0.5;\n\t\t\tconst cr = 0.5 - sy / (v.size - 1);\n\t\t\tconst r = Math.min(1, Math.max(0, 0.6 + 1.5748 * cr));\n\t\t\tconst g = Math.min(1, Math.max(0, 0.6 - 0.1873 * cb - 0.4681 * cr));\n\t\t\tconst b = Math.min(1, Math.max(0, 0.6 + 1.8556 * cb));\n\t\t\tplot(img, x, y, [r * 255, g * 255, b * 255], a);\n\t\t}\n\treturn img;\n}\n\n/** One scope of a frame drawn `width` × `height` (a vectorscope is square: the smaller side). */\nexport function videoScopeImage(kind: VideoScopeKind, pixels: VideoPixels, width = 256, height = 128, options: VideoScopeOptions = {}): VideoScopeImage {\n\tif (kind === 'histogram') return videoHistogramImage(videoHistogram(pixels, { ...options, bins: Math.min(256, width) }), width, height);\n\tif (kind === 'waveform') return videoWaveformImage(videoLumaWaveform(pixels, { ...options, columns: Math.min(pixels.width, width), levels: Math.min(256, height) }), width, height);\n\tconst size = Math.min(width, height);\n\treturn videoVectorscopeImage(videoVectorscope(pixels, { ...options, size }), size);\n}\n\n/** A frame the preview drew: when, whether every picture in it had loaded, and its pixels. */\nexport interface VideoPreviewSnapshot {\n\ttime: number;\n\t/** False while a picture is still loading or seeking (the frame shows a placeholder or a stale picture). */\n\tcomplete: boolean;\n\tpixels: VideoPixels;\n}\n\n/**\n * Where a preview's pixels can be read, for scopes and agents: a `VideoPreviewEngine` attaches its\n * canvas and says each time it draws. `VideoEditorController.previewPixels` is one, so a preview,\n * a scopes panel and the agent tools of one editor find each other without wiring.\n */\nexport class VideoPreviewPixels {\n\tprivate reader: (() => VideoPreviewSnapshot | null) | null = null;\n\tprivate drawnAt: { time: number; complete: boolean } | null = null;\n\tprivate readonly listeners = new Set<() => void>();\n\n\t/** Gives the canvas to read (the latest preview wins). Returns a function that takes it away again. */\n\tattach(reader: () => VideoPreviewSnapshot | null): () => void {\n\t\tthis.reader = reader;\n\t\tthis.drawnAt = null;\n\t\treturn () => {\n\t\t\tif (this.reader !== reader) return;\n\t\t\tthis.reader = null;\n\t\t\tthis.drawnAt = null;\n\t\t\tthis.emit();\n\t\t};\n\t}\n\n\t/** Whether a preview is attached. */\n\tget attached(): boolean {\n\t\treturn this.reader !== null;\n\t}\n\n\t/** The preview drew the frame at `time`; `complete` is false while a picture was missing or stale. */\n\tdrawn(time: number, complete: boolean): void {\n\t\tthis.drawnAt = { time, complete };\n\t\tthis.emit();\n\t}\n\n\t/** The last frame drawn, with its pixels; `null` without a preview. Throws when the canvas cannot be read. */\n\tread(): VideoPreviewSnapshot | null {\n\t\tif (!this.reader) return null;\n\t\ttry {\n\t\t\treturn this.reader();\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthrow 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.`);\n\t\t}\n\t}\n\n\t/** Called after each frame the preview draws. */\n\tsubscribe(listener: () => void): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t}\n\n\t/**\n\t * The frame at `time` once it is complete: now, if the last frame drawn was it, else the next\n\t * complete drawing of it. Fails after `timeout` ms (default 5000).\n\t */\n\tnext(time: number, timeout = 5000): Promise<VideoPreviewSnapshot> {\n\t\tconst matches = () => this.drawnAt && this.drawnAt.complete && Math.abs(this.drawnAt.time - time) < 0.5;\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst done = (fn: () => void) => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\toff();\n\t\t\t\tfn();\n\t\t\t};\n\t\t\tconst check = () => {\n\t\t\t\tif (!this.reader) return done(() => reject(new Error('The preview was closed before it drew the frame.')));\n\t\t\t\tif (!matches()) return;\n\t\t\t\ttry {\n\t\t\t\t\tconst snap = this.read();\n\t\t\t\t\tdone(() => (snap ? resolve(snap) : reject(new Error('The preview has no pixels to read.'))));\n\t\t\t\t} catch (e) {\n\t\t\t\t\tdone(() => reject(e));\n\t\t\t\t}\n\t\t\t};\n\t\t\tconst off = this.subscribe(check);\n\t\t\tconst 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);\n\t\t\tcheck();\n\t\t});\n\t}\n\n\tprivate emit() {\n\t\tfor (const l of [...this.listeners]) l();\n\t}\n}\n\n/** What a scopes panel shows for the preview's latest frame (see `readVideoScopesPanel`). */\nexport type VideoScopesPanelReading =\n\t| { ok: true; time: number; complete: boolean; images: Partial<Record<VideoScopeKind, VideoScopeImage>>; stats: VideoExposureStats; notes: string[] }\n\t| { ok: false; message: string };\n\n/**\n * The preview's latest frame as a scopes panel draws it: an image per scope `kinds`, each\n * `width` × `height` (vectorscopes square), and the exposure figures. Reads at most `maxPixels`\n * (default 65 536) so it keeps up with playback. Says why when there is nothing to show.\n */\nexport function readVideoScopesPanel(feed: VideoPreviewPixels, kinds: readonly VideoScopeKind[] = VIDEO_SCOPE_KINDS, options: { width?: number; height?: number; maxPixels?: number } = {}): VideoScopesPanelReading {\n\tlet snap: VideoPreviewSnapshot | null;\n\ttry {\n\t\tsnap = feed.read();\n\t} catch (e) {\n\t\treturn { ok: false, message: e instanceof Error ? e.message : String(e) };\n\t}\n\tif (!snap) return { ok: false, message: feed.attached ? 'The preview has not drawn a frame yet.' : 'Show the preview to see its scopes.' };\n\tconst width = options.width ?? 256;\n\tconst height = options.height ?? 128;\n\tconst opts = { maxPixels: options.maxPixels ?? 65_536 };\n\tconst images: Partial<Record<VideoScopeKind, VideoScopeImage>> = {};\n\tfor (const kind of kinds) images[kind] = videoScopeImage(kind, snap.pixels, width, height, opts);\n\tconst stats = videoExposureStats(snap.pixels, opts);\n\treturn { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };\n}\n","import type { VideoEdit } from './edit.js';\nimport { snapToVideoFrame, sortedVideoClips, videoAssetMap, videoClipEnd, videoClipSpeed, VIDEO_TIME_EPSILON } from './timeline.js';\nimport type { VideoProject, VideoTranscriptWord } from './types.js';\n\n/**\n * Text-based editing: an asset's transcript is a list of words timed in its source, so it stays\n * right however the clips that use it are cut. `videoTimelineTranscript` maps the words through the\n * clips onto the timeline; searches, fillers and pauses come back as timeline ranges that\n * `videoRemoveRangesEdits` cuts out in one step.\n */\n\n// ---------------------------------------------------------------------------------------------\n// Reading transcripts\n// ---------------------------------------------------------------------------------------------\n\nconst isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** `HH:MM:SS,mmm`, `HH:MM:SS.mmm` or `MM:SS.mmm` as ms. */\nfunction cueTime(s: string): number {\n\tconst m = s.trim().match(/^(?:(\\d+):)?(\\d{1,2}):(\\d{1,2})[.,](\\d{1,3})$/);\n\tif (!m) throw new Error(`\"${s.trim()}\" is not a cue time (HH:MM:SS,mmm).`);\n\treturn ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1000 + Number(m[4].padEnd(3, '0'));\n}\n\n/** Words of a cue spread evenly across its time (cues have no word timings). */\nfunction cueWords(text: string, start: number, end: number): VideoTranscriptWord[] {\n\tconst words = text\n\t\t.replace(/<[^>]+>/g, '')\n\t\t.split(/\\s+/)\n\t\t.filter(Boolean);\n\tconst step = (end - start) / Math.max(1, words.length);\n\treturn words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));\n}\n\n/**\n * A transcript from text a speech-to-text service or captioning tool wrote: Whisper-style JSON\n * (`{ segments: [{ words: [{ word, start, end }] }] }` or `{ words: [...] }`, times in seconds, or a\n * plain `[{ word, start, end }]` array in ms), SubRip (`.srt`) or WebVTT. Captions have no word\n * timings, so their words are spread evenly across each cue (`approximate: true`). Throws when it\n * finds no words.\n */\nexport function parseVideoTranscript(input: string | unknown): { words: VideoTranscriptWord[]; approximate: boolean } {\n\tlet data: unknown = input;\n\tif (typeof input === 'string') {\n\t\tconst text = input.replace(/^/, '').trim();\n\t\tif (text.startsWith('{') || text.startsWith('[')) {\n\t\t\ttry {\n\t\t\t\tdata = JSON.parse(text);\n\t\t\t} catch {\n\t\t\t\tthrow new Error('The transcript looks like JSON but is not valid JSON.');\n\t\t\t}\n\t\t} else {\n\t\t\t// SubRip or WebVTT: blocks of an optional id, a \"start --> end\" line and text lines.\n\t\t\tconst words: VideoTranscriptWord[] = [];\n\t\t\tfor (const block of text.replace(/\\r\\n?/g, '\\n').split(/\\n{2,}/)) {\n\t\t\t\tconst lines = block.split('\\n');\n\t\t\t\tconst at = lines.findIndex((l) => l.includes('-->'));\n\t\t\t\tif (at < 0) continue;\n\t\t\t\tconst [a, b] = lines[at].split('-->');\n\t\t\t\twords.push(...cueWords(lines.slice(at + 1).join(' '), cueTime(a), cueTime(b.trim().split(/\\s+/)[0])));\n\t\t\t}\n\t\t\tif (!words.length) throw new Error('No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.');\n\t\t\treturn { words, approximate: true };\n\t\t}\n\t}\n\tconst seconds = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 1000) : NaN);\n\tconst fromList = (list: unknown[], toMs: (v: unknown) => number) =>\n\t\tlist.map((w, i) => {\n\t\t\tif (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);\n\t\t\tconst word = String(w.word ?? w.text ?? '').trim();\n\t\t\tconst start = toMs(w.start);\n\t\t\tconst end = toMs(w.end);\n\t\t\tif (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);\n\t\t\treturn { word, start, end: Math.max(start, end) };\n\t\t});\n\tlet words: VideoTranscriptWord[] = [];\n\tif (Array.isArray(data)) words = fromList(data, (v) => (typeof v === 'number' ? v : NaN));\n\telse if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);\n\telse if (isRecord(data) && Array.isArray(data.segments)) {\n\t\tconst segments = data.segments.filter(isRecord);\n\t\tif (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => (Array.isArray(s.words) ? s.words : [])), seconds);\n\t\telse {\n\t\t\tfor (const s of segments) words.push(...cueWords(String(s.text ?? ''), seconds(s.start), seconds(s.end)));\n\t\t\tif (!words.length) throw new Error('No words found in the segments.');\n\t\t\treturn { words, approximate: true };\n\t\t}\n\t}\n\tif (!words.length) throw new Error('No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.');\n\treturn { words: words.sort((a, b) => a.start - b.start), approximate: false };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Words on the timeline\n// ---------------------------------------------------------------------------------------------\n\n/** A word where it plays on the timeline. */\nexport interface VideoTimelineWord {\n\tword: string;\n\t/** Timeline times (ms). */\n\tstart: number;\n\tend: number;\n\t/** The clip that plays it. */\n\tclip: string;\n\ttrack: string;\n\t/** Its index in the asset's transcript. */\n\tindex: number;\n}\n\n/**\n * The track whose words make the timeline transcript: `track` if given, else the first audio track\n * with a transcribed clip, else the first video track with one. `null` when nothing is transcribed.\n */\nexport function videoTranscriptTrack(project: VideoProject, track?: string): string | null {\n\tif (track !== undefined) {\n\t\tif (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track \"${track}\"; the tracks are ${project.tracks.map((t) => `\"${t.id}\"`).join(', ')}.`);\n\t\treturn track;\n\t}\n\tconst assets = videoAssetMap(project);\n\tconst transcribed = (t: VideoProject['tracks'][number]) => t.clips.some((c) => c.type === 'media' && assets.get(c.asset)?.transcript?.length);\n\treturn (project.tracks.find((t) => t.kind === 'audio' && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;\n}\n\n/**\n * The words the timeline plays on one track, in order: each transcribed media clip's words whose\n * middle falls inside the stretch of source it plays, moved to timeline time through its in point\n * and speed and clamped to the clip.\n */\nexport function videoTimelineTranscript(project: VideoProject, options: { track?: string } = {}): VideoTimelineWord[] {\n\tconst id = videoTranscriptTrack(project, options.track);\n\tif (!id) return [];\n\tconst track = project.tracks.find((t) => t.id === id)!;\n\tconst assets = videoAssetMap(project);\n\tconst out: VideoTimelineWord[] = [];\n\tfor (const clip of sortedVideoClips(track)) {\n\t\tif (clip.type !== 'media') continue;\n\t\tconst words = assets.get(clip.asset)?.transcript;\n\t\tif (!words?.length) continue;\n\t\tconst speed = videoClipSpeed(clip);\n\t\tconst from = clip.in ?? 0;\n\t\tconst to = from + clip.duration * speed;\n\t\tconst toTimeline = (s: number) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));\n\t\twords.forEach((w, index) => {\n\t\t\tconst mid = (w.start + w.end) / 2;\n\t\t\tif (mid < from || mid >= to) return;\n\t\t\tout.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });\n\t\t});\n\t}\n\treturn out;\n}\n\n// ---------------------------------------------------------------------------------------------\n// Finding things to cut\n// ---------------------------------------------------------------------------------------------\n\n/** A stretch of the timeline (ms) and the words in it. */\nexport interface VideoTranscriptRange {\n\tfrom: number;\n\tto: number;\n\ttext: string;\n}\n\n/** A word as compared: lower case, without surrounding punctuation. */\nexport const normalizeVideoWord = (word: string): string => word.toLowerCase().replace(/^[^\\p{L}\\p{N}']+|[^\\p{L}\\p{N}']+$/gu, '');\n\nconst rangeOf = (words: readonly VideoTimelineWord[], i: number, n: number): VideoTranscriptRange => ({ from: words[i].start, to: words[i + n - 1].end, text: words.slice(i, i + n).map((w) => w.word).join(' ') });\n\n/** Every place a phrase is said (case and punctuation ignored), as timeline ranges. */\nexport function findVideoTranscriptText(words: readonly VideoTimelineWord[], query: string): VideoTranscriptRange[] {\n\tconst q = query.split(/\\s+/).map(normalizeVideoWord).filter(Boolean);\n\tif (!q.length) throw new Error('Search for at least one word.');\n\tconst norm = words.map((w) => normalizeVideoWord(w.word));\n\tconst out: VideoTranscriptRange[] = [];\n\tfor (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));\n\treturn out;\n}\n\n/** Filler words and phrases removed by default. \"like\" and \"so\" are left out: too often meant. */\nexport const VIDEO_DEFAULT_FILLERS = ['um', 'umm', 'uh', 'uhh', 'uhm', 'erm', 'er', 'ah', 'hmm', 'mm', 'you know', 'i mean'] as const;\n\n/** Fillers in the transcript, longest phrase first (\"you know\" before \"you\"), as timeline ranges. */\nexport function findVideoFillers(words: readonly VideoTimelineWord[], fillers: readonly string[] = VIDEO_DEFAULT_FILLERS): VideoTranscriptRange[] {\n\tconst phrases = fillers\n\t\t.map((f) => f.split(/\\s+/).map(normalizeVideoWord).filter(Boolean))\n\t\t.filter((p) => p.length)\n\t\t.sort((a, b) => b.length - a.length);\n\tconst norm = words.map((w) => normalizeVideoWord(w.word));\n\tconst out: VideoTranscriptRange[] = [];\n\tfor (let i = 0; i < norm.length; ) {\n\t\tconst hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));\n\t\tif (hit) {\n\t\t\tout.push(rangeOf(words, i, hit.length));\n\t\t\ti += hit.length;\n\t\t} else i++;\n\t}\n\treturn out;\n}\n\nexport interface VideoPauseOptions {\n\t/** Shortest gap between words that counts as a pause (ms, default 700). */\n\tmin?: number;\n\t/** Silence to keep next to each word so speech does not sound clipped (ms, default 150). */\n\tkeep?: number;\n\t/** Frame rate to snap the cut inward to (default: none). */\n\tfps?: number;\n}\n\n/** Gaps between consecutive words on the same clip longer than `min`, less `keep` either side, as timeline ranges. */\nexport function findVideoTranscriptPauses(words: readonly VideoTimelineWord[], options: VideoPauseOptions = {}): VideoTranscriptRange[] {\n\tconst min = options.min ?? 700;\n\tconst keep = options.keep ?? 150;\n\tconst out: VideoTranscriptRange[] = [];\n\tfor (let i = 1; i < words.length; i++) {\n\t\tconst a = words[i - 1];\n\t\tconst b = words[i];\n\t\tif (a.clip !== b.clip || b.start - a.end < min) continue;\n\t\tlet from = a.end + keep;\n\t\tlet to = b.start - keep;\n\t\tif (options.fps) {\n\t\t\t// Snap inward, so the cut never eats into the kept silence.\n\t\t\tconst f = 1000 / options.fps;\n\t\t\tfrom = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);\n\t\t\tto = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);\n\t\t}\n\t\tif (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after \"${a.word}\")` });\n\t}\n\treturn out;\n}\n\n/** Ranges sorted and merged where they touch or overlap. */\nexport function mergeVideoRanges(ranges: readonly { from: number; to: number }[]): { from: number; to: number }[] {\n\tconst sorted = ranges.filter((r) => r.to - r.from > VIDEO_TIME_EPSILON).map((r) => ({ from: r.from, to: r.to })).sort((a, b) => a.from - b.from);\n\tconst out: { from: number; to: number }[] = [];\n\tfor (const r of sorted) {\n\t\tconst last = out[out.length - 1];\n\t\tif (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);\n\t\telse out.push(r);\n\t}\n\treturn out;\n}\n\n/**\n * The edits that cut ranges out of the timeline and close them up: one `extract_range` per merged\n * range, last first, so earlier ranges keep their times. Apply them together as one undo step.\n */\nexport function videoRemoveRangesEdits(ranges: readonly { from: number; to: number }[], tracks?: string[]): VideoEdit[] {\n\treturn mergeVideoRanges(ranges)\n\t\t.reverse()\n\t\t.map((r) => ({ op: 'extract_range' as const, from: r.from, to: r.to, ...(tracks ? { tracks } : {}) }));\n}\n\n/** The timeline stretch from word `a` to word `b` (either order), for deleting a selection of words. */\nexport function videoWordsRange(words: readonly VideoTimelineWord[], a: number, b: number): { from: number; to: number } {\n\tconst [i, j] = a <= b ? [a, b] : [b, a];\n\treturn { from: words[i].start, to: words[j].end };\n}\n\n/** The edit that attaches a transcript file's text to an asset (see `parseVideoTranscript`). Throws a readable error for text it cannot read. */\nexport function videoTranscriptEdit(assetId: string, text: string): VideoEdit {\n\treturn { op: 'update_asset', asset: assetId, set: { transcript: parseVideoTranscript(text).words } };\n}\n\n/** The word playing at a timeline time, as its index in `words`, or -1. */\nexport function videoWordAt(words: readonly VideoTimelineWord[], time: number): number {\n\tlet lo = 0;\n\tlet hi = words.length - 1;\n\twhile (lo <= hi) {\n\t\tconst mid = (lo + hi) >> 1;\n\t\tif (words[mid].end <= time) lo = mid + 1;\n\t\telse if (words[mid].start > time) hi = mid - 1;\n\t\telse return mid;\n\t}\n\treturn -1;\n}\n","/**\n * Proxies: smaller copies of an asset's media the preview plays instead of the original, so\n * editing stays smooth on large footage. They live on the asset (`VideoAsset.proxies`); the preview\n * picks one by its preview scale; the export, EDL and agents' source times always use the original.\n */\nimport { videoShellQuote } from './export.js';\nimport type { VideoAsset, VideoAssetProxy, VideoProject } from './types.js';\n\n/**\n * How large a picture the preview plays: `full` (the original), a fraction (0.5 = half size, using\n * the smallest proxy at least that large), or `auto` (what the preview canvas shows).\n */\nexport type VideoPreviewScale = 'auto' | 'full' | number;\n/** The scales a preview-scale control offers, besides `auto` and `full`. */\nexport const VIDEO_PROXY_SCALES = [0.5, 0.25] as const;\n\n/** The proxy the preview plays at `scale` (0–1]: the smallest at least that large, else `null` (play the original). */\nexport function videoAssetProxy(asset: Pick<VideoAsset, 'proxies'> | undefined, scale: number): VideoAssetProxy | null {\n\tif (!asset?.proxies?.length || !(scale > 0) || scale >= 1) return null;\n\tlet best: VideoAssetProxy | null = null;\n\tfor (const p of asset.proxies) if (p.scale >= scale - 1e-9 && p.scale < 1 && (!best || p.scale < best.scale)) best = p;\n\treturn best;\n}\n\n/** The `src` the preview plays for an asset at `scale`: a proxy's, or the original's. */\nexport const videoPreviewSrc = (asset: VideoAsset | undefined, scale: number, fallback = asset?.src ?? ''): string => videoAssetProxy(asset, scale)?.src ?? fallback;\n\n/** A preview scale as a fraction: `full` is 1, `auto` is `shown` (the canvas's resolution, at most 1). */\nexport function resolveVideoPreviewScale(scale: VideoPreviewScale | undefined, shown: number): number {\n\tif (scale === undefined || scale === 'auto') return Math.min(1, Math.max(0, shown));\n\tif (scale === 'full') return 1;\n\treturn Math.min(1, Math.max(0, scale));\n}\n\nexport interface VideoProxyOptions {\n\t/** Size of the proxy as a fraction of the original (default 0.5). */\n\tscale?: number;\n\t/** Output file (default: the source's name with `.proxy-50.mp4`, for scale 0.5). */\n\toutput?: string;\n\t/** The path or URL ffmpeg should open (default the asset's `src`). */\n\tresolve?: (asset: VideoAsset) => string;\n\t/** Frames between keyframes (default 12): short, so seeking and scrubbing decode little. */\n\tgop?: number;\n\t/** x264 quality (default 28: small files; proxies are for editing, not viewing). */\n\tcrf?: number;\n}\n\n/** A proxy's file name next to `src`: `clip.mov` at 0.5 → `clip.proxy-50.mp4`. */\nexport function videoProxyName(src: string, scale = 0.5): string {\n\tconst clean = src.replace(/[?#].*$/, '');\n\tconst base = clean.replace(/\\.[^./\\\\]+$/, '');\n\treturn `${base}.proxy-${Math.round(scale * 100)}.mp4`;\n}\n\n/** An ffmpeg command making an asset's proxy, and the proxy entry to add once it exists. */\nexport interface VideoProxyCommand {\n\tasset: string;\n\targs: string[];\n\t/** One shell line. */\n\tcommand: string;\n\t/** The entry for `VideoAsset.proxies` (its `src` is the output; map it to a URL the preview can load if needed). */\n\tproxy: VideoAssetProxy;\n}\n\n/**\n * The ffmpeg command for a proxy of a video asset: scaled to `scale` (even sizes), H.264 with a\n * short keyframe interval (`gop`) and no B-frames so seeking is cheap, `fastdecode`, the sound\n * kept as AAC, and `faststart` so a browser can play it while it loads.\n */\nexport function videoProxyFfmpeg(asset: VideoAsset, options: VideoProxyOptions = {}): VideoProxyCommand {\n\tif (asset.kind !== 'video') throw new Error(`Asset \"${asset.id}\" is ${asset.kind === 'image' ? 'an image' : 'audio'}; only video assets have proxies.`);\n\tconst scale = options.scale ?? 0.5;\n\tif (!(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.`);\n\tconst gop = Math.max(1, Math.round(options.gop ?? 12));\n\tconst output = options.output ?? videoProxyName(asset.src, scale);\n\tconst src = options.resolve ? options.resolve(asset) : asset.src;\n\tconst size = `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`;\n\tconst args = [\n\t\t'-y',\n\t\t'-i',\n\t\tsrc,\n\t\t'-vf',\n\t\tsize,\n\t\t'-c:v',\n\t\t'libx264',\n\t\t'-preset',\n\t\t'veryfast',\n\t\t'-tune',\n\t\t'fastdecode',\n\t\t'-crf',\n\t\tString(options.crf ?? 28),\n\t\t'-g',\n\t\tString(gop),\n\t\t'-keyint_min',\n\t\tString(gop),\n\t\t'-sc_threshold',\n\t\t'0',\n\t\t'-bf',\n\t\t'0',\n\t\t'-pix_fmt',\n\t\t'yuv420p',\n\t\t...(asset.hasAudio === false ? ['-an'] : ['-c:a', 'aac', '-b:a', '128k']),\n\t\t'-movflags',\n\t\t'+faststart',\n\t\toutput\n\t];\n\tconst proxy: VideoAssetProxy = { scale, src: output, ...(asset.width && asset.height ? { width: Math.trunc((asset.width * scale) / 2) * 2, height: Math.trunc((asset.height * scale) / 2) * 2 } : {}) };\n\treturn { asset: asset.id, args, command: ['ffmpeg', ...args].map(videoShellQuote).join(' '), proxy };\n}\n\n/** Commands for every video asset (or those listed) without a proxy at `scale`. */\nexport function videoProxyPlan(project: VideoProject, options: VideoProxyOptions & { assets?: readonly string[]; output?: never } = {}): VideoProxyCommand[] {\n\tconst scale = options.scale ?? 0.5;\n\tconst wanted = options.assets ? new Set(options.assets) : null;\n\tif (options.assets) for (const id of options.assets) {\n\t\tconst a = project.assets.find((x) => x.id === id);\n\t\tif (!a) throw new Error(`There is no asset \"${id}\"; the assets are ${project.assets.map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\tif (a.kind !== 'video') throw new Error(`Asset \"${id}\" is ${a.kind === 'image' ? 'an image' : 'audio'}; only video assets have proxies.`);\n\t}\n\treturn project.assets\n\t\t.filter((a) => a.kind === 'video' && (!wanted || wanted.has(a.id)) && !a.proxies?.some((p) => Math.abs(p.scale - scale) < 1e-9))\n\t\t.map((a) => videoProxyFfmpeg(a, { ...options, scale }));\n}\n\n/** The choices a preview-size control offers, with labels: `value` round-trips through `parseVideoPreviewScale`. */\nexport const VIDEO_PREVIEW_SCALE_OPTIONS: readonly { value: string; label: string; scale: VideoPreviewScale }[] = [\n\t{ value: 'auto', label: 'Auto', scale: 'auto' },\n\t{ value: 'full', label: 'Full', scale: 'full' },\n\t...VIDEO_PROXY_SCALES.map((s) => ({ value: String(s), label: s === 0.5 ? '½' : s === 0.25 ? '¼' : `${s * 100}%`, scale: s }))\n];\n\n/** A preview-size control's value back as a scale (`auto` for anything unknown). */\nexport const parseVideoPreviewScale = (value: string): VideoPreviewScale => (value === 'full' ? 'full' : Number(value) > 0 && Number(value) <= 1 ? Number(value) : 'auto');\n\n/** Whether any asset has proxies (so a preview-size control is worth showing). */\nexport const videoProjectHasProxies = (project: VideoProject): boolean => project.assets.some((a) => !!a.proxies?.length);\n","/**\n * A cache of decoded source frames for the preview, with prefetch ahead of the playhead, after the\n * way Blender's sequencer caches strips: scrubbing and stepping show cached frames at once instead\n * of waiting for a `<video>` to seek. It is host-side and optional: give a `VideoFrameCache` to the\n * controller (`frameCache`) or the preview engine.\n *\n * Frames are keyed by the file the preview plays (the asset's `src`, or the proxy it picks) and the\n * source frame, not by clip: every clip of an asset shares them, and edits never invalidate them.\n * Eviction removes the frames furthest from the playhead, counting frames behind it (against the\n * play direction) as further, and never touches the prefetch window.\n */\nimport { videoFrameAt, videoFrameLeafLayers, type VideoLayer } from './evaluate.js';\nimport type { VideoProject } from './types.js';\n\n/** The source frame showing at a source time (ms) on an `fps` grid: a frame shows from its start. */\nexport const videoSourceFrame = (sourceTime: number, fps: number): number => Math.max(0, Math.floor((sourceTime * fps) / 1000 + 1e-6));\n\n/** The middle of a source frame (ms): where a decoder should seek, away from the frame's edges. */\nexport const videoSourceFrameTime = (frame: number, fps: number): number => ((frame + 0.5) * 1000) / fps;\n\n/** A cache key: the file and the source frame. */\nexport const videoFrameCacheKey = (src: string, frame: number): string => `${frame}@${src}`;\n\n/** A frame the preview will need: the file, the source frame and when on the timeline it shows. */\nexport interface VideoFrameRequest {\n\tkey: string;\n\tsrc: string;\n\tframe: number;\n\t/** Where to seek in the source (ms): the middle of the frame. */\n\tsourceTime: number;\n\t/** When it shows on the timeline (ms). */\n\ttime: number;\n}\n\n/** The video layers' source frames at timeline `time`, as the preview would play them. */\nexport function videoFrameRequestsAt(project: VideoProject, time: number, srcFor: (layer: VideoLayer) => string | undefined = (l) => l.src): VideoFrameRequest[] {\n\tconst out: VideoFrameRequest[] = [];\n\t// Media inside compound clips is decoded like any other.\n\tfor (const layer of videoFrameLeafLayers(videoFrameAt(project, time).layers)) {\n\t\tif (layer.type !== 'media' || layer.assetKind !== 'video') continue;\n\t\tconst src = srcFor(layer);\n\t\tif (!src) continue;\n\t\tconst frame = videoSourceFrame(layer.sourceTime ?? 0, project.fps);\n\t\tout.push({ key: videoFrameCacheKey(src, frame), src, frame, sourceTime: videoSourceFrameTime(frame, project.fps), time });\n\t}\n\treturn out;\n}\n\n/**\n * The frames to have ready from `time` over the next `window` ms (backwards with `direction` -1),\n * a frame apart, nearest first, each key once.\n */\nexport function planVideoPrefetch(project: VideoProject, time: number, options: { window?: number; direction?: 1 | -1; srcFor?: (layer: VideoLayer) => string | undefined } = {}): VideoFrameRequest[] {\n\tconst window = options.window ?? 2000;\n\tconst dir = options.direction ?? 1;\n\tconst step = 1000 / project.fps;\n\tconst seen = new Set<string>();\n\tconst out: VideoFrameRequest[] = [];\n\tfor (let i = 0; i * step <= window + 1e-6; i++) {\n\t\tconst t = time + dir * i * step;\n\t\tif (t < 0) break;\n\t\tfor (const r of videoFrameRequestsAt(project, t, options.srcFor)) {\n\t\t\tif (seen.has(r.key)) continue;\n\t\t\tseen.add(r.key);\n\t\t\tout.push(r);\n\t\t}\n\t}\n\treturn out;\n}\n\n/** A cached frame's bookkeeping for eviction. */\nexport interface VideoCacheEntryInfo {\n\tkey: string;\n\t/** When it last showed (or will show) on the timeline (ms). */\n\ttime: number;\n\tbytes: number;\n}\n\n/**\n * Which entries to drop to get under `maxBytes`: never those in the prefetch window\n * (`protect`), then the furthest from the playhead first, with frames behind it (against\n * `direction`) counted `behind` times as far (default 2), so the way you are going is kept.\n */\nexport function videoCacheEvictions(entries: readonly VideoCacheEntryInfo[], options: { playhead: number; maxBytes: number; direction?: 1 | -1; behind?: number; protect?: ReadonlySet<string> }): string[] {\n\tlet total = entries.reduce((n, e) => n + e.bytes, 0);\n\tif (total <= options.maxBytes) return [];\n\tconst dir = options.direction ?? 1;\n\tconst behind = options.behind ?? 2;\n\tconst cost = (e: VideoCacheEntryInfo) => {\n\t\tconst d = (e.time - options.playhead) * dir;\n\t\treturn d >= 0 ? d : -d * behind;\n\t};\n\tconst out: string[] = [];\n\tfor (const e of entries.filter((x) => !options.protect?.has(x.key)).sort((a, b) => cost(b) - cost(a))) {\n\t\tif (total <= options.maxBytes) break;\n\t\tout.push(e.key);\n\t\ttotal -= e.bytes;\n\t}\n\treturn out;\n}\n\n/** A decoded frame: anything `drawImage` takes, with its size (`close` is called when it is evicted). */\nexport interface VideoDecodedFrame {\n\timage: CanvasImageSource & { close?: () => void };\n\twidth: number;\n\theight: number;\n}\n\n/** Decodes the frame of `src` at a source time (ms). */\nexport type VideoFrameDecoder = (src: string, sourceTime: number) => Promise<VideoDecodedFrame>;\n\nexport interface VideoFrameCacheOptions {\n\t/** How frames are decoded for prefetch (default in a browser: `createVideoElementDecoder()`; elsewhere none, so only frames the preview hands over are kept). */\n\tdecode?: VideoFrameDecoder | null;\n\t/** Memory budget in bytes (4 a pixel; default 512 MB). */\n\tmaxBytes?: number;\n\t/** How far ahead of the playhead to prefetch (ms, default 2000; 0 turns prefetch off). */\n\tprefetch?: number;\n\t/** How much further frames behind the playhead count when evicting (default 2). */\n\tbehind?: number;\n\t/** Frames decoded at once (default 2). */\n\tconcurrency?: number;\n\t/** Copies a ready media element's current picture (default `createImageBitmap`), to keep frames the preview has shown. */\n\tcapture?: (source: CanvasImageSource) => Promise<VideoDecodedFrame | null>;\n}\n\n/** The cache's state, for a status line and agents. */\nexport interface VideoFrameCacheStats {\n\tframes: number;\n\tbytes: number;\n\tmaxBytes: number;\n\thits: number;\n\tmisses: number;\n\t/** Frames being decoded or waiting to be. */\n\tpending: number;\n\tprefetch: number;\n}\n\ninterface Entry extends VideoCacheEntryInfo {\n\tframe: VideoDecodedFrame;\n}\n\nconst defaultCapture = async (source: CanvasImageSource): Promise<VideoDecodedFrame | null> => {\n\tif (typeof createImageBitmap !== 'function') return null;\n\tconst image = await createImageBitmap(source as ImageBitmapSource);\n\treturn { image, width: image.width, height: image.height };\n};\n\n/**\n * Decoded source frames for the preview, filled ahead of the playhead and evicted by distance from\n * it (see the module notes). `VideoPreviewEngine` calls `update` as the playhead moves, draws hits\n * from `get` while paused or while an element seeks, and hands over frames it has shown.\n */\nexport class VideoFrameCache {\n\tprivate readonly entries = new Map<string, Entry>();\n\tprivate readonly inFlight = new Set<string>();\n\tprivate queue: VideoFrameRequest[] = [];\n\tprivate protect = new Set<string>();\n\tprivate readonly listeners = new Set<() => void>();\n\tprivate playhead = 0;\n\tprivate direction: 1 | -1 = 1;\n\tprivate hits = 0;\n\tprivate misses = 0;\n\tprivate bytes = 0;\n\tprivate generation = 0;\n\tprivate destroyed = false;\n\tprivate srcFor: ((layer: VideoLayer) => string | undefined) | undefined;\n\treadonly maxBytes: number;\n\treadonly prefetch: number;\n\tprivate readonly decode: VideoFrameDecoder | null;\n\n\tconstructor(private readonly options: VideoFrameCacheOptions = {}) {\n\t\tthis.maxBytes = options.maxBytes ?? 512 * 1024 * 1024;\n\t\tthis.prefetch = options.prefetch ?? 2000;\n\t\tthis.decode = options.decode !== undefined ? options.decode : typeof document !== 'undefined' ? createVideoElementDecoder() : null;\n\t}\n\n\t/** The cached frame of `src` showing at a source time (ms), on the project's `fps` grid. Counts a hit or a miss. */\n\tget(src: string, sourceTime: number, fps: number): CanvasImageSource | undefined {\n\t\tconst entry = this.entries.get(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));\n\t\tif (entry) this.hits++;\n\t\telse this.misses++;\n\t\treturn entry?.frame.image;\n\t}\n\n\t/** Whether a frame is cached, without counting it. */\n\thas(src: string, sourceTime: number, fps: number): boolean {\n\t\treturn this.entries.has(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));\n\t}\n\n\t/** Keeps a decoded frame (replacing any with the same key), then evicts to the budget. */\n\tput(request: Pick<VideoFrameRequest, 'key' | 'time'>, frame: VideoDecodedFrame): void {\n\t\tif (this.destroyed) return void frame.image.close?.();\n\t\tconst old = this.entries.get(request.key);\n\t\tif (old) this.drop(old);\n\t\tconst bytes = Math.max(1, frame.width * frame.height * 4);\n\t\tthis.entries.set(request.key, { key: request.key, time: request.time, bytes, frame });\n\t\tthis.bytes += bytes;\n\t\tthis.evict();\n\t\tthis.emit();\n\t}\n\n\t/** Keeps what a ready media element shows now as the frame of `src` at `sourceTime` (async; does nothing if it is cached). */\n\tcapture(src: string, sourceTime: number, fps: number, time: number, source: CanvasImageSource): void {\n\t\tconst frame = videoSourceFrame(sourceTime, fps);\n\t\tconst key = videoFrameCacheKey(src, frame);\n\t\tif (this.entries.has(key) || this.inFlight.has(key)) return;\n\t\tthis.inFlight.add(key);\n\t\tvoid (this.options.capture ?? defaultCapture)(source)\n\t\t\t.then((f) => {\n\t\t\t\tif (f) this.put({ key, time }, f);\n\t\t\t})\n\t\t\t.catch(() => {})\n\t\t\t.finally(() => this.inFlight.delete(key));\n\t}\n\n\t/**\n\t * Follows the playhead: protects the prefetch window, evicts to the budget, and queues the\n\t * window's missing frames for decoding (nearest first). `srcFor` gives the file a layer plays.\n\t */\n\tupdate(project: VideoProject, time: number, options: { playing?: boolean; direction?: 1 | -1; srcFor?: (layer: VideoLayer) => string | undefined } = {}): void {\n\t\tif (this.destroyed) return;\n\t\tthis.direction = options.direction ?? (time < this.playhead && !options.playing ? -1 : 1);\n\t\tthis.playhead = time;\n\t\tthis.srcFor = options.srcFor;\n\t\tconst plan = this.prefetch > 0 ? planVideoPrefetch(project, time, { window: this.prefetch, direction: this.direction, srcFor: options.srcFor }) : videoFrameRequestsAt(project, time, options.srcFor);\n\t\tthis.protect = new Set(plan.map((r) => r.key));\n\t\t// Frames in the window show soon: their timeline time is now where they will be needed.\n\t\tfor (const r of plan) {\n\t\t\tconst e = this.entries.get(r.key);\n\t\t\tif (e) e.time = r.time;\n\t\t}\n\t\tthis.evict();\n\t\tthis.generation++;\n\t\tthis.queue = this.decode ? plan.filter((r) => !this.entries.has(r.key) && !this.inFlight.has(r.key)) : [];\n\t\tthis.pump();\n\t}\n\n\t/**\n\t * The stretches of the timeline whose video frames are all cached, between `from` and `to` (ms),\n\t * for the files the preview last played (its `srcFor`, e.g. proxies) unless `srcFor` is given.\n\t * `step` (ms, at least a frame) samples more coarsely.\n\t */\n\tcachedRanges(project: VideoProject, options: { from?: number; to?: number; srcFor?: (layer: VideoLayer) => string | undefined; step?: number } = {}): { start: number; end: number }[] {\n\t\t// One check a frame by default; a coarser `step` (ms) suits drawing a long timeline.\n\t\tconst step = Math.max(1000 / project.fps, options.step ?? 0);\n\t\tconst from = Math.max(0, options.from ?? 0);\n\t\tconst to = options.to ?? Math.max(...project.tracks.flatMap((t) => t.clips.map((c) => c.start + c.duration)), 0);\n\t\tconst out: { start: number; end: number }[] = [];\n\t\tfor (let t = from; t < to - 1e-6; t += step) {\n\t\t\tconst requests = videoFrameRequestsAt(project, t, options.srcFor ?? this.srcFor);\n\t\t\tconst cached = requests.length > 0 && requests.every((r) => this.entries.has(r.key));\n\t\t\tconst last = out[out.length - 1];\n\t\t\tif (!cached) continue;\n\t\t\tconst end = Math.min(to, t + step);\n\t\t\tif (last && Math.abs(last.end - t) < 1e-6) last.end = end;\n\t\t\telse out.push({ start: t, end });\n\t\t}\n\t\treturn out.map((r) => ({ start: Math.round(r.start * 10) / 10, end: Math.round(r.end * 10) / 10 }));\n\t}\n\n\tstats(): VideoFrameCacheStats {\n\t\treturn { 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 };\n\t}\n\n\t/** Called when frames arrive or leave. */\n\tsubscribe(listener: () => void): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t}\n\n\t/** Drops every frame (closing them) and the queue. */\n\tclear(): void {\n\t\tfor (const e of [...this.entries.values()]) this.drop(e);\n\t\tthis.queue = [];\n\t\tthis.generation++;\n\t\tthis.emit();\n\t}\n\n\t/** Clears and stops decoding for good. */\n\tdestroy(): void {\n\t\tthis.clear();\n\t\tthis.destroyed = true;\n\t\tthis.listeners.clear();\n\t}\n\n\tprivate pump() {\n\t\tconst limit = Math.max(1, this.options.concurrency ?? 2);\n\t\twhile (this.decode && this.inFlight.size < limit && this.queue.length) {\n\t\t\tconst r = this.queue.shift()!;\n\t\t\tif (this.entries.has(r.key) || this.inFlight.has(r.key)) continue;\n\t\t\t// Stop filling once the window alone is at the budget: decoding more would only evict it.\n\t\t\tif (this.bytes >= this.maxBytes && [...this.entries.keys()].every((k) => this.protect.has(k))) {\n\t\t\t\tthis.queue = [];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tconst generation = this.generation;\n\t\t\tthis.inFlight.add(r.key);\n\t\t\tthis.decode(r.src, r.sourceTime)\n\t\t\t\t.then(\n\t\t\t\t\t(frame) => {\n\t\t\t\t\t\tthis.inFlight.delete(r.key);\n\t\t\t\t\t\t// A frame that left the window while it decoded is still worth keeping if there is room.\n\t\t\t\t\t\tif (generation === this.generation || this.bytes + frame.width * frame.height * 4 <= this.maxBytes) this.put(r, frame);\n\t\t\t\t\t\telse frame.image.close?.();\n\t\t\t\t\t},\n\t\t\t\t\t() => this.inFlight.delete(r.key)\n\t\t\t\t)\n\t\t\t\t.finally(() => this.pump());\n\t\t}\n\t}\n\n\tprivate evict() {\n\t\tconst drop = videoCacheEvictions([...this.entries.values()], { playhead: this.playhead, maxBytes: this.maxBytes, direction: this.direction, behind: this.options.behind, protect: this.protect });\n\t\tfor (const key of drop) this.drop(this.entries.get(key)!);\n\t}\n\n\tprivate drop(e: Entry) {\n\t\tthis.entries.delete(e.key);\n\t\tthis.bytes -= e.bytes;\n\t\te.frame.image.close?.();\n\t}\n\n\tprivate emit() {\n\t\tfor (const l of [...this.listeners]) l();\n\t}\n}\n\n/**\n * A browser decoder for prefetch: one hidden, muted `<video>` per file, seeking to each requested\n * time in turn and copying the picture with `createImageBitmap` once it is presented\n * (`requestVideoFrameCallback` where the browser has it, else `seeked`). Media from another origin\n * needs CORS, as for scopes.\n */\nexport function createVideoElementDecoder(options: { createVideo?: () => HTMLVideoElement; timeout?: number } = {}): VideoFrameDecoder {\n\tconst videos = new Map<string, { el: HTMLVideoElement; chain: Promise<unknown> }>();\n\tconst make =\n\t\toptions.createVideo ??\n\t\t(() => {\n\t\t\tconst el = document.createElement('video');\n\t\t\tel.muted = true;\n\t\t\tel.preload = 'auto';\n\t\t\tel.crossOrigin = 'anonymous';\n\t\t\tel.playsInline = true;\n\t\t\treturn el;\n\t\t});\n\tconst timeout = options.timeout ?? 5000;\n\tconst once = (el: HTMLVideoElement, type: string) =>\n\t\tnew Promise<void>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => reject(new Error(`The video did not ${type} within ${timeout} ms.`)), timeout);\n\t\t\tel.addEventListener(type, () => (clearTimeout(timer), resolve()), { once: true });\n\t\t\tel.addEventListener('error', () => (clearTimeout(timer), reject(new Error('The video could not be loaded.'))), { once: true });\n\t\t});\n\treturn (src, sourceTime) => {\n\t\tlet v = videos.get(src);\n\t\tif (!v) {\n\t\t\tconst el = make();\n\t\t\tel.src = src;\n\t\t\tv = { el, chain: el.readyState >= 1 ? Promise.resolve() : once(el, 'loadedmetadata') };\n\t\t\tvideos.set(src, v);\n\t\t}\n\t\tconst { el } = v;\n\t\tconst next = v.chain.then(async () => {\n\t\t\tconst presented = 'requestVideoFrameCallback' in el ? new Promise<void>((r) => (el as any).requestVideoFrameCallback(() => r())) : null;\n\t\t\tconst seeked = once(el, 'seeked');\n\t\t\tel.currentTime = sourceTime / 1000;\n\t\t\tawait seeked;\n\t\t\tif (presented) await Promise.race([presented, new Promise((r) => setTimeout(r, 100))]);\n\t\t\tconst image = await createImageBitmap(el);\n\t\t\treturn { image, width: image.width, height: image.height };\n\t\t});\n\t\tv.chain = next.catch(() => {});\n\t\treturn next;\n\t};\n}\n","import { videoFrameAt, videoFrameLeafLayers, type VideoFrame, type VideoLayer } from './evaluate.js';\nimport { drawVideoFrame, type VideoCanvasContext, type VideoScratchCanvas } from './render.js';\nimport type { VideoFrameCache } from './frame-cache.js';\nimport type { VideoLut, VideoLutCache } from './lut.js';\nimport type { VideoPreviewPixels, VideoPreviewSnapshot } from './scopes.js';\nimport { resolveVideoPreviewScale, videoPreviewSrc, type VideoPreviewScale } from './proxy.js';\nimport type { VideoProject } from './types.js';\n\n/** What a preview follows: a `VideoEditorController` fits. */\nexport interface VideoPreviewSource {\n\tgetState(): { project: VideoProject; time: number; playing: boolean; previewScale?: VideoPreviewScale };\n\tsubscribe(listener: () => void): () => void;\n\t/** Where to publish the drawn pixels (a controller has one), for scopes and agents. */\n\tpreviewPixels?: VideoPreviewPixels;\n\t/** Where grades' LUTs load from (a controller has one). */\n\tluts?: VideoLutCache;\n\t/** Decoded source frames with prefetch (a controller has one when the host gives it). */\n\tframeCache?: VideoFrameCache | null;\n}\n\n/** The parts of a media element the preview drives (so tests can pass fakes). */\nexport interface VideoPreviewMedia {\n\tsrc: string;\n\tcurrentTime: number;\n\tplaybackRate: number;\n\tvolume: number;\n\tmuted: boolean;\n\tpaused: boolean;\n\t/** True while a seek is under way (the element still shows the old frame). */\n\tseeking?: boolean;\n\treadyState: number;\n\tpreload: string;\n\tplay(): Promise<void> | void;\n\tpause(): void;\n\taddEventListener(type: string, listener: () => void): void;\n\tremoveEventListener(type: string, listener: () => void): void;\n\tremoveAttribute?(name: string): void;\n\tload?(): void;\n}\n\nexport interface VideoPreviewImage {\n\tsrc: string;\n\tcomplete: boolean;\n\taddEventListener(type: string, listener: () => void): void;\n}\n\nexport interface VideoPreviewOptions {\n\t/** Canvas pixels per frame pixel (default: fits the canvas's displayed width at the device pixel ratio, at most 1). */\n\tresolution?: number;\n\t/** How large a picture to play, overriding the source's `previewScale` (see `VideoPreviewScale`). */\n\tpreviewScale?: VideoPreviewScale;\n\t/** How far (ms) a playing element may drift from the timeline before it is re-seeked (default 200). */\n\ttolerance?: number;\n\t/** Makes media and image elements (default `document.createElement`). */\n\tcreateMedia?: (kind: 'video' | 'audio') => VideoPreviewMedia;\n\tcreateImage?: () => VideoPreviewImage;\n\t/** The 2D context to draw on (default `canvas.getContext('2d')`). */\n\tcontext?: VideoCanvasContext | null;\n\t/** Called after each frame is drawn. */\n\tonFrame?: (frame: VideoFrame) => void;\n\t/**\n\t * Puts an SVG filter (white balance in a grade) where the canvas's `filter: url(#id)` can find\n\t * it. Default: a hidden `<svg>` added to `document.body`. `null` leaves white balance out.\n\t */\n\tdefineSvgFilter?: ((id: string, markup: string) => void) | null;\n\t/**\n\t * Where to publish the canvas's pixels after each frame, for scopes and the `get_video_scopes`\n\t * tool (default: the source's `previewPixels`). `null` publishes nothing.\n\t */\n\tpixels?: VideoPreviewPixels | null;\n\t/**\n\t * Decoded source frames to draw while paused, scrubbing or waiting for a seek, prefetched ahead\n\t * of the playhead (default: the source's `frameCache`; `null` uses none).\n\t */\n\tframeCache?: VideoFrameCache | null;\n\t/** Where grades' LUTs load from (default: the source's `luts`). Without one, LUTs are left out. */\n\tluts?: VideoLutCache | null;\n\t/**\n\t * Makes an offscreen canvas compound clips and LUT-graded layers are drawn on (default: a\n\t * `<canvas>` with `willReadFrequently`), once per `slot` (see `VideoDrawOptions.scratch`). LUTs\n\t * run on the CPU, so a lower `resolution` keeps playback smooth.\n\t */\n\tcreateScratch?: (width: number, height: number, slot: number) => VideoScratchCanvas | null;\n}\n\ninterface Entry {\n\tclip: string;\n\tsrc: string;\n\tmedia: VideoPreviewMedia;\n\tonReady: () => void;\n}\n\n/**\n * Plays a project in the browser: keeps one `<video>` or `<audio>` element per playing clip in step\n * with the timeline (seeking when it drifts, matching speed and gain, pausing with the editor), and\n * draws each frame (`videoFrameAt` + `drawVideoFrame`) on a canvas. Framework-free: React and Svelte\n * wrap it in `<VideoPreview>`. Elements for clips that stop playing are paused and released.\n */\nexport class VideoPreviewEngine {\n\tprivate readonly canvas: { width: number; height: number; clientWidth?: number; getContext?: (id: '2d') => unknown };\n\tprivate readonly ctx: VideoCanvasContext | null;\n\tprivate readonly media = new Map<string, Entry>();\n\tprivate readonly images = new Map<string, VideoPreviewImage>();\n\tprivate readonly unsubscribe: () => void;\n\tprivate scheduled = false;\n\tprivate destroyed = false;\n\tprivate filterHost: Element | null = null;\n\tprivate readonly filters = new Set<string>();\n\tprivate readonly detachPixels: () => void;\n\tprivate readonly pixelsTarget: VideoPreviewPixels | null;\n\tprivate drawn: { time: number; complete: boolean } | null = null;\n\tprivate complete = true;\n\tprivate readonly lutCache: VideoLutCache | null;\n\tprivate readonly frameCache: VideoFrameCache | null;\n\tprivate readonly unsubscribeFrames: () => void;\n\tprivate srcFor: (layer: VideoLayer) => string | undefined = (layer) => layer.src;\n\tprivate playing = false;\n\tprivate fps = 30;\n\tprivate readonly unsubscribeLuts: () => void;\n\tprivate readonly scratchCanvases = new Map<number, VideoScratchCanvas | null>();\n\n\tconstructor(\n\t\tprivate readonly source: VideoPreviewSource,\n\t\tcanvas: { width: number; height: number; clientWidth?: number; getContext?: (id: '2d') => unknown },\n\t\tprivate readonly options: VideoPreviewOptions = {}\n\t) {\n\t\tthis.canvas = canvas;\n\t\tthis.ctx = options.context !== undefined ? options.context : ((canvas.getContext?.('2d') as VideoCanvasContext | null) ?? null);\n\t\tthis.unsubscribe = source.subscribe(() => this.update());\n\t\tthis.lutCache = options.luts !== undefined ? options.luts : (source.luts ?? null);\n\t\tthis.unsubscribeLuts = this.lutCache ? this.lutCache.subscribe(() => this.redraw()) : () => {};\n\t\tthis.frameCache = options.frameCache !== undefined ? options.frameCache : (source.frameCache ?? null);\n\t\tthis.unsubscribeFrames = this.frameCache ? this.frameCache.subscribe(() => this.redraw()) : () => {};\n\t\tconst pixels = options.pixels !== undefined ? options.pixels : source.previewPixels;\n\t\tthis.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {};\n\t\tthis.pixelsTarget = pixels ?? null;\n\t\tthis.update();\n\t}\n\n\t/** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */\n\treadPixels(): VideoPreviewSnapshot | null {\n\t\tconst ctx = this.ctx as (VideoCanvasContext & { getImageData?: (x: number, y: number, w: number, h: number) => ImageData }) | null;\n\t\tif (!ctx?.getImageData || !this.drawn) return null;\n\t\treturn { ...this.drawn, pixels: ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) };\n\t}\n\n\tprivate createMedia(kind: 'video' | 'audio'): VideoPreviewMedia {\n\t\tif (this.options.createMedia) return this.options.createMedia(kind);\n\t\tconst el = document.createElement(kind);\n\t\tel.preload = 'auto';\n\t\tel.crossOrigin = 'anonymous';\n\t\tif (kind === 'video') (el as HTMLVideoElement).playsInline = true;\n\t\treturn el as unknown as VideoPreviewMedia;\n\t}\n\n\tprivate image(src: string): VideoPreviewImage {\n\t\tlet img = this.images.get(src);\n\t\tif (!img) {\n\t\t\timg = this.options.createImage ? this.options.createImage() : (new Image() as unknown as VideoPreviewImage);\n\t\t\timg.addEventListener('load', () => this.redraw());\n\t\t\timg.src = src;\n\t\t\tthis.images.set(src, img);\n\t\t}\n\t\treturn img;\n\t}\n\n\tprivate entry(clip: string, src: string, kind: 'video' | 'audio'): Entry {\n\t\tconst existing = this.media.get(clip);\n\t\tif (existing && existing.src === src) return existing;\n\t\tif (existing) this.release(existing);\n\t\tconst media = this.createMedia(kind);\n\t\tconst onReady = () => this.redraw();\n\t\tmedia.addEventListener('seeked', onReady);\n\t\tmedia.addEventListener('loadeddata', onReady);\n\t\tmedia.src = src;\n\t\tconst entry = { clip, src, media, onReady };\n\t\tthis.media.set(clip, entry);\n\t\treturn entry;\n\t}\n\n\tprivate release(entry: Entry) {\n\t\tentry.media.pause();\n\t\tentry.media.removeEventListener('seeked', entry.onReady);\n\t\tentry.media.removeEventListener('loadeddata', entry.onReady);\n\t\tentry.media.removeAttribute?.('src');\n\t\tentry.media.load?.();\n\t\tthis.media.delete(entry.clip);\n\t}\n\n\t/** Brings the media elements in step with the editor and draws the frame. */\n\tupdate(): void {\n\t\tif (this.destroyed) return;\n\t\tconst state = this.source.getState();\n\t\tconst { project, time, playing } = state;\n\t\tconst frame = videoFrameAt(project, time);\n\t\t// Video plays from the proxy the preview scale picks; sound-only clips use the original.\n\t\tconst scale = resolveVideoPreviewScale(this.options.previewScale ?? state.previewScale, this.resolutionFor(project.width));\n\t\tconst assets = new Map(project.assets.map((a) => [a.id, a]));\n\t\tthis.srcFor = (layer) => videoPreviewSrc(assets.get(layer.asset ?? ''), scale, layer.src);\n\t\tthis.playing = playing;\n\t\tthis.fps = project.fps;\n\t\tthis.frameCache?.update(project, time, { playing, srcFor: this.srcFor });\n\t\tconst tolerance = (this.options.tolerance ?? 200) / 1000;\n\t\tconst active = new Set<string>();\n\t\tconst sync = (clip: string, src: string, kind: 'video' | 'audio', sourceTime: number, speed: number, gain: number | null) => {\n\t\t\tconst { media } = this.entry(clip, src, kind);\n\t\t\tactive.add(clip);\n\t\t\tconst target = sourceTime / 1000;\n\t\t\t// Browsers refuse rates below about 1/16; slower than that (a freeze) is shown by seeking a paused element.\n\t\t\tconst frozen = speed < 0.0625;\n\t\t\tif (!frozen && Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;\n\t\t\tif (gain === null) media.muted = true;\n\t\t\telse {\n\t\t\t\tmedia.muted = gain <= 0;\n\t\t\t\tmedia.volume = Math.min(1, Math.max(0, gain));\n\t\t\t}\n\t\t\tif (playing && !frozen) {\n\t\t\t\tif (media.paused || Math.abs(media.currentTime - target) > tolerance) media.currentTime = target;\n\t\t\t\tif (media.paused) void Promise.resolve(media.play()).catch(() => {});\n\t\t\t} else {\n\t\t\t\tif (!media.paused) media.pause();\n\t\t\t\tif (Math.abs(media.currentTime - target) > 1 / (2 * project.fps)) media.currentTime = target;\n\t\t\t}\n\t\t};\n\t\tconst sounding = new Map(frame.audio.map((a) => [a.clip, a]));\n\t\tfor (const layer of videoFrameLeafLayers(frame.layers)) {\n\t\t\tif (layer.type !== 'media' || layer.assetKind !== 'video' || !layer.src) continue;\n\t\t\tconst sound = sounding.get(layer.clip);\n\t\t\tsync(layer.clip, this.srcFor(layer)!, 'video', layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);\n\t\t\tsounding.delete(layer.clip);\n\t\t}\n\t\t// Sound with no picture (audio tracks, hidden video tracks): an audio element is enough.\n\t\tfor (const a of sounding.values()) sync(a.clip, a.src, 'audio', a.sourceTime, a.speed, a.gain);\n\t\tfor (const entry of [...this.media.values()]) if (!active.has(entry.clip)) this.release(entry);\n\t\tthis.draw(frame);\n\t}\n\n\tprivate redraw() {\n\t\tif (this.scheduled || this.destroyed) return;\n\t\tthis.scheduled = true;\n\t\tqueueMicrotask(() => {\n\t\t\tthis.scheduled = false;\n\t\t\tif (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));\n\t\t});\n\t}\n\n\tprivate sourceFor = (layer: VideoLayer): CanvasImageSource | null => {\n\t\tif (layer.assetKind === 'image' && layer.src) {\n\t\t\tconst img = this.image(layer.src);\n\t\t\treturn img.complete ? (img as unknown as CanvasImageSource) : null;\n\t\t}\n\t\tconst entry = this.media.get(layer.clip);\n\t\tconst ready = !!entry && entry.media.readyState >= 2;\n\t\tconst cache = this.frameCache;\n\t\tconst src = entry?.src ?? this.srcFor(layer);\n\t\tconst sourceTime = layer.sourceTime ?? 0;\n\t\t// Paused, scrubbing or mid-seek: a cached frame is the right picture now; a seeking element is not.\n\t\tif (cache && src && (!this.playing || !ready || entry!.media.seeking)) {\n\t\t\tconst hit = cache.get(src, sourceTime, this.fps);\n\t\t\tif (hit) return hit;\n\t\t}\n\t\tif (!ready) return null;\n\t\tif (entry!.media.seeking) this.complete = false;\n\t\telse if (cache && !this.playing) cache.capture(entry!.src, sourceTime, this.fps, this.source.getState().time, entry!.media as unknown as CanvasImageSource);\n\t\treturn entry!.media as unknown as CanvasImageSource;\n\t};\n\n\t/** Canvas pixels per frame pixel: the option, else the displayed width at the device pixel ratio (at most 1). */\n\tprivate resolutionFor(frameWidth: number): number {\n\t\tif (this.options.resolution !== undefined) return this.options.resolution;\n\t\tconst shown = this.canvas.clientWidth || frameWidth;\n\t\tconst dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;\n\t\treturn Math.min(1, (shown * dpr) / frameWidth);\n\t}\n\n\tprivate draw(frame: VideoFrame) {\n\t\tif (!this.ctx) return;\n\t\tconst resolution = this.resolutionFor(frame.width);\n\t\tconst w = Math.max(1, Math.round(frame.width * resolution));\n\t\tconst h = Math.max(1, Math.round(frame.height * resolution));\n\t\tif (this.canvas.width !== w) this.canvas.width = w;\n\t\tif (this.canvas.height !== h) this.canvas.height = h;\n\t\tthis.complete = true;\n\t\tconst sourceFor = (layer: VideoLayer) => {\n\t\t\tconst source = this.sourceFor(layer);\n\t\t\tif (!source) this.complete = false;\n\t\t\treturn source;\n\t\t};\n\t\tdrawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter, lutFor: this.lutFor, scratch: this.scratch });\n\t\tthis.drawn = { time: frame.time, complete: this.complete };\n\t\tthis.options.onFrame?.(frame);\n\t\tthis.pixelsTarget?.drawn(frame.time, this.complete);\n\t}\n\n\t/** A grade's LUT once loaded; while it loads (or if it fails) the frame is drawn without it and counts as incomplete. */\n\tprivate readonly lutFor = (src: string): VideoLut | undefined => {\n\t\tconst lut = this.lutCache?.get(src);\n\t\tif (!lut) this.complete = false;\n\t\treturn lut;\n\t};\n\n\tprivate readonly scratch = (width: number, height: number, slot: number): VideoScratchCanvas | null => {\n\t\tif (!this.scratchCanvases.has(slot)) {\n\t\t\tlet made: VideoScratchCanvas | null = null;\n\t\t\tif (this.options.createScratch) made = this.options.createScratch(width, height, slot);\n\t\t\telse if (typeof document !== 'undefined') {\n\t\t\t\tconst canvas = document.createElement('canvas');\n\t\t\t\tconst ctx = canvas.getContext('2d', { willReadFrequently: true });\n\t\t\t\tmade = ctx ? { canvas, ctx: ctx as unknown as VideoScratchCanvas['ctx'] } : null;\n\t\t\t}\n\t\t\tthis.scratchCanvases.set(slot, made);\n\t\t}\n\t\tconst s = this.scratchCanvases.get(slot)!;\n\t\tif (!s) return null;\n\t\tconst c = s.canvas as { width?: number; height?: number };\n\t\tif (c.width !== width) c.width = width;\n\t\tif (c.height !== height) c.height = height;\n\t\treturn s;\n\t};\n\n\tprivate readonly defineSvgFilter = (id: string, markup: string) => {\n\t\tif (this.options.defineSvgFilter === null) return;\n\t\tif (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);\n\t\tif (this.filters.has(id) || typeof document === 'undefined') return;\n\t\tif (!this.filterHost) {\n\t\t\tconst svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n\t\t\tsvg.setAttribute('width', '0');\n\t\t\tsvg.setAttribute('height', '0');\n\t\t\tsvg.setAttribute('aria-hidden', 'true');\n\t\t\tsvg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden');\n\t\t\tdocument.body.appendChild(svg);\n\t\t\tthis.filterHost = svg;\n\t\t}\n\t\tthis.filterHost.insertAdjacentHTML('beforeend', markup);\n\t\tthis.filters.add(id);\n\t};\n\n\t/** Stops following the editor and releases every media element. */\n\tdestroy(): void {\n\t\tthis.destroyed = true;\n\t\tthis.detachPixels();\n\t\tthis.unsubscribeLuts();\n\t\tthis.unsubscribeFrames();\n\t\tthis.filterHost?.remove();\n\t\tthis.filterHost = null;\n\t\tthis.filters.clear();\n\t\tthis.unsubscribe();\n\t\tfor (const entry of [...this.media.values()]) this.release(entry);\n\t\tthis.images.clear();\n\t}\n}\n","import type { Clock } from '../animation/clock.js';\nimport type { AnimationLoopMode } from '../animation/evaluate.js';\nimport { AnimationPlayer } from '../animation/player.js';\nimport { HistoryManager } from '../history/HistoryManager.js';\nimport type { Command } from '../history/types.js';\nimport { VideoAudioPeaksCache, type VideoAudioPeaksLoader } from './audio.js';\nimport { checkVideoProject, type VideoIssue } from './check.js';\nimport { applyVideoEdits, type VideoEdit, type VideoEditResult } from './edit.js';\nimport { videoFrameAt, type VideoFrame } from './evaluate.js';\nimport { toVideoEdl, toVideoFfmpeg, toVideoLoudnessMeasure, type VideoEdlOptions, type VideoFfmpegExport, type VideoFfmpegOptions } from './export.js';\nimport { VideoLutCache, type VideoLutLoader } from './lut.js';\nimport { createVideoProject, parseVideoProject } from './parse.js';\nimport { VideoPreviewPixels } from './scopes.js';\nimport type { VideoPreviewScale } from './proxy.js';\nimport type { VideoFrameCache } from './frame-cache.js';\nimport { videoProjectSvg, type VideoSvgOptions } from './render.js';\nimport { snapToVideoFrame, snapVideoTime, videoClipEnd, videoClipsAt, videoProjectDuration, videoSnapPoints, findVideoClip } from './timeline.js';\nimport type { VideoProject } from './types.js';\n\nexport interface VideoEditorOptions {\n\t/** The project to open (default: an empty 1920 × 1080, 30 fps project with tracks v1 and a1). */\n\tproject?: VideoProject;\n\t/** Shared undo history (default: a new one). */\n\thistory?: HistoryManager;\n\t/** Time source for playback (default: requestAnimationFrame). Pass a `ManualClock` to step frames. */\n\tclock?: Clock;\n\treadOnly?: boolean;\n\t/** Called after every change to the project (not on playback or selection). */\n\tonChange?: (project: VideoProject) => void;\n\t/**\n\t * Where sound levels for waveforms and `get_video_audio_levels` come from: a shared cache, or a\n\t * loader for one (default: fetch and decode each asset's `src` with Web Audio, in a browser).\n\t */\n\taudioPeaks?: VideoAudioPeaksCache | VideoAudioPeaksLoader;\n\t/** Where grades' LUT files (`.cube`) come from: a shared cache, or a loader (default: `fetch` each `src`). */\n\tluts?: VideoLutCache | VideoLutLoader;\n\t/** How large a picture the preview plays (default `auto`: a proxy as large as the canvas shows, if the asset has one). */\n\tpreviewScale?: VideoPreviewScale;\n\t/**\n\t * Decoded source frames with prefetch for the preview (see `VideoFrameCache`): scrubbing and\n\t * stepping then show cached frames at once. Host-side and optional (default none).\n\t */\n\tframeCache?: VideoFrameCache;\n}\n\n/** A snapshot of the editor. The same object is returned until something changes. */\nexport interface VideoEditorState {\n\tproject: VideoProject;\n\t/** Total length (ms): the end of the last clip. */\n\tduration: number;\n\t/** Playhead (ms). */\n\ttime: number;\n\tplaying: boolean;\n\tloop: AnimationLoopMode;\n\t/** Selected clip ids, in the order they were selected. */\n\tselection: readonly string[];\n\t/** Clips under the playhead, top track first. */\n\tcurrent: readonly string[];\n\t/** The project's problems (see `checkVideoProject`). */\n\tissues: readonly VideoIssue[];\n\treadOnly: boolean;\n\t/** Why the last edit was refused, if it was. */\n\terror: string | null;\n\tcanUndo: boolean;\n\tcanRedo: boolean;\n\tundoLabel: string;\n\tredoLabel: string;\n\t/** How large a picture the preview plays: `auto`, `full` or a fraction that picks a proxy. */\n\tpreviewScale: VideoPreviewScale;\n\t/** Bumped on every change. */\n\trevision: number;\n}\n\n/**\n * Headless non-linear video editor: a `VideoProject` of assets and tracks of clips, edited with\n * undoable, all-or-nothing edits (insert, ripple, trim, split, move, transitions), played on an\n * injectable clock, with a clip selection, snapping and frames at any time. UI-agnostic: React and\n * Svelte bind it with `useVideoEditor` / `createVideoEditor`, `VideoPreviewEngine` plays it in a\n * browser and agents drive it through `createVideoEditorToolProvider`. `getState()` returns the same\n * object until something changes, so it works with `useSyncExternalStore` and Svelte runes.\n */\nexport class VideoEditorController {\n\treadonly history: HistoryManager;\n\treadonly player: AnimationPlayer;\n\t/** Sound levels per asset, loaded on demand for waveforms and agents. */\n\treadonly audioPeaks: VideoAudioPeaksCache;\n\t/** The pixels the preview last drew, for scopes and `get_video_scopes` (a `VideoPreviewEngine` fills it). */\n\treadonly previewPixels = new VideoPreviewPixels();\n\t/** Grades' LUTs, loaded on demand for the preview, SVG frames and agents. */\n\treadonly luts: VideoLutCache;\n\t/** The host's frame cache, if it gave one (the preview fills and reads it). */\n\treadonly frameCache: VideoFrameCache | null;\n\tprivate project: VideoProject;\n\tprivate issues: VideoIssue[];\n\tprivate selection: string[] = [];\n\tprivate readOnly: boolean;\n\tprivate previewScale: VideoPreviewScale;\n\tprivate error: string | null = null;\n\tprivate revision = 0;\n\tprivate applying = false;\n\tprivate readonly onChange?: (project: VideoProject) => void;\n\tprivate readonly listeners = new Set<(state: VideoEditorState) => void>();\n\tprivate readonly unsubscribers: (() => void)[] = [];\n\tprivate state!: VideoEditorState;\n\n\tconstructor(options: VideoEditorOptions = {}) {\n\t\tthis.history = options.history ?? new HistoryManager();\n\t\tthis.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);\n\t\tthis.luts = options.luts instanceof VideoLutCache ? options.luts : new VideoLutCache(options.luts);\n\t\tthis.frameCache = options.frameCache ?? null;\n\t\tthis.project = options.project ? parseVideoProject(options.project) : createVideoProject();\n\t\tthis.issues = checkVideoProject(this.project);\n\t\tthis.readOnly = options.readOnly ?? false;\n\t\tthis.previewScale = checkPreviewScale(options.previewScale ?? 'auto');\n\t\tthis.onChange = options.onChange;\n\t\tthis.player = new AnimationPlayer({ clock: options.clock, duration: () => videoProjectDuration(this.project), loop: 'none' });\n\t\tthis.state = this.computeState();\n\t\tthis.connect();\n\t}\n\n\tprivate connect() {\n\t\tif (this.unsubscribers.length) return;\n\t\tthis.unsubscribers.push(\n\t\t\tthis.player.subscribe(() => this.notify()),\n\t\t\tthis.history.subscribe(() => {\n\t\t\t\tif (!this.applying) this.notify();\n\t\t\t})\n\t\t);\n\t}\n\n\t/** Pauses and stops listening to the player and history. Subscribing again reconnects (React StrictMode). */\n\tdestroy(): void {\n\t\tthis.player.pause();\n\t\tthis.unsubscribers.splice(0).forEach((u) => u());\n\t}\n\n\tgetState = (): VideoEditorState => this.state;\n\n\tsubscribe = (listener: (state: VideoEditorState) => void): (() => void) => {\n\t\tthis.connect();\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t};\n\n\t/** The current project (immutable; edit through the controller). */\n\tgetProject(): VideoProject {\n\t\treturn this.project;\n\t}\n\n\tprivate computeState(): VideoEditorState {\n\t\tconst p = this.player.getState();\n\t\tconst h = this.history.getState();\n\t\treturn {\n\t\t\tproject: this.project,\n\t\t\tduration: videoProjectDuration(this.project),\n\t\t\ttime: p.time,\n\t\t\tplaying: p.playing,\n\t\t\tloop: p.loop,\n\t\t\tselection: this.selection,\n\t\t\tcurrent: videoClipsAt(this.project, p.time).map((c) => c.clip.id),\n\t\t\tissues: this.issues,\n\t\t\treadOnly: this.readOnly,\n\t\t\terror: this.error,\n\t\t\tcanUndo: h.canUndo,\n\t\t\tcanRedo: h.canRedo,\n\t\t\tundoLabel: h.undoName,\n\t\t\tredoLabel: h.redoName,\n\t\t\tpreviewScale: this.previewScale,\n\t\t\trevision: this.revision\n\t\t};\n\t}\n\n\tprivate notify() {\n\t\tthis.revision++;\n\t\tthis.state = this.computeState();\n\t\tconst state = this.state;\n\t\tthis.listeners.forEach((listener) => {\n\t\t\ttry {\n\t\t\t\tlistener(state);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error('[VideoEditorController] Error in state listener:', error);\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate setProject(project: VideoProject) {\n\t\tthis.applying = true;\n\t\ttry {\n\t\t\tthis.project = project;\n\t\t\tthis.issues = checkVideoProject(project);\n\t\t\tconst ids = new Set(project.tracks.flatMap((t) => t.clips.map((c) => c.id)));\n\t\t\tif (this.selection.some((id) => !ids.has(id))) this.selection = this.selection.filter((id) => ids.has(id));\n\t\t\tthis.player.setDuration(() => videoProjectDuration(this.project));\n\t\t} finally {\n\t\t\tthis.applying = false;\n\t\t}\n\t\tthis.notify();\n\t\tthis.onChange?.(project);\n\t}\n\n\tprivate commit(label: string, next: VideoProject) {\n\t\tconst before = this.project;\n\t\tthis.error = null;\n\t\tif (next === before) {\n\t\t\tthis.notify();\n\t\t\treturn;\n\t\t}\n\t\tconst command: Command = {\n\t\t\tname: label,\n\t\t\ttag: 'video-editor',\n\t\t\texecute: () => this.setProject(next),\n\t\t\tundo: () => this.setProject(before),\n\t\t\tredo: () => this.setProject(next)\n\t\t};\n\t\tthis.history.execute(command);\n\t}\n\n\tprivate refuse(message: string): VideoEditResult {\n\t\tthis.error = message;\n\t\tthis.notify();\n\t\treturn { ok: false, error: message };\n\t}\n\n\t/** Why edits are refused now, or `null` when they are allowed. */\n\treadOnlyReason(): string | null {\n\t\treturn this.readOnly ? 'The video project is read-only.' : null;\n\t}\n\n\t/**\n\t * Applies edits as one undo step, all or nothing (see `applyVideoEdits`). With `dryRun` it\n\t * reports what would happen and changes nothing.\n\t */\n\tapply(edits: VideoEdit | readonly VideoEdit[], label = 'Edit video', options: { dryRun?: boolean } = {}): VideoEditResult {\n\t\tconst reason = this.readOnlyReason();\n\t\tif (reason) return this.refuse(reason);\n\t\tconst result = applyVideoEdits(this.project, edits);\n\t\tif (!result.ok) return options.dryRun ? result : this.refuse(result.error);\n\t\tif (!options.dryRun) this.commit(label, result.project);\n\t\treturn result;\n\t}\n\n\t/** Opens a project (JSON or object). `clearHistory` drops the undo stack (opening a file); otherwise it is one undo step. */\n\tload(project: VideoProject | string, options: { clearHistory?: boolean } = {}): VideoEditResult {\n\t\tlet parsed: VideoProject;\n\t\ttry {\n\t\t\tparsed = parseVideoProject(project);\n\t\t} catch (error) {\n\t\t\treturn this.refuse((error as Error).message);\n\t\t}\n\t\tif (options.clearHistory) {\n\t\t\tthis.history.clear();\n\t\t\tthis.error = null;\n\t\t\tthis.selection = [];\n\t\t\tthis.setProject(parsed);\n\t\t\tthis.player.seek(0);\n\t\t\treturn { ok: true, project: parsed, issues: this.issues, outcomes: [{ op: 'replace_project' }] };\n\t\t}\n\t\treturn this.apply({ op: 'replace_project', project: parsed }, 'Replace project');\n\t}\n\n\tsetReadOnly(readOnly: boolean): void {\n\t\tif (this.readOnly === readOnly) return;\n\t\tthis.readOnly = readOnly;\n\t\tthis.notify();\n\t}\n\n\t/**\n\t * Sets how large a picture the preview plays: `full` (the originals), a fraction such as 0.5\n\t * (the smallest proxy at least that large, else the original) or `auto` (as large as the canvas\n\t * shows). A view setting, not part of the project: no undo step.\n\t */\n\tsetPreviewScale(scale: VideoPreviewScale): void {\n\t\tconst next = checkPreviewScale(scale);\n\t\tif (next === this.previewScale) return;\n\t\tthis.previewScale = next;\n\t\tthis.notify();\n\t}\n\n\t/** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */\n\tselect(clips: readonly string[] | null, options: { add?: boolean } = {}): void {\n\t\tconst known = clips?.filter((id) => findVideoClip(this.project, id)) ?? [];\n\t\tlet next: string[];\n\t\tif (options.add) {\n\t\t\tnext = [...this.selection];\n\t\t\tfor (const id of known) next = next.includes(id) ? next.filter((x) => x !== id) : [...next, id];\n\t\t} else next = [...new Set(known)];\n\t\tif (next.length === this.selection.length && next.every((id, i) => id === this.selection[i])) return;\n\t\tthis.selection = next;\n\t\tthis.notify();\n\t}\n\n\tplay(): void {\n\t\tthis.player.play();\n\t}\n\n\tpause(): void {\n\t\tthis.player.pause();\n\t}\n\n\ttoggle(): void {\n\t\tthis.player.toggle();\n\t}\n\n\t/** Moves the playhead (ms). */\n\tseek(time: number): void {\n\t\tthis.player.seek(time);\n\t}\n\n\t/** Moves the playhead by whole frames (negative steps back), pausing. */\n\tstep(frames: number): void {\n\t\tthis.player.pause();\n\t\tconst fps = this.project.fps;\n\t\tthis.player.seek(Math.max(0, snapToVideoFrame(this.player.currentTime, fps) + (frames * 1000) / fps));\n\t}\n\n\tsetLoop(loop: AnimationLoopMode): void {\n\t\tthis.player.setLoop(loop);\n\t}\n\n\tundo(): boolean {\n\t\treturn this.history.undo();\n\t}\n\n\tredo(): boolean {\n\t\treturn this.history.redo();\n\t}\n\n\t/**\n\t * `time` moved to the nearest clip edge, marker or the playhead within `threshold` ms (default:\n\t * 8 frames), else to the nearest frame. Clips in `exclude` (the ones being dragged) are ignored.\n\t */\n\tsnap(time: number, options: { exclude?: Iterable<string>; threshold?: number; playhead?: boolean } = {}): { time: number; snapped: number | null } {\n\t\tconst threshold = options.threshold ?? (8 * 1000) / this.project.fps;\n\t\tconst points = videoSnapPoints(this.project, { exclude: options.exclude, playhead: options.playhead === false ? undefined : this.player.currentTime });\n\t\tconst hit = snapVideoTime(time, points, threshold);\n\t\treturn hit.snapped !== null ? hit : { time: Math.max(0, snapToVideoFrame(time, this.project.fps)), snapped: null };\n\t}\n\n\t/** Splits the selected clips (or every clip) under the playhead there, as one undo step. */\n\tsplitAtPlayhead(): VideoEditResult {\n\t\tconst t = snapToVideoFrame(this.player.currentTime, this.project.fps);\n\t\tconst under = videoClipsAt(this.project, t).filter(({ clip }) => t > clip.start && t < videoClipEnd(clip));\n\t\tconst chosen = this.selection.length ? under.filter(({ clip }) => this.selection.includes(clip.id)) : under;\n\t\tif (!chosen.length) return this.refuse('No clip is under the playhead to split.');\n\t\treturn this.apply(chosen.map(({ clip }) => ({ op: 'split_clip' as const, clip: clip.id, at: t })), 'Split');\n\t}\n\n\t/** Deletes the selected clips; `ripple` closes the gaps they leave. */\n\tdeleteSelection(options: { ripple?: boolean } = {}): VideoEditResult {\n\t\tif (!this.selection.length) return this.refuse('Select clips to delete first.');\n\t\treturn this.apply({ op: 'delete_clips', clips: [...this.selection], ripple: options.ripple }, options.ripple ? 'Ripple delete' : 'Delete clips');\n\t}\n\n\t/** What plays at `time` (default: the playhead). */\n\tframe(time = this.player.currentTime): VideoFrame {\n\t\treturn videoFrameAt(this.project, time);\n\t}\n\n\t/** The frame at `time` (default: the playhead) as SVG. */\n\ttoSvg(time = this.player.currentTime, options: VideoSvgOptions = {}): string {\n\t\treturn videoProjectSvg(this.project, time, { lutFor: (src) => this.luts.peek(src), ...options });\n\t}\n\n\t/** The project as an ffmpeg command. */\n\ttoFfmpeg(options: VideoFfmpegOptions = {}): VideoFfmpegExport {\n\t\treturn toVideoFfmpeg(this.project, options);\n\t}\n\n\t/** The ffmpeg command that measures the mix's loudness (see `toVideoLoudnessMeasure`). */\n\ttoLoudnessMeasure(options: Pick<VideoFfmpegOptions, 'resolve' | 'loudness'> = {}): VideoFfmpegExport {\n\t\treturn toVideoLoudnessMeasure(this.project, options);\n\t}\n\n\t/** One track as a CMX 3600 EDL. */\n\ttoEdl(options: VideoEdlOptions = {}): string {\n\t\treturn toVideoEdl(this.project, options);\n\t}\n}\n\nfunction checkPreviewScale(scale: VideoPreviewScale): VideoPreviewScale {\n\tif (scale === 'auto' || scale === 'full') return scale;\n\tif (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)}.`);\n\treturn scale === 1 ? 'full' : scale;\n}\n","import type { VideoEdit } from './edit.js';\nimport { normalizeVideoGrade } from './grade.js';\nimport { isVideoKeyframes, removeVideoKey, setVideoKey, VIDEO_KEYFRAME_LIMITS, VIDEO_TRANSFORM_PROPERTIES, videoValueAt, type VideoAnimatable, type VideoKeyframeProperty } from './keyframes.js';\nimport { findVideoClip, formatVideoTimecode, videoClipEnd, videoClipSpeed, videoAssetMap, videoAssetHasSound } from './timeline.js';\nimport type { VideoClipAnimationEffect, VideoTransitionType, VideoAsset, VideoAssetKind, VideoBlendMode, VideoClip, VideoFadeCurve, VideoGradeLut, VideoPlacementMode, VideoProject, VideoSequence } from './types.js';\n\n// Framework-free helpers the React and Svelte timelines share, so both draw and drag alike.\n\nexport interface VideoRulerTick {\n\ttime: number;\n\t/** Pixels from the timeline's left edge. */\n\tx: number;\n\t/** Major ticks carry a label. */\n\tmajor: boolean;\n\tlabel?: string;\n}\n\nconst STEPS = [40, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000];\n\n/** Ruler ticks for `[0, duration]` at `pxPerSecond`: a major tick at least every ~80 px, labelled with timecode. */\nexport function videoRulerTicks(duration: number, pxPerSecond: number, fps: number): VideoRulerTick[] {\n\tconst pxPerMs = pxPerSecond / 1000;\n\tconst major = STEPS.find((s) => s * pxPerMs >= 80) ?? STEPS[STEPS.length - 1];\n\tconst minor = STEPS.slice()\n\t\t.reverse()\n\t\t.find((s) => s < major && major % s === 0 && s * pxPerMs >= 10);\n\tconst step = minor ?? major;\n\tconst ticks: VideoRulerTick[] = [];\n\tfor (let t = 0; t <= duration + 1e-6; t += step) {\n\t\tconst isMajor = Math.abs(t / major - Math.round(t / major)) < 1e-9;\n\t\tticks.push({ time: t, x: t * pxPerMs, major: isMajor, ...(isMajor ? { label: formatVideoTimecode(t, fps).replace(/^00:/, '') } : {}) });\n\t}\n\treturn ticks;\n}\n\n/** Fill colour a timeline uses for a clip, by what it is. */\n/** Which clips a timeline draws waveforms in: audio files only (default), anything with sound, or none. */\nexport type VideoWaveformMode = 'audio' | 'all' | 'none';\n\n/** Whether a timeline in `mode` draws a waveform in this clip. */\nexport function videoClipShowsWaveform(clip: VideoClip, asset: VideoAsset | undefined, mode: VideoWaveformMode = 'audio'): asset is VideoAsset {\n\tif (clip.type !== 'media' || mode === 'none' || !videoAssetHasSound(asset)) return false;\n\treturn mode === 'all' || asset!.kind === 'audio';\n}\n\n/** Names for the entrance and exit animations, for menus. */\nexport const VIDEO_CLIP_ANIMATION_LABELS: Readonly<Record<VideoClipAnimationEffect, string>> = {\n\tfade: 'Fade',\n\trise: 'Rise',\n\tdrop: 'Drop',\n\t'slide-left': 'Slide left',\n\t'slide-right': 'Slide right',\n\tzoom: 'Zoom',\n\tpop: 'Pop'\n};\n\n/** Names for the transitions, for menus. */\nexport const VIDEO_TRANSITION_LABELS: Readonly<Record<VideoTransitionType, string>> = {\n\tcrossfade: 'Crossfade',\n\tdip: 'Dip to colour',\n\twipe: 'Wipe',\n\tpush: 'Push',\n\tslide: 'Slide',\n\tiris: 'Iris'\n};\n\n/** Whether a transition type takes a `direction`. */\nexport const videoTransitionHasDirection = (type: VideoTransitionType): boolean => type === 'wipe' || type === 'push' || type === 'slide';\n\n/** Names for the blend modes, for menus. */\nexport const VIDEO_BLEND_MODE_LABELS: Readonly<Record<VideoBlendMode, string>> = {\n\tnormal: 'Normal',\n\tmultiply: 'Multiply',\n\tscreen: 'Screen',\n\toverlay: 'Overlay',\n\tdarken: 'Darken',\n\tlighten: 'Lighten',\n\t'color-dodge': 'Colour dodge',\n\t'color-burn': 'Colour burn',\n\t'hard-light': 'Hard light',\n\t'soft-light': 'Soft light',\n\tdifference: 'Difference',\n\texclusion: 'Exclusion',\n\tadd: 'Add'\n};\n\n/** Names for the sound fade curves, for menus. */\nexport const VIDEO_FADE_CURVE_LABELS: Readonly<Record<VideoFadeCurve, string>> = {\n\tlinear: 'Linear',\n\t'equal-power': 'Equal power',\n\t's-curve': 'S-curve',\n\texponential: 'Exponential',\n\tlogarithmic: 'Logarithmic'\n};\n\nexport function videoClipTint(clip: VideoClip, asset?: VideoAsset): string {\n\tif (clip.type === 'text') return '#a855f7';\n\tif (clip.type === 'sequence') return '#0d9488';\n\tif (clip.type === 'color') return clip.color;\n\tif (!asset) return '#ef4444';\n\treturn asset.kind === 'audio' ? '#10b981' : asset.kind === 'image' ? '#f59e0b' : '#3b82f6';\n}\n\n/** A short label for a clip on the timeline (pass the sequence a compound clip plays, for its name). */\nexport function videoClipLabel(clip: VideoClip, asset?: VideoAsset, sequence?: VideoSequence): string {\n\tif (clip.name) return clip.name;\n\tif (clip.type === 'text') return clip.text.split('\\n')[0];\n\tif (clip.type === 'color') return clip.color;\n\tif (clip.type === 'sequence') return `Compound: ${sequence?.name ?? clip.sequence}`;\n\tconst base = asset?.name ?? asset?.src.replace(/^.*[\\\\/]/, '') ?? clip.asset;\n\tconst speed = videoClipSpeed(clip);\n\treturn speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;\n}\n\nexport type VideoClipDragMode = 'move' | 'trim-start' | 'trim-end';\n\nexport interface VideoClipDragInput {\n\tclip: string;\n\tmode: VideoClipDragMode;\n\t/** How far the pointer moved along the timeline (ms). */\n\tdelta: number;\n\t/** For move: the track under the pointer (default the clip's own). */\n\ttrack?: string;\n\t/** Push later clips along instead of refusing overlaps (trims) — hold a modifier for it. */\n\tripple?: boolean;\n\t/**\n\t * For move: what to do with clips where it lands (default refuse). The timelines use Alt for\n\t * insert and Shift for overwrite.\n\t */\n\tplacement?: VideoPlacementMode;\n\t/** Snaps a time (e.g. `controller.snap(t, { exclude: [clip] }).time`); default: none. */\n\tsnap?: (time: number) => number;\n}\n\nexport interface VideoClipDragPlan {\n\t/** Where the clip would be: draw this while dragging. */\n\tstart: number;\n\tduration: number;\n\ttrack: string;\n\t/** The edit to apply on release (`null` when nothing changed). */\n\tedit: VideoEdit | null;\n}\n\n/**\n * What dragging a clip (or one of its edges) by `delta` would do: the clip's new place for drawing\n * while the pointer moves, and the edit to apply on release. Moves snap the edge nearer a snap\n * point; trims snap the edge being dragged. The edit is checked when it is applied, so an overlap\n * or a trim past the source is refused then (with the reason in the editor's state).\n */\nexport function planVideoClipDrag(project: VideoProject, input: VideoClipDragInput): VideoClipDragPlan | null {\n\tconst at = findVideoClip(project, input.clip);\n\tif (!at) return null;\n\tconst { clip, track } = at;\n\tconst snap = input.snap ?? ((t: number) => t);\n\tif (input.mode === 'move') {\n\t\tconst raw = Math.max(0, clip.start + input.delta);\n\t\t// Whichever edge snapped by less wins; an edge that did not snap does not count.\n\t\tconst head = snap(raw) - raw;\n\t\tconst tail = snap(raw + clip.duration) - (raw + clip.duration);\n\t\tconst shifts = [head, tail].filter((d) => Math.abs(d) > 1e-9);\n\t\tconst start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => (Math.abs(b) < Math.abs(a) ? b : a)) : 0));\n\t\tconst target = input.track ?? track.id;\n\t\tconst moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;\n\t\tconst mode = input.placement && input.placement !== 'refuse' ? { mode: input.placement } : {};\n\t\treturn { start, duration: clip.duration, track: target, edit: moved ? { op: 'move_clip', clip: clip.id, start, ...(target !== track.id ? { track: target } : {}), ...mode } : null };\n\t}\n\tconst minimum = 1000 / project.fps;\n\tif (input.mode === 'trim-start') {\n\t\tconst to = Math.min(snap(Math.max(0, clip.start + input.delta)), videoClipEnd(clip) - minimum);\n\t\tconst changed = Math.abs(to - clip.start) > 1e-6;\n\t\tconst plan = input.ripple ? { start: clip.start, duration: clip.duration - (to - clip.start) } : { start: to, duration: videoClipEnd(clip) - to };\n\t\treturn { ...plan, track: track.id, edit: changed ? { op: 'trim_clip', clip: clip.id, edge: 'start', to, ...(input.ripple ? { ripple: true } : {}) } : null };\n\t}\n\tconst to = Math.max(snap(videoClipEnd(clip) + input.delta), clip.start + minimum);\n\tconst changed = Math.abs(to - videoClipEnd(clip)) > 1e-6;\n\treturn { start: clip.start, duration: to - clip.start, track: track.id, edit: changed ? { op: 'trim_clip', clip: clip.id, edge: 'end', to, ...(input.ripple ? { ripple: true } : {}) } : null };\n}\n\n/** Tracks a clip may be dragged onto: the same kind as its own, unlocked. */\nexport function videoDropTracks(project: VideoProject, clipId: string): string[] {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return [];\n\treturn project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);\n}\n\n/** The label for the undo step a drag makes. */\nexport function videoDragLabel(edit: VideoEdit): string {\n\tif (edit.op === 'move_clip') return edit.mode === 'insert' ? 'Insert clip' : edit.mode === 'overwrite' ? 'Overwrite clip' : 'Move clip';\n\treturn edit.op === 'trim_clip' && edit.ripple ? 'Ripple trim' : 'Trim clip';\n}\n\n/** The edit that adds an asset as a clip where the person dropped it (or at the end of the first fitting track). */\nexport function videoAssetDropEdit(project: VideoProject, assetId: string, options: { track?: string; start?: number; ripple?: boolean; mode?: VideoPlacementMode } = {}): VideoEdit | null {\n\tconst asset = videoAssetMap(project).get(assetId);\n\tif (!asset) return null;\n\tconst kind = asset.kind === 'audio' ? 'audio' : 'video';\n\tconst track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;\n\tif (!track) return null;\n\treturn { op: 'add_clip', track, clip: { type: 'media', asset: asset.id, ...(options.start !== undefined ? { start: options.start } : {}) }, ...(options.ripple ? { ripple: true } : {}), ...(options.mode && options.mode !== 'refuse' ? { mode: options.mode } : {}) };\n}\n\n/** What `probeVideoMedia` found out about a file. */\nexport interface VideoMediaProbe {\n\tkind: VideoAssetKind;\n\tduration?: number;\n\twidth?: number;\n\theight?: number;\n}\n\n/** The asset kind for a MIME type or file name, or `null` when it is not media. */\nexport function videoAssetKindOf(typeOrName: string): VideoAssetKind | null {\n\t// URLs may carry a query or hash after the extension (`song.mp3?v=2`).\n\tconst s = typeOrName.toLowerCase().replace(/[?#].*$/, '');\n\tif (s.startsWith('video/') || /\\.(mp4|m4v|mov|webm|ogv|mkv)$/.test(s)) return 'video';\n\tif (s.startsWith('audio/') || /\\.(mp3|m4a|aac|wav|ogg|oga|flac|opus)$/.test(s)) return 'audio';\n\tif (s.startsWith('image/') || /\\.(png|jpe?g|gif|webp|avif|svg|bmp)$/.test(s)) return 'image';\n\treturn null;\n}\n\n/**\n * Reads a media file's length and size in the browser by loading its metadata into a detached\n * `<video>`, `<audio>` or `<img>`. Rejects with a readable error when it cannot be loaded.\n */\nexport function probeVideoMedia(src: string, kind: VideoAssetKind, timeout = 15000): Promise<VideoMediaProbe> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (typeof document === 'undefined') return reject(new Error('Media can only be probed in a browser.'));\n\t\tconst timer = setTimeout(() => reject(new Error(`\"${src}\" took too long to load.`)), timeout);\n\t\tconst done = (probe: VideoMediaProbe) => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve(probe);\n\t\t};\n\t\tconst failed = () => {\n\t\t\tclearTimeout(timer);\n\t\t\treject(new Error(`\"${src}\" could not be loaded as ${kind}.`));\n\t\t};\n\t\tif (kind === 'image') {\n\t\t\tconst img = new Image();\n\t\t\timg.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });\n\t\t\timg.onerror = failed;\n\t\t\timg.src = src;\n\t\t\treturn;\n\t\t}\n\t\tconst el = document.createElement(kind);\n\t\tel.preload = 'metadata';\n\t\tel.onloadedmetadata = () => {\n\t\t\tconst duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1000) : undefined;\n\t\t\tconst v = el as HTMLVideoElement;\n\t\t\tdone({ kind, ...(duration ? { duration } : {}), ...(kind === 'video' && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {}) });\n\t\t};\n\t\tel.onerror = failed;\n\t\tel.src = src;\n\t});\n}\n\n// ---------------------------------------------------------------------------------------------\n// Keyframes in an inspector\n// ---------------------------------------------------------------------------------------------\n\nconst KEY_LABELS: Record<VideoKeyframeProperty, { label: string; step: number }> = {\n\topacity: { label: 'Opacity', step: 0.05 },\n\tvolume: { label: 'Volume', step: 0.05 },\n\tx: { label: 'X', step: 1 },\n\ty: { label: 'Y', step: 1 },\n\tscale: { label: 'Scale', step: 0.05 },\n\trotation: { label: 'Rotation', step: 1 }\n};\n\n/** One animatable property of a clip as an inspector shows it at the playhead. */\nexport interface VideoKeyframeRow {\n\tproperty: VideoKeyframeProperty;\n\tlabel: string;\n\t/** Its value at the playhead (the playhead held inside the clip). */\n\tvalue: number;\n\t/** Whether it has keyframes. */\n\tkeyed: boolean;\n\t/** How many keys it has. */\n\tkeys: number;\n\t/** Whether a key sits at the playhead. */\n\tonKey: boolean;\n\t/** The playhead in clip time (ms), where new keys go. */\n\tclipTime: number;\n\tstep: number;\n\tmin?: number;\n\tmax?: number;\n}\n\nconst animatableOf = (clip: VideoClip, property: VideoKeyframeProperty): VideoAnimatable | undefined =>\n\t(VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(property) ? clip.transform?.[property as 'x'] : property === 'volume' ? (clip.type === 'media' || clip.type === 'sequence' ? clip.volume : undefined) : clip.opacity;\n\nconst playheadInClip = (clip: VideoClip, time: number) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));\n\n/**\n * The clip's animatable properties for an inspector at timeline `time`: opacity and the transform\n * for pictures, volume for sound, each with its value there and whether it is keyed.\n */\nexport function videoKeyframeRows(project: VideoProject, clipId: string, time: number): VideoKeyframeRow[] {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return [];\n\tconst { clip } = at;\n\tconst asset = clip.type === 'media' ? videoAssetMap(project).get(clip.asset) : undefined;\n\tconst visual = !(asset?.kind === 'audio' || at.track.kind === 'audio');\n\tconst audible = (clip.type === 'media' && videoAssetHasSound(asset)) || clip.type === 'sequence';\n\tconst properties: VideoKeyframeProperty[] = [...(visual ? (['opacity'] as const) : []), ...(audible ? (['volume'] as const) : []), ...(visual ? VIDEO_TRANSFORM_PROPERTIES : [])];\n\tconst clipTime = playheadInClip(clip, time);\n\treturn properties.map((property) => {\n\t\tconst prop = animatableOf(clip, property);\n\t\tconst keys = isVideoKeyframes(prop) ? prop.keys : [];\n\t\tconst limits = VIDEO_KEYFRAME_LIMITS[property];\n\t\treturn {\n\t\t\tproperty,\n\t\t\t...KEY_LABELS[property],\n\t\t\tvalue: Math.round(videoValueAt(prop, clipTime, limits.default) * 1000) / 1000,\n\t\t\tkeyed: keys.length > 0,\n\t\t\tkeys: keys.length,\n\t\t\tonKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),\n\t\t\tclipTime,\n\t\t\t...(limits.min !== undefined ? { min: limits.min } : {}),\n\t\t\t...(limits.max !== undefined ? { max: limits.max } : {})\n\t\t};\n\t});\n}\n\n/** A plain (unkeyed) value as an edit: `undefined` puts it back to its default. */\nfunction plainValueEdit(clip: VideoClip, property: VideoKeyframeProperty, value: number | undefined): VideoEdit {\n\tif (!(VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(property))\n\t\treturn value === undefined ? { op: 'update_clip', clip: clip.id, remove: [property] } : { op: 'update_clip', clip: clip.id, set: { [property]: value } };\n\tconst transform: Record<string, unknown> = { ...clip.transform, [property]: value };\n\tif (value === undefined) delete transform[property];\n\treturn Object.keys(transform).length ? { op: 'update_clip', clip: clip.id, set: { transform } } : { op: 'update_clip', clip: clip.id, remove: ['transform'] };\n}\n\n/**\n * Typing a value in an inspector: on a keyed property it sets (or moves) the key at the playhead;\n * otherwise it sets the plain value (`undefined` resets it).\n */\nexport function videoKeyframeValueEdit(project: VideoProject, clipId: string, property: VideoKeyframeProperty, time: number, value: number | undefined): VideoEdit | null {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return null;\n\tconst prop = animatableOf(at.clip, property);\n\tif (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);\n\tif (value === undefined) return null;\n\treturn { op: 'set_keyframes', clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };\n}\n\n/**\n * The keyframe toggle: adds a key at the playhead holding the current value (the first key turns\n * the plain value into keys), or removes the key there (the last one leaves its value plain).\n */\nexport function videoKeyframeToggleEdit(project: VideoProject, clipId: string, property: VideoKeyframeProperty, time: number): VideoEdit | null {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return null;\n\tconst prop = animatableOf(at.clip, property);\n\tconst t = playheadInClip(at.clip, time);\n\tconst fallback = VIDEO_KEYFRAME_LIMITS[property].default;\n\tif (isVideoKeyframes(prop)) {\n\t\tconst key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);\n\t\tif (key) {\n\t\t\tconst rest = removeVideoKey(prop, key.t);\n\t\t\treturn isVideoKeyframes(rest) ? { op: 'set_keyframes', clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? undefined : rest);\n\t\t}\n\t}\n\tconst v = videoValueAt(prop, t, fallback);\n\treturn { op: 'set_keyframes', clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };\n}\n\n/**\n * Edits giving clips a grade LUT (keeping their other grade settings), or taking it off with\n * `null`. Clips without a grade change are left out; one `controller.apply` makes it one undo step.\n */\nexport function videoLutEdits(project: VideoProject, clips: readonly string[], lut: VideoGradeLut | null): VideoEdit[] {\n\tconst edits: VideoEdit[] = [];\n\tfor (const id of clips) {\n\t\tconst at = findVideoClip(project, id);\n\t\tif (!at) continue;\n\t\tconst { lut: current, ...rest } = at.clip.grade ?? {};\n\t\tif (!lut && !current) continue;\n\t\tconst grade = normalizeVideoGrade(lut ? { ...rest, lut } : rest);\n\t\tedits.push(grade ? { op: 'update_clip', clip: id, set: { grade } } : { op: 'update_clip', clip: id, remove: ['grade'] });\n\t}\n\treturn edits;\n}\n","import type { AgentTool, AgentToolProvider } from '../tools/registry.js';\nimport type { JsonSchema } from '../tools/schema.js';\nimport type { VideoIssue } from './check.js';\nimport type { VideoEditorController } from './controller.js';\nimport { VIDEO_EDIT_OPS, type VideoEdit, type VideoEditOutcome, type VideoEditResult } from './edit.js';\nimport { findVideoAudioSilences, videoAudioLevels } from './audio.js';\nimport { findVideoFillers, findVideoTranscriptPauses, findVideoTranscriptText, mergeVideoRanges, parseVideoTranscript, VIDEO_DEFAULT_FILLERS, videoRemoveRangesEdits, videoTimelineTranscript, videoTranscriptTrack, type VideoTranscriptRange } from './transcript.js';\nimport { videoFreezeFrameEdit, videoSpeedRampEdit, videoSpeedRampKeys } from './remap.js';\nimport { VIDEO_GRADE_CONTROLS, VIDEO_GRADE_PRESET_NAMES, videoGradeLook } from './grade.js';\nimport { videoLutEdits } from './ui.js';\nimport { videoFrameAt } from './evaluate.js';\nimport { summarizeVideoScopes, type VideoPixels } from './scopes.js';\nimport { videoAssetProxy, videoProxyPlan } from './proxy.js';\nimport { VIDEO_EASE_NAMES, VIDEO_KEYFRAME_PROPERTIES, VIDEO_TRANSFORM_PROPERTIES, type VideoAnimatable, type VideoKeyframeProperty } from './keyframes.js';\nimport { videoContactSheetSvg, videoProjectSvg } from './render.js';\nimport { allVideoTracks, findVideoClip, findVideoGaps, findVideoSequenceCycle, formatVideoSeconds, formatVideoTimecode, sortedVideoClips, VIDEO_TIME_EPSILON, videoClipEnd, videoClipSourceRange, videoProjectDuration, videoSequenceChildren, videoSequenceDuration } from './timeline.js';\nimport { VIDEO_BLEND_MODES, VIDEO_CLIP_ANIMATION_EFFECTS, VIDEO_FADE_CURVES, VIDEO_FITS, VIDEO_PLACEMENT_MODES, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoAsset, type VideoProject, type VideoSequence, type VideoTrack } from './types.js';\n\n/** Times in results, to a tenth of a millisecond. */\nconst round = (ms: number) => Math.round(ms * 10) / 10;\n\nexport interface VideoEditorToolProviderOptions {\n\t/** Provider id (unique per registry). Default `video`. */\n\tid?: string;\n\t/** Always prefix tool names with `<namespace>__`. */\n\tnamespace?: string;\n\t/** The project cannot be edited: write tools are hidden and `get_video_project` says why. A string is the reason. */\n\treadOnly?: boolean | string | (() => boolean | string);\n\t/** Turns SVG into PNG (e.g. `rasterizeSvg`), so `render_video_frame` can return an image. */\n\trasterize?: (svg: string, size: { width: number; height: number }) => Promise<{ data: string; mimeType: string }> | { data: string; mimeType: string };\n\t/**\n\t * The rendered pixels of the frame at `time` (ms), for `get_video_scopes` (e.g. a frame decoded\n\t * by ffmpeg on a server). Default: what the editor's `<VideoPreview>` draws, through\n\t * `controller.previewPixels`, seeking it there and back when `time` is not the playhead.\n\t */\n\tframePixels?: (time: number) => VideoPixels | null | Promise<VideoPixels | null>;\n\t/** How long `get_video_scopes` waits for the preview to finish drawing a frame (ms, default 5000). */\n\tframePixelsTimeout?: number;\n}\n\nconst READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;\nconst WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;\nconst WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;\nconst DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false } as const;\n\nconst object = (properties: Record<string, JsonSchema>, required: string[] = [], description?: string): JsonSchema & { type: 'object' } => ({\n\ttype: 'object' as const,\n\t...(description ? { description } : {}),\n\tproperties,\n\t...(required.length ? { required } : {}),\n\tadditionalProperties: false\n});\n\nconst MS = (what: string) => ({ type: 'number', minimum: 0, description: `${what}, in milliseconds.` }) as const;\nconst CLIP_ID = { type: 'string', minLength: 1, description: 'A clip id from get_video_project (e.g. \"c1\").' } as const;\nconst TRACK_ID = { type: 'string', minLength: 1, description: 'A track id from get_video_project (e.g. \"v1\" or \"a1\").' } as const;\nconst ASSET_ID = { type: 'string', minLength: 1, description: 'An asset id from get_video_project.' } as const;\nconst COLOR = (what: string) => ({ type: 'string', minLength: 1, description: `${what}: a CSS colour such as \"#ffcc00\".` }) as const;\nconst RIPPLE = {\n\ttype: 'boolean',\n\tdescription:\n\t\t'Push (or pull) later material along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap). When the track is sync-locked (the default), every other unlocked sync-locked track moves too: clips across the edit point are cut when time opens, and closing time is refused if another sync-locked track has a clip in that stretch.'\n} as const;\nconst MODE = {\n\ttype: 'string',\n\tenum: [...VIDEO_PLACEMENT_MODES],\n\tdescription:\n\t\t'What to do with clips already where it lands: \"refuse\" (default) fails with the next free place; \"overwrite\" cuts, trims or removes what is underneath on that track; \"insert\" cuts everything across the start and pushes later material along by its length on this track and every sync-locked track.'\n} as const;\nconst DELTA = (what: string) => ({ type: 'number', description: `${what}, in milliseconds of timeline time: positive is later, negative earlier. It is clamped to what the media and neighbours allow; the result says how far it went.` }) as const;\nconst RANGE_TRACKS = { type: 'array', items: TRACK_ID, minItems: 1, uniqueItems: true, description: 'Tracks to work on (default: every unlocked track). Locked tracks are refused.' } as const;\n\nconst TRANSFORM = object(\n\t{\n\t\tx: { type: 'number', description: 'Offset of the centre to the right of the frame\\'s centre, in pixels (default 0).' },\n\t\ty: { type: 'number', description: 'Offset of the centre below the frame\\'s centre, in pixels (default 0).' },\n\t\tscale: { type: 'number', minimum: 0, description: 'Size after fitting (1 = fitted size, default 1).' },\n\t\trotation: { type: 'number', description: 'Degrees clockwise (default 0).' }\n\t},\n\t[],\n\t'Where the picture sits after it is fitted.'\n);\nconst TRANSITION = object(\n\t{\n\t\ttype: { type: 'string', enum: [...VIDEO_TRANSITION_TYPES], description: 'crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre).' },\n\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none.' },\n\t\tcolor: COLOR('For dip: the colour passed through (default black)'),\n\t\tdirection: { type: 'string', enum: [...VIDEO_WIPE_DIRECTIONS], description: 'For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left).' },\n\t\taudioCurve: { type: 'string', enum: [...VIDEO_FADE_CURVES], description: 'Shape of the sound\\'s fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic.' }\n\t},\n\t['type', 'duration'],\n\t'A transition from the clip that ends where this one starts on the same track.'\n);\nconst LUT_SRC = { type: 'string', minLength: 1, description: 'A .cube 3D LUT file the host can load and ffmpeg can read: a URL or path, often from the user (e.g. \"luts/teal-orange.cube\").' } as const;\nconst LUT_STRENGTH = { type: 'number', minimum: 0, maximum: 1, description: 'How much of the LUT\\'s change to apply, 0 (none) to 1 (full, the default); 0.3-0.7 for a subtler look.' } as const;\nconst GRADE = object(\n\t{\n\t\t...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: 'number', minimum: c.min, maximum: c.max, description: c.description }])),\n\t\tlut: object({ src: LUT_SRC, strength: LUT_STRENGTH, name: { type: 'string', minLength: 1, description: 'A label for the look.' } }, ['src'], 'A 3D LUT applied after the other settings (set_video_lut checks the file first).')\n\t},\n\t[],\n\t'Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint), then a LUT. Leave a setting out for no change; the object replaces the clip\\'s whole grade.'\n);\nconst ANIMATION = (when: string) =>\n\tobject(\n\t\t{\n\t\t\teffect: { type: 'string', enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: 'fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards.' },\n\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms (default 500).' }\n\t\t},\n\t\t['effect'],\n\t\twhen\n\t);\nconst CLIP_COMMON: Record<string, JsonSchema> = {\n\tname: { type: 'string', description: 'A label for the clip.' },\n\topacity: { type: 'number', minimum: 0, maximum: 1, description: 'Opacity 0–1 (default 1). A number replaces any keyframes; set_video_keyframes animates it.' },\n\ttransform: TRANSFORM,\n\tfit: { type: 'string', enum: [...VIDEO_FITS], description: 'How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours).' },\n\tgrade: GRADE,\n\tanimateIn: ANIMATION('How the picture enters, from the clip\\'s start (on top of any fade in). Titles usually rise, pop or fade in.'),\n\tanimateOut: ANIMATION('How the picture leaves, ending at the clip\\'s end.'),\n\tblend: { type: 'string', enum: [...VIDEO_BLEND_MODES], description: 'How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert.' },\n\tfadeIn: MS('Fade up from nothing (picture and sound) over this long'),\n\tfadeOut: MS('Fade down to nothing over this long')\n};\nconst MEDIA_FIELDS: Record<string, JsonSchema> = {\n\tin: MS('Where in the source it starts (default 0)'),\n\tspeed: { type: 'number', minimum: 0.05, maximum: 20, description: 'Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer.' },\n\tvolume: { type: 'number', minimum: 0, maximum: 4, description: 'Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it.' },\n\tmuted: { type: 'boolean', description: 'Silence this clip (default false).' },\n\tfadeInCurve: { type: 'string', enum: [...VIDEO_FADE_CURVES], description: 'Shape of the sound\\'s fade in (default linear; the picture always fades linearly): linear, equal-power (steady loudness, best for crossfades), s-curve (gentle at both ends), exponential (slow start, natural from silence) or logarithmic (fast start).' },\n\tfadeOutCurve: { type: 'string', enum: [...VIDEO_FADE_CURVES], description: 'Shape of the sound\\'s fade out (default linear), the same choices as fadeInCurve; exponential sounds natural fading to silence.' }\n};\nconst TEXT_FIELDS: Record<string, JsonSchema> = {\n\ttext: { type: 'string', minLength: 1, description: 'The title\\'s text (\"\\\\n\" breaks lines).' },\n\tcolor: COLOR('Text colour (default white)'),\n\tfontSize: { type: 'number', exclusiveMinimum: 0, description: 'Pixels (default 64).' },\n\tfontFamily: { type: 'string', description: 'CSS font family (default sans-serif).' },\n\tfontWeight: { type: 'number', minimum: 100, maximum: 1000, description: 'CSS weight (default 700).' },\n\tbackground: COLOR('A box behind the text (default none)')\n};\nconst PLACEMENT: Record<string, JsonSchema> = {\n\tid: { type: 'string', minLength: 1, description: 'Id for the clip (default: a fresh one such as \"c3\").' },\n\tstart: MS('Where it starts on the timeline (default: the end of the track)'),\n\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length on the timeline, in ms (default: the rest of the source for media, 5000 for stills, 3000 for titles and colours).' }\n};\nconst NEW_CLIP: JsonSchema = {\n\tdescription: 'The clip: media (a stretch of an asset), text (a title) or color (a solid fill).',\n\toneOf: [\n\t\tobject({ type: { const: 'media', description: 'A stretch of an asset.' }, asset: ASSET_ID, ...PLACEMENT, ...CLIP_COMMON, ...MEDIA_FIELDS }, ['type', 'asset'], 'A media clip.'),\n\t\tobject({ type: { const: 'text', description: 'A title over the picture.' }, ...PLACEMENT, ...CLIP_COMMON, ...TEXT_FIELDS }, ['type', 'text'], 'A title.'),\n\t\tobject({ type: { const: 'color', description: 'A solid colour filling the frame.' }, color: COLOR('The fill'), ...PLACEMENT, ...CLIP_COMMON }, ['type', 'color'], 'A colour.')\n\t]\n};\nconst SETTINGS: Record<string, JsonSchema> = {\n\tname: { type: 'string', description: 'Project name.' },\n\twidth: { type: 'integer', minimum: 16, maximum: 8192, description: 'Frame width in pixels.' },\n\theight: { type: 'integer', minimum: 16, maximum: 8192, description: 'Frame height in pixels.' },\n\tfps: { type: 'number', exclusiveMinimum: 0, maximum: 240, description: 'Frames per second (e.g. 24, 25, 30, 60).' },\n\tbackground: COLOR('What shows where no clip covers the frame (default black)')\n};\nconst TRACK_FIELDS: Record<string, JsonSchema> = {\n\tname: { type: 'string', description: 'Track label.' },\n\tmuted: { type: 'boolean', description: 'Silence every clip on it.' },\n\thidden: { type: 'boolean', description: 'Leave its pictures out of the frame.' },\n\tlocked: { type: 'boolean', description: 'Refuse edits to its clips.' },\n\tsyncLocked: { type: 'boolean', description: 'Move with ripple edits on other sync-locked tracks so it stays in sync (default true). Turn off for a track that should stay put, e.g. a music bed.' },\n\tvolume: { type: 'number', minimum: 0, maximum: 4, description: 'Gain for every clip on it (1 = unchanged).' }\n};\nconst ASSET_FIELDS: Record<string, JsonSchema> = {\n\tsrc: { type: 'string', minLength: 1, description: 'URL, path or data URL the player and exporter can open.' },\n\tname: { type: 'string', description: 'Display name (e.g. the file name).' },\n\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length of the source in ms (required to check trims of video and audio).' },\n\twidth: { type: 'number', exclusiveMinimum: 0, description: 'Pixel width (video and image; default the project width).' },\n\theight: { type: 'number', exclusiveMinimum: 0, description: 'Pixel height.' },\n\thasAudio: { type: 'boolean', description: 'For video: whether it has sound (default true).' },\n\tposter: { type: 'string', description: 'For video: a still (URL) to show in rendered frames.' }\n};\n\nconst KEY_PROPERTY = {\n\ttype: 'string',\n\tenum: [...VIDEO_KEYFRAME_PROPERTIES],\n\tdescription: 'Which property: opacity (0–1), volume (media only, 0–4 gain), x / y (pixels from the frame\\'s centre, right / down), scale (1 = fitted size) or rotation (degrees clockwise).'\n} as const;\nconst KEYS: JsonSchema = {\n\ttype: 'array',\n\tminItems: 1,\n\tmaxItems: 500,\n\tdescription: 'The keys, replacing any the property had (any order, one per time). Before the first key the property holds the first value, after the last the last value.',\n\titems: object(\n\t\t{\n\t\t\tt: { type: 'number', description: 'Time in ms from the clip\\'s start (clip time, not timeline time: 0 is the clip\\'s first frame, its duration the end). Keys stay with the clip when it moves, is trimmed or split.' },\n\t\t\tv: { type: 'number', description: 'The value at that time, in the property\\'s units.' },\n\t\t\tease: {\n\t\t\t\tdescription: 'How the value moves from this key to the next (default linear): linear, hold (stays, then jumps at the next key), ease-in, ease-out, ease-in-out, or CSS cubic-bezier points [x1, y1, x2, y2] with x1 and x2 in 0–1.',\n\t\t\t\tanyOf: [\n\t\t\t\t\t{ type: 'string', enum: [...VIDEO_EASE_NAMES] },\n\t\t\t\t\t{ type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4 }\n\t\t\t\t]\n\t\t\t}\n\t\t},\n\t\t['t', 'v'],\n\t\t'One keyframe.'\n\t)\n};\n\nconst issueSummary = (issues: readonly VideoIssue[]) => ({\n\terrors: issues.filter((i) => i.severity === 'error').length,\n\twarnings: issues.filter((i) => i.severity === 'warning').length,\n\tnotes: issues.filter((i) => i.severity === 'note').length,\n\tlines: issues.map((i) => `${i.severity}: ${i.message}`)\n});\n\n/** An asset as agents see it: a transcript is summarised as its word count (get_video_transcript reads it). */\nconst videoAssetOut = (a: VideoAsset) => {\n\tconst { transcript, ...rest } = a;\n\treturn transcript ? { ...rest, transcriptWords: transcript.length } : rest;\n};\n\n/** Tracks as agents see them: each with its clips in time order, their ends and (media and compounds) the source they play. */\nconst describeVideoTracks = (tracks: readonly VideoTrack[]) =>\n\ttracks.map((t, index) => ({\n\t\tid: t.id,\n\t\tkind: t.kind,\n\t\tindex,\n\t\t...(t.name ? { name: t.name } : {}),\n\t\t...(t.muted ? { muted: true } : {}),\n\t\t...(t.hidden ? { hidden: true } : {}),\n\t\t...(t.locked ? { locked: true } : {}),\n\t\t...(t.syncLocked === false ? { syncLocked: false } : {}),\n\t\t...(t.volume !== undefined ? { volume: t.volume } : {}),\n\t\tclips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...(c.type === 'media' || c.type === 'sequence' ? { source: videoClipSourceRange(c) } : {}) }))\n\t}));\n\n/** The compound clips that play a sequence, wherever they are (the project's tracks or other sequences). */\nconst videoSequenceUsers = (project: VideoProject, id: string) =>\n\tallVideoTracks(project).flatMap(({ sequence, track }) =>\n\t\ttrack.clips.flatMap((c) => (c.type === 'sequence' && c.sequence === id ? [{ clip: c.id, ...(sequence ? { inSequence: sequence } : {}), track: track.id, start: c.start, end: videoClipEnd(c), source: videoClipSourceRange(c)! }] : []))\n\t);\n\n/** A sequence as agents see it: its length, the sequences it nests, who plays it, and (with `tracks`) its tracks and clips. */\nexport function describeVideoSequence(project: VideoProject, sequence: VideoSequence, tracks = true) {\n\tconst cycle = findVideoSequenceCycle(project, sequence.id);\n\treturn {\n\t\tid: sequence.id,\n\t\t...(sequence.name ? { name: sequence.name } : {}),\n\t\tduration: videoSequenceDuration(sequence),\n\t\tclipCount: sequence.tracks.reduce((n, t) => n + t.clips.length, 0),\n\t\tnests: videoSequenceChildren(sequence),\n\t\tusedBy: videoSequenceUsers(project, sequence.id),\n\t\t...(cycle ? { cycle } : {}),\n\t\t...(tracks ? { tracks: describeVideoTracks(sequence.tracks) } : { trackCount: sequence.tracks.length })\n\t};\n}\n\n/** A project as agents see it: settings, assets, each track's clips in order with their times, and a summary of each sequence. */\nexport function describeVideoProject(project: VideoProject) {\n\treturn {\n\t\t...(project.name ? { name: project.name } : {}),\n\t\twidth: project.width,\n\t\theight: project.height,\n\t\tfps: project.fps,\n\t\tduration: videoProjectDuration(project),\n\t\t...(project.background ? { background: project.background } : {}),\n\t\tassets: project.assets.map(videoAssetOut),\n\t\ttracks: describeVideoTracks(project.tracks),\n\t\t...(project.sequences?.length ? { sequences: project.sequences.map((q) => describeVideoSequence(project, q, false)) } : {}),\n\t\tmarkers: project.markers ?? []\n\t};\n}\n\n/**\n * Agent tools for a `VideoEditorController`: read the project (assets, tracks with clips and their\n * times, gaps, what plays at a moment), check it, render frames, export to ffmpeg or an EDL, and\n * edit it a step at a time (add assets and tracks, add / move / trim / split / delete clips with\n * overwrite, insert and sync-locked ripple, slip / roll / slide, lift / extract a range, cut across\n * tracks, transitions, markers). Every write is checked first (unknown ids with \"did you mean\", overlaps,\n * trims past the source, locked tracks), applies as one undo step, and returns what it made and\n * the resulting length and issues. Write tools are hidden while the project is read-only.\n */\nexport function createVideoEditorToolProvider(controller: VideoEditorController, options: VideoEditorToolProviderOptions = {}): AgentToolProvider {\n\tconst readOnlyReason = (): string | null => {\n\t\tconst r = typeof options.readOnly === 'function' ? options.readOnly() : options.readOnly;\n\t\tif (r) return typeof r === 'string' ? r : 'This video project is read-only.';\n\t\treturn controller.readOnlyReason();\n\t};\n\tconst project = () => controller.getProject();\n\tconst outcome = (result: VideoEditResult, extra: Record<string, unknown> = {}) => {\n\t\tif (!result.ok) throw new Error(result.error);\n\t\treturn { ...extra, duration: videoProjectDuration(result.project), issues: issueSummary(result.issues) };\n\t};\n\tconst apply = (edits: VideoEdit | VideoEdit[], label: string, extra?: (r: Extract<VideoEditResult, { ok: true }>) => Record<string, unknown>) => {\n\t\tconst result = controller.apply(edits, label);\n\t\treturn outcome(result, result.ok && extra ? extra(result) : {});\n\t};\n\tconst clipOut = (p: VideoProject, id: string) => {\n\t\tfor (const t of p.tracks) {\n\t\t\tconst c = t.clips.find((x) => x.id === id);\n\t\t\tif (c) return { track: t.id, ...c, end: videoClipEnd(c), ...(c.type === 'media' || c.type === 'sequence' ? { source: videoClipSourceRange(c) } : {}) };\n\t\t}\n\t\treturn null;\n\t};\n\n\t/** What a placement or ripple did besides the clip itself. */\n\tconst placed = (o: VideoEditOutcome) => ({\n\t\t...(o.created?.length ? { created: o.created } : {}),\n\t\t...(o.trimmed?.length ? { trimmed: o.trimmed } : {}),\n\t\t...(o.removed?.length ? { removed: o.removed } : {}),\n\t\t...(o.rippled ? { rippled: o.rippled } : {})\n\t});\n\tconst keysOut = (p: VideoProject, clip: string, property: VideoKeyframeProperty) => {\n\t\tconst c = clipOut(p, clip) as any;\n\t\tconst value: VideoAnimatable | undefined = (VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(property) ? c?.transform?.[property] : c?.[property];\n\t\treturn typeof value === 'object' ? { keys: value.keys } : { keys: [], value: value ?? null };\n\t};\n\n\tconst getProject: AgentTool = {\n\t\tname: 'get_video_project',\n\t\ttitle: 'Read the video project',\n\t\tdescription:\n\t\t\t'Returns the whole project: frame size, fps, length (ms), assets (id, kind, src, duration), tracks top to bottom (an earlier video track draws over a later one) with each clip in time order (id, type, start, duration, end, source in/out for media and compound clips, fades, transition), a summary of each nested sequence (id, name, length, clip count, the sequences it nests, the compound clips that play it) and markers, plus the playhead, selection, issues and whether it can be edited. Compound clips (type \"sequence\") play a sequence\\'s tracks as one picture and sound; list_video_sequences reads what is inside them.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Read the video project', ...READ },\n\t\thandler: () => {\n\t\t\tconst state = controller.getState();\n\t\t\tconst reason = readOnlyReason();\n\t\t\treturn { ...describeVideoProject(state.project), playhead: state.time, previewScale: state.previewScale, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...(reason ? { readOnlyReason: reason } : {}) };\n\t\t}\n\t};\n\n\tconst getFrame: AgentTool = {\n\t\tname: 'get_video_frame',\n\t\ttitle: 'What plays at a moment',\n\t\tdescription: 'Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe or iris) and the sounds (clip, source time, gain after volumes, fades and transitions). A compound clip is one picture with its sequence, the moment of the sequence it shows (sourceTime) and the sequence\\'s own pictures as \"layers\"; its sounds are listed with clip paths such as \"compound/inner\".',\n\t\tinputSchema: object({ time: MS('Time from the start (default: the playhead)') }),\n\t\tannotations: { title: 'What plays at a moment', ...READ },\n\t\thandler: ({ time }) => {\n\t\t\tconst frame = videoFrameAt(project(), time ?? controller.getState().time);\n\t\t\treturn { ...frame, timecode: formatVideoTimecode(frame.time, project().fps) };\n\t\t}\n\t};\n\n\tconst listSequences: AgentTool = {\n\t\tname: 'list_video_sequences',\n\t\ttitle: 'List nested sequences',\n\t\tdescription:\n\t\t\t'Lists the project\\'s nested sequences, the tracks that compound clips (type \"sequence\") play as one picture and sound, without changing anything. For each: id, name, length (ms), the sequences it nests, the compound clips that play it (clip id, the track and sequence they sit on, timeline start and end, and the stretch of the sequence they play as source in / out) and its tracks with their clips, in the same shape as get_video_project. A sequence that contains itself lists the loop as \"cycle\". Clips inside sequences can be read here but not edited yet; edit the compound clip instead (move, trim, its opacity, blend, grade, volume).',\n\t\tinputSchema: object({ sequence: { type: 'string', minLength: 1, description: 'Only this sequence id (default: every sequence).' } }),\n\t\tannotations: { title: 'List nested sequences', ...READ },\n\t\thandler: ({ sequence }) => {\n\t\t\tconst p = project();\n\t\t\tconst all = p.sequences ?? [];\n\t\t\tif (sequence !== undefined) {\n\t\t\t\tconst found = all.find((q) => q.id === sequence);\n\t\t\t\tif (!found) throw new Error(`There is no sequence \"${sequence}\"; ${all.length ? `the sequences are ${all.map((q) => `\"${q.id}\"`).join(', ')}` : 'the project has no sequences'}.`);\n\t\t\t\treturn { sequences: [describeVideoSequence(p, found)] };\n\t\t\t}\n\t\t\treturn { sequences: all.map((q) => describeVideoSequence(p, q)) };\n\t\t}\n\t};\n\n\tconst findGaps: AgentTool = {\n\t\tname: 'find_video_gaps',\n\t\ttitle: 'Find gaps',\n\t\tdescription: 'Lists empty stretches between clips (and before the first) on every track or one track, as { track, start, end } in ms. close_video_gaps removes them.',\n\t\tinputSchema: object({ track: { ...TRACK_ID, description: 'Only this track (default all).' } }),\n\t\tannotations: { title: 'Find gaps', ...READ },\n\t\thandler: ({ track }) => {\n\t\t\tif (track !== undefined && !project().tracks.some((t) => t.id === track)) throw new Error(`There is no track \"${track}\"; the tracks are ${project().tracks.map((t) => `\"${t.id}\"`).join(', ')}.`);\n\t\t\treturn { gaps: findVideoGaps(project(), track) };\n\t\t}\n\t};\n\n\tconst checkProject: AgentTool = {\n\t\tname: 'check_video_project',\n\t\ttitle: 'Check the video project',\n\t\tdescription: 'Lists the project\\'s problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades or animations longer than the clip, animations a clip\\'s own keyframes override, keyframes outside their clip, assets with no duration, unused assets.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Check the video project', ...READ },\n\t\thandler: () => {\n\t\t\tconst issues = controller.getState().issues;\n\t\t\treturn { ...issueSummary(issues), issues };\n\t\t}\n\t};\n\n\tconst renderFrame: AgentTool = {\n\t\tname: 'render_video_frame',\n\t\ttitle: 'Render a frame',\n\t\tdescription:\n\t\t\t'Draws the project at one moment (or several, as one labelled contact sheet) to check the picture, without changing it: SVG by default, PNG (format \"png\") when the host can rasterize. Images and video posters are drawn; video without a poster shows as a labelled box with its source timecode.',\n\t\tinputSchema: object({\n\t\t\ttime: MS('Time from the start (default: the playhead)'),\n\t\t\ttimes: { type: 'array', items: { type: 'number', minimum: 0 }, minItems: 2, maxItems: 16, description: 'Several moments (ms) drawn as one contact sheet instead of \"time\".' },\n\t\t\tcolumns: { type: 'integer', minimum: 1, maximum: 8, description: 'With times: frames a row (default about square).' },\n\t\t\tformat: { type: 'string', enum: ['svg', 'png'], description: 'svg (default) or png (only when the host supports it).' }\n\t\t}),\n\t\tannotations: { title: 'Render a frame', ...READ },\n\t\thandler: async ({ time, times, columns, format = 'svg' }) => {\n\t\t\tconst p = project();\n\t\t\t// LUTs are drawn from their tables (approximated in SVG), so load the ones in use first.\n\t\t\tconst lutSrcs = [...new Set(p.tracks.flatMap((t) => t.clips.map((c) => c.grade?.lut?.src).filter((x): x is string => !!x)))];\n\t\t\tconst missing = (await Promise.all(lutSrcs.map((src) => controller.luts.load(src).then(() => null, (e: Error) => e.message)))).filter((m): m is string => !!m);\n\t\t\tconst svgOptions = { lutFor: (src: string) => controller.luts.peek(src), ...(columns ? { columns } : {}) };\n\t\t\tconst svg = times ? videoContactSheetSvg(p, times, svgOptions) : videoProjectSvg(p, time ?? controller.getState().time, svgOptions);\n\t\t\tconst notes = [...missing.map((m) => `${m}; that LUT is left out of the picture.`), ...(lutSrcs.length > missing.length ? ['LUTs are drawn approximately in SVG (curves and a colour matrix); the preview and export apply them exactly.'] : [])];\n\t\t\tif (format === 'png') {\n\t\t\t\tif (!options.rasterize) throw new Error('This host cannot make PNG images; call render_video_frame with format \"svg\".');\n\t\t\t\tconst size = /viewBox=\"0 0 ([\\d.]+) ([\\d.]+)\"/.exec(svg)!;\n\t\t\t\tconst image = await options.rasterize(svg, { width: Number(size[1]), height: Number(size[2]) });\n\t\t\t\treturn { content: [{ type: 'image', data: image.data, mimeType: image.mimeType }, ...(notes.length ? [{ type: 'text' as const, text: notes.join(' ') }] : [])] };\n\t\t\t}\n\t\t\treturn { svg, ...(notes.length ? { notes } : {}) };\n\t\t}\n\t};\n\n\tconst LOUDNESS_TARGET: Record<string, JsonSchema> = {\n\t\tintegrated: { type: 'number', minimum: -70, maximum: -5, description: 'Integrated loudness to reach, in LUFS (default -14, what streaming services play at; -16 for podcasts, -23 for EBU R128 broadcast).' },\n\t\ttruePeak: { type: 'number', minimum: -9, maximum: 0, description: 'Highest true peak, in dBTP (default -1).' },\n\t\trange: { type: 'number', minimum: 1, maximum: 50, description: 'Loudness range, in LU (default 11).' }\n\t};\n\tconst exportFfmpeg: AgentTool = {\n\t\tname: 'export_video_ffmpeg',\n\t\ttitle: 'Export an ffmpeg command',\n\t\tdescription:\n\t\t\t'Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. With loudness, the mix is normalised to a target loudness (EBU R128); add loudness.measured from a measuring pass (export_video_loudness_measure) for an exact fixed gain. Fails when the project has no clips.',\n\t\tinputSchema: object({\n\t\t\toutput: { type: 'string', minLength: 1, description: 'Output file name (default output.mp4).' },\n\t\t\tcrf: { type: 'integer', minimum: 0, maximum: 51, description: 'x264 quality, lower is better (default 18).' },\n\t\t\tloudness: object(\n\t\t\t\t{\n\t\t\t\t\t...LOUDNESS_TARGET,\n\t\t\t\t\tmeasured: object(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tintegrated: { type: 'number', description: 'Measured integrated loudness, LUFS (loudnorm\\'s \"input_i\").' },\n\t\t\t\t\t\t\ttruePeak: { type: 'number', description: 'Measured true peak, dBTP (\"input_tp\").' },\n\t\t\t\t\t\t\trange: { type: 'number', description: 'Measured loudness range, LU (\"input_lra\").' },\n\t\t\t\t\t\t\tthreshold: { type: 'number', description: 'Measured gating threshold, LUFS (\"input_thresh\").' },\n\t\t\t\t\t\t\toffset: { type: 'number', description: 'Offset for the second pass, LU (\"target_offset\").' }\n\t\t\t\t\t\t},\n\t\t\t\t\t\t['integrated', 'truePeak', 'range', 'threshold', 'offset'],\n\t\t\t\t\t\t'What the measuring pass printed (the JSON at the end of its stderr), for a linear second pass that keeps the dynamics. Leave out for one-pass normalisation.'\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\t[],\n\t\t\t\t'Normalise the mix\\'s loudness (default: not normalised). An empty object uses -14 LUFS, -1 dBTP.'\n\t\t\t)\n\t\t}),\n\t\tannotations: { title: 'Export an ffmpeg command', ...READ },\n\t\thandler: ({ output, crf, loudness }) => controller.toFfmpeg({ ...(output ? { output } : {}), ...(crf !== undefined ? { crf } : {}), ...(loudness ? { loudness } : {}) })\n\t};\n\n\tconst exportLoudnessMeasure: AgentTool = {\n\t\tname: 'export_video_loudness_measure',\n\t\ttitle: 'Export a loudness measuring command',\n\t\tdescription:\n\t\t\t'Returns the ffmpeg command for the first pass of a loudness normalisation: it mixes only the sound and prints its loudness as JSON at the end of stderr (input_i, input_tp, input_lra, input_thresh, target_offset) instead of writing a file. Pass those numbers as loudness.measured (integrated, truePeak, range, threshold, offset) to export_video_ffmpeg, with the same target. Fails when nothing makes a sound.',\n\t\tinputSchema: object(LOUDNESS_TARGET),\n\t\tannotations: { title: 'Export a loudness measuring command', ...READ },\n\t\thandler: (target) => controller.toLoudnessMeasure({ loudness: target })\n\t};\n\n\tconst exportEdl: AgentTool = {\n\t\tname: 'export_video_edl',\n\t\ttitle: 'Export an EDL',\n\t\tdescription: 'Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode (drop-frame at 29.97 and 59.94 fps unless dropFrame is false), dissolves, speed changes and clip names.',\n\t\tinputSchema: object({\n\t\t\ttrack: { ...TRACK_ID, description: 'Track to export (default: the bottom video track).' },\n\t\t\ttitle: { type: 'string', description: 'TITLE line (default: the project name).' },\n\t\t\tdropFrame: { type: 'boolean', description: 'Drop-frame timecode (HH:MM:SS;FF). Default true at 29.97 and 59.94 fps, where it is the norm; ignored at other rates, which have no drop-frame form.' }\n\t\t}),\n\t\tannotations: { title: 'Export an EDL', ...READ },\n\t\thandler: ({ track, title, dropFrame }) => ({ edl: controller.toEdl({ ...(track ? { track } : {}), ...(title ? { title } : {}), ...(dropFrame !== undefined ? { dropFrame } : {}) }) })\n\t};\n\n\tconst seek: AgentTool = {\n\t\tname: 'seek_video',\n\t\ttitle: 'Show a moment',\n\t\tdescription: 'Moves the playhead the person sees to a time, a clip\\'s start or a marker, and plays or pauses. Does not change the project. Returns the playhead and the clips under it.',\n\t\tinputSchema: object({\n\t\t\ttime: MS('Time from the start'),\n\t\t\tclip: { ...CLIP_ID, description: 'Go to the start of this clip instead of \"time\".' },\n\t\t\tmarker: { type: 'string', minLength: 1, description: 'Go to this marker instead of \"time\".' },\n\t\t\tplay: { type: 'boolean', description: 'true plays from there, false pauses (default: leave as is).' }\n\t\t}),\n\t\tannotations: { title: 'Show a moment', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ time, clip, marker, play }) => {\n\t\t\tconst p = project();\n\t\t\tif (clip !== undefined) {\n\t\t\t\tconst c = clipOut(p, clip);\n\t\t\t\tif (!c) throw new Error(`There is no clip \"${clip}\".`);\n\t\t\t\tcontroller.seek(c.start);\n\t\t\t} else if (marker !== undefined) {\n\t\t\t\tconst m = (p.markers ?? []).find((x) => x.id === marker);\n\t\t\t\tif (!m) throw new Error(`There is no marker \"${marker}\"; the markers are ${(p.markers ?? []).map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\t\t\tcontroller.seek(m.time);\n\t\t\t} else if (time !== undefined) controller.seek(time);\n\t\t\tif (play === true) controller.play();\n\t\t\tif (play === false) controller.pause();\n\t\t\tconst state = controller.getState();\n\t\t\treturn { time: state.time, timecode: formatVideoTimecode(state.time, p.fps), playing: state.playing, clips: state.current };\n\t\t}\n\t};\n\n\tconst setSettings: AgentTool = {\n\t\tname: 'set_video_settings',\n\t\ttitle: 'Change project settings',\n\t\tdescription: 'Sets the name, frame size, frame rate or background colour (one undo step). Returns the length and issues.',\n\t\tinputSchema: object(SETTINGS),\n\t\tannotations: { title: 'Change project settings', ...WRITE_IDEMPOTENT },\n\t\thandler: (args) => {\n\t\t\tif (!Object.keys(args).length) throw new Error('Give at least one setting to change (name, width, height, fps, background).');\n\t\t\treturn apply({ op: 'set_settings', ...args }, 'Change settings');\n\t\t}\n\t};\n\n\tconst addAsset: AgentTool = {\n\t\tname: 'add_video_asset',\n\t\ttitle: 'Add media',\n\t\tdescription: 'Adds a video, audio or image file to the project so clips can use it (one undo step). Give the duration of video and audio so trims can be checked. Returns the asset id.',\n\t\tinputSchema: object({ id: { type: 'string', minLength: 1, description: 'Id for it (default: from its name or file name).' }, kind: { type: 'string', enum: ['video', 'audio', 'image'], description: 'What it is.' }, ...ASSET_FIELDS }, ['kind', 'src']),\n\t\tannotations: { title: 'Add media', ...WRITE },\n\t\thandler: (asset) => apply({ op: 'add_asset', asset: asset as any }, 'Add media', (r) => ({ asset: r.outcomes[0].asset }))\n\t};\n\n\tconst updateAsset: AgentTool = {\n\t\tname: 'update_video_asset',\n\t\ttitle: 'Change media details',\n\t\tdescription: '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.',\n\t\tinputSchema: 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']),\n\t\tannotations: { title: 'Change media details', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ asset, set, remove }) => apply({ op: 'update_asset', asset, set, remove }, 'Change media', (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)!) }))\n\t};\n\n\tconst removeAsset: AgentTool = {\n\t\tname: 'remove_video_asset',\n\t\ttitle: 'Remove media',\n\t\tdescription: 'Removes an asset no clip uses (one undo step); refused while clips use it, naming them. Returns the remaining asset ids.',\n\t\tinputSchema: object({ asset: ASSET_ID }, ['asset']),\n\t\tannotations: { title: 'Remove media', ...DESTRUCTIVE },\n\t\thandler: ({ asset }) => apply({ op: 'remove_asset', asset }, 'Remove media', (r) => ({ removed: asset, assets: r.project.assets.map((a) => a.id) }))\n\t};\n\n\tconst addTrack: AgentTool = {\n\t\tname: 'add_video_track',\n\t\ttitle: 'Add a track',\n\t\tdescription: 'Adds an empty video or audio track (one undo step). By default a video track goes on top (drawing over the others) and an audio track at the bottom. Returns its id and the track order.',\n\t\tinputSchema: object({ kind: { type: 'string', enum: ['video', 'audio'], description: 'Video (pictures, titles, colours) or audio.' }, id: { type: 'string', minLength: 1, description: 'Id (default \"v2\", \"a2\", …).' }, at: { type: 'integer', minimum: 0, description: 'Position, 0 = top.' }, ...TRACK_FIELDS }, ['kind']),\n\t\tannotations: { title: 'Add a track', ...WRITE },\n\t\thandler: ({ kind, id, at, ...fields }) => apply({ op: 'add_track', track: { kind, ...(id ? { id } : {}), ...fields }, at }, 'Add track', (r) => ({ track: r.outcomes[0].track, tracks: r.project.tracks.map((t) => t.id) }))\n\t};\n\n\tconst updateTrack: AgentTool = {\n\t\tname: 'update_video_track',\n\t\ttitle: 'Change a track',\n\t\tdescription: 'Renames, mutes, hides, locks, sync-locks or sets the volume of a track (one undo step). Returns the track\\'s settings.',\n\t\tinputSchema: object({ track: TRACK_ID, set: object(TRACK_FIELDS, [], 'Fields to replace.'), remove: { type: 'array', items: { type: 'string', enum: ['name', 'muted', 'hidden', 'locked', 'syncLocked', 'volume'] }, description: 'Fields to clear.' } }, ['track']),\n\t\tannotations: { title: 'Change a track', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ track, set, remove }) =>\n\t\t\tapply({ op: 'update_track', track, set, remove }, 'Change track', (r) => {\n\t\t\t\tconst { clips, ...t } = r.project.tracks.find((x) => x.id === track)!;\n\t\t\t\treturn { track: { ...t, clips: clips.length } };\n\t\t\t})\n\t};\n\n\tconst removeTrack: AgentTool = {\n\t\tname: 'remove_video_track',\n\t\ttitle: 'Remove a track',\n\t\tdescription: 'Deletes a track and every clip on it (one undo step; refused when locked). Returns the clips removed with it.',\n\t\tinputSchema: object({ track: TRACK_ID }, ['track']),\n\t\tannotations: { title: 'Remove a track', ...DESTRUCTIVE },\n\t\thandler: ({ track }) => apply({ op: 'remove_track', track }, 'Remove track', (r) => ({ removed: track, clips: r.outcomes[0].clips }))\n\t};\n\n\tconst moveTrack: AgentTool = {\n\t\tname: 'move_video_track',\n\t\ttitle: 'Reorder a track',\n\t\tdescription: 'Moves a track to a new position (0 = top; an earlier video track draws over later ones). Returns the track order.',\n\t\tinputSchema: object({ track: TRACK_ID, to: { type: 'integer', minimum: 0, description: 'New position, 0 = top.' } }, ['track', 'to']),\n\t\tannotations: { title: 'Reorder a track', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ track, to }) => apply({ op: 'move_track', track, to }, 'Move track', (r) => ({ tracks: r.project.tracks.map((t) => t.id) }))\n\t};\n\n\tconst addClip: AgentTool = {\n\t\tname: 'add_video_clip',\n\t\ttitle: 'Add a clip',\n\t\tdescription:\n\t\t\t'Puts a clip on a track (one undo step): media (a stretch of an asset from its \"in\" point), a title or a colour. With no start it goes at the end of the track. Where it lands on other clips, mode decides: refuse (default, with the next free place), overwrite (replace what is underneath) or insert (push later material along on every sync-locked track). Returns the clip with its end and source range, plus any clips it cut (created), trimmed or removed and the tracks it rippled.',\n\t\tinputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, mode: MODE, ripple: { type: 'boolean', description: 'Same as mode \"insert\" (kept for older callers; default false).' } }, ['track', 'clip']),\n\t\tannotations: { title: 'Add a clip', ...WRITE },\n\t\thandler: ({ track, clip, mode, ripple }) => apply({ op: 'add_clip', track, clip, mode, ripple }, mode === 'overwrite' ? 'Overwrite clip' : mode === 'insert' || ripple ? 'Insert clip' : 'Add clip', (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip!), ...placed(r.outcomes[0]) }))\n\t};\n\n\tconst moveClip: AgentTool = {\n\t\tname: 'move_video_clip',\n\t\ttitle: 'Move a clip',\n\t\tdescription:\n\t\t\t'Moves a clip to a new start and/or another track of the same kind (one undo step), leaving a gap where it was. Where it lands on other clips, mode decides: refuse (default, naming the clip and the next free place), overwrite or insert. Returns the clip, plus any clips it cut (created), trimmed or removed and the tracks it rippled.',\n\t\tinputSchema: object({ clip: CLIP_ID, start: MS('New start on the timeline (default: unchanged)'), track: { ...TRACK_ID, description: 'Track to move it to (default: its own).' }, mode: MODE }, ['clip']),\n\t\tannotations: { title: 'Move a clip', ...WRITE },\n\t\thandler: ({ clip, start, track, mode }) => {\n\t\t\tif (start === undefined && track === undefined) throw new Error('Give \"start\", \"track\" or both.');\n\t\t\treturn apply({ op: 'move_clip', clip, start, track, mode }, mode === 'overwrite' ? 'Overwrite clip' : mode === 'insert' ? 'Insert clip' : 'Move clip', (r) => ({ clip: clipOut(r.project, clip), ...placed(r.outcomes[0]) }));\n\t\t}\n\t};\n\n\tconst trimClip: AgentTool = {\n\t\tname: 'trim_video_clip',\n\t\ttitle: 'Trim a clip',\n\t\tdescription:\n\t\t\t'Moves one edge of a clip to a timeline time (one undo step): \"start\" also moves its source in point, \"end\" its out point. Refused past the ends of the source (the error says how far it can go) or below one frame. With ripple the clip keeps its start and the later clips on the track move by the change, so no gap or overlap opens. Returns the clip.',\n\t\tinputSchema: object({ clip: CLIP_ID, edge: { type: 'string', enum: ['start', 'end'], description: 'Which edge to move.' }, to: MS('Where that edge goes on the timeline'), ripple: RIPPLE }, ['clip', 'edge', 'to']),\n\t\tannotations: { title: 'Trim a clip', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, edge, to, ripple }) => apply({ op: 'trim_clip', clip, edge, to, ripple }, ripple ? 'Ripple trim' : 'Trim clip', (r) => ({ clip: clipOut(r.project, clip), ...placed(r.outcomes[0]) }))\n\t};\n\n\tconst splitClip: AgentTool = {\n\t\tname: 'split_video_clip',\n\t\ttitle: 'Split a clip',\n\t\tdescription: 'Cuts a clip in two at a timeline time (at least a frame from either end), as one undo step. The left part keeps the id, fade in and transition; the right part gets a new id and the fade out. Returns both parts.',\n\t\tinputSchema: object({ clip: CLIP_ID, at: MS('Where to cut, on the timeline'), id: { type: 'string', minLength: 1, description: 'Id for the right part (default \"<clip>-1\").' } }, ['clip', 'at']),\n\t\tannotations: { title: 'Split a clip', ...WRITE },\n\t\thandler: ({ clip, at, id }) => apply({ op: 'split_clip', clip, at, id }, 'Split clip', (r) => ({ left: clipOut(r.project, clip), right: clipOut(r.project, r.outcomes[0].clip!) }))\n\t};\n\n\tconst deleteClips: AgentTool = {\n\t\tname: 'delete_video_clips',\n\t\ttitle: 'Delete clips',\n\t\tdescription:\n\t\t\t'Removes clips (one undo step). With ripple the later material moves up to close the gaps they leave: on every sync-locked track when they were on one (refused, naming the clip, if another sync-locked track has something in a gap), else on their own track. Returns the ids removed and the tracks rippled.',\n\t\tinputSchema: object(\n\t\t\t{ clips: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clip ids.' }, ripple: { type: 'boolean', description: 'Close the gaps they leave, on every sync-locked track (default false).' } },\n\t\t\t['clips']\n\t\t),\n\t\tannotations: { title: 'Delete clips', ...DESTRUCTIVE },\n\t\thandler: ({ clips, ripple }) => apply({ op: 'delete_clips', clips, ripple }, ripple ? 'Ripple delete' : 'Delete clips', (r) => ({ removed: clips, ...(r.outcomes[0].rippled ? { rippled: r.outcomes[0].rippled } : {}) }))\n\t};\n\n\tconst updateClip: AgentTool = {\n\t\tname: 'update_video_clip',\n\t\ttitle: 'Change a clip',\n\t\tdescription:\n\t\t\t'Changes a clip\\'s properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound\\'s fade curves, speed (keeps the stretch of source, so the length changes) or asset; for compound clips in point, volume, mute and fade curves (no speed); for titles text and style; for colours the colour. Fields in \"remove\" go back to their defaults. Use move / trim for timing. Returns the clip.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclip: CLIP_ID,\n\t\t\t\tset: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: 'For media: play another asset (same in point).' }, ...TEXT_FIELDS, color: COLOR('Text colour, or a colour clip\\'s fill') }, [], 'Fields to replace (only those that fit the clip\\'s type).'),\n\t\t\t\tremove: { type: 'array', items: { type: 'string', enum: ['name', 'opacity', 'transform', 'fit', 'blend', 'grade', 'animateIn', 'animateOut', 'fadeIn', 'fadeOut', 'fadeInCurve', 'fadeOutCurve', 'in', 'speed', 'volume', 'muted', 'fontSize', 'fontFamily', 'fontWeight', 'background'] }, description: 'Fields to reset to their defaults.' }\n\t\t\t},\n\t\t\t['clip']\n\t\t),\n\t\tannotations: { title: 'Change a clip', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, set, remove }) => {\n\t\t\tconst current = clipOut(project(), clip);\n\t\t\tif (current && set) {\n\t\t\t\tconst allowed = new Set(['name', 'opacity', 'transform', 'fit', 'blend', 'grade', 'animateIn', 'animateOut', 'fadeIn', 'fadeOut', ...(current.type === 'media' ? ['in', 'speed', 'volume', 'muted', 'asset', 'fadeInCurve', 'fadeOutCurve'] : current.type === 'sequence' ? ['in', 'volume', 'muted', 'fadeInCurve', 'fadeOutCurve'] : current.type === 'text' ? Object.keys(TEXT_FIELDS) : ['color'])]);\n\t\t\t\tconst wrong = Object.keys(set).filter((k) => !allowed.has(k));\n\t\t\t\tif (wrong.length) throw new Error(`${wrong.map((k) => `\"${k}\"`).join(', ')} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(', ')}.`);\n\t\t\t}\n\t\t\treturn apply({ op: 'update_clip', clip, set, remove }, 'Change clip', (r) => ({ clip: clipOut(r.project, clip) }));\n\t\t}\n\t};\n\n\t/** Clips a grade can change: pictures, on video tracks. */\n\tconst gradeTargets = (ids: string[]) => {\n\t\tconst p = project();\n\t\tfor (const id of ids) {\n\t\t\tconst at = findVideoClip(p, id);\n\t\t\tif (!at) throw new Error(`There is no clip \"${id}\"; get_video_project lists the clips.`);\n\t\t\tconst { clip } = at;\n\t\t\tif (at.track.kind !== 'video' || (clip.type === 'media' && p.assets.find((a) => a.id === clip.asset)?.kind === 'audio'))\n\t\t\t\tthrow new Error(`Clip \"${id}\" is sound only; grades change pictures. Pass clips on video tracks.`);\n\t\t}\n\t};\n\n\tconst applyGradePreset: AgentTool = {\n\t\tname: 'apply_video_grade_preset',\n\t\ttitle: 'Apply a colour look',\n\t\tdescription:\n\t\t\t'Gives clips a named colour look at a strength (one undo step), replacing their grade settings (a LUT stays): warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip\\'s resulting grade; tweak it with update_video_clip set.grade.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclips: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clips on video tracks to grade.' },\n\t\t\t\tpreset: { type: 'string', enum: [...VIDEO_GRADE_PRESET_NAMES], description: 'The look.' },\n\t\t\t\tamount: { type: 'number', minimum: 0, maximum: 1, description: 'Strength, 0 (none) to 1 (full, the default); 0.5 is a subtler version.' }\n\t\t\t},\n\t\t\t['clips', 'preset']\n\t\t),\n\t\tannotations: { title: 'Apply a colour look', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clips, preset, amount }) => {\n\t\t\tgradeTargets(clips);\n\t\t\t// A clip's LUT stays: the look replaces only the grade's settings.\n\t\t\tconst edits: VideoEdit[] = clips.map((clip: string) => {\n\t\t\t\tconst grade = videoGradeLook(findVideoClip(project(), clip)!.clip.grade, preset, amount ?? 1);\n\t\t\t\treturn grade ? { op: 'update_clip', clip, set: { grade } } : { op: 'update_clip', clip, remove: ['grade'] };\n\t\t\t});\n\t\t\treturn apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id: string) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));\n\t\t}\n\t};\n\n\tconst copyGrade: AgentTool = {\n\t\tname: 'copy_video_grade',\n\t\ttitle: 'Copy a colour grade',\n\t\tdescription:\n\t\t\t'Gives clips the same colour grade as another clip (one undo step), to match shots in a scene; a source with no grade clears theirs. Returns the grade copied and the clips changed.',\n\t\tinputSchema: object({ from: { ...CLIP_ID, description: 'The clip whose grade to copy.' }, to: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clips on video tracks to give it to.' } }, ['from', 'to']),\n\t\tannotations: { title: 'Copy a colour grade', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ from, to }) => {\n\t\t\tgradeTargets([from, ...to]);\n\t\t\tconst grade = findVideoClip(project(), from)!.clip.grade;\n\t\t\tconst edits: VideoEdit[] = to.map((clip: string) => (grade ? { op: 'update_clip', clip, set: { grade: { ...grade } } } : { op: 'update_clip', clip, remove: ['grade'] }));\n\t\t\treturn apply(edits, 'Copy grade', () => ({ grade: grade ?? null, clips: to }));\n\t\t}\n\t};\n\n\n\tconst setLut: AgentTool = {\n\t\tname: 'set_video_lut',\n\t\ttitle: 'Apply a LUT',\n\t\tdescription:\n\t\t\t'Gives clips a 3D LUT (a .cube file) at a strength, applied after the rest of their grade (one undo step); their other grade settings stay. Reads the file first and fails, changing nothing, if it cannot be loaded or is not a 3D .cube. Returns the LUT\\'s title and size and each clip\\'s resulting grade. Remove it with remove_video_lut.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclips: { type: 'array', items: CLIP_ID, minItems: 1, uniqueItems: true, description: 'Clips on video tracks to grade.' },\n\t\t\t\tsrc: LUT_SRC,\n\t\t\t\tstrength: LUT_STRENGTH,\n\t\t\t\tname: { type: 'string', minLength: 1, description: 'A label for the look (default the file\\'s TITLE).' }\n\t\t\t},\n\t\t\t['clips', 'src']\n\t\t),\n\t\tannotations: { title: 'Apply a LUT', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n\t\thandler: async ({ clips, src, strength, name }) => {\n\t\t\tif (strength === 0) throw new Error('A strength of 0 changes nothing; pass a strength above 0, or remove the LUT with remove_video_lut.');\n\t\t\tgradeTargets(clips);\n\t\t\tconst lut = await controller.luts.load(src);\n\t\t\tconst label = name ?? lut.title;\n\t\t\tconst setting = { src, ...(strength !== undefined && strength !== 1 ? { strength } : {}), ...(label ? { name: label } : {}) };\n\t\t\treturn apply(videoLutEdits(project(), clips, setting), 'Apply LUT', (r) => ({ lut: { src, ...(lut.title ? { title: lut.title } : {}), size: lut.size }, clips: clips.map((id: string) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));\n\t\t}\n\t};\n\n\tconst removeLut: AgentTool = {\n\t\tname: 'remove_video_lut',\n\t\ttitle: 'Remove a LUT',\n\t\tdescription: 'Takes the LUT off clips\\' grades (one undo step), keeping their other grade settings. Fails if none of them has one. Returns each clip\\'s resulting grade.',\n\t\tinputSchema: object({ clips: { type: 'array', items: CLIP_ID, minItems: 1, uniqueItems: true, description: 'Clips whose LUT to remove.' } }, ['clips']),\n\t\tannotations: { title: 'Remove a LUT', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clips }) => {\n\t\t\tgradeTargets(clips);\n\t\t\tconst p = project();\n\t\t\tconst withLut = clips.filter((id: string) => findVideoClip(p, id)!.clip.grade?.lut);\n\t\t\tif (!withLut.length) throw new Error(`None of ${clips.map((c: string) => `\"${c}\"`).join(', ')} has a LUT; get_video_project shows each clip's grade.`);\n\t\t\treturn apply(videoLutEdits(p, withLut, null), 'Remove LUT', (r) => ({ clips: clips.map((id: string) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));\n\t\t}\n\t};\n\n\t// -----------------------------------------------------------------------------------------\n\t// Proxies\n\t// -----------------------------------------------------------------------------------------\n\n\tconst 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.' } as const;\n\tconst planProxies: AgentTool = {\n\t\tname: 'plan_video_proxies',\n\t\ttitle: 'Plan proxy files',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object({\n\t\t\tscale: PROXY_SCALE,\n\t\t\tassets: { type: 'array', items: ASSET_ID, minItems: 1, uniqueItems: true, description: 'Only these video assets (default: every video asset without a proxy at this scale).' },\n\t\t\tgop: { type: 'integer', minimum: 1, maximum: 300, description: 'Frames between keyframes (default 12): fewer makes seeking cheaper and files larger.' },\n\t\t\tcrf: { type: 'integer', minimum: 0, maximum: 51, description: 'x264 quality, lower is better (default 28: proxies are for editing).' }\n\t\t}),\n\t\tannotations: { title: 'Plan proxy files', ...READ },\n\t\thandler: ({ scale, assets, gop, crf }) => {\n\t\t\tconst commands = videoProxyPlan(project(), { scale, assets, gop, crf });\n\t\t\treturn { scale: scale ?? 0.5, commands, ...(commands.length ? {} : { note: 'Every video asset already has a proxy at this scale.' }) };\n\t\t}\n\t};\n\n\tconst setProxies: AgentTool = {\n\t\tname: 'set_video_proxies',\n\t\ttitle: 'Attach proxies',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tasset: { ...ASSET_ID, description: 'A video asset id from get_video_project.' },\n\t\t\t\tproxies: {\n\t\t\t\t\ttype: 'array',\n\t\t\t\t\tmaxItems: 8,\n\t\t\t\t\tdescription: 'Its proxies, one per scale (empty removes them all).',\n\t\t\t\t\titems: 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.')\n\t\t\t\t}\n\t\t\t},\n\t\t\t['asset', 'proxies']\n\t\t),\n\t\tannotations: { title: 'Attach proxies', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ asset, proxies }) => {\n\t\t\tconst a = project().assets.find((x) => x.id === asset);\n\t\t\tif (!a) throw new Error(`There is no asset \"${asset}\"; the assets are ${project().assets.map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\t\tif (a.kind !== 'video') throw new Error(`Asset \"${asset}\" is ${a.kind === 'image' ? 'an image' : 'audio'}; only video assets have proxies.`);\n\t\t\tconst edit: VideoEdit = proxies.length ? { op: 'update_asset', asset, set: { proxies } } : { op: 'update_asset', asset, remove: ['proxies'] };\n\t\t\treturn apply(edit, proxies.length ? 'Attach proxies' : 'Remove proxies', (r) => ({ asset: videoAssetOut(r.project.assets.find((x) => x.id === asset)!) }));\n\t\t}\n\t};\n\n\tconst setPreviewScale: AgentTool = {\n\t\tname: 'set_video_preview_scale',\n\t\ttitle: 'Set the preview size',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object(\n\t\t\t{ 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).' } },\n\t\t\t['scale']\n\t\t),\n\t\tannotations: { title: 'Set the preview size', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ scale }) => {\n\t\t\tcontroller.setPreviewScale(scale);\n\t\t\tconst s = controller.getState().previewScale;\n\t\t\tconst fixed = s === 'full' ? 1 : typeof s === 'number' ? s : null;\n\t\t\tconst plays = fixed === null ? undefined : project().assets.filter((a) => a.kind === 'video').map((a) => ({ asset: a.id, src: videoAssetProxy(a, fixed)?.src ?? a.src, proxy: !!videoAssetProxy(a, fixed) }));\n\t\t\treturn { 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.' }) };\n\t\t}\n\t};\n\n\tconst previewCache: AgentTool = {\n\t\tname: 'get_video_preview_cache',\n\t\ttitle: 'Read the preview\\'s frame cache',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: 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)') }),\n\t\tannotations: { title: 'Read the preview\\'s frame cache', ...READ },\n\t\thandler: ({ from, to }) => {\n\t\t\tconst cache = controller.frameCache;\n\t\t\tif (!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.' };\n\t\t\tif (from !== undefined && to !== undefined && !(to > from)) throw new Error(`\"to\" (${to} ms) must be after \"from\" (${from} ms).`);\n\t\t\tconst ranges = cache.cachedRanges(project(), { from, to });\n\t\t\treturn { enabled: true, ...cache.stats(), cachedRanges: ranges };\n\t\t}\n\t};\n\n\t// -----------------------------------------------------------------------------------------\n\t// Text-based editing\n\t// -----------------------------------------------------------------------------------------\n\n\tconst 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).' };\n\tconst RANGE_OUT = (r: VideoTranscriptRange) => ({ from: round(r.from), to: round(r.to), text: r.text });\n\tconst timelineWords = (track?: string) => {\n\t\tconst words = videoTimelineTranscript(project(), { track });\n\t\tif (!words.length) throw new Error(track ? `No clip on track \"${track}\" plays a transcribed asset; attach one with set_video_transcript.` : 'No clip plays a transcribed asset; attach a transcript to the media with set_video_transcript first.');\n\t\treturn words;\n\t};\n\n\tconst setTranscript: AgentTool = {\n\t\tname: 'set_video_transcript',\n\t\ttitle: 'Attach a transcript',\n\t\tdescription:\n\t\t\t'Attaches what is said in a video or audio asset, word by word, so the edit can be cut by text (one undo step). Pass \"text\" (Whisper-style JSON with word timings, SubRip or WebVTT, as the file says it) or \"words\" ([{ word, start, end }] in ms of the source). Captions have no word timings, so their words are spread across each cue (approximate). Replaces any transcript the asset had. Returns the word count, whether timings are approximate, and the first and last words.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tasset: { ...ASSET_ID, description: 'A video or audio asset.' },\n\t\t\t\ttext: { type: 'string', minLength: 1, description: 'The transcript file\\'s contents: Whisper-style JSON (times in seconds), .srt or .vtt. Pass this or words.' },\n\t\t\t\twords: { type: 'array', minItems: 1, items: object({ word: { type: 'string', minLength: 1, description: 'The word as said.' }, start: { type: 'number', minimum: 0, description: 'Start in ms of the source.' }, end: { type: 'number', minimum: 0, description: 'End in ms of the source.' } }, ['word', 'start', 'end'], 'One word.'), description: 'Words with source times in ms. Pass this or text.' }\n\t\t\t},\n\t\t\t['asset']\n\t\t),\n\t\tannotations: { title: 'Attach a transcript', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ asset, text, words }) => {\n\t\t\tif ((text === undefined) === (words === undefined)) throw new Error('Pass either \"text\" (a transcript file\\'s contents) or \"words\", not both.');\n\t\t\tconst a = project().assets.find((x) => x.id === asset);\n\t\t\tif (!a) throw new Error(`There is no asset \"${asset}\"; the assets are ${project().assets.map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\t\tif (a.kind === 'image') throw new Error(`Asset \"${asset}\" is an image and has no sound to transcribe.`);\n\t\t\tconst parsed = text !== undefined ? parseVideoTranscript(text) : { words, approximate: false };\n\t\t\tconst list = parsed.words as { word: string; start: number; end: number }[];\n\t\t\treturn apply({ op: 'update_asset', asset, set: { transcript: list } }, 'Attach transcript', () => ({\n\t\t\t\tasset,\n\t\t\t\twords: list.length,\n\t\t\t\tapproximate: parsed.approximate,\n\t\t\t\tfirst: list[0],\n\t\t\t\tlast: list[list.length - 1],\n\t\t\t\t...(a.duration !== undefined && list[list.length - 1].end > a.duration + VIDEO_TIME_EPSILON ? { warning: `The transcript runs to ${list[list.length - 1].end} ms, past the asset's ${a.duration} ms: is it for this asset?` } : {})\n\t\t\t}));\n\t\t}\n\t};\n\n\tconst getTranscript: AgentTool = {\n\t\tname: 'get_video_transcript',\n\t\ttitle: 'Read the transcript',\n\t\tdescription:\n\t\t\t'Returns what the timeline says, in order, from the transcripts of the media its clips play on one track: the text, and each word with its timeline start and end (ms) and the clip that plays it, so ranges can go straight to remove_video_ranges or split_video_at. Words cut out of the edit are not included.',\n\t\tinputSchema: object({\n\t\t\ttrack: TRANSCRIPT_TRACK,\n\t\t\tfrom: MS('Only words from this timeline time (default 0)'),\n\t\t\tto: MS('Only words up to this timeline time (default the end)'),\n\t\t\tlimit: { type: 'integer', minimum: 1, maximum: 5000, description: 'Most words to return (default 1000); \"more\" says if there are others.' }\n\t\t}),\n\t\tannotations: { title: 'Read the transcript', ...READ },\n\t\thandler: ({ track, from, to, limit }) => {\n\t\t\tconst all = timelineWords(track).filter((w) => w.end > (from ?? 0) && w.start < (to ?? Infinity));\n\t\t\tconst words = all.slice(0, limit ?? 1000);\n\t\t\treturn { track: words[0]?.track ?? videoTranscriptTrack(project(), track), text: words.map((w) => w.word).join(' '), words: words.map((w) => ({ word: w.word, start: round(w.start), end: round(w.end), clip: w.clip })), more: all.length > words.length };\n\t\t}\n\t};\n\n\tconst findText: AgentTool = {\n\t\tname: 'find_video_transcript_text',\n\t\ttitle: 'Find words in the transcript',\n\t\tdescription: 'Lists every place a word or phrase is said on the timeline (case and punctuation ignored) as { from, to, text } ranges in timeline ms, ready for remove_video_ranges, split_video_at or seek_video.',\n\t\tinputSchema: object({ query: { type: 'string', minLength: 1, description: 'The word or phrase.' }, track: TRANSCRIPT_TRACK }, ['query']),\n\t\tannotations: { title: 'Find words in the transcript', ...READ },\n\t\thandler: ({ query, track }) => ({ matches: findVideoTranscriptText(timelineWords(track), query).map(RANGE_OUT) })\n\t};\n\n\tconst findFillers: AgentTool = {\n\t\tname: 'find_video_fillers',\n\t\ttitle: 'Find filler words',\n\t\tdescription: `Lists filler words and phrases on the timeline as { from, to, text } ranges in timeline ms, longest phrase first so \"you know\" is found whole. Default fillers: ${VIDEO_DEFAULT_FILLERS.join(', ')} (\"like\" and \"so\" are left out as too often meant). Check them, then pass the ones to cut to remove_video_ranges.`,\n\t\tinputSchema: object({ track: TRANSCRIPT_TRACK, fillers: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 }, description: 'Words or phrases to treat as fillers instead of the defaults.' } }),\n\t\tannotations: { title: 'Find filler words', ...READ },\n\t\thandler: ({ track, fillers }) => {\n\t\t\tconst found = findVideoFillers(timelineWords(track), fillers);\n\t\t\treturn { fillers: found.map(RANGE_OUT), total: round(found.reduce((n, r) => n + r.to - r.from, 0)) };\n\t\t}\n\t};\n\n\tconst findPauses: AgentTool = {\n\t\tname: 'find_video_pauses',\n\t\ttitle: 'Find pauses between words',\n\t\tdescription:\n\t\t\t'Lists the gaps between consecutive words on the timeline (within one clip) longer than \"min\", less \"keep\" next to each word so speech is not clipped, snapped inward to frames, as { from, to } ranges in timeline ms ready for remove_video_ranges. Uses word timings; find_video_silences measures the sound instead.',\n\t\tinputSchema: object({\n\t\t\ttrack: TRANSCRIPT_TRACK,\n\t\t\tmin: { type: 'number', exclusiveMinimum: 0, description: 'Shortest gap that counts, in ms (default 700).' },\n\t\t\tkeep: { type: 'number', minimum: 0, description: 'Silence to keep after the word before and before the word after, in ms (default 150).' }\n\t\t}),\n\t\tannotations: { title: 'Find pauses between words', ...READ },\n\t\thandler: ({ track, min, keep }) => {\n\t\t\tconst found = findVideoTranscriptPauses(timelineWords(track), { min, keep, fps: project().fps });\n\t\t\treturn { pauses: found.map(RANGE_OUT), total: round(found.reduce((n, r) => n + r.to - r.from, 0)) };\n\t\t}\n\t};\n\n\tconst removeRanges: AgentTool = {\n\t\tname: 'remove_video_ranges',\n\t\ttitle: 'Cut out stretches',\n\t\tdescription:\n\t\t\t'Cuts stretches of the timeline out and closes them up, as one undo step: the ranges are merged where they touch and extracted from the last to the first, so the times from find_video_fillers, find_video_pauses, find_video_transcript_text or find_video_silences can be passed as they are. Like extract_video_range, every sync-locked track moves with the cut. Returns how many stretches and how much time were removed and the new length.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tranges: { type: 'array', minItems: 1, maxItems: 500, items: object({ from: MS('Start, on the timeline'), to: MS('End, on the timeline'), text: { type: 'string', description: 'Ignored: the words in the stretch, as the find tools return them.' } }, ['from', 'to'], 'A stretch to remove.'), description: 'The stretches, in any order (timeline ms).' },\n\t\t\t\ttracks: { type: 'array', items: TRACK_ID, minItems: 1, description: 'Tracks to cut (default every unlocked track).' }\n\t\t\t},\n\t\t\t['ranges']\n\t\t),\n\t\tannotations: { title: 'Cut out stretches', ...DESTRUCTIVE },\n\t\thandler: ({ ranges, tracks }) => {\n\t\t\tconst bad = (ranges as { from: number; to: number }[]).findIndex((r) => !(r.to > r.from));\n\t\t\tif (bad >= 0) throw new Error(`Range ${bad + 1} is empty or backwards (${ranges[bad].from}–${ranges[bad].to} ms); \"to\" must be after \"from\".`);\n\t\t\tconst merged = mergeVideoRanges(ranges);\n\t\t\tconst before = videoProjectDuration(project());\n\t\t\treturn apply(videoRemoveRangesEdits(merged, tracks), merged.length > 1 ? `Cut ${merged.length} stretches` : 'Cut stretch', (r) => ({ removed: merged.length, time: round(before - videoProjectDuration(r.project)), duration: videoProjectDuration(r.project) }));\n\t\t}\n\t};\n\n\t// -----------------------------------------------------------------------------------------\n\t// Speed ramps and freeze frames\n\t// -----------------------------------------------------------------------------------------\n\n\t/** A media clip a ramp can change, with its asset. */\n\tconst rampTarget = (id: string) => {\n\t\tconst at = findVideoClip(project(), id);\n\t\tif (!at) throw new Error(`There is no clip \"${id}\"; get_video_project lists the clips.`);\n\t\tconst { clip } = at;\n\t\tif (clip.type === 'sequence') throw new Error(`Clip \"${id}\" is a compound clip, which plays its sequence at normal speed; change the speed of the clips inside the sequence instead.`);\n\t\tif (clip.type !== 'media') throw new Error(`Clip \"${id}\" is a ${clip.type === 'text' ? 'title' : 'colour'}, so it has no source to change the speed of; use a video or audio clip.`);\n\t\tif (project().assets.find((a) => a.id === clip.asset)?.kind === 'image') throw new Error(`Clip \"${id}\" is a still, so it has no motion to change the speed of.`);\n\t\treturn clip;\n\t};\n\tconst rampOut = (r: Extract<VideoEditResult, { ok: true }>, id: string) => {\n\t\tconst outcome = r.outcomes[r.outcomes.length - 1];\n\t\treturn { clip: clipOut(r.project, id), ...(outcome.rippled ? { rippled: outcome.rippled } : {}), duration: videoProjectDuration(r.project) };\n\t};\n\tconst RIPPLE_RAMP = { type: 'boolean', description: 'Move later material on every sync-locked track by the change in length (default true); false refuses if the clip would overlap the next one.' } as const;\n\n\tconst setSpeedRamp: AgentTool = {\n\t\tname: 'set_video_speed_ramp',\n\t\ttitle: 'Ramp the speed',\n\t\tdescription:\n\t\t\t'Plays a video or audio clip through stretches of speed, from its first frame: e.g. [{ duration: 1500, speed: 1 }, { duration: 500, speed: 1, toSpeed: 0.25 }, { duration: 2000, speed: 0.25 }] for a slow-motion ramp. \"toSpeed\" glides evenly to a new speed over the stretch; speed 0 is a freeze frame. The clip\\'s length becomes the stretches\\' total (one undo step). Replaces any ramp or speed it had. Returns the clip (with its timeRemap keys and source range).',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclip: CLIP_ID,\n\t\t\t\tsegments: {\n\t\t\t\t\ttype: 'array',\n\t\t\t\t\tminItems: 1,\n\t\t\t\t\tmaxItems: 100,\n\t\t\t\t\tdescription: 'The stretches in order, from the clip\\'s start.',\n\t\t\t\t\titems: object(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length of the stretch on the timeline, in ms.' },\n\t\t\t\t\t\t\tspeed: { type: 'number', minimum: 0, maximum: 20, description: 'Speed at its start: 1 normal, 0.5 half speed (slow motion), 2 double, 0 a freeze frame.' },\n\t\t\t\t\t\t\ttoSpeed: { type: 'number', minimum: 0, maximum: 20, description: 'Glide evenly to this speed by the stretch\\'s end (default: stay at speed). A ramp between two speeds.' }\n\t\t\t\t\t\t},\n\t\t\t\t\t\t['duration', 'speed'],\n\t\t\t\t\t\t'One stretch.'\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\tripple: RIPPLE_RAMP\n\t\t\t},\n\t\t\t['clip', 'segments']\n\t\t),\n\t\tannotations: { title: 'Ramp the speed', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, segments, ripple }) => {\n\t\t\t// Starts from the moment of source the clip shows at its first frame now.\n\t\t\tconst edit = videoSpeedRampEdit(rampTarget(clip), segments, ripple ?? true);\n\t\t\treturn apply(edit, 'Speed ramp', (r) => ({ ...rampOut(r, clip), source: Math.round(videoSpeedRampKeys(segments).source) }));\n\t\t}\n\t};\n\n\tconst addFreeze: AgentTool = {\n\t\tname: 'add_video_freeze_frame',\n\t\ttitle: 'Add a freeze frame',\n\t\tdescription:\n\t\t\t'Holds the frame a video clip shows at a timeline time for a while, then plays on (one undo step): the clip gets longer by the hold, and with ripple (default) everything after it on sync-locked tracks moves along. Works on clips with or without a speed ramp. Returns the clip.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclip: CLIP_ID,\n\t\t\t\tat: MS('Timeline time of the frame to hold (inside the clip)'),\n\t\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'How long to hold it, in ms.' },\n\t\t\t\tripple: RIPPLE_RAMP\n\t\t\t},\n\t\t\t['clip', 'at', 'duration']\n\t\t),\n\t\tannotations: { title: 'Add a freeze frame', ...WRITE },\n\t\thandler: ({ clip, at, duration, ripple }) => {\n\t\t\tconst c = rampTarget(clip);\n\t\t\tconst t = at - c.start;\n\t\t\tif (t < 0 || t > c.duration) throw new Error(`${formatVideoSeconds(at)} is outside clip \"${clip}\" (${formatVideoSeconds(c.start)}–${formatVideoSeconds(videoClipEnd(c))}); pick a time inside it.`);\n\t\t\treturn apply(videoFreezeFrameEdit(c, t, duration, ripple ?? true), 'Freeze frame', (r) => rampOut(r, clip));\n\t\t}\n\t};\n\n\tconst removeRamp: AgentTool = {\n\t\tname: 'remove_video_speed_ramp',\n\t\ttitle: 'Remove a speed ramp',\n\t\tdescription: 'Puts a ramped clip back to normal speed from the moment its ramp started (one undo step), keeping its length unless \"duration\" is given. Returns the clip.',\n\t\tinputSchema: object({ clip: CLIP_ID, duration: { type: 'number', exclusiveMinimum: 0, description: 'New length in ms (default: unchanged).' }, ripple: RIPPLE_RAMP }, ['clip']),\n\t\tannotations: { title: 'Remove a speed ramp', ...WRITE },\n\t\thandler: ({ clip, duration, ripple }) => {\n\t\t\trampTarget(clip);\n\t\t\treturn apply({ op: 'set_time_remap', clip, keys: null, ...(duration !== undefined ? { duration } : {}), ripple: ripple ?? true }, 'Remove speed ramp', (r) => rampOut(r, clip));\n\t\t}\n\t};\n\n\tconst setTransition: AgentTool = {\n\t\tname: 'set_video_transition',\n\t\ttitle: 'Set a transition',\n\t\tdescription:\n\t\t\t'Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). Every type but dip plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.',\n\t\tinputSchema: object({ clip: { ...CLIP_ID, description: 'The incoming clip (the one after the cut).' }, transition: { anyOf: [TRANSITION, { type: 'null' }], description: 'The transition, or null to remove it.' } }, ['clip', 'transition']),\n\t\tannotations: { title: 'Set a transition', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, transition }) => apply({ op: 'set_transition', clip, transition }, transition ? 'Set transition' : 'Remove transition', (r) => ({ clip: clipOut(r.project, clip) }))\n\t};\n\n\tconst slipClip: AgentTool = {\n\t\tname: 'slip_video_clip',\n\t\ttitle: 'Slip a clip',\n\t\tdescription:\n\t\t\t'Shows a different stretch of a video or audio clip\\'s source without moving it on the timeline: its in and out points both move by delta (one undo step). Clamped to the media past either end, keeping what its transitions need. Returns the clip (with its new source range) and the delta applied.',\n\t\tinputSchema: object({ clip: CLIP_ID, delta: DELTA('How far to move the source (positive shows later media)') }, ['clip', 'delta']),\n\t\tannotations: { title: 'Slip a clip', ...WRITE },\n\t\thandler: ({ clip, delta }) => apply({ op: 'slip_clip', clip, delta }, 'Slip clip', (r) => ({ clip: clipOut(r.project, clip), delta: r.outcomes[0].delta }))\n\t};\n\n\tconst rollEdit: AgentTool = {\n\t\tname: 'roll_video_edit',\n\t\ttitle: 'Roll a cut',\n\t\tdescription:\n\t\t\t'Moves the cut between two clips that meet on one track (one undo step): the left one\\'s out point and the right one\\'s in point move together, so nothing else moves and no gap opens. Clamped to each clip\\'s media and at least a frame of each. Returns both clips, the new cut time and the delta applied.',\n\t\tinputSchema: object({ left: { ...CLIP_ID, description: 'The clip before the cut (from get_video_project).' }, right: { ...CLIP_ID, description: 'The clip after the cut, starting where \"left\" ends.' }, delta: DELTA('How far to move the cut') }, ['left', 'right', 'delta']),\n\t\tannotations: { title: 'Roll a cut', ...WRITE },\n\t\thandler: ({ left, right, delta }) =>\n\t\t\tapply({ op: 'roll_edit', left, right, delta }, 'Roll edit', (r) => {\n\t\t\t\tconst after = clipOut(r.project, right)!;\n\t\t\t\treturn { left: clipOut(r.project, left), right: after, cut: after.start, delta: r.outcomes[0].delta };\n\t\t\t})\n\t};\n\n\tconst slideClip: AgentTool = {\n\t\tname: 'slide_video_clip',\n\t\ttitle: 'Slide a clip',\n\t\tdescription:\n\t\t\t'Moves a clip along its track keeping its content, trimming the clips either side so the stretch stays gapless (one undo step): the one before gets longer as the one after gets shorter, or the other way. Without a neighbour on one side it moves through the gap there. Clamped to the neighbours\\' media and at least a frame of each. Returns the clip, its neighbours and the delta applied.',\n\t\tinputSchema: object({ clip: CLIP_ID, delta: DELTA('How far to move it') }, ['clip', 'delta']),\n\t\tannotations: { title: 'Slide a clip', ...WRITE },\n\t\thandler: ({ clip, delta }) =>\n\t\t\tapply({ op: 'slide_clip', clip, delta }, 'Slide clip', (r) => {\n\t\t\t\tconst ids = r.outcomes[0].clips!;\n\t\t\t\tconst i = ids.indexOf(clip);\n\t\t\t\treturn { clip: clipOut(r.project, clip), ...(i > 0 ? { before: clipOut(r.project, ids[0]) } : {}), ...(i < ids.length - 1 ? { after: clipOut(r.project, ids[ids.length - 1]) } : {}), delta: r.outcomes[0].delta };\n\t\t\t})\n\t};\n\n\tconst rangeArgs = object({ from: MS('Start of the range on the timeline'), to: MS('End of the range on the timeline (after \"from\")'), tracks: RANGE_TRACKS }, ['from', 'to']);\n\tconst rangeOut = (o: VideoEditOutcome) => ({ removed: o.removed ?? [], trimmed: o.trimmed ?? [], created: o.created ?? [], ...(o.rippled ? { rippled: o.rippled } : {}) });\n\n\tconst liftRange: AgentTool = {\n\t\tname: 'lift_video_range',\n\t\ttitle: 'Lift a range',\n\t\tdescription:\n\t\t\t'Empties a stretch of time on some or all tracks, leaving a gap (one undo step): clips inside are deleted, clips across an edge are trimmed to it, and a clip across both is cut in two. Returns the clips removed, trimmed and created (right-hand parts of cuts).',\n\t\tinputSchema: rangeArgs,\n\t\tannotations: { title: 'Lift a range', ...DESTRUCTIVE },\n\t\thandler: ({ from, to, tracks }) => apply({ op: 'lift_range', from, to, tracks }, 'Lift', (r) => rangeOut(r.outcomes[0]))\n\t};\n\n\tconst extractRange: AgentTool = {\n\t\tname: 'extract_video_range',\n\t\ttitle: 'Extract a range',\n\t\tdescription:\n\t\t\t'Removes a stretch of time and closes it up (one undo step): like lift, then everything after it moves earlier by its length on those tracks and every sync-locked track. Refused, naming the clip, when a sync-locked track left out of \"tracks\" has something in the range. Returns the clips removed, trimmed and created and the tracks that moved.',\n\t\tinputSchema: rangeArgs,\n\t\tannotations: { title: 'Extract a range', ...DESTRUCTIVE },\n\t\thandler: ({ from, to, tracks }) => apply({ op: 'extract_range', from, to, tracks }, 'Extract', (r) => rangeOut(r.outcomes[0]))\n\t};\n\n\tconst splitAt: AgentTool = {\n\t\tname: 'split_video_at',\n\t\ttitle: 'Cut across tracks',\n\t\tdescription:\n\t\t\t'Cuts every clip that runs across a time on some or all tracks (a razor through the timeline), as one undo step. Each left part keeps its id, fade in and transition; each right part gets a new id and the fade out. Refused when no clip runs across that time. Returns the pairs { left, right }.',\n\t\tinputSchema: object({ at: MS('Where to cut, on the timeline'), tracks: RANGE_TRACKS }, ['at']),\n\t\tannotations: { title: 'Cut across tracks', ...WRITE },\n\t\thandler: ({ at, tracks }) =>\n\t\t\tapply({ op: 'split_at', at, tracks }, 'Split', (r) => {\n\t\t\t\tconst o = r.outcomes[0];\n\t\t\t\treturn { cuts: o.clips!.map((left, i) => ({ left: clipOut(r.project, left), right: clipOut(r.project, o.created![i]) })) };\n\t\t\t})\n\t};\n\n\tconst setKeyframes: AgentTool = {\n\t\tname: 'set_video_keyframes',\n\t\ttitle: 'Animate a clip property',\n\t\tdescription:\n\t\t\t'Animates one property of a clip with keyframes (one undo step), replacing any keys it had: opacity, volume, or the transform\\'s x, y, scale and rotation. Times are clip time (ms from the clip\\'s start), so a key at 0 is the clip\\'s first frame; get_video_project gives each clip\\'s duration. Plays in the preview, renders and the ffmpeg export. Returns the property\\'s keys, sorted.',\n\t\tinputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY, keys: KEYS }, ['clip', 'property', 'keys']),\n\t\tannotations: { title: 'Animate a clip property', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, property, keys }) => apply({ op: 'set_keyframes', clip, property, keys }, `Keyframe ${property}`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))\n\t};\n\n\tconst removeKeyframes: AgentTool = {\n\t\tname: 'remove_video_keyframes',\n\t\ttitle: 'Stop animating a clip property',\n\t\tdescription:\n\t\t\t'Removes all of a property\\'s keyframes (one undo step), leaving it at the value it had at the clip\\'s start. Returns the remaining keys (none) and that value (null for the default).',\n\t\tinputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY }, ['clip', 'property']),\n\t\tannotations: { title: 'Stop animating a clip property', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, property }) => apply({ op: 'set_keyframes', clip, property, keys: null }, `Remove ${property} keyframes`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))\n\t};\n\n\tconst closeGaps: AgentTool = {\n\t\tname: 'close_video_gaps',\n\t\ttitle: 'Close gaps',\n\t\tdescription: 'Slides the clips on a track earlier so no gaps are left between them (from a time on, default the start), as one undo step. Returns the clips that moved.',\n\t\tinputSchema: object({ track: TRACK_ID, from: MS('Only close gaps after this time (default 0)') }, ['track']),\n\t\tannotations: { title: 'Close gaps', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ track, from }) => apply({ op: 'close_gaps', track, from }, 'Close gaps', (r) => ({ moved: r.outcomes[0].clips }))\n\t};\n\n\tconst addMarker: AgentTool = {\n\t\tname: 'add_video_marker',\n\t\ttitle: 'Add a marker',\n\t\tdescription: 'Adds a named marker at a time (one undo step), e.g. a beat or a note for review. Clip edges snap to markers. Returns its id.',\n\t\tinputSchema: object({ time: MS('Where it goes'), label: { type: 'string', description: 'What it marks.' }, color: COLOR('Marker colour'), id: { type: 'string', minLength: 1, description: 'Id (default \"m1\", \"m2\", …).' } }, ['time']),\n\t\tannotations: { title: 'Add a marker', ...WRITE },\n\t\thandler: (marker) => apply({ op: 'add_marker', marker: marker as any }, 'Add marker', (r) => ({ marker: r.outcomes[0].marker }))\n\t};\n\n\tconst removeMarker: AgentTool = {\n\t\tname: 'remove_video_marker',\n\t\ttitle: 'Remove a marker',\n\t\tdescription: 'Removes a marker by id (one undo step). Returns the remaining markers.',\n\t\tinputSchema: object({ marker: { type: 'string', minLength: 1, description: 'Marker id from get_video_project.' } }, ['marker']),\n\t\tannotations: { title: 'Remove a marker', ...DESTRUCTIVE },\n\t\thandler: ({ marker }) => apply({ op: 'remove_marker', marker }, 'Remove marker', (r) => ({ markers: r.project.markers ?? [] }))\n\t};\n\n\tconst EDIT_OP: JsonSchema = {\n\t\ttype: 'object',\n\t\tdescription: `One edit: { \"op\": one of ${VIDEO_EDIT_OPS.filter((o) => o !== 'replace_project').join(', ')}, …its fields }. Fields match the single tools: add_clip { track, clip, mode, ripple }, move_clip { clip, start, track, mode }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, set_time_remap { clip, keys (clip ms → source ms after the in point; null removes), duration, ripple }, slip_clip { clip, delta }, roll_edit { left, right, delta }, slide_clip { clip, delta }, lift_range { from, to, tracks }, extract_range { from, to, tracks }, split_at { at, tracks }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, … }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,\n\t\tproperties: { op: { type: 'string', enum: VIDEO_EDIT_OPS.filter((o) => o !== 'replace_project'), description: 'Which edit.' } },\n\t\trequired: ['op']\n\t};\n\n\tconst editVideo: AgentTool = {\n\t\tname: 'edit_video',\n\t\ttitle: 'Make several edits at once',\n\t\tdescription:\n\t\t\t'Applies several edits in order as one undo step, all or nothing: if one fails nothing changes and the error names the op (\"op 2 (trim_clip): …\"). Use it for a rough cut (assets, then clips) or anything that only makes sense together. Returns each op\\'s outcome (the clip, clips, track, asset or marker it made or changed), the length and issues. With dry_run it only reports them.',\n\t\tinputSchema: object({ ops: { type: 'array', items: EDIT_OP, minItems: 1, maxItems: 200, description: 'Edits, applied in order.' }, dry_run: { type: 'boolean', description: 'Check without applying (default false).' } }, ['ops']),\n\t\tannotations: { title: 'Make several edits at once', ...WRITE },\n\t\thandler: ({ ops, dry_run = false }) => {\n\t\t\tif (ops.some((o: { op: string }) => o.op === 'replace_project')) throw new Error('edit_video cannot replace the whole project; use the individual ops.');\n\t\t\tconst result = controller.apply(ops as VideoEdit[], 'Edit video', { dryRun: dry_run });\n\t\t\treturn outcome(result, result.ok ? { outcomes: result.outcomes, ...(dry_run ? { dry_run: true, hint: 'Nothing was applied; call again without dry_run to apply.' } : {}) } : {});\n\t\t}\n\t};\n\n\tconst undo: AgentTool = {\n\t\tname: 'undo_video',\n\t\ttitle: 'Undo a video edit',\n\t\tdescription: 'Undoes the last edit. Returns whether something was undone and its label.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Undo a video edit', ...WRITE },\n\t\thandler: () => {\n\t\t\tconst label = controller.getState().undoLabel;\n\t\t\tconst undone = controller.undo();\n\t\t\treturn { undone, ...(undone ? { label } : {}) };\n\t\t}\n\t};\n\n\tconst redo: AgentTool = {\n\t\tname: 'redo_video',\n\t\ttitle: 'Redo a video edit',\n\t\tdescription: 'Redoes the last undone edit. Returns whether something was redone and its label.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Redo a video edit', ...WRITE },\n\t\thandler: () => {\n\t\t\tconst label = controller.getState().redoLabel;\n\t\t\tconst redone = controller.redo();\n\t\t\treturn { redone, ...(redone ? { label } : {}) };\n\t\t}\n\t};\n\n\t/** The source stretch a level or silence query reads, and how to map its times back. */\n\tconst soundRange = (args: { clip?: string; asset?: string; from?: number; to?: number }) => {\n\t\tconst p = project();\n\t\tif ((args.clip === undefined) === (args.asset === undefined)) throw new Error('Pass either \"clip\" (a media clip, times on the timeline) or \"asset\" (times in the source), not both.');\n\t\tlet asset: VideoAsset | undefined;\n\t\tlet toSource = (t: number) => t;\n\t\tlet toOut = (t: number) => t;\n\t\tlet from: number;\n\t\tlet to: number;\n\t\tif (args.clip !== undefined) {\n\t\t\tconst found = findVideoClip(p, args.clip);\n\t\t\tif (!found) throw new Error(`There is no clip \"${args.clip}\"; get_video_project lists the clips.`);\n\t\t\tconst c = found.clip;\n\t\t\tif (c.type === 'sequence') throw new Error(`Clip \"${c.id}\" is a compound clip; its sound is the mix of its sequence \"${c.sequence}\", so read the levels of the media in it (list_video_sequences lists its clips and assets).`);\n\t\t\tif (c.type !== 'media') throw new Error(`Clip \"${c.id}\" is a ${c.type} clip and has no sound; pass a media clip.`);\n\t\t\tasset = p.assets.find((a) => a.id === c.asset);\n\t\t\tconst speed = c.speed ?? 1;\n\t\t\ttoSource = (t: number) => (c.in ?? 0) + (t - c.start) * speed;\n\t\t\ttoOut = (s: number) => c.start + (s - (c.in ?? 0)) / speed;\n\t\t\tfrom = Math.max(c.start, args.from ?? c.start);\n\t\t\tto = Math.min(videoClipEnd(c), args.to ?? videoClipEnd(c));\n\t\t} else {\n\t\t\tasset = p.assets.find((a) => a.id === args.asset);\n\t\t\tif (!asset) throw new Error(`There is no asset \"${args.asset}\"; the assets are ${p.assets.map((a) => `\"${a.id}\"`).join(', ') || 'none'}.`);\n\t\t\tfrom = args.from ?? 0;\n\t\t\tto = args.to ?? asset.duration ?? 0;\n\t\t}\n\t\tif (!asset) throw new Error(`Clip \"${args.clip}\" uses a missing asset.`);\n\t\tif (asset.kind === 'image' || asset.hasAudio === false) throw new Error(`Asset \"${asset.id}\" has no sound.`);\n\t\tif (!(to > from)) throw new Error(`The stretch to read is empty (${from}–${to} ms); \"to\" must be after \"from\"${args.clip !== undefined ? ' and both inside the clip' : asset.duration === undefined ? ', and the asset has no duration, so pass \"to\"' : ''}.`);\n\t\treturn { asset, from, to, toSource, toOut };\n\t};\n\tconst SOUND_TARGET = {\n\t\tclip: { ...CLIP_ID, description: 'A media clip: read the stretch of source it plays, with times on the timeline. Pass this or asset.' },\n\t\tasset: { ...ASSET_ID, description: 'A video or audio asset: read its source, with times in the source. Pass this or clip.' },\n\t\tfrom: MS('Start of the stretch (default: the clip\\'s start, or 0 for an asset)'),\n\t\tto: MS('End of the stretch (default: the clip\\'s end, or the asset\\'s duration)')\n\t};\n\tconst SOUND_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } as const;\n\n\tconst audioLevels: AgentTool = {\n\t\tname: 'get_video_audio_levels',\n\t\ttitle: 'Read sound levels',\n\t\tdescription:\n\t\t\t'Measures how loud the source sound is over a clip or an asset, without changing anything: the peak level in dBFS (0 = full scale, -100 = silence) for each of \"buckets\" equal stretches, plus the loudest peak. Levels are the recording as it is, before clip volume and fades. Use it to find where speech or a beat starts, or to judge whether a clip is too loud. The first call for an asset decodes its file, which can take a moment.',\n\t\tinputSchema: object({ ...SOUND_TARGET, buckets: { type: 'integer', minimum: 1, maximum: 500, description: 'How many equal stretches to measure (default 50).' } }),\n\t\tannotations: { title: 'Read sound levels', ...SOUND_ANNOTATIONS },\n\t\thandler: async (args) => {\n\t\t\tconst r = soundRange(args);\n\t\t\tconst peaks = await controller.audioPeaks.load(r.asset);\n\t\t\tconst levels = videoAudioLevels(peaks, r.toSource(r.from), r.toSource(r.to), args.buckets ?? 50).map((l) => ({ start: round(r.toOut(l.start)), end: round(r.toOut(l.end)), peakDb: l.peakDb }));\n\t\t\treturn { asset: r.asset.id, from: r.from, to: r.to, peakDb: Math.max(...levels.map((l) => l.peakDb)), levels };\n\t\t}\n\t};\n\n\tconst findSilences: AgentTool = {\n\t\tname: 'find_video_silences',\n\t\ttitle: 'Find silences',\n\t\tdescription:\n\t\t\t'Lists the stretches of a clip or an asset whose source sound stays below a level for at least a minimum length, as { start, end } ms (timeline times for a clip, so they can go straight to extract_video_range or split_video_at; source times for an asset). Use it to cut pauses, dead air or the gaps between takes. The first call for an asset decodes its file.',\n\t\tinputSchema: object({\n\t\t\t...SOUND_TARGET,\n\t\t\tthresholdDb: { type: 'number', minimum: -100, maximum: 0, description: 'Quieter than this peak level counts as silence, in dBFS (default -40; -50 for a quiet room, -30 over background noise).' },\n\t\t\tminDuration: { type: 'number', exclusiveMinimum: 0, description: 'Shortest silence to report, in ms (default 300).' }\n\t\t}),\n\t\tannotations: { title: 'Find silences', ...SOUND_ANNOTATIONS },\n\t\thandler: async (args) => {\n\t\t\tconst r = soundRange(args);\n\t\t\tconst peaks = await controller.audioPeaks.load(r.asset);\n\t\t\tconst silences = findVideoAudioSilences(peaks, r.toSource(r.from), r.toSource(r.to), { thresholdDb: args.thresholdDb, minDuration: args.minDuration }).map((x) => ({ start: round(r.toOut(x.start)), end: round(r.toOut(x.end)) }));\n\t\t\treturn { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n, x) => n + x.end - x.start, 0)) };\n\t\t}\n\t};\n\n\n\t/** The pixels of the frame at `time`: the host's, or the preview's (seeking it there and back). */\n\tconst pixelsAt = async (time: number): Promise<VideoPixels> => {\n\t\tif (options.framePixels) {\n\t\t\tconst px = await options.framePixels(time);\n\t\t\tif (!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.`);\n\t\t\treturn px;\n\t\t}\n\t\tconst feed = controller.previewPixels;\n\t\tif (!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.');\n\t\tconst state = controller.getState();\n\t\tif (Math.abs(time - state.time) < 0.5) return (await feed.next(state.time, options.framePixelsTimeout)).pixels;\n\t\tconst { time: before, playing } = state;\n\t\tif (playing) controller.pause();\n\t\tcontroller.seek(time);\n\t\ttry {\n\t\t\treturn (await feed.next(controller.getState().time, options.framePixelsTimeout)).pixels;\n\t\t} finally {\n\t\t\tcontroller.seek(before);\n\t\t\tif (playing) controller.play();\n\t\t}\n\t};\n\n\tconst getScopes: AgentTool = {\n\t\tname: 'get_video_scopes',\n\t\ttitle: 'Measure a frame with scopes',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object({\n\t\t\ttime: MS('Time from the start to measure (default: the playhead). Another time is shown briefly and the playhead put back'),\n\t\t\tbins: { type: 'integer', minimum: 2, maximum: 64, description: 'Histogram steps, darkest first (default 16).' },\n\t\t\tcolumns: { type: 'integer', minimum: 1, maximum: 32, description: 'Waveform strips across the picture, left to right (default 8).' }\n\t\t}),\n\t\tannotations: { title: 'Measure a frame with scopes', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },\n\t\thandler: async ({ time, bins, columns }) => {\n\t\t\tconst p = project();\n\t\t\tconst end = videoProjectDuration(p);\n\t\t\tconst t = time ?? controller.getState().time;\n\t\t\tif (time !== undefined && time > end) throw new Error(`Time ${time} ms is past the end of the project (${end} ms).`);\n\t\t\tconst pixels = await pixelsAt(t);\n\t\t\treturn { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };\n\t\t}\n\t};\n\n\tconst reads = [getProject, listSequences, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];\n\tconst 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];\n\n\treturn {\n\t\tid: options.id ?? 'video',\n\t\tnamespace: options.namespace,\n\t\ttools: () => (readOnlyReason() ? reads : [...reads, ...writes]),\n\t\tsubscribe: (notify) => {\n\t\t\tlet last = readOnlyReason();\n\t\t\treturn controller.subscribe(() => {\n\t\t\t\tconst now = readOnlyReason();\n\t\t\t\tif (now !== last) {\n\t\t\t\t\tlast = now;\n\t\t\t\t\tnotify();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/editor/editor/packages/core/dist/video-editor/index.cjs","../../src/video-editor/parse.ts","../../src/video-editor/check.ts","../../src/video-editor/edit.ts","../../src/video-editor/audio.ts","../../src/video-editor/scopes.ts","../../src/video-editor/transcript.ts","../../src/video-editor/proxy.ts","../../src/video-editor/frame-cache.ts","../../src/video-editor/preview.ts","../../src/video-editor/controller.ts","../../src/video-editor/ui.ts","../../src/video-editor/tools.ts","../../src/video-editor/effect-tools.ts"],"names":["v","words","el","canvas","changed","outcome","MS"],"mappings":"AAAA;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B;AACE;AACA;AACF,yDAA8B;AAC9B,iCAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B,iCAA8B;AAC9B,iCAA8B;AAC9B,iCAA8B;AAC9B;AACA;ACzJA,IAAM,SAAA,EAAW,CAAC,CAAA,EAAA,GAAyC,OAAO,EAAA,IAAM,SAAA,GAAY,EAAA,IAAM,KAAA,GAAQ,CAAC,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA;AAClH,IAAM,KAAA,EAAO,CAAC,IAAA,EAAc,IAAA,EAAA,GAAwB;AACnD,EAAA,MAAM,IAAI,KAAA,CAAM,CAAA,EAAA;AACjB;AACyB;AACd,EAAA;AACO,EAAA;AACV,EAAA;AACR;AACyB;AACd,EAAA;AACO,EAAA;AACD,EAAA;AACJ,EAAA;AACA,EAAA;AACL,EAAA;AACR;AAC0B;AACJ,EAAA;AACJ,EAAA;AACV,EAAA;AACR;AAC6C;AAClC,EAAA;AACG,EAAA;AACN,EAAA;AACR;AAEmC;AAE1B;AACa,EAAA;AACX,EAAA;AACH,IAAA;AACK,IAAA;AACG,IAAA;AACf,EAAA;AACgB,EAAA;AACjB;AAMgB;AACG,EAAA;AACP,EAAA;AACA,EAAA;AACK,EAAA;AACD,IAAA;AACC,IAAA;AACJ,IAAA;AACI,IAAA;AACf,EAAA;AACe,EAAA;AACF,EAAA;AACf;AAGgB;AACL,EAAA;AACO,EAAA;AACA,EAAA;AACH,EAAA;AACf;AAEgB;AACM,EAAA;AACH,EAAA;AACP,EAAA;AACI,EAAA;AACX,IAAA;AACA,IAAA;AACI,IAAA;AACG,IAAA;AACV,EAAA;AACF;AAEgB;AACM,EAAA;AACP,EAAA;AACH,EAAA;AACG,EAAA;AACV,EAAA;AACH,IAAA;AACW,EAAA;AACD,IAAA;AACX,EAAA;AACO,EAAA;AACR;AAEgB;AACM,EAAA;AACH,EAAA;AACH,EAAA;AACA,IAAA;AACA,IAAA;AACd,EAAA;AACF;AAEgB;AACM,EAAA;AACH,EAAA;AACJ,EAAA;AACE,EAAA;AACH,EAAA;AACI,EAAA;AAClB;AAEgB;AACM,EAAA;AACH,EAAA;AACF,EAAA;AACH,EAAA;AACI,EAAA;AAClB;AAEgB;AACM,EAAA;AACH,EAAA;AACL,EAAA;AACA,EAAA;AACA,EAAA;AACE,EAAA;AACd,IAAA;AACc,IAAA;AACD,IAAA;AACF,IAAA;AACC,IAAA;AACF,IAAA;AACV,EAAA;AACF;AAEM;AAQU;AACM,EAAA;AACH,EAAA;AACc,EAAA;AAChB,EAAA;AACX,IAAA;AACK,IAAA;AACI,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAGgB;AACG,EAAA;AACF,EAAA;AACH,EAAA;AACI,EAAA;AAClB;AAGgB;AACM,EAAA;AACV,EAAA;AACC,EAAA;AACE,EAAA;AACC,IAAA;AACC,IAAA;AACT,IAAA;AACO,IAAA;AACE,IAAA;AACD,IAAA;AACJ,IAAA;AACF,IAAA;AACO,IAAA;AACf,EAAA;AACU,EAAA;AACZ;AAEgB;AACM,EAAA;AACV,EAAA;AACK,EAAA;AACA,IAAA;AACF,IAAA;AACC,IAAA;AACF,IAAA;AACG,IAAA;AACf,EAAA;AACY,EAAA;AACd;AAEgB;AACG,EAAA;AACL,EAAA;AACa,EAAA;AACX,IAAA;AACd,IAAA;AACW,IAAA;AACC,IAAA;AACE,IAAA;AACD,IAAA;AACC,IAAA;AACC,IAAA;AACD,IAAA;AACF,IAAA;AACH,IAAA;AACT,EAAA;AACY,EAAA;AACA,EAAA;AACA,EAAA;AACN,EAAA;AACR;AAEgB;AACM,EAAA;AACV,EAAA;AACG,EAAA;AACC,IAAA;AACC,IAAA;AACT,IAAA;AACO,IAAA;AACC,IAAA;AACE,IAAA;AACD,IAAA;AACf,EAAA;AACc,EAAA;AACC,EAAA;AACA,IAAA;AACF,IAAA;AACd,EAAA;AACW,EAAA;AACZ;AAEgB;AACG,EAAA;AACL,EAAA;AACA,EAAA;AACE,IAAA;AACF,IAAA;AACC,IAAA;AACC,IAAA;AACL,IAAA;AACE,IAAA;AACE,IAAA;AACE,IAAA;AACR,IAAA;AACI,IAAA;AACC,IAAA;AACE,IAAA;AACC,IAAA;AACH,IAAA;AACH,IAAA;AACV,EAAA;AACa,EAAA;AACG,IAAA;AACd,MAAA;AACG,MAAA;AACU,MAAA;AACH,MAAA;AACG,MAAA;AACL,MAAA;AACI,MAAA;AACD,MAAA;AACE,MAAA;AACb,MAAA;AACA,IAAA;AACW,EAAA;AAED,IAAA;AACD,MAAA;AACK,IAAA;AACd,MAAA;AACG,MAAA;AACO,MAAA;AACA,MAAA;AACF,MAAA;AACI,MAAA;AACC,MAAA;AACb,MAAA;AACA,IAAA;AACF,EAAA;AACa,EAAA;AACG,IAAA;AACd,MAAA;AACG,MAAA;AACS,MAAA;AACC,MAAA;AACH,MAAA;AACE,MAAA;AACA,MAAA;AACA,MAAA;AACZ,IAAA;AACW,EAAA;AACA,IAAA;AACG,IAAA;AAChB,EAAA;AACiB,EAAA;AAClB;AAEgB;AACG,EAAA;AACF,EAAA;AACD,EAAA;AACA,IAAA;AACA,IAAA;AACF,IAAA;AACE,IAAA;AACC,IAAA;AACA,IAAA;AACH,IAAA;AACE,IAAA;AACH,IAAA;AACX,EAAA;AACF;AAGgB;AACG,EAAA;AACD,EAAA;AACe,EAAA;AACjB,IAAA;AACF,IAAA;AACA,IAAA;AACZ,EAAA;AACY,EAAA;AACG,EAAA;AACA,IAAA;AACF,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAEgB;AACG,EAAA;AACD,EAAA;AAClB;AASgB;AACP,EAAA;AACS,EAAA;AACZ,IAAA;AACM,MAAA;AACD,IAAA;AACE,MAAA;AACX,IAAA;AACD,EAAA;AACkB,EAAA;AACZ,EAAA;AACK,EAAA;AACmB,EAAA;AACpB,IAAA;AACG,IAAA;AACC,IAAA;AACC,IAAA;AACH,IAAA;AACC,IAAA;AACA,IAAA;AACA,IAAA;AACC,IAAA;AACF,IAAA;AACX,EAAA;AACe,EAAA;AACF,IAAA;AACF,IAAA;AACG,MAAA;AACF,MAAA;AACZ,IAAA;AACD,EAAA;AACe,EAAA;AACA,EAAA;AACC,EAAA;AAEL,EAAA;AACK,EAAA;AACT,EAAA;AACR;AAcgB;AACA,EAAA;AACL,IAAA;AACK,IAAA;AACC,IAAA;AACP,IAAA;AACK,IAAA;AACD,IAAA;AACH,IAAA;AACD,IAAA;AACK,MAAA;AACA,MAAA;AACb,IAAA;AACA,EAAA;AACF;ADiGmB;AACA;AE7fH;AACH,EAAA;AACC,IAAA;AACA,IAAA;AACT,IAAA;AACJ,EAAA;AACW,EAAA;AACZ;AAIa;AACZ,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACD;AAiBa;AASG;AACe,EAAA;AACf,EAAA;AACT,EAAA;AACO,EAAA;AACP,EAAA;AACS,EAAA;AACJ,EAAA;AACC,IAAA;AACE,MAAA;AACR,EAAA;AACS,IAAA;AACA,IAAA;AACD,IAAA;AACC,MAAA;AACA,MAAA;AACJ,MAAA;AACR,QAAA;AACM,QAAA;AACI,QAAA;AACL,QAAA;AACC,UAAA;AACC,UAAA;AACF,UAAA;AACE,UAAA;AACA,UAAA;AACF,UAAA;AACI,YAAA;AACT,QAAA;AACU,MAAA;AACD,QAAA;AACH,QAAA;AACM,QAAA;AACP,QAAA;AACA,UAAA;AACA,UAAA;AACE,UAAA;AACF,UAAA;AACI,YAAA;AACT,QAAA;AACU,MAAA;AACE,MAAA;AACD,MAAA;AACJ,QAAA;AACE,MAAA;AACF,QAAA;AACF,MAAA;AACK,MAAA;AACH,QAAA;AACG,MAAA;AACA,QAAA;AACF,QAAA;AACF,QAAA;AACI,QAAA;AACF,UAAA;AACT,MAAA;AACa,MAAA;AACN,QAAA;AACF,QAAA;AACI,UAAA;AACA,YAAA;AACN,YAAA;AACG,YAAA;AACH,YAAA;AACA,UAAA;AACH,MAAA;AACS,MAAA;AACE,QAAA;AACJ,QAAA;AACD,QAAA;AACG,UAAA;AACP,UAAA;AACD,QAAA;AACQ,QAAA;AACA,UAAA;AACP,UAAA;AACD,QAAA;AAES,QAAA;AACH,QAAA;AACA,QAAA;AACA,QAAA;AACF,QAAA;AACG,UAAA;AACC,UAAA;AACA,YAAA;AACN,YAAA;AACG,YAAA;AACH,YAAA;AACA,UAAA;AACF,QAAA;AACD,MAAA;AACD,IAAA;AACD,EAAA;AACW,EAAA;AACA,EAAA;AACA,EAAA;AACL,IAAA;AACK,EAAA;AACC,EAAA;AACD,EAAA;AACC,IAAA;AACC,EAAA;AACE,EAAA;AACD,EAAA;AACf;AFmemB;AACA;AG7mBb;AA0CO;AACZ,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;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,MAAA;AACA,MAAA;AACO,QAAA;AACL,QAAA;AACA,QAAA;AACK,QAAA;AACL,QAAA;AACA,QAAA;AACC,UAAA;AACD,UAAA;AACG,UAAA;AACT,QAAA;AACO,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACA,QAAA;AACK,QAAA;AACL,QAAA;AACC,QAAA;AACR,MAAA;AACK,MAAA;AACI,QAAA;AACH,QAAA;AACA,QAAA;AACC,QAAA;AACA,QAAA;AACA,QAAA;AACI,QAAA;AACJ,QAAA;AACF,QAAA;AACE,QAAA;AAEC,UAAA;AACE,UAAA;AACF,QAAA;AACD,UAAA;AACD,UAAA;AACA,UAAA;AACH,YAAA;AACQ,UAAA;AACA,YAAA;AACT,UAAA;AACQ,UAAA;AACT,QAAA;AACI,QAAA;AACD,UAAA;AACE,UAAA;AACC,UAAA;AACK,QAAA;AACF,QAAA;AACF,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACD,QAAA;AACI,QAAA;AACH,QAAA;AACK,QAAA;AACD,UAAA;AACH,YAAA;AACC,YAAA;AACP,UAAA;AACK,UAAA;AACN,QAAA;AACO,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACA,QAAA;AACA,QAAA;AACE,QAAA;AACA,QAAA;AACD,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACI,QAAA;AACC,QAAA;AACL,QAAA;AACA,QAAA;AACK,QAAA;AACF,QAAA;AACD,QAAA;AACD,QAAA;AACR,MAAA;AACK,MAAA;AACE,QAAA;AACD,QAAA;AACE,QAAA;AACG,QAAA;AACH,QAAA;AACR,MAAA;AACK,MAAA;AACK,QAAA;AACF,QAAA;AACR,MAAA;AACA,MAAA;AACQ,QAAA;AACT,IAAA;AACD,EAAA;AACD;AAQgB;AACF,EAAA;AACK,EAAA;AACH,EAAA;AACC,EAAA;AACF,IAAA;AACT,IAAA;AACQ,MAAA;AACH,IAAA;AACF,MAAA;AACO,MAAA;AACd,IAAA;AACD,EAAA;AACI,EAAA;AACA,EAAA;AACI,IAAA;AACQ,EAAA;AACF,IAAA;AACd,EAAA;AACe,EAAA;AACA,EAAA;AACD,EAAA;AACH,EAAA;AACF,EAAA;AACI,EAAA;AACd;AH+emB;AACA;AI33CN;AAGG;AACT,EAAA;AACS,EAAA;AACA,EAAA;AACA,EAAA;AACC,EAAA;AACN,IAAA;AACA,IAAA;AACG,IAAA;AACD,IAAA;AACG,MAAA;AACF,QAAA;AACL,QAAA;AACO,QAAA;AACA,QAAA;AACb,MAAA;AACD,IAAA;AACY,IAAA;AACG,IAAA;AAChB,EAAA;AACgB,EAAA;AACF,EAAA;AACC,IAAA;AACJ,IAAA;AACK,IAAA;AACF,IAAA;AACH,MAAA;AACA,MAAA;AACI,MAAA;AACF,QAAA;AACA,QAAA;AACX,MAAA;AACY,MAAA;AACA,MAAA;AACb,IAAA;AACc,IAAA;AACf,EAAA;AACS,EAAA;AACV;AAGa;AAMG;AACA,EAAA;AACE,EAAA;AACA,EAAA;AACL,EAAA;AAEF,IAAA;AACD,IAAA;AACT,EAAA;AACU,EAAA;AACA,EAAA;AACI,EAAA;AACD,IAAA;AACG,IAAA;AACD,IAAA;AACf,EAAA;AAEgB,EAAA;AACD,IAAA;AACJ,IAAA;AACI,MAAA;AACb,MAAA;AACD,IAAA;AACW,IAAA;AACA,IAAA;AACN,IAAA;AACA,IAAA;AACN,EAAA;AACe,EAAA;AAChB;AAGgB;AACA,EAAA;AACC,EAAA;AACF,EAAA;AACE,EAAA;AACF,IAAA;AACA,IAAA;AACA,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAOgB;AACA,EAAA;AACC,EAAA;AACF,EAAA;AACA,EAAA;AACD,EAAA;AACG,EAAA;AACF,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAMgB;AACC,EAAA;AACN,EAAA;AACE,EAAA;AACD,EAAA;AACA,EAAA;AAEG,EAAA;AACS,EAAA;AACG,EAAA;AACV,EAAA;AACC,EAAA;AACH,EAAA;AACf;AAMgB;AACC,EAAA;AACT,EAAA;AACR;AAG4B;AAWZ;AACD,EAAA;AACA,EAAA;AACD,EAAA;AACd;AAYgB;AACT,EAAA;AACA,EAAA;AACA,EAAA;AACU,EAAA;AAC+B,EAAA;AACtB,EAAA;AACV,EAAA;AACC,EAAA;AACD,IAAA;AACD,IAAA;AACE,IAAA;AACF,MAAA;AACF,MAAA;AACJ,MAAA;AACP,IAAA;AACA,EAAA;AACM,EAAA;AACR;AAGsB;AACJ,EAAA;AACD,EAAA;AACC,EAAA;AACC,EAAA;AACL,EAAA;AACT,EAAA;AACA,EAAA;AACY,IAAA;AACR,EAAA;AACG,IAAA;AACX,EAAA;AACO,EAAA;AACR;AASa;AAMiB,EAAA;AAAA,IAAA;AAA8E,EAAA;AAA9E,EAAA;AALJ,kBAAA;AACE,kBAAA;AACD,kBAAA;AACG,kBAAA;AAAoB;AAKI,EAAA;AACxC,IAAA;AACb,EAAA;AAAA;AAGoD,EAAA;AACrC,IAAA;AACC,IAAA;AAAgE,IAAA;AACxE,IAAA;AACR,EAAA;AAAA;AAG6C,EAAA;AAChC,IAAA;AACb,EAAA;AAAA;AAGkD,EAAA;AACnC,IAAA;AACH,IAAA;AACI,IAAA;AACH,IAAA;AACE,IAAA;AACA,IAAA;AACH,MAAA;AACE,QAAA;AACL,UAAA;AACA,UAAA;AACA,UAAA;AACE,UAAA;AACR,QAAA;AACgB,QAAA;AACV,UAAA;AACC,UAAA;AACD,UAAA;AACA,UAAA;AACC,UAAA;AACP,QAAA;AACD,MAAA;AACa,MAAA;AACd,IAAA;AACO,IAAA;AACR,EAAA;AAAA;AAG+C,EAAA;AAC/B,IAAA;AACH,IAAA;AACF,IAAA;AACX,EAAA;AAEsC,EAAA;AACtB,IAAA;AACF,IAAA;AACd,EAAA;AAEe,EAAA;AACH,IAAA;AACZ,EAAA;AACD;AJ0zCmB;AACA;AKzlDN;AAGa;AAGV;AACL,EAAA;AACG,EAAA;AACd;AAWmB;AACH,EAAA;AACD,EAAA;AACD,EAAA;AACD,EAAA;AACE,EAAA;AACE,IAAA;AACD,IAAA;AACH,MAAA;AACC,MAAA;AACC,MAAA;AACZ,MAAA;AACD,IAAA;AACD,EAAA;AACO,EAAA;AACR;AAc0B;AAEV;AACF,EAAA;AACiB,EAAA;AAClB,EAAA;AACI,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACf,EAAA;AACM,EAAA;AACR;AAcgB;AACC,EAAA;AACD,EAAA;AACE,EAAA;AACN,EAAA;AACD,EAAA;AACM,IAAA;AACD,IAAA;AACF,IAAA;AACZ,EAAA;AACQ,EAAA;AACV;AAca;AAEG;AACF,EAAA;AACI,EAAA;AACN,EAAA;AACD,EAAA;AACI,IAAA;AACE,IAAA;AACA,IAAA;AACH,IAAA;AACZ,EAAA;AACc,EAAA;AAChB;AAsBgB;AACY;AAEZ;AACF,EAAA;AACC,EAAA;AACG,EAAA;AACN,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACE,EAAA;AACA,EAAA;AACC,EAAA;AACG,EAAA;AACR,EAAA;AACA,EAAA;AACC,EAAA;AACI,EAAA;AACH,IAAA;AACI,IAAA;AACN,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACO,IAAA;AACH,IAAA;AACF,IAAA;AACA,IAAA;AACI,IAAA;AACC,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACf,EAAA;AACK,EAAA;AACU,IAAA;AACJ,IAAA;AACE,IAAA;AACJ,MAAA;AACI,MAAA;AACb,IAAA;AACO,IAAA;AACR,EAAA;AACU,EAAA;AACG,EAAA;AACH,EAAA;AACG,EAAA;AACN,EAAA;AACE,IAAA;AACM,IAAA;AACH,IAAA;AACG,IAAA;AACD,IAAA;AACd,EAAA;AACD;AAGgB;AACU,EAAA;AACZA,EAAAA;AACH,EAAA;AACA,EAAA;AACL,EAAA;AACE,IAAA;AACO,IAAA;AACd,EAAA;AACe,EAAA;AACA,EAAA;AACA,EAAA;AACD,EAAA;AACC,EAAA;AACA,EAAA;AACD,IAAA;AACF,IAAA;AACZ,EAAA;AACO,EAAA;AACR;AAea;AAEG;AACD,EAAA;AACD,EAAA;AACH,EAAA;AACK,EAAA;AACC,EAAA;AACN,EAAA;AACO,EAAA;AACD,IAAA;AACL,IAAA;AACD,IAAA;AACG,IAAA;AACH,IAAA;AACG,IAAA;AACD,IAAA;AACE,IAAA;AACA,IAAA;AACJ,MAAA;AACI,MAAA;AACF,QAAA;AACT,QAAA;AACD,MAAA;AACD,IAAA;AACS,IAAA;AACI,EAAA;AAE8B,EAAA;AAClC,EAAA;AACM,IAAA;AACF,IAAA;AACb,EAAA;AACe,EAAA;AACJ,EAAA;AACG,EAAA;AACA,EAAA;AACE,EAAA;AACX,EAAA;AACC,EAAA;AACN,IAAA;AACO,IAAA;AACM,IAAA;AACb,IAAA;AACe,IAAA;AAChB,EAAA;AACD;AAGa;AAEM,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACH,EAAA;AAEC;AACC,EAAA;AACF,EAAA;AACf;AASe;AACE,EAAA;AACD,EAAA;AACL,IAAA;AACI,IAAA;AACA,IAAA;AACA,IAAA;AACf,EAAA;AACgB,EAAA;AACjB;AAEoC;AACnB,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACA,EAAA;AACjB;AAE4C;AAEf;AAGb;AACH,EAAA;AACK,EAAA;AAEF,EAAA;AACF,EAAA;AACI,EAAA;AACD,EAAA;AACH,IAAA;AACN,IAAA;AACO,MAAA;AACD,MAAA;AACA,MAAA;AACZ,IAAA;AACe,IAAA;AACF,MAAA;AACC,MAAA;AACd,IAAA;AACa,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAGgB;AACH,EAAA;AACK,EAAA;AACD,EAAA;AACA,IAAA;AACF,IAAA;AACN,MAAA;AACI,MAAA;AACE,MAAA;AACb,IAAA;AACD,EAAA;AACO,EAAA;AACR;AAGgB;AACH,EAAA;AACD,EAAA;AACK,EAAA;AACA,IAAA;AACD,IAAA;AACf,EAAA;AAEc,EAAA;AACE,EAAA;AACA,EAAA;AACA,IAAA;AACD,IAAA;AACD,IAAA;AACF,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACX,IAAA;AACD,EAAA;AACgB,EAAA;AACF,IAAA;AACD,MAAA;AACA,MAAA;AACD,MAAA;AACF,MAAA;AAEG,MAAA;AACA,MAAA;AACD,MAAA;AACA,MAAA;AACA,MAAA;AACG,MAAA;AACd,IAAA;AACM,EAAA;AACR;AAGgB;AACF,EAAA;AACA,EAAA;AACA,EAAA;AACN,EAAA;AACR;AAea;AACiD,kBAAA;AACC,kBAAA;AACjC,kBAAA;AAAoB;AAGa,EAAA;AAC/C,IAAA;AACC,IAAA;AACF,IAAA;AACH,MAAA;AACJ,MAAA;AACA,MAAA;AACK,MAAA;AACX,IAAA;AACD,EAAA;AAAA;AAGwB,EAAA;AACX,IAAA;AACb,EAAA;AAAA;AAGoB,EAAA;AACJ,IAAA;AACL,IAAA;AACX,EAAA;AAAA;AAGoC,EAAA;AACzB,IAAA;AACN,IAAA;AACS,MAAA;AACD,IAAA;AACL,MAAA;AACI,MAAA;AACX,IAAA;AACD,EAAA;AAAA;AAGU,EAAA;AACM,IAAA;AACF,IAAA;AACP,MAAA;AACN,IAAA;AACD,EAAA;AAAA;AAAA;AAAA;AAAA;AAMmB,EAAA;AACZ,IAAA;AACK,IAAA;AACG,MAAA;AACZ,QAAA;AACI,QAAA;AACD,QAAA;AACJ,MAAA;AACM,MAAA;AACK,QAAA;AACL,QAAA;AACD,QAAA;AACG,UAAA;AACD,UAAA;AACG,QAAA;AACH,UAAA;AACN,QAAA;AACD,MAAA;AACY,MAAA;AACN,MAAA;AACA,MAAA;AACN,IAAA;AACF,EAAA;AAEe,EAAA;AACH,IAAA;AACZ,EAAA;AACD;AAYgB;AACX,EAAA;AACA,EAAA;AACS,IAAA;AACD,EAAA;AACE,IAAA;AACd,EAAA;AACW,EAAA;AACG,EAAA;AACC,EAAA;AACA,EAAA;AACmD,EAAA;AACvD,EAAA;AACG,EAAA;AACD,EAAA;AACd;ALk8CmB;AACA;AMh9DD;AAGD;AACC,EAAA;AACH,EAAA;AACE,EAAA;AACjB;AAGkB;AACH,EAAA;AAIA,EAAA;AACG,EAAA;AAClB;AASgB;AACK,EAAA;AACT,EAAA;AACG,IAAA;AACJ,IAAA;AACJ,MAAA;AACI,QAAA;AACA,MAAA;AACG,QAAA;AACX,MAAA;AACM,IAAA;AAEAC,MAAAA;AACK,MAAA;AACJ,QAAA;AACK,QAAA;AACC,QAAA;AACD,QAAA;AACL,QAAA;AACP,MAAA;AACW,MAAA;AACF,MAAA;AACV,IAAA;AACD,EAAA;AACiB,EAAA;AACA,EAAA;AAEA,IAAA;AACF,IAAA;AACC,IAAA;AACF,IAAA;AACE,IAAA;AACC,IAAA;AACf,EAAA;AACkC,EAAA;AAC1B,EAAA;AACD,EAAA;AACA,EAAA;AACF,IAAA;AACO,IAAA;AACR,IAAA;AACO,MAAA;AACA,MAAA;AACF,MAAA;AACV,IAAA;AACD,EAAA;AACW,EAAA;AACK,EAAA;AACjB;AAuBgB;AACD,EAAA;AACA,IAAA;AACN,IAAA;AACR,EAAA;AACe,EAAA;AACT,EAAA;AACU,EAAA;AACjB;AAOgB;AACJ,EAAA;AACM,EAAA;AACH,EAAA;AACC,EAAA;AACmB,EAAA;AACvB,EAAA;AACD,IAAA;AACK,IAAA;AACF,IAAA;AACE,IAAA;AACD,IAAA;AACF,IAAA;AACL,IAAA;AACS,IAAA;AACD,MAAA;AACH,MAAA;AACC,MAAA;AACX,IAAA;AACF,EAAA;AACO,EAAA;AACR;AAca;AAEI;AAGD;AACC,EAAA;AACD,EAAA;AACF,EAAA;AACwB,EAAA;AACrB,EAAA;AACT,EAAA;AACR;AAGa;AAGG;AACC,EAAA;AAIH,EAAA;AACwB,EAAA;AACrB,EAAA;AACH,IAAA;AACH,IAAA;AACC,MAAA;AACA,MAAA;AACH,IAAA;AACR,EAAA;AACO,EAAA;AACR;AAYgB;AACH,EAAA;AACC,EAAA;AACwB,EAAA;AACrB,EAAA;AACL,IAAA;AACA,IAAA;AACK,IAAA;AACF,IAAA;AACF,IAAA;AACC,IAAA;AAED,MAAA;AACH,MAAA;AACF,MAAA;AACN,IAAA;AACS,IAAA;AACV,EAAA;AACO,EAAA;AACR;AAGgB;AACA,EAAA;AAC8B,EAAA;AAC7B,EAAA;AACF,IAAA;AACC,IAAA;AACC,IAAA;AAChB,EAAA;AACO,EAAA;AACR;AAMgB;AACR,EAAA;AAGR;AAGgB;AACA,EAAA;AACA,EAAA;AAChB;AAGgB;AACF,EAAA;AACd;AAGgB;AACN,EAAA;AACM,EAAA;AACE,EAAA;AACH,IAAA;AACE,IAAA;AACA,IAAA;AACH,IAAA;AACb,EAAA;AACO,EAAA;AACR;ANm3DmB;AACA;AOtnEN;AAGG;AACH,EAAA;AACuB,EAAA;AACnB,EAAA;AACT,EAAA;AACR;AAGa;AAGG;AACD,EAAA;AACA,EAAA;AACE,EAAA;AACjB;AAgBgB;AACD,EAAA;AACD,EAAA;AACC,EAAA;AACf;AAiBgB;AACL,EAAA;AACI,EAAA;AACA,EAAA;AACG,EAAA;AACF,EAAA;AACH,EAAA;AACC,EAAA;AACA,EAAA;AACZ,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACe,IAAA;AACf,IAAA;AACU,IAAA;AACV,IAAA;AACU,IAAA;AACV,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACU,IAAA;AACV,IAAA;AACA,IAAA;AACA,IAAA;AACD,EAAA;AACiC,EAAA;AACjB,EAAA;AACjB;AAGgB;AACD,EAAA;AACC,EAAA;AACH,EAAA;AACD,IAAA;AACI,IAAA;AACC,IAAA;AAChB,EAAA;AACe,EAAA;AAGhB;AAGa;AACK,EAAA;AACA,EAAA;AACd,EAAA;AACJ;AAGa;AAGA;APykEM;AACA;AQlsEN;AAGA;AAGA;AAcG;AACmB,EAAA;AAEvB,EAAA;AACA,IAAA;AACE,IAAA;AACF,IAAA;AACI,IAAA;AACH,IAAA;AACZ,EAAA;AACO,EAAA;AACR;AAMgB;AACA,EAAA;AACH,EAAA;AACC,EAAA;AACA,EAAA;AACqB,EAAA;AAClB,EAAA;AACL,IAAA;AACC,IAAA;AACA,IAAA;AACG,MAAA;AACF,MAAA;AACD,MAAA;AACX,IAAA;AACD,EAAA;AACO,EAAA;AACR;AAegB;AACH,EAAA;AACC,EAAA;AACD,EAAA;AACG,EAAA;AACD,EAAA;AACA,IAAA;AACD,IAAA;AACb,EAAA;AACuB,EAAA;AACP,EAAA;AACF,IAAA;AACC,IAAA;AACH,IAAA;AACZ,EAAA;AACO,EAAA;AACR;AA2CM;AACM,EAAA;AACG,EAAA;AACE,EAAA;AACjB;AAOa;AAkBiB,EAAA;AAAA,IAAA;AACvB,IAAA;AACA,IAAA;AACS,IAAA;AACf,EAAA;AAJ6B,EAAA;AAjBF,kBAAA;AACC,mBAAA;AACU,mBAAA;AACpB,mBAAA;AACW,mBAAA;AACV,mBAAA;AACS,mBAAA;AACb,mBAAA;AACE,mBAAA;AACD,mBAAA;AACK,mBAAA;AACD,mBAAA;AACZ,EAAA;AACC,EAAA;AACA,EAAA;AACQ,EAAA;AAAA;AASA,EAAA;AACF,IAAA;AACH,IAAA;AACD,IAAA;AACI,IAAA;AACf,EAAA;AAAA;AAGiB,EAAA;AACJ,IAAA;AACb,EAAA;AAAA;AAGsD,EAAA;AAC5C,IAAA;AACG,IAAA;AACE,IAAA;AACA,IAAA;AACD,IAAA;AACC,IAAA;AACH,IAAA;AACD,IAAA;AACX,EAAA;AAAA;AAGqB,EAAA;AACN,IAAA;AACF,IAAA;AACH,IAAA;AACK,IAAA;AACH,IAAA;AAEG,MAAA;AAEA,IAAA;AACJ,IAAA;AACX,EAAA;AAAA;AAAA;AAAA;AAAA;AAM8B,EAAA;AACpB,IAAA;AACJ,IAAA;AACA,IAAA;AACS,IAAA;AACD,IAAA;AACE,IAAA;AAEJ,IAAA;AACA,MAAA;AACD,MAAA;AACV,IAAA;AACW,IAAA;AACN,IAAA;AACQ,IAAA;AACH,IAAA;AACX,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOa,EAAA;AAEC,IAAA;AACA,IAAA;AACF,IAAA;AACoC,IAAA;AAClC,IAAA;AACN,MAAA;AACA,MAAA;AACO,MAAA;AACA,MAAA;AACD,MAAA;AACA,MAAA;AACH,MAAA;AACV,IAAA;AACe,IAAA;AAChB,EAAA;AAE8B,EAAA;AACpB,IAAA;AACV,EAAA;AAAA;AAGU,EAAA;AACM,IAAA;AACF,IAAA;AACP,MAAA;AACN,IAAA;AACD,EAAA;AAAA;AAGc,EAAA;AACF,IAAA;AACG,IAAA;AACT,IAAA;AACK,IAAA;AACX,EAAA;AAAA;AAGgB,EAAA;AACJ,IAAA;AACN,IAAA;AACU,IAAA;AAChB,EAAA;AAEe,EAAA;AACA,IAAA;AACF,IAAA;AACD,MAAA;AACD,MAAA;AAEA,MAAA;AACH,QAAA;AACL,QAAA;AACD,MAAA;AACM,MAAA;AACD,MAAA;AACO,MAAA;AAEC,QAAA;AACL,UAAA;AAED,UAAA;AACC,UAAA;AACN,QAAA;AACW,QAAA;AAEH,MAAA;AACX,IAAA;AACD,EAAA;AAEgB,EAAA;AACF,IAAA;AACF,IAAA;AACZ,EAAA;AAEuB,EAAA;AACT,IAAA;AACC,IAAA;AACA,oBAAA;AACf,EAAA;AAEe,EAAA;AACH,IAAA;AACZ,EAAA;AACD;AAQgB;AACA,EAAA;AAEd,EAAA;AAEY,IAAA;AACA,IAAA;AACE,IAAA;AACV,IAAA;AACA,IAAA;AACI,IAAA;AACR,EAAA;AACe,EAAA;AACF,EAAA;AAEE,IAAA;AACX,IAAA;AACA,IAAA;AACH,EAAA;AACW,EAAA;AACG,IAAA;AACP,IAAA;AACI,MAAA;AACF,MAAA;AACHC,MAAAA;AACK,MAAA;AACZ,IAAA;AACe,IAAA;AACA,IAAA;AACR,MAAA;AACA,MAAA;AACH,MAAA;AACG,MAAA;AACF,MAAA;AACE,MAAA;AACG,MAAA;AACT,IAAA;AACc,IAAA;AAAc,IAAA;AACtB,IAAA;AACR,EAAA;AACD;ARilEmB;AACA;ASn2EN;AA0BM,EAAA;AAAA,IAAA;AAEA,IAAA;AAEHC,IAAAA;AACH,IAAA;AACN,IAAA;AACA,IAAA;AACA,IAAA;AAAwF,IAAA;AACxF,IAAA;AACA,IAAA;AAA8F,IAAA;AACpF,IAAA;AACV,IAAA;AAAyF,IAAA;AAC/E,IAAA;AACV,IAAA;AAAwE,IAAA;AACxE,IAAA;AACO,IAAA;AACb,EAAA;AAjBkB,EAAA;AAEA,EAAA;AA3BD,EAAA;AACA,EAAA;AACQ,mBAAA;AACC,mBAAA;AACT,EAAA;AACG,mBAAA;AACA,mBAAA;AACiB,mBAAA;AACV,mBAAA;AACV,EAAA;AACA,EAAA;AAC2C,mBAAA;AACzC,mBAAA;AACF,EAAA;AACA,EAAA;AACA,EAAA;AAC4C,mBAAA;AAC3C,mBAAA;AACJ,mBAAA;AACG,EAAA;AACA,mBAAA;AACA,EAAA;AACA,EAAA;AAAA;AAuByB,EAAA;AAC7B,IAAA;AACF,IAAA;AACE,IAAA;AACb,EAAA;AAEoB,EAAA;AACV,IAAA;AACE,IAAA;AACE,IAAA;AACV,IAAA;AACU,IAAA;AACN,IAAA;AACR,EAAA;AAE8C,EAAA;AAC9B,IAAA;AACL,IAAA;AACE,MAAA;AACP,MAAA;AACM,MAAA;AACE,MAAA;AACb,IAAA;AACO,IAAA;AACR,EAAA;AAEyC,EAAA;AAClC,IAAA;AACF,IAAA;AACU,IAAA;AACA,IAAA;AACR,IAAA;AACA,IAAA;AACA,IAAA;AACM,IAAA;AACE,IAAA;AACC,IAAA;AACR,IAAA;AACR,EAAA;AAE8B,EAAA;AACjB,IAAA;AACA,IAAA;AACA,IAAA;AACA,oBAAA;AACA,oBAAA;AACD,IAAA;AACZ,EAAA;AAAA;AAGe,EAAA;AACL,IAAA;AACK,IAAA;AACN,IAAA;AACM,IAAA;AAEA,IAAA;AACC,IAAA;AACA,IAAA;AACA,IAAA;AACJ,IAAA;AACN,oBAAA;AACC,IAAA;AACS,IAAA;AACD,IAAA;AACL,MAAA;AACG,MAAA;AACL,MAAA;AAEA,MAAA;AACD,MAAA;AACQ,MAAA;AACR,MAAA;AACE,QAAA;AACA,QAAA;AACP,MAAA;AACI,MAAA;AACO,QAAA;AACA,QAAA;AAAyD,QAAA;AAC7D,MAAA;AACK,QAAA;AACF,QAAA;AACV,MAAA;AACD,IAAA;AACM,IAAA;AACK,IAAA;AACA,MAAA;AACJ,MAAA;AACK,MAAA;AACF,MAAA;AACV,IAAA;AAEW,IAAA;AACA,IAAA;AACI,IAAA;AAChB,EAAA;AAAA;AAGiB,EAAA;AACN,IAAA;AACC,IAAA;AACG,IAAA;AACf,EAAA;AAEiB,EAAA;AACP,IAAA;AACJ,IAAA;AACU,IAAA;AACT,MAAA;AACI,MAAA;AACD,MAAA;AACE,MAAA;AACV,IAAA;AACF,EAAA;AAEqB,mBAAA;AACV,IAAA;AACG,MAAA;AACD,MAAA;AACZ,IAAA;AACc,IAAA;AACC,IAAA;AACD,IAAA;AACF,IAAA;AACN,IAAA;AAEO,IAAA;AACA,MAAA;AACH,MAAA;AACV,IAAA;AACY,IAAA;AACD,IAAA;AACF,IAAA;AACK,IAAA;AACf,EAAA;AAAA;AAGsB,EAAA;AACZ,IAAA;AACK,IAAA;AACF,IAAA;AACA,IAAA;AACb,EAAA;AAEgC,EAAA;AAChB,IAAA;AACT,IAAA;AACS,IAAA;AACA,IAAA;AACN,IAAA;AACA,IAAA;AACJ,IAAA;AACC,IAAA;AAEK,MAAA;AACJ,MAAA;AACO,MAAA;AACN,MAAA;AACR,IAAA;AACe,IAAA;AACd,MAAA;AACa,MAAA;AACJ,MAAA;AACA,MAAA;AACT,MAAA;AACM,QAAA;AACN,MAAA;AACA,IAAA;AACc,IAAA;AACF,oBAAA;AACR,oBAAA;AACN,EAAA;AAAA;AAG2B,mBAAA;AACd,IAAA;AACG,IAAA;AACR,IAAA;AACR,EAAA;AAE4B,mBAAA;AACjB,IAAA;AAC6B,MAAA;AAC7B,MAAA;AACA,MAAA;AACFA,QAAAA;AACA,QAAA;AACC,QAAA;AACR,MAAA;AACK,MAAA;AACN,IAAA;AACe,IAAA;AACA,IAAA;AACH,IAAA;AACN,IAAA;AACA,IAAA;AACC,IAAA;AACR,EAAA;AAEiB,mBAAA;AACP,IAAA;AACA,IAAA;AACA,IAAA;AACC,IAAA;AACG,MAAA;AACR,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACK,MAAA;AACJ,MAAA;AACN,IAAA;AACK,IAAA;AACQ,IAAA;AACd,EAAA;AAAA;AAGgB,EAAA;AACV,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,oBAAA;AACA,IAAA;AACQ,IAAA;AACR,IAAA;AACM,IAAA;AACC,IAAA;AACb,EAAA;AACD;ATq1EmB;AACA;AUhnFN;AACH,EAAA;AACA,EAAA;AAAA;AAEA,EAAA;AAAA;AAEgB,mBAAA;AAAuB;AAEvC,EAAA;AAAA;AAEA,EAAA;AAAA;AAEA,EAAA;AAAA;AAEA,EAAA;AACD,EAAA;AACA,EAAA;AACuB,mBAAA;AACvB,EAAA;AACA,EAAA;AACuB,mBAAA;AACZ,mBAAA;AACA,mBAAA;AACF,EAAA;AACY,mBAAA;AACqB,mBAAA;AAC1C,EAAA;AAEI,EAAA;AACI,IAAA;AACV,IAAA;AACO,IAAA;AACP,IAAA;AACU,IAAA;AACH,IAAA;AACP,IAAA;AACU,IAAA;AACD,IAAA;AACT,IAAA;AACA,IAAA;AACA,IAAA;AACS,IAAA;AACD,IAAA;AACA,IAAA;AACd,EAAA;AAEkB,EAAA;AACR,IAAA;AACJ,IAAA;AACQ,MAAA;AACC,MAAA;AACF,QAAA;AACV,MAAA;AACY,MAAA;AACd,IAAA;AACD,EAAA;AAAA;AAGgB,EAAA;AACH,IAAA;AACP,IAAA;AACA,IAAA;AACN,EAAA;AAEmC,mBAAA;AAEtB,mBAAA;AACC,IAAA;AACE,IAAA;AACF,IAAA;AACP,MAAA;AACN,IAAA;AACD,EAAA;AAAA;AAG2B,EAAA;AACd,IAAA;AACb,EAAA;AAEyC,EAAA;AACzB,IAAA;AACA,IAAA;AACR,IAAA;AACG,MAAA;AACC,MAAA;AACF,MAAA;AACG,MAAA;AACH,MAAA;AACG,MAAA;AACF,MAAA;AACI,MAAA;AACH,MAAA;AACE,MAAA;AACD,MAAA;AACA,MAAA;AACE,MAAA;AACA,MAAA;AACb,MAAA;AACA,MAAA;AACU,MAAA;AACX,IAAA;AACD,EAAA;AAEiB,EAAA;AACX,IAAA;AACQ,IAAA;AACC,IAAA;AACC,IAAA;AACV,MAAA;AACM,QAAA;AACD,MAAA;AACA,QAAA;AACT,MAAA;AACA,IAAA;AACF,EAAA;AAEmB,EAAA;AACb,IAAA;AACD,IAAA;AACE,MAAA;AACA,MAAA;AACO,MAAA;AACH,MAAA;AACG,MAAA;AACX,IAAA;AACI,MAAA;AACN,IAAA;AACY,IAAA;AACP,oBAAA;AACN,EAAA;AAE8B,EAAA;AACd,IAAA;AACF,IAAA;AACA,IAAA;AACA,MAAA;AACZ,MAAA;AACD,IAAA;AACM,IAAA;AACC,MAAA;AACD,MAAA;AACI,MAAA;AACG,MAAA;AACA,MAAA;AACb,IAAA;AACW,IAAA;AACD,IAAA;AACX,EAAA;AAEiD,EAAA;AACnC,IAAA;AACD,IAAA;AACC,IAAA;AACd,EAAA;AAAA;AAGgC,EAAA;AACnB,IAAA;AACb,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAO+C,EAAA;AAC/B,IAAA;AACH,IAAA;AACG,IAAA;AACC,IAAA;AACH,IAAA;AACN,IAAA;AACR,EAAA;AAAA;AAGqC,EAAA;AAChC,IAAA;AACA,IAAA;AACM,MAAA;AACD,IAAA;AACI,MAAA;AACb,IAAA;AACY,IAAA;AACE,MAAA;AACA,MAAA;AACR,MAAA;AACA,MAAA;AACO,MAAA;AACC,MAAA;AACd,IAAA;AACY,IAAA;AACb,EAAA;AAEY,EAAA;AACF,IAAA;AACJ,IAAA;AACO,IAAA;AACb,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOgB,EAAA;AACF,IAAA;AACA,IAAA;AACR,IAAA;AACO,IAAA;AACb,EAAA;AAAA;AAGwC,EAAA;AACzB,IAAA;AACV,IAAA;AACQ,IAAA;AACA,MAAA;AACA,MAAA;AACG,IAAA;AACN,IAAA;AACJ,IAAA;AACO,IAAA;AACb,EAAA;AAEa,EAAA;AACA,IAAA;AACb,EAAA;AAEc,EAAA;AACD,IAAA;AACb,EAAA;AAEe,EAAA;AACF,IAAA;AACb,EAAA;AAAA;AAGyB,EAAA;AACZ,IAAA;AACb,EAAA;AAAA;AAG2B,EAAA;AACd,IAAA;AACA,IAAA;AACA,IAAA;AACb,EAAA;AAEuC,EAAA;AAC1B,IAAA;AACb,EAAA;AAEgB,EAAA;AACH,IAAA;AACb,EAAA;AAEgB,EAAA;AACH,IAAA;AACb,EAAA;AAAA;AAAA;AAAA;AAAA;AAMmB,EAAA;AACZ,IAAA;AACS,IAAA;AACH,IAAA;AACD,IAAA;AACZ,EAAA;AAAA;AAGA,EAAA;AACW,IAAA;AACI,IAAA;AACC,IAAA;AACH,IAAA;AACA,IAAA;AACb,EAAA;AAAA;AAGgB,EAAA;AACL,IAAA;AACE,IAAA;AACb,EAAA;AAAA;AAAA;AAAA;AAAA;AAQI,EAAA;AACS,IAAA;AACb,EAAA;AAAA;AAGA,EAAA;AACc,IAAA;AACd,EAAA;AAAA;AAGW,EAAA;AACE,IAAA;AACb,EAAA;AAAA;AAGM,EAAA;AACK,IAAA;AACC,IAAA;AACJ,IAAA;AACR,EAAA;AAE+D,EAAA;AAClD,IAAA;AACb,EAAA;AAEyB,EAAA;AACT,IAAA;AACH,IAAA;AACR,IAAA;AACA,IAAA;AACU,MAAA;AACL,IAAA;AACJ,MAAA;AACE,MAAA;AACP,IAAA;AACe,IAAA;AACC,IAAA;AACJ,IAAA;AACb,EAAA;AAE2C,EAAA;AAC/B,IAAA;AACI,IAAA;AACL,IAAA;AACX,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOgB,EAAA;AACH,IAAA;AACL,MAAA;AACI,MAAA;AACJ,MAAA;AACA,MAAA;AACA,MAAA;AACE,MAAA;AACK,MAAA;AACP,MAAA;AACM,MAAA;AACA,MAAA;AACN,MAAA;AACO,MAAA;AACA,MAAA;AACJ,MAAA;AACT,IAAA;AACF,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOiB,EAAA;AACJ,IAAA;AACL,MAAA;AACA,MAAA;AACI,MAAA;AACC,MAAA;AACC,MAAA;AACkB,MAAA;AAClB,MAAA;AACD,QAAA;AACJ,QAAA;AACA,QAAA;AACA,QAAA;AACK,QAAA;AACJ,UAAA;AACA,UAAA;AACP,QAAA;AACK,QAAA;AACN,MAAA;AACY,MAAA;AACP,QAAA;AACM,QAAA;AACX,MAAA;AACY,MAAA;AACC,MAAA;AACP,MAAA;AACO,MAAA;AACA,MAAA;AACJ,MAAA;AACC,IAAA;AACZ,EAAA;AAAA;AAGiB,EAAA;AACD,IAAA;AACH,IAAA;AACD,IAAA;AACF,IAAA;AACK,IAAA;AACF,IAAA;AACF,IAAA;AACG,IAAA;AACE,IAAA;AACD,IAAA;AACf,EAAA;AAAA;AAAA;AAAA;AAAA;AAMA,EAAA;AACa,IAAA;AACN,MAAA;AACK,MAAA;AACJ,MAAA;AACA,MAAA;AACG,MAAA;AACT,IAAA;AACF,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOc,EAAA;AACD,IAAA;AACD,MAAA;AACJ,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACC,MAAA;AACE,QAAA;AACR,QAAA;AACM,QAAA;AACP,MAAA;AACe,IAAA;AACjB,EAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOA,EAAA;AACa,IAAA;AACL,MAAA;AACO,MAAA;AACR,QAAA;AACM,QAAA;AACD,QAAA;AACV,MAAA;AACU,MAAA;AACH,MAAA;AACG,MAAA;AACJ,MAAA;AACG,MAAA;AACC,IAAA;AACZ,EAAA;AAAA;AAGA,EAAA;AACO,IAAA;AACM,IAAA;AACE,IAAA;AACA,IAAA;AACF,IAAA;AACb,EAAA;AAAA;AAGa,EAAA;AACL,IAAA;AACR,EAAA;AAAA;AAGa,EAAA;AACL,IAAA;AACR,EAAA;AAAA;AAGS,EAAA;AACD,IAAA;AACR,EAAA;AAAA;AAGA,EAAA;AACQ,IAAA;AACR,EAAA;AAAA;AAGkC,EAAA;AAC1B,IAAA;AACR,EAAA;AACD;AAES;AACM,EAAA;AACH,EAAA;AACM,EAAA;AAClB;AVgkFmB;AACA;AW5oGA;AAGH;AACC,EAAA;AACF,EAAA;AACA,EAAA;AAGD,EAAA;AACoB,EAAA;AACjB,EAAA;AACT,IAAA;AACO,IAAA;AACd,EAAA;AACO,EAAA;AACR;AAOgB;AACN,EAAA;AACO,EAAA;AACjB;AAGa;AACN,EAAA;AACA,EAAA;AACA,EAAA;AACQ,EAAA;AACC,EAAA;AACT,EAAA;AACD,EAAA;AACN;AAGa;AACD,EAAA;AACN,EAAA;AACC,EAAA;AACA,EAAA;AACC,EAAA;AACD,EAAA;AACE,EAAA;AACT;AAGa;AAGA;AACJ,EAAA;AACE,EAAA;AACF,EAAA;AACC,EAAA;AACD,EAAA;AACC,EAAA;AACM,EAAA;AACD,EAAA;AACA,EAAA;AACA,EAAA;AACF,EAAA;AACD,EAAA;AACN,EAAA;AACN;AAGa;AACJ,EAAA;AACO,EAAA;AACJ,EAAA;AACE,EAAA;AACA,EAAA;AACd;AAEgB;AACN,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACG,EAAA;AACC,EAAA;AACd;AAGgB;AACA,EAAA;AACN,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACI,EAAA;AACC,EAAA;AACG,EAAA;AAClB;AAqCgB;AACJ,EAAA;AACK,EAAA;AACF,EAAA;AACD,EAAA;AACH,EAAA;AACG,IAAA;AAEC,IAAA;AACA,IAAA;AACE,IAAA;AACD,IAAA;AACC,IAAA;AACD,IAAA;AACD,IAAA;AACJ,IAAA;AACV,EAAA;AACgB,EAAA;AACN,EAAA;AACE,IAAA;AACLC,IAAAA;AACO,IAAA;AACD,IAAA;AACb,EAAA;AACgB,EAAA;AACA,EAAA;AACA,EAAA;AACjB;AAGgB;AACJ,EAAA;AACM,EAAA;AACF,EAAA;AAChB;AAGgB;AACC,EAAA;AACJ,EAAA;AACb;AAGgB;AACD,EAAA;AACF,EAAA;AACC,EAAA;AACC,EAAA;AACF,EAAA;AACC,EAAA;AACd;AAWgB;AAEL,EAAA;AACO,EAAA;AACA,EAAA;AACA,EAAA;AACV,EAAA;AACR;AAMgB;AACJ,EAAA;AACC,IAAA;AACG,IAAA;AACA,IAAA;AACA,MAAA;AACA,MAAA;AACd,IAAA;AACe,IAAA;AACD,MAAA;AACF,MAAA;AACZ,IAAA;AACa,IAAA;AACA,MAAA;AACC,MAAA;AACT,MAAA;AACM,MAAA;AACV,MAAA;AACD,IAAA;AACW,IAAA;AACE,IAAA;AACV,IAAA;AACI,MAAA;AACI,MAAA;AACG,MAAA;AACd,IAAA;AACa,IAAA;AACJ,IAAA;AACT,EAAA;AACF;AAMmF;AACvE,EAAA;AACM,EAAA;AACA,EAAA;AACA,EAAA;AACD,EAAA;AACJ,EAAA;AACb;AAqBqB;AAGf;AAMU;AACJ,EAAA;AACM,EAAA;AACA,EAAA;AACH,EAAA;AACG,EAAA;AACA,EAAA;AACX,EAAA;AACW,EAAA;AACV,EAAA;AACO,IAAA;AACA,IAAA;AACE,IAAA;AACR,IAAA;AACN,MAAA;AACG,MAAA;AACS,MAAA;AACA,MAAA;AACD,MAAA;AACC,MAAA;AACZ,MAAA;AACW,MAAA;AACA,MAAA;AACZ,IAAA;AACA,EAAA;AACF;AAGS;AACF,EAAA;AACE,IAAA;AACF,EAAA;AACQ,EAAA;AACA,EAAA;AACf;AAMgB;AACJ,EAAA;AACK,EAAA;AACH,EAAA;AACR,EAAA;AACS,EAAA;AACD,EAAA;AACd;AAMgB;AACJ,EAAA;AACK,EAAA;AACH,EAAA;AACH,EAAA;AACO,EAAA;AACb,EAAA;AACS,IAAA;AACH,IAAA;AACK,MAAA;AACN,MAAA;AACR,IAAA;AACD,EAAA;AACU,EAAA;AACG,EAAA;AACd;AAMgB;AACa,EAAA;AACX,EAAA;AACL,IAAA;AACF,IAAA;AACI,IAAA;AACA,IAAA;AACC,IAAA;AACH,IAAA;AACZ,EAAA;AACO,EAAA;AACR;AX2gGmB;AACA;AYx3GJ;AAqBA;AACC;AACV;AACc;AAEJ;AACT,EAAA;AACY,EAAA;AAClB,EAAA;AACa,EAAA;AACb,EAAA;AACD;AAEY;AACM;AACC;AACA;AACJ;AACA;AACR,EAAA;AAEL,EAAA;AACF;AACa;AACN,EAAA;AACI,EAAA;AAET,EAAA;AACF;AACe;AACM;AAEH;AACjB,EAAA;AACY,IAAA;AACA,IAAA;AACI,IAAA;AACH,IAAA;AACb,EAAA;AACC,EAAA;AACD,EAAA;AACD;AACmB;AAClB,EAAA;AACe,IAAA;AACF,IAAA;AACC,IAAA;AACA,IAAA;AACC,IAAA;AACf,EAAA;AACS,EAAA;AACT,EAAA;AACD;AACkB;AACG;AACP;AACb,EAAA;AACW,IAAA;AACI,IAAA;AACf,EAAA;AACC,EAAA;AACD,EAAA;AACD;AACmB;AAEjB,EAAA;AACW,IAAA;AACE,IAAA;AACb,EAAA;AACS,EAAA;AACT,EAAA;AACD;AAC+C;AACjC,EAAA;AACG,EAAA;AACN,EAAA;AACE,EAAA;AACN,EAAA;AACI,EAAA;AACC,EAAA;AACG,EAAA;AACJ,EAAA;AACC,EAAA;AACb;AACiD;AACzC,EAAA;AACQ,EAAA;AACC,EAAA;AACD,EAAA;AACA,EAAA;AACC,EAAA;AACjB;AACgD;AACjC,EAAA;AACD,EAAA;AACD,EAAA;AACE,EAAA;AACA,EAAA;AACF,EAAA;AACb;AAC8C;AACjC,EAAA;AACF,EAAA;AACE,EAAA;AACb;AAC6B;AACf,EAAA;AACN,EAAA;AACS,IAAA;AACA,IAAA;AACA,IAAA;AAChB,EAAA;AACD;AAC6C;AAC9B,EAAA;AACC,EAAA;AACC,EAAA;AACH,EAAA;AACD,EAAA;AACb;AACiD;AAClC,EAAA;AACC,EAAA;AACC,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACjB;AACiD;AACnC,EAAA;AACC,EAAA;AACF,EAAA;AACG,EAAA;AACC,EAAA;AACJ,EAAA;AACI,EAAA;AACjB;AAEqB;AACd,EAAA;AACI,EAAA;AACG,EAAA;AACd;AACyB;AAClB,EAAA;AACI,EAAA;AACA,EAAA;AACG,EAAA;AACN,EAAA;AACN,IAAA;AACY,MAAA;AACA,MAAA;AACL,MAAA;AACL,QAAA;AACO,QAAA;AACE,UAAA;AACA,UAAA;AACT,QAAA;AACD,MAAA;AACD,IAAA;AACS,IAAA;AACT,IAAA;AACD,EAAA;AACD;AAEqB;AACL,EAAA;AACE,EAAA;AACH,EAAA;AACA,EAAA;AACf;AAGM;AACG,EAAA;AACD,EAAA;AACR;AAGM;AAEE,EAAA;AACE,EAAA;AACR,EAAA;AACe,EAAA;AACC,EAAA;AACC,EAAA;AACA,EAAA;AACX,EAAA;AACW,EAAA;AACV,EAAA;AACN;AAGG;AACwC,EAAA;AAE7C;AAGe;AACD,EAAA;AACP,EAAA;AACO,IAAA;AACA,IAAA;AACH,IAAA;AACC,IAAA;AACJ,IAAA;AACC,IAAA;AACM,IAAA;AACC,IAAA;AAChB,EAAA;AACD;AAGgB;AACR,EAAA;AACM,IAAA;AACG,IAAA;AACP,IAAA;AACK,IAAA;AACH,IAAA;AACE,IAAA;AACJ,IAAA;AACA,IAAA;AACI,IAAA;AACH,IAAA;AACV,EAAA;AACD;AAWgB;AACT,EAAA;AACK,IAAA;AACI,IAAA;AACP,IAAA;AACR,EAAA;AACgB,EAAA;AACC,EAAA;AACA,IAAA;AACJ,IAAA;AACb,EAAA;AACe,EAAA;AACC,IAAA;AACA,IAAA;AAChB,EAAA;AACiB,EAAA;AACL,IAAA;AACE,MAAA;AACL,MAAA;AACR,IAAA;AACO,IAAA;AACR,EAAA;AAGgB,EAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACC,IAAA;AACjB,EAAA;AACiB,EAAA;AACN,IAAA;AACkC,IAAA;AAC9B,IAAA;AACf,EAAA;AAEM,EAAA;AACC,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACE,IAAA;AACA,IAA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ACE,YAAA;AACA,YAAA;AACF,YAAA;AACA,YAAA;AACE,UAAA;AACA,UAAA;AACE,UAAA;AACV,QAAA;AACD,MAAA;AACD,IAAA;AACA,EAAA;AACF;Ab6/ImB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/editor/editor/packages/core/dist/video-editor/index.cjs","sourcesContent":[null,"import { VIDEO_GRADE_CONTROLS } from './grade.js';\nimport { checkVideoTimeRemap } from './remap.js';\nimport { VIDEO_EASE_NAMES, type VideoAnimatable, type VideoEase, type VideoKey, type VideoKeyframes } from './keyframes.js';\nimport { VIDEO_BLEND_MODES, VIDEO_CLIP_ANIMATION_EFFECTS, VIDEO_CLIP_TYPES, VIDEO_FADE_CURVES, VIDEO_FITS, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoAsset, type VideoAssetProxy, type VideoClip, type VideoClipEffect, type VideoEffectRef, type VideoEffectValues, type VideoClipAnimation, type VideoGrade, type VideoGradeLut, type VideoMarker, type VideoProject, type VideoSequence, type VideoTrack, type VideoTranscriptWord, type VideoTransform, type VideoTransition } from './types.js';\n\nconst isObject = (v: unknown): v is Record<string, any> => typeof v === 'object' && v !== null && !Array.isArray(v);\nconst fail = (path: string, what: string): never => {\n\tthrow new Error(`${path} ${what}.`);\n};\nconst str = (v: unknown, path: string, optional = false): string | undefined => {\n\tif (v === undefined && optional) return undefined;\n\tif (typeof v !== 'string' || (!optional && !v.length)) fail(path, 'must be a non-empty string');\n\treturn v as string;\n};\nconst num = (v: unknown, path: string, { min, positive, optional, max }: { min?: number; positive?: boolean; optional?: boolean; max?: number } = {}): number | undefined => {\n\tif (v === undefined && optional) return undefined;\n\tif (typeof v !== 'number' || !Number.isFinite(v)) return fail(path, 'must be a number');\n\tif (positive && !(v > 0)) fail(path, 'must be greater than 0');\n\tif (min !== undefined && v < min) fail(path, `must be at least ${min}`);\n\tif (max !== undefined && v > max) fail(path, `must be at most ${max}`);\n\treturn v;\n};\nconst bool = (v: unknown, path: string): boolean | undefined => {\n\tif (v === undefined) return undefined;\n\tif (typeof v !== 'boolean') fail(path, 'must be true or false');\n\treturn v as boolean;\n};\nconst oneOf = <T extends string>(v: unknown, options: readonly T[], path: string, optional = true): T | undefined => {\n\tif (v === undefined && optional) return undefined;\n\tif (!options.includes(v as T)) fail(path, `must be one of ${options.map((o) => `\"${o}\"`).join(', ')}`);\n\treturn v as T;\n};\n/** Copies only the defined keys, so parsed objects carry no `undefined` fields. */\nconst compact = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;\n\nfunction parseVideoEase(v: unknown, path: string): VideoEase | undefined {\n\tif (v === undefined) return undefined;\n\tif (Array.isArray(v)) {\n\t\tif (v.length !== 4 || !v.every((n) => typeof n === 'number' && Number.isFinite(n))) fail(path, 'must be four numbers [x1, y1, x2, y2] (CSS cubic-bezier)');\n\t\tif (v[0] < 0 || v[0] > 1 || v[2] < 0 || v[2] > 1) fail(path, 'must have x1 and x2 between 0 and 1');\n\t\treturn [v[0], v[1], v[2], v[3]];\n\t}\n\treturn oneOf(v, VIDEO_EASE_NAMES, path)! as VideoEase;\n}\n\n/**\n * Keyframes `{ keys: [{ t, v, ease }] }`: at least one key, in strictly increasing time (clip-relative\n * ms), each value within `range`.\n */\nexport function parseVideoKeyframes(v: unknown, path: string, { min, max }: { min?: number; max?: number } = {}): VideoKeyframes {\n\tif (!isObject(v)) return fail(path, 'must be a number or keyframes { keys: [{ t, v, ease }] }');\n\tfor (const key of Object.keys(v)) if (key !== 'keys') fail(`${path}.${key}`, 'is not a keyframes field (keys)');\n\tif (!Array.isArray(v.keys) || !v.keys.length) fail(`${path}.keys`, 'must be an array of at least one key { t, v, ease }');\n\tconst keys = (v.keys as unknown[]).map((k, i): VideoKey => {\n\t\tconst at = `${path}.keys[${i}]`;\n\t\tif (!isObject(k)) return fail(at, 'must be an object { t, v, ease }');\n\t\tfor (const key of Object.keys(k)) if (!['t', 'v', 'ease'].includes(key)) fail(`${at}.${key}`, 'is not a key field (t, v, ease)');\n\t\treturn compact({ t: num(k.t, `${at}.t`)!, v: num(k.v, `${at}.v`, { min, max })!, ease: parseVideoEase(k.ease, `${at}.ease`) });\n\t});\n\tfor (let i = 1; i < keys.length; i++) if (!(keys[i].t > keys[i - 1].t)) fail(`${path}.keys[${i}].t`, `must be later than the key before (${keys[i - 1].t} ms); keys go in time order, one per time`);\n\treturn { keys };\n}\n\n/** A number within `range`, or keyframes for one. */\nexport function parseVideoAnimatable(v: unknown, path: string, range: { min?: number; max?: number; optional?: boolean } = {}): VideoAnimatable | undefined {\n\tif (v === undefined && range.optional) return undefined;\n\tconst { min, max } = range;\n\tif (isObject(v)) return parseVideoKeyframes(v, path, { min, max });\n\treturn num(v, path, { min, max });\n}\n\nexport function parseVideoTransform(v: unknown, path: string): VideoTransform | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { x, y, scale, rotation }');\n\tfor (const key of Object.keys(v)) if (!['x', 'y', 'scale', 'rotation'].includes(key)) fail(`${path}.${key}`, 'is not a transform field (x, y, scale, rotation)');\n\treturn compact({\n\t\tx: parseVideoAnimatable(v.x, `${path}.x`, { optional: true }),\n\t\ty: parseVideoAnimatable(v.y, `${path}.y`, { optional: true }),\n\t\tscale: parseVideoAnimatable(v.scale, `${path}.scale`, { optional: true, min: 0 }),\n\t\trotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })\n\t});\n}\n\nexport function parseVideoTimeRemap(v: unknown, speed: unknown, path: string): VideoKeyframes | undefined {\n\tif (v === undefined) return undefined;\n\tconst remap = parseVideoAnimatable(v, path, { optional: true, min: 0 });\n\tif (typeof remap === 'number' || remap === undefined) return fail(path, 'must be keyframes { keys: [{ t, v, ease }] } (v: ms of source after the in point)');\n\tif (speed !== undefined && speed !== 1) fail(path, 'cannot be used with a speed; a ramp sets the speed itself (remove \"speed\")');\n\ttry {\n\t\tcheckVideoTimeRemap(remap, path);\n\t} catch (e) {\n\t\tthrow new Error((e as Error).message.replace(/\\.?$/, '.'));\n\t}\n\treturn remap;\n}\n\nexport function parseVideoClipAnimation(v: unknown, path: string): VideoClipAnimation | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { effect, duration }');\n\treturn compact({\n\t\teffect: oneOf(v.effect, VIDEO_CLIP_ANIMATION_EFFECTS, `${path}.effect`, false)!,\n\t\tduration: num(v.duration, `${path}.duration`, { optional: true, positive: true })\n\t});\n}\n\nexport function parseVideoGrade(v: unknown, path: string): VideoGrade | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object such as { exposure, contrast, saturation }');\n\tconst known = new Set([...VIDEO_GRADE_CONTROLS.map((c) => c.key as string), 'lut']);\n\tconst unknown = Object.keys(v).find((k) => !known.has(k));\n\tif (unknown) fail(`${path}.${unknown}`, `is not a grade setting; use ${[...known].join(', ')}`);\n\treturn compact({ ...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])), lut: parseVideoGradeLut(v.lut, `${path}.lut`) }) as VideoGrade;\n}\n\nexport function parseVideoGradeLut(v: unknown, path: string): VideoGradeLut | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { src, strength } naming a .cube file');\n\tconst unknown = Object.keys(v).find((k) => !['src', 'strength', 'name'].includes(k));\n\tif (unknown) fail(`${path}.${unknown}`, 'is not a LUT setting; use src, strength, name');\n\treturn compact({ src: str(v.src, `${path}.src`)!, strength: num(v.strength, `${path}.strength`, { optional: true, min: 0, max: 1 }), name: str(v.name, `${path}.name`, true) });\n}\n\nexport function parseVideoTransition(v: unknown, path: string): VideoTransition | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object { type, duration }');\n\tconst type = oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false)!;\n\tif (type === 'effect' && v.effect === undefined) fail(`${path}.effect`, 'is required for an effect transition: { effect: \"<effect id>\", values }');\n\tif (type !== 'effect' && v.effect !== undefined) fail(`${path}.effect`, 'is only for transitions of type \"effect\"');\n\treturn compact({\n\t\ttype,\n\t\tduration: num(v.duration, `${path}.duration`, { positive: true })!,\n\t\tcolor: str(v.color, `${path}.color`, true),\n\t\tdirection: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`),\n\t\taudioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`),\n\t\teffect: v.effect === undefined ? undefined : parseVideoEffectRef(v.effect, `${path}.effect`, false)\n\t});\n}\n\nconst isEffectValue = (x: unknown) =>\n\ttypeof x === 'string' || typeof x === 'boolean' || (typeof x === 'number' && Number.isFinite(x)) || (Array.isArray(x) && x.every((n) => typeof n === 'number' && Number.isFinite(n)));\n\n/**\n * Effect control values: plain values (numbers, strings, switches, number lists) or, when\n * `keyframes` is allowed, keyframes for numbers. Only their shape is checked here; the effect's\n * control schema needs the provider (`resolveEffectValues`).\n */\nexport function parseVideoEffectValues(v: unknown, path: string, keyframes = true): VideoEffectValues | undefined {\n\tif (v === undefined) return undefined;\n\tif (!isObject(v)) return fail(path, 'must be an object of control values from describe_effect');\n\tconst out: VideoEffectValues = {};\n\tfor (const [k, x] of Object.entries(v)) {\n\t\tif (isEffectValue(x)) out[k] = Array.isArray(x) ? [...x] : x;\n\t\telse if (keyframes && isObject(x)) out[k] = parseVideoKeyframes(x, `${path}.${k}`);\n\t\telse fail(`${path}.${k}`, keyframes ? 'must be a number, string, true / false, a list of numbers, or keyframes { keys }' : 'must be a number, string, true / false or a list of numbers');\n\t}\n\treturn out;\n}\n\n/** `{ effect, provider, name, values }`. */\nexport function parseVideoEffectRef(v: unknown, path: string, keyframes = true): VideoEffectRef {\n\tif (!isObject(v)) return fail(path, 'must be an object { effect, values }');\n\tconst unknown = Object.keys(v).find((k) => !['effect', 'provider', 'name', 'values'].includes(k));\n\tif (unknown) fail(`${path}.${unknown}`, 'is not an effect field; use effect, provider, name, values');\n\treturn compact({ effect: str(v.effect, `${path}.effect`)!, provider: str(v.provider, `${path}.provider`, true), name: str(v.name, `${path}.name`, true), values: parseVideoEffectValues(v.values, `${path}.values`, keyframes) });\n}\n\n/** A clip's effect stack: unique ids, each `{ id, effect, provider, name, enabled, opacity, values }`. */\nexport function parseVideoClipEffects(v: unknown, path: string): VideoClipEffect[] | undefined {\n\tif (v === undefined) return undefined;\n\tif (!Array.isArray(v)) return fail(path, 'must be a list of effects { id, effect, values }');\n\tconst ids = new Set<string>();\n\tconst out = v.map((e, i): VideoClipEffect => {\n\t\tconst at = `${path}[${i}]`;\n\t\tif (!isObject(e)) return fail(at, 'must be an object { id, effect, values }');\n\t\tconst unknown = Object.keys(e).find((k) => !['id', 'effect', 'provider', 'name', 'values', 'enabled', 'opacity'].includes(k));\n\t\tif (unknown) fail(`${at}.${unknown}`, 'is not a clip effect field; use id, effect, provider, name, values, enabled, opacity');\n\t\tconst id = str(e.id, `${at}.id`)!;\n\t\tif (ids.has(id)) fail(`${at}.id`, `repeats \"${id}\"; each effect on a clip needs its own id`);\n\t\tids.add(id);\n\t\tconst { effect, provider, name, values } = parseVideoEffectRef({ effect: e.effect, provider: e.provider, name: e.name, values: e.values }, at);\n\t\treturn compact({ id, effect, provider, name, values, enabled: bool(e.enabled, `${at}.enabled`), opacity: parseVideoAnimatable(e.opacity, `${at}.opacity`, { optional: true, min: 0, max: 1 }) });\n\t});\n\treturn out.length ? out : undefined;\n}\n\nexport function parseVideoTranscriptWords(v: unknown, path: string): VideoTranscriptWord[] | undefined {\n\tif (v === undefined) return undefined;\n\tif (!Array.isArray(v)) return fail(path, 'must be an array of { word, start, end } (ms in the source)');\n\tconst words = v.map((w, i) => {\n\t\tif (!isObject(w)) return fail(`${path}[${i}]`, 'must be an object { word, start, end }');\n\t\tconst word = str(w.word, `${path}[${i}].word`)!;\n\t\tconst start = num(w.start, `${path}[${i}].start`, { min: 0 })!;\n\t\tconst end = num(w.end, `${path}[${i}].end`, { min: start })!;\n\t\treturn { word, start, end };\n\t});\n\treturn words.sort((a, b) => a.start - b.start);\n}\n\nexport function parseVideoAsset(v: unknown, path = 'asset'): VideoAsset {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\tconst kind = oneOf(v.kind, ['video', 'audio', 'image'] as const, `${path}.kind`, false)!;\n\tconst asset: VideoAsset = compact({\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tkind,\n\t\tsrc: str(v.src, `${path}.src`)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\tduration: num(v.duration, `${path}.duration`, { optional: true, positive: true }),\n\t\twidth: num(v.width, `${path}.width`, { optional: true, positive: true }),\n\t\theight: num(v.height, `${path}.height`, { optional: true, positive: true }),\n\t\thasAudio: bool(v.hasAudio, `${path}.hasAudio`),\n\t\tposter: str(v.poster, `${path}.poster`, true),\n\t\ttranscript: parseVideoTranscriptWords(v.transcript, `${path}.transcript`),\n\t\tproxies: parseVideoAssetProxies(v.proxies, `${path}.proxies`)\n\t});\n\tif (kind !== 'video' && asset.proxies) fail(`${path}.proxies`, 'are only for video assets');\n\tif (kind === 'image' && asset.transcript) fail(`${path}.transcript`, 'is only for video and audio assets');\n\tif (kind === 'image' && asset.duration !== undefined) fail(`${path}.duration`, 'is only for video and audio assets');\n\treturn asset;\n}\n\nexport function parseVideoAssetProxies(v: unknown, path: string): VideoAssetProxy[] | undefined {\n\tif (v === undefined) return undefined;\n\tif (!Array.isArray(v)) return fail(path, 'must be a list of { scale, src }');\n\tconst out = v.map((p, i) => {\n\t\tconst at = `${path}[${i}]`;\n\t\tif (!isObject(p)) return fail(at, 'must be an object { scale, src }');\n\t\tconst unknown = Object.keys(p).find((k) => !['scale', 'src', 'width', 'height'].includes(k));\n\t\tif (unknown) fail(`${at}.${unknown}`, 'is not a proxy setting; use scale, src, width, height');\n\t\tconst scale = num(p.scale, `${at}.scale`, { positive: true })!;\n\t\tif (scale >= 1) fail(`${at}.scale`, 'must be less than 1 (0.5 is half size)');\n\t\treturn 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 }) });\n\t});\n\tconst scales = new Set<number>();\n\tfor (const [i, p] of out.entries()) {\n\t\tif (scales.has(p.scale)) fail(`${path}[${i}].scale`, `repeats ${p.scale}; give each proxy its own scale`);\n\t\tscales.add(p.scale);\n\t}\n\treturn out.length ? out : undefined;\n}\n\nexport function parseVideoClip(v: unknown, path = 'clip'): VideoClip {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\tconst type = oneOf(v.type ?? 'media', VIDEO_CLIP_TYPES, `${path}.type`, false)!;\n\tconst base = {\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\tstart: num(v.start, `${path}.start`, { min: 0 })!,\n\t\tduration: num(v.duration, `${path}.duration`, { positive: true })!,\n\t\topacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),\n\t\ttransform: parseVideoTransform(v.transform, `${path}.transform`),\n\t\tfit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),\n\t\tblend: oneOf(v.blend, VIDEO_BLEND_MODES, `${path}.blend`),\n\t\tgrade: parseVideoGrade(v.grade, `${path}.grade`),\n\t\tanimateIn: parseVideoClipAnimation(v.animateIn, `${path}.animateIn`),\n\t\tanimateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),\n\t\tfadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),\n\t\tfadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),\n\t\ttransition: parseVideoTransition(v.transition, `${path}.transition`),\n\t\teffects: parseVideoClipEffects(v.effects, `${path}.effects`)\n\t};\n\tif (type === 'media')\n\t\treturn compact({\n\t\t\ttype,\n\t\t\t...base,\n\t\t\tasset: str(v.asset, `${path}.asset`)!,\n\t\t\tin: num(v.in, `${path}.in`, { optional: true, min: 0 }),\n\t\t\tspeed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),\n\t\t\tvolume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),\n\t\t\tmuted: bool(v.muted, `${path}.muted`),\n\t\t\ttimeRemap: parseVideoTimeRemap(v.timeRemap, v.speed, `${path}.timeRemap`),\n\t\t\tfadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),\n\t\t\tfadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)\n\t\t});\n\tif (type === 'sequence') {\n\t\t// The sound and pictures of a sequence are not re-timed yet, so a compound plays at normal speed.\n\t\tfor (const key of ['speed', 'timeRemap'])\n\t\t\tif (v[key] !== undefined && !(key === 'speed' && v[key] === 1)) fail(`${path}.${key}`, 'is not supported on a compound clip (type \"sequence\"), which plays its sequence at normal speed; remove it, or change the speed of the clips inside the sequence');\n\t\treturn compact({\n\t\t\ttype,\n\t\t\t...base,\n\t\t\tsequence: str(v.sequence, `${path}.sequence`)!,\n\t\t\tin: num(v.in, `${path}.in`, { optional: true, min: 0 }),\n\t\t\tvolume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),\n\t\t\tmuted: bool(v.muted, `${path}.muted`),\n\t\t\tfadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),\n\t\t\tfadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)\n\t\t});\n\t}\n\tif (type === 'text')\n\t\treturn compact({\n\t\t\ttype,\n\t\t\t...base,\n\t\t\ttext: str(v.text, `${path}.text`)!,\n\t\t\tcolor: str(v.color, `${path}.color`, true),\n\t\t\tfontSize: num(v.fontSize, `${path}.fontSize`, { optional: true, positive: true }),\n\t\t\tfontFamily: str(v.fontFamily, `${path}.fontFamily`, true),\n\t\t\tfontWeight: num(v.fontWeight, `${path}.fontWeight`, { optional: true, min: 100, max: 1000 }),\n\t\t\tbackground: str(v.background, `${path}.background`, true)\n\t\t});\n\tif (type === 'effect') {\n\t\tconst ref = parseVideoEffectRef({ effect: v.effect, provider: v.provider, name: v.name === undefined ? undefined : v.name, values: v.values }, path);\n\t\treturn compact({ type, ...base, ...ref, name: base.name });\n\t}\n\treturn compact({ type, ...base, color: str(v.color, `${path}.color`)! });\n}\n\nexport function parseVideoTrack(v: unknown, path = 'track'): VideoTrack {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\tif (v.clips !== undefined && !Array.isArray(v.clips)) fail(`${path}.clips`, 'must be an array');\n\treturn compact({\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tkind: oneOf(v.kind, ['video', 'audio'] as const, `${path}.kind`, false)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\tmuted: bool(v.muted, `${path}.muted`),\n\t\thidden: bool(v.hidden, `${path}.hidden`),\n\t\tlocked: bool(v.locked, `${path}.locked`),\n\t\tsyncLocked: bool(v.syncLocked, `${path}.syncLocked`),\n\t\tvolume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),\n\t\tclips: ((v.clips ?? []) as unknown[]).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))\n\t});\n}\n\n/** A nested sequence `{ id, name, tracks }` (see `VideoSequence`). */\nexport function parseVideoSequence(v: unknown, path = 'sequence'): VideoSequence {\n\tif (!isObject(v)) return fail(path, 'must be an object { id, name, tracks }');\n\tif (v.tracks !== undefined && !Array.isArray(v.tracks)) fail(`${path}.tracks`, 'must be an array');\n\tconst sequence: VideoSequence = compact({\n\t\tid: str(v.id, `${path}.id`)!,\n\t\tname: str(v.name, `${path}.name`, true),\n\t\ttracks: ((v.tracks ?? []) as unknown[]).map((t, i) => parseVideoTrack(t, `${path}.tracks[${i}]`))\n\t});\n\tconst seen = new Set<string>();\n\tfor (const t of sequence.tracks) {\n\t\tif (seen.has(t.id)) throw new Error(`Two tracks in sequence \"${sequence.id}\" share the id \"${t.id}\"; ids must be unique.`);\n\t\tseen.add(t.id);\n\t}\n\treturn sequence;\n}\n\nexport function parseVideoMarker(v: unknown, path = 'marker'): VideoMarker {\n\tif (!isObject(v)) return fail(path, 'must be an object');\n\treturn compact({ id: str(v.id, `${path}.id`)!, time: num(v.time, `${path}.time`, { min: 0 })!, label: str(v.label, `${path}.label`, true), color: str(v.color, `${path}.color`, true) });\n}\n\n/**\n * Reads a project from JSON text or an object, checking every field. Throws an error naming the\n * first bad field (`tracks[0].clips[2].duration must be greater than 0.`). Ids must be unique\n * within assets, tracks, sequences, clips (across the project and its sequences) and markers.\n * Overlaps, missing media and sequences, and sequences that contain themselves are left to\n * `checkVideoProject`, so a project with problems can still be opened and fixed.\n */\nexport function parseVideoProject(input: unknown): VideoProject {\n\tlet v = input;\n\tif (typeof v === 'string') {\n\t\ttry {\n\t\t\tv = JSON.parse(v);\n\t\t} catch (error) {\n\t\t\tthrow new Error(`The project is not valid JSON: ${(error as Error).message}`);\n\t\t}\n\t}\n\tif (!isObject(v)) return fail('The project', 'must be an object');\n\tif (v.version !== undefined && v.version !== 1) fail('version', 'must be 1');\n\tfor (const key of ['assets', 'tracks', 'sequences', 'markers']) if (v[key] !== undefined && !Array.isArray(v[key])) fail(key, 'must be an array');\n\tconst project: VideoProject = compact({\n\t\tversion: 1 as const,\n\t\tname: str(v.name, 'name', true),\n\t\twidth: num(v.width, 'width', { positive: true })!,\n\t\theight: num(v.height, 'height', { positive: true })!,\n\t\tfps: num(v.fps ?? 30, 'fps', { positive: true, max: 240 })!,\n\t\tbackground: str(v.background, 'background', true),\n\t\tassets: ((v.assets ?? []) as unknown[]).map((a, i) => parseVideoAsset(a, `assets[${i}]`)),\n\t\ttracks: ((v.tracks ?? []) as unknown[]).map((t, i) => parseVideoTrack(t, `tracks[${i}]`)),\n\t\tsequences: v.sequences === undefined ? undefined : (v.sequences as unknown[]).map((q, i) => parseVideoSequence(q, `sequences[${i}]`)),\n\t\tmarkers: v.markers === undefined ? undefined : (v.markers as unknown[]).map((m, i) => parseVideoMarker(m, `markers[${i}]`))\n\t});\n\tconst unique = (ids: string[], what: string) => {\n\t\tconst seen = new Set<string>();\n\t\tfor (const id of ids) {\n\t\t\tif (seen.has(id)) throw new Error(`Two ${what} share the id \"${id}\"; ids must be unique.`);\n\t\t\tseen.add(id);\n\t\t}\n\t};\n\tunique(project.assets.map((a) => a.id), 'assets');\n\tunique(project.tracks.map((t) => t.id), 'tracks');\n\tunique((project.sequences ?? []).map((q) => q.id), 'sequences');\n\t// Clip ids are unique across the project and every sequence, so an id names one clip.\n\tunique([...project.tracks, ...(project.sequences ?? []).flatMap((q) => q.tracks)].flatMap((t) => t.clips.map((c) => c.id)), 'clips');\n\tunique((project.markers ?? []).map((m) => m.id), 'markers');\n\treturn project;\n}\n\nexport interface CreateVideoProjectOptions {\n\tname?: string;\n\t/** Default 1920. */\n\twidth?: number;\n\t/** Default 1080. */\n\theight?: number;\n\t/** Default 30. */\n\tfps?: number;\n\tbackground?: string;\n}\n\n/** An empty project with one video track (`v1`) and one audio track (`a1`). */\nexport function createVideoProject(options: CreateVideoProjectOptions = {}): VideoProject {\n\treturn compact({\n\t\tversion: 1 as const,\n\t\tname: options.name,\n\t\twidth: options.width ?? 1920,\n\t\theight: options.height ?? 1080,\n\t\tfps: options.fps ?? 30,\n\t\tbackground: options.background,\n\t\tassets: [],\n\t\ttracks: [\n\t\t\t{ id: 'v1', kind: 'video' as const, name: 'Video 1', clips: [] },\n\t\t\t{ id: 'a1', kind: 'audio' as const, name: 'Audio 1', clips: [] }\n\t\t]\n\t});\n}\n","import { findVideoSequenceCycle, formatVideoSeconds, sortedVideoClips, VIDEO_TIME_EPSILON, videoAssetMap, videoClipBefore, videoClipEnd, videoClipHandles, videoClipSourceRange, videoProjectDuration, videoSequenceDuration, videoSequenceMap } from './timeline.js';\nimport { VIDEO_CLIP_ANIMATION_DURATION, videoClipAnimationProperties } from './clip-animation.js';\nimport { VIDEO_TRANSFORM_PROPERTIES, videoKeysOf } from './keyframes.js';\nimport type { VideoClip, VideoProject, VideoTrack } from './types.js';\n\n/** A clip's keyframed properties (`opacity`, `volume`, `x`…) with their keys. */\nexport function videoClipKeyframes(clip: VideoClip): { property: string; keys: ReturnType<typeof videoKeysOf> }[] {\n\tconst out = [\n\t\t{ property: 'opacity', keys: videoKeysOf(clip.opacity) },\n\t\t{ property: 'volume', keys: clip.type === 'media' || clip.type === 'sequence' ? videoKeysOf(clip.volume) : [] },\n\t\t...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p as string, keys: videoKeysOf(clip.transform?.[p]) }))\n\t];\n\treturn out.filter((o) => o.keys.length);\n}\n\nexport type VideoIssueSeverity = 'error' | 'warning' | 'note';\n\nexport const VIDEO_ISSUE_CODES = [\n\t'missing-asset',\n\t'missing-sequence',\n\t'sequence-cycle',\n\t'wrong-track',\n\t'overlap',\n\t'source-overrun',\n\t'transition-without-cut',\n\t'transition-too-long',\n\t'transition-handles',\n\t'fades-too-long',\n\t'animations-too-long',\n\t'animation-keyed',\n\t'keyframes-outside-clip',\n\t'unknown-duration',\n\t'unused-asset',\n\t'unused-sequence',\n\t'marker-past-end',\n\t'empty'\n] as const;\nexport type VideoIssueCode = (typeof VIDEO_ISSUE_CODES)[number];\n\nexport interface VideoIssue {\n\tcode: VideoIssueCode;\n\tseverity: VideoIssueSeverity;\n\t/** One sentence saying what is wrong and how to fix it. */\n\tmessage: string;\n\t/** The sequence whose track it is about (absent for the project's own tracks). */\n\tsequence?: string;\n\ttrack?: string;\n\tclip?: string;\n\tasset?: string;\n\tmarker?: string;\n}\n\n/** A stable key for an issue (code and what it is about), to tell new problems from old ones. */\nexport const videoIssueKey = (i: VideoIssue): string => [i.code, i.sequence ?? '', i.track ?? '', i.clip ?? '', i.asset ?? '', i.marker ?? ''].join('|');\n\n/**\n * Everything wrong with a project, errors first: clips pointing at missing media or sitting on the\n * wrong kind of track, overlaps, clips running past the end of their source, transitions with no\n * cut to play over or not enough media either side of it, and softer notes (unused assets, markers\n * past the end). Sequences' tracks are checked the same way, and compound clips must play a\n * sequence that exists, does not contain itself and is long enough. Edits never add errors (see `applyVideoEdits`), so errors only come from a loaded file.\n */\nexport function checkVideoProject(project: VideoProject): VideoIssue[] {\n\tconst issues: VideoIssue[] = [];\n\tconst assets = videoAssetMap(project);\n\tconst sequences = videoSequenceMap(project);\n\tconst used = new Set<string>();\n\tconst usedSequences = new Set<string>();\n\tconst cycles = new Map<string, string[] | null>();\n\tfor (const asset of project.assets)\n\t\tif ((asset.kind === 'video' || asset.kind === 'audio') && asset.duration === undefined)\n\t\t\tissues.push({ code: 'unknown-duration', severity: 'warning', asset: asset.id, message: `Asset \"${asset.id}\" has no duration, so clips of it cannot be checked against its length; set \"duration\" in ms.` });\n\tconst checkTrack = (track: VideoTrack, sequence?: string) => {\n\t\tconst clips = sortedVideoClips(track);\n\t\tconst where = sequence ? ` in sequence \"${sequence}\"` : '';\n\t\tfor (let i = 0; i < clips.length; i++) {\n\t\t\tconst clip = clips[i];\n\t\t\tconst at = { ...(sequence ? { sequence } : {}), track: track.id, clip: clip.id };\n\t\t\tif (clip.type === 'sequence') {\n\t\t\t\tusedSequences.add(clip.sequence);\n\t\t\t\tconst seq = sequences.get(clip.sequence);\n\t\t\t\tif (!seq) issues.push({ code: 'missing-sequence', severity: 'error', ...at, message: `Compound clip \"${clip.id}\" plays sequence \"${clip.sequence}\", which is not in the project; add the sequence or delete the clip.` });\n\t\t\t\telse {\n\t\t\t\t\tif (!cycles.has(seq.id)) cycles.set(seq.id, findVideoSequenceCycle(project, seq.id));\n\t\t\t\t\tconst cycle = cycles.get(seq.id);\n\t\t\t\t\tif (cycle) issues.push({ code: 'sequence-cycle', severity: 'error', ...at, message: `Compound clip \"${clip.id}\" plays sequence \"${seq.id}\", which contains itself (${cycle.join(' → ')}), so it is left out of the picture and sound; remove the compound clip that closes the loop.` });\n\t\t\t\t\tconst length = videoSequenceDuration(seq);\n\t\t\t\t\tconst range = videoClipSourceRange(clip)!;\n\t\t\t\t\tif (range.out > length + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\tissues.push({ code: 'source-overrun', severity: 'error', ...at, message: `Compound clip \"${clip.id}\" plays to ${formatVideoSeconds(range.out)} of sequence \"${seq.id}\", which is only ${formatVideoSeconds(length)} long; shorten the clip or move its in point.` });\n\t\t\t\t}\n\t\t\t} else if (clip.type === 'media') {\n\t\t\t\tused.add(clip.asset);\n\t\t\t\tconst asset = assets.get(clip.asset);\n\t\t\t\tif (!asset) issues.push({ code: 'missing-asset', severity: 'error', ...at, asset: clip.asset, message: `Clip \"${clip.id}\" plays asset \"${clip.asset}\", which is not in the project; add the asset or delete the clip.` });\n\t\t\t\telse {\n\t\t\t\t\tif (track.kind === 'audio' && asset.kind === 'image') issues.push({ code: 'wrong-track', severity: 'error', ...at, message: `Clip \"${clip.id}\" is an image on audio track \"${track.id}\"; move it to a video track.` });\n\t\t\t\t\tif (track.kind === 'video' && asset.kind === 'audio') issues.push({ code: 'wrong-track', severity: 'error', ...at, message: `Clip \"${clip.id}\" is audio on video track \"${track.id}\"; move it to an audio track.` });\n\t\t\t\t\tconst range = videoClipSourceRange(clip)!;\n\t\t\t\t\tif (asset.duration !== undefined && range.out > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\tissues.push({ code: 'source-overrun', severity: 'error', ...at, message: `Clip \"${clip.id}\" plays to ${formatVideoSeconds(range.out)} of \"${asset.id}\", which is only ${formatVideoSeconds(asset.duration)} long; shorten the clip or move its in point.` });\n\t\t\t\t}\n\t\t\t} else if (track.kind === 'audio') issues.push({ code: 'wrong-track', severity: 'error', ...at, message: `Clip \"${clip.id}\" is a ${clip.type} clip on audio track \"${track.id}\"; move it to a video track.` });\n\t\t\tconst next = clips[i + 1];\n\t\t\tif (next && next.start < videoClipEnd(clip) - VIDEO_TIME_EPSILON)\n\t\t\t\tissues.push({ code: 'overlap', severity: 'error', ...at, message: `Clips \"${clip.id}\" and \"${next.id}\" overlap on track \"${track.id}\"${where} from ${formatVideoSeconds(next.start)}; move one of them.` });\n\t\t\tif ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)\n\t\t\t\tissues.push({ code: 'fades-too-long', severity: 'warning', ...at, message: `Clip \"${clip.id}\"'s fades (${clip.fadeIn ?? 0} + ${clip.fadeOut ?? 0} ms) are longer than the clip (${clip.duration} ms); shorten them.` });\n\t\t\tconst anim = (clip.animateIn ? (clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION) : 0) + (clip.animateOut ? (clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION) : 0);\n\t\t\tif (anim > clip.duration + VIDEO_TIME_EPSILON)\n\t\t\t\tissues.push({ code: 'animations-too-long', severity: 'warning', ...at, message: `Clip \"${clip.id}\"'s entrance and exit (${anim} ms together) are longer than the clip (${clip.duration} ms), so both are shortened; give them shorter durations.` });\n\t\t\tfor (const which of ['animateIn', 'animateOut'] as const) {\n\t\t\t\tconst a = clip[which];\n\t\t\t\tif (!a) continue;\n\t\t\t\tconst keyed = videoClipAnimationProperties(a).filter((p) => videoKeysOf(p === 'opacity' ? clip.opacity : clip.transform?.[p]).length);\n\t\t\t\tif (keyed.length)\n\t\t\t\t\tissues.push({ code: 'animation-keyed', severity: 'note', ...at, message: `Clip \"${clip.id}\"'s ${a.effect} ${which === 'animateIn' ? 'entrance' : 'exit'} leaves ${keyed.join(', ')} alone, because the clip keys ${keyed.length > 1 ? 'them' : 'it'} itself; remove those keyframes or the animation.` });\n\t\t\t}\n\t\t\tfor (const { property, keys } of videoClipKeyframes(clip)) {\n\t\t\t\tconst outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);\n\t\t\t\tif (outside.length)\n\t\t\t\t\tissues.push({\n\t\t\t\t\t\tcode: 'keyframes-outside-clip',\n\t\t\t\t\t\tseverity: 'warning',\n\t\t\t\t\t\t...at,\n\t\t\t\t\t\tmessage: `Clip \"${clip.id}\" has ${property} key${outside.length > 1 ? 's' : ''} at ${outside.map((k) => `${Math.round(k.t)} ms`).join(', ')}, outside the clip (0–${Math.round(clip.duration)} ms in clip time), so ${outside.length > 1 ? 'they never play' : 'it never plays'} directly; move ${outside.length > 1 ? 'them' : 'it'} inside or remove ${outside.length > 1 ? 'them' : 'it'} (set_video_keyframes).`\n\t\t\t\t\t});\n\t\t\t}\n\t\t\tif (clip.transition) {\n\t\t\t\tconst d = clip.transition.duration;\n\t\t\t\tconst before = videoClipBefore(track, clip);\n\t\t\t\tif (!before) {\n\t\t\t\t\tissues.push({ code: 'transition-without-cut', severity: 'warning', ...at, message: `Clip \"${clip.id}\" has a transition but no clip ends where it starts on track \"${track.id}\", so it plays as nothing; butt a clip up against it or remove the transition.` });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (d > clip.duration + VIDEO_TIME_EPSILON || d > before.duration + VIDEO_TIME_EPSILON) {\n\t\t\t\t\tissues.push({ code: 'transition-too-long', severity: 'error', ...at, message: `The ${d} ms transition into \"${clip.id}\" is longer than \"${clip.id}\" (${clip.duration} ms) or \"${before.id}\" (${before.duration} ms); make it at most ${Math.min(clip.duration, before.duration)} ms.` });\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t// A dip cuts under its colour, so it plays no media past the cut.\n\t\t\t\tif (clip.transition.type === 'dip') continue;\n\t\t\t\tconst outgoing = videoClipHandles(before, assets, sequences).after;\n\t\t\t\tconst incoming = videoClipHandles(clip, assets, sequences).before;\n\t\t\t\tconst half = d / 2;\n\t\t\t\tif (outgoing < half - VIDEO_TIME_EPSILON || incoming < half - VIDEO_TIME_EPSILON) {\n\t\t\t\t\tconst fits = 2 * Math.min(outgoing, incoming);\n\t\t\t\t\tissues.push({\n\t\t\t\t\t\tcode: 'transition-handles',\n\t\t\t\t\t\tseverity: 'error',\n\t\t\t\t\t\t...at,\n\t\t\t\t\t\tmessage: `The ${d} ms transition into \"${clip.id}\" needs ${half} ms of media past the cut on both sides, but \"${before.id}\" has ${Math.floor(outgoing)} ms after its out point and \"${clip.id}\" ${Math.floor(incoming)} ms before its in point; make it at most ${Math.floor(fits)} ms, or trim the clips to leave handles.`\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\tfor (const track of project.tracks) checkTrack(track);\n\tfor (const sequence of project.sequences ?? []) for (const track of sequence.tracks) checkTrack(track, sequence.id);\n\tfor (const sequence of project.sequences ?? [])\n\t\tif (!usedSequences.has(sequence.id)) issues.push({ code: 'unused-sequence', severity: 'note', sequence: sequence.id, message: `Sequence \"${sequence.id}\" is not played by any compound clip.` });\n\tfor (const asset of project.assets) if (!used.has(asset.id)) issues.push({ code: 'unused-asset', severity: 'note', asset: asset.id, message: `Asset \"${asset.id}\" is not used by any clip.` });\n\tconst end = videoProjectDuration(project);\n\tfor (const marker of project.markers ?? [])\n\t\tif (marker.time > end + VIDEO_TIME_EPSILON) issues.push({ code: 'marker-past-end', severity: 'note', marker: marker.id, message: `Marker \"${marker.id}\" at ${formatVideoSeconds(marker.time)} is past the end (${formatVideoSeconds(end)}).` });\n\tif (!project.tracks.some((t) => t.clips.length)) issues.push({ code: 'empty', severity: 'note', message: 'The project has no clips yet; add assets and then clips.' });\n\tconst rank = { error: 0, warning: 1, note: 2 } as const;\n\treturn issues.sort((a, b) => rank[a.severity] - rank[b.severity]);\n}\n","import { checkVideoProject, videoIssueKey, type VideoIssue } from './check.js';\nimport { isVideoKeyframes, retimeVideoAnimatable, sortVideoKeys, splitVideoAnimatable, VIDEO_KEYFRAME_LIMITS, VIDEO_KEYFRAME_PROPERTIES, VIDEO_TRANSFORM_PROPERTIES, videoValueAt, type VideoAnimatable, type VideoKey, type VideoKeyframeProperty } from './keyframes.js';\nimport { parseVideoAnimatable, parseVideoAsset, parseVideoClip, parseVideoMarker, parseVideoProject, parseVideoTimeRemap, parseVideoTransition } from './parse.js';\nimport { checkVideoTimeRemap, videoTimeRemapSpan } from './remap.js';\nimport {\n\tallVideoClipIds,\n\tallVideoTracks,\n\tfindVideoClip,\n\tfindVideoSequenceClip,\n\tformatVideoSeconds,\n\tnextFreeVideoSlot,\n\tsortedVideoClips,\n\tVIDEO_TIME_EPSILON,\n\tvideoAssetMap,\n\tvideoClipAfter,\n\tvideoClipBefore,\n\tvideoClipEnd,\n\tvideoClipHandles,\n\tvideoClipOverhang,\n\tvideoClipSpeed,\n\tvideoOverlap,\n\tvideoSequenceDuration,\n\tvideoSequenceMap,\n\tvideoTrackSyncLocked\n} from './timeline.js';\nimport { VIDEO_PLACEMENT_MODES, type VideoAsset, type VideoClip, type VideoMarker, type VideoMediaClip, type VideoPlacementMode, type VideoProject, type VideoTrack, type VideoTrackKind, type VideoTransition } from './types.js';\nimport { namedWithSuggestion } from '../animation-editor/issues.js';\n\ntype Settings = { name?: string; width?: number; height?: number; fps?: number; background?: string };\ntype TrackFields = { name?: string; muted?: boolean; hidden?: boolean; locked?: boolean; syncLocked?: boolean; volume?: number };\nconst TRACK_FIELD_KEYS = ['name', 'muted', 'hidden', 'locked', 'syncLocked', 'volume'];\n/** A clip to add: `id` and `start` are optional (default: a fresh id, the end of the track). */\nexport type NewVideoClip = { [K in VideoClip['type']]: Omit<Extract<VideoClip, { type: K }>, 'id' | 'start' | 'duration'> & { id?: string; start?: number; duration?: number } }[VideoClip['type']];\n\n/** One change to a project. `applyVideoEdits` applies a list of them all or nothing. */\nexport type VideoEdit =\n\t| ({ op: 'set_settings' } & Settings)\n\t| { op: 'add_asset'; asset: Omit<VideoAsset, 'id'> & { id?: string } }\n\t| { op: 'update_asset'; asset: string; set?: Partial<Omit<VideoAsset, 'id'>>; remove?: string[] }\n\t| { op: 'remove_asset'; asset: string }\n\t| { op: 'add_track'; track: TrackFields & { id?: string; kind: VideoTrackKind }; at?: number }\n\t| { op: 'update_track'; track: string; set?: TrackFields; remove?: string[] }\n\t| { op: 'remove_track'; track: string }\n\t| { op: 'move_track'; track: string; to: number }\n\t| { op: 'add_clip'; track: string; clip: NewVideoClip; mode?: VideoPlacementMode; ripple?: boolean }\n\t| { op: 'move_clip'; clip: string; start?: number; track?: string; mode?: VideoPlacementMode }\n\t| { op: 'trim_clip'; clip: string; edge: 'start' | 'end'; to: number; ripple?: boolean }\n\t| { op: 'split_clip'; clip: string; at: number; id?: string }\n\t| { op: 'delete_clips'; clips: string[]; ripple?: boolean }\n\t| { op: 'update_clip'; clip: string; set?: Record<string, unknown>; remove?: string[] }\n\t| { op: 'set_transition'; clip: string; transition: VideoTransition | null }\n\t| { op: 'slip_clip'; clip: string; delta: number }\n\t| { op: 'roll_edit'; left: string; right: string; delta: number }\n\t| { op: 'slide_clip'; clip: string; delta: number }\n\t| { op: 'lift_range'; from: number; to: number; tracks?: string[] }\n\t| { op: 'extract_range'; from: number; to: number; tracks?: string[] }\n\t| { op: 'split_at'; at: number; tracks?: string[] }\n\t/** Keys for one property (in any order; clip-relative ms), or null to stop animating it and keep its value at the clip's start. */\n\t| { op: 'set_keyframes'; clip: string; property: VideoKeyframeProperty; keys: VideoKey[] | null }\n\t/**\n\t * A speed ramp: keys mapping clip time to source ms after the in point (see `timeRemap`), or\n\t * null to play at normal speed from where the ramp started. `duration` sets the clip's new length\n\t * (default: unchanged); `ripple` moves later material with the change.\n\t */\n\t| { op: 'set_time_remap'; clip: string; keys: VideoKey[] | null; duration?: number; ripple?: boolean }\n\t| { op: 'close_gaps'; track: string; from?: number }\n\t| { op: 'add_marker'; marker: Omit<VideoMarker, 'id'> & { id?: string } }\n\t| { op: 'update_marker'; marker: string; set?: Partial<Omit<VideoMarker, 'id'>>; remove?: string[] }\n\t| { op: 'remove_marker'; marker: string }\n\t| { op: 'replace_project'; project: VideoProject };\n\nexport type VideoEditOp = VideoEdit['op'];\nexport const VIDEO_EDIT_OPS: readonly VideoEditOp[] = [\n\t'set_settings',\n\t'add_asset',\n\t'update_asset',\n\t'remove_asset',\n\t'add_track',\n\t'update_track',\n\t'remove_track',\n\t'move_track',\n\t'add_clip',\n\t'move_clip',\n\t'trim_clip',\n\t'split_clip',\n\t'delete_clips',\n\t'update_clip',\n\t'set_transition',\n\t'slip_clip',\n\t'roll_edit',\n\t'slide_clip',\n\t'lift_range',\n\t'extract_range',\n\t'split_at',\n\t'set_keyframes',\n\t'set_time_remap',\n\t'close_gaps',\n\t'add_marker',\n\t'update_marker',\n\t'remove_marker',\n\t'replace_project'\n];\n\n/** What one op made or changed, so the next step can refer to it. */\nexport interface VideoEditOutcome {\n\top: VideoEditOp;\n\tclip?: string;\n\tclips?: string[];\n\ttrack?: string;\n\tasset?: string;\n\tmarker?: string;\n\t/** Clips the op made by cutting others (the right-hand parts). */\n\tcreated?: string[];\n\t/** Clips it deleted to make room (overwrite, lift, extract). */\n\tremoved?: string[];\n\t/** Clips it shortened to make room. */\n\ttrimmed?: string[];\n\t/** Tracks a ripple moved (the edited ones and every sync-locked one with them). */\n\trippled?: string[];\n\t/** For slip, roll and slide: how far it actually moved (ms), after clamping to what the media allows. */\n\tdelta?: number;\n}\n\nexport type VideoEditResult =\n\t| { ok: true; project: VideoProject; issues: VideoIssue[]; outcomes: VideoEditOutcome[] }\n\t| { ok: false; error: string };\n\n/** Default lengths (ms) for clips added without one. */\nexport const VIDEO_DEFAULT_STILL_DURATION = 5000;\nexport const VIDEO_DEFAULT_TITLE_DURATION = 3000;\n\nclass EditError extends Error {}\nconst refuse = (message: string): never => {\n\tthrow new EditError(message);\n};\n\nfunction freshId(prefix: string, taken: Iterable<string>): string {\n\tconst used = new Set(taken);\n\tfor (let n = 1; ; n++) if (!used.has(`${prefix}${n}`)) return `${prefix}${n}`;\n}\n\nconst slug = (s: string) =>\n\ts\n\t\t.replace(/^.*[\\\\/]/, '')\n\t\t.replace(/\\.[a-z0-9]+(\\?.*)?$/i, '')\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, '-')\n\t\t.replace(/^-|-$/g, '')\n\t\t.slice(0, 32);\n\n/** Sorts `[from, to]` spans and merges the ones that overlap or touch. */\nfunction mergeSpans(spans: [number, number][]): [number, number][] {\n\tconst out: [number, number][] = [];\n\tfor (const [from, to] of [...spans].sort((a, b) => a[0] - b[0])) {\n\t\tconst last = out[out.length - 1];\n\t\tif (last && from <= last[1] + VIDEO_TIME_EPSILON) last[1] = Math.max(last[1], to);\n\t\telse out.push([from, to]);\n\t}\n\treturn out;\n}\n\n/** Whether a clip plays through a speed ramp. */\nconst isRamped = (clip: VideoClip): boolean => clip.type === 'media' && !!clip.timeRemap;\n\n/** Every keyframeable value of a clip, with a way to read and replace it. */\nfunction clipAnimatables(clip: VideoClip): { get: () => VideoAnimatable | undefined; set: (v: VideoAnimatable | undefined) => void }[] {\n\tconst c = clip as VideoClip & { volume?: VideoAnimatable };\n\tconst out = [\n\t\t{ get: () => c.opacity, set: (v: VideoAnimatable | undefined) => (c.opacity = v) },\n\t\t{ get: () => c.volume, set: (v: VideoAnimatable | undefined) => (c.volume = v) },\n\t\t// A speed ramp is keys in clip time too, so trims and splits keep it on the same frames of source.\n\t\t{ get: () => (c as { timeRemap?: VideoAnimatable }).timeRemap, set: (v: VideoAnimatable | undefined) => ((c as { timeRemap?: VideoAnimatable }).timeRemap = v) }\n\t];\n\tfor (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => c.transform?.[p], set: (v) => c.transform && (c.transform[p] = v) });\n\treturn out;\n}\n\n/** Applies `fn` to every keyframed value of a clip (plain numbers are left alone). */\nfunction retimeClipKeys(clip: VideoClip, fn: (v: VideoAnimatable) => VideoAnimatable) {\n\tfor (const a of clipAnimatables(clip)) {\n\t\tconst v = a.get();\n\t\tif (isVideoKeyframes(v)) a.set(fn(v));\n\t}\n}\n\nconst OPTIONAL_CLIP_FIELDS = ['name', 'effects', 'values', 'provider', 'opacity', 'transform', 'fit', 'blend', 'grade', 'animateIn', 'animateOut', 'fadeIn', 'fadeOut', 'fadeInCurve', 'fadeOutCurve', 'transition', 'in', 'speed', 'timeRemap', 'volume', 'muted', 'color', 'fontSize', 'fontFamily', 'fontWeight', 'background'];\n\n/** Times the new range and nudge ops require, checked before anything changes. */\nconst TIME_FIELDS: Partial<Record<VideoEditOp, string[]>> = { slip_clip: ['delta'], roll_edit: ['delta'], slide_clip: ['delta'], lift_range: ['from', 'to'], extract_range: ['from', 'to'], split_at: ['at'] };\n\n/** Applies edits to a mutable copy of a project. */\nclass Editor {\n\treadonly outcomes: VideoEditOutcome[] = [];\n\tconstructor(public p: VideoProject) {}\n\n\ttrack(id: string): VideoTrack {\n\t\tconst t = this.p.tracks.find((x) => x.id === id);\n\t\tif (!t) refuse(`There is no track ${namedWithSuggestion(id, this.p.tracks.map((x) => x.id))}; the tracks are ${this.p.tracks.map((x) => `\"${x.id}\" (${x.kind})`).join(', ') || 'none'}.`);\n\t\treturn t!;\n\t}\n\n\tclip(id: string) {\n\t\tconst at = findVideoClip(this.p, id);\n\t\tconst nested = at ? null : findVideoSequenceClip(this.p, id);\n\t\tif (nested) refuse(`Clip \"${id}\" is inside sequence \"${nested.sequence.id}\", and clips inside sequences cannot be edited yet; edit the compound clip that plays the sequence instead (list_video_sequences shows which).`);\n\t\tif (!at) {\n\t\t\tconst ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));\n\t\t\trefuse(`There is no clip ${namedWithSuggestion(id, ids)}${ids.length ? '' : '; the project has no clips yet'}.`);\n\t\t}\n\t\treturn at!;\n\t}\n\n\tunlocked(track: VideoTrack) {\n\t\tif (track.locked) refuse(`Track \"${track.id}\" is locked; unlock it (update_track with locked false) first.`);\n\t}\n\n\tasset(id: string): VideoAsset {\n\t\tconst a = this.p.assets.find((x) => x.id === id);\n\t\tif (!a) refuse(`There is no asset ${namedWithSuggestion(id, this.p.assets.map((x) => x.id))}; add it first (the assets are ${this.p.assets.map((x) => `\"${x.id}\"`).join(', ') || 'none yet'}).`);\n\t\treturn a!;\n\t}\n\n\tfits(track: VideoTrack, start: number, duration: number, ignore: Set<string>, what: string) {\n\t\tconst hit = videoOverlap(track, start, start + duration, ignore);\n\t\tif (hit) {\n\t\t\tconst free = nextFreeVideoSlot(track, start, duration, ignore);\n\t\t\trefuse(`${what} at ${formatVideoSeconds(start)}–${formatVideoSeconds(start + duration)} would overlap clip \"${hit.id}\" (${formatVideoSeconds(hit.start)}–${formatVideoSeconds(videoClipEnd(hit))}) on track \"${track.id}\"; the next free place is ${formatVideoSeconds(free)}, or use mode \"insert\" to push later clips along or \"overwrite\" to replace what is there.`);\n\t\t}\n\t}\n\n\tkindFits(track: VideoTrack, clip: { type: string; asset?: string; sequence?: string }) {\n\t\tconst asset = clip.type === 'media' ? this.asset(clip.asset!) : null;\n\t\tif (clip.type === 'sequence') {\n\t\t\tconst ids = (this.p.sequences ?? []).map((q) => q.id);\n\t\t\tif (typeof clip.sequence !== 'string' || !ids.includes(clip.sequence)) refuse(`There is no sequence ${namedWithSuggestion(String(clip.sequence), ids)}; the sequences are ${ids.map((q) => `\"${q}\"`).join(', ') || 'none'}.`);\n\t\t\t// On an audio track a compound plays only its sound.\n\t\t\treturn;\n\t\t}\n\t\tif (track.kind === 'audio' && (clip.type !== 'media' || asset!.kind === 'image')) refuse(`Track \"${track.id}\" is an audio track; ${clip.type === 'media' ? 'images' : `${clip.type} clips`} go on a video track.`);\n\t\tif (track.kind === 'video' && asset?.kind === 'audio') refuse(`Track \"${track.id}\" is a video track; audio asset \"${asset.id}\" goes on an audio track.`);\n\t}\n\n\t/** Moves every clip on `track` starting at or after `from` by `delta`. */\n\tshift(track: VideoTrack, from: number, delta: number, except: ReadonlySet<string> = new Set()) {\n\t\tif (!delta) return;\n\t\tfor (const c of track.clips) if (!except.has(c.id) && c.start >= from - VIDEO_TIME_EPSILON) c.start = Math.max(0, c.start + delta);\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t}\n\n\tget frame() {\n\t\treturn 1000 / this.p.fps;\n\t}\n\n\tids() {\n\t\treturn allVideoClipIds(this.p);\n\t}\n\n\t/**\n\t * The tracks a ripple on `sources` moves, top to bottom: the sources, and every unlocked\n\t * sync-locked track when one of the sources is sync-locked.\n\t */\n\trippleTracks(sources: Iterable<VideoTrack>): VideoTrack[] {\n\t\tconst set = new Set(sources);\n\t\tif ([...set].some(videoTrackSyncLocked)) for (const t of this.p.tracks) if (videoTrackSyncLocked(t) && !t.locked) set.add(t);\n\t\treturn this.p.tracks.filter((t) => set.has(t));\n\t}\n\n\t/** The tracks a range edit works on: the named ones (unlocked), or every unlocked track. */\n\trangeTracks(ids: readonly string[] | undefined): VideoTrack[] {\n\t\tif (ids) {\n\t\t\tif (!ids.length) refuse('\"tracks\" is empty; name at least one track, or leave it out for every unlocked track.');\n\t\t\tconst tracks = [...new Set(ids)].map((id) => this.track(id));\n\t\t\ttracks.forEach((t) => this.unlocked(t));\n\t\t\treturn this.p.tracks.filter((t) => tracks.includes(t));\n\t\t}\n\t\tconst open = this.p.tracks.filter((t) => !t.locked);\n\t\tif (!open.length) refuse('Every track is locked; unlock one (update_track with locked false) first.');\n\t\treturn open;\n\t}\n\n\trange(from: number, to: number) {\n\t\tif (!(to - from > VIDEO_TIME_EPSILON)) refuse(`\"to\" (${formatVideoSeconds(to)}) must be after \"from\" (${formatVideoSeconds(from)}).`);\n\t}\n\n\t/** Moves a media or compound clip's in point by `delta` of timeline time (stills, titles and colours have none). */\n\tmoveIn(clip: VideoClip, delta: number) {\n\t\tif (clip.type === 'sequence') {\n\t\t\tclip.in = Math.max(0, (clip.in ?? 0) + delta);\n\t\t\tif (clip.in < 1e-9) delete clip.in;\n\t\t\treturn;\n\t\t}\n\t\tif (clip.type !== 'media' || videoAssetMap(this.p).get(clip.asset)?.kind === 'image') return;\n\t\tclip.in = Math.max(0, (clip.in ?? 0) + delta * videoClipSpeed(clip));\n\t\tif (clip.in < 1e-9) delete clip.in;\n\t}\n\n\t/** Moves a clip's head `delta` later in time (start, in point and keys); the caller sets start and duration. */\n\ttrimHead(clip: VideoClip, delta: number) {\n\t\tif (!isRamped(clip)) this.moveIn(clip, delta);\n\t\t// Keys stay on the picture they were set on, so they move by the trim.\n\t\tretimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));\n\t}\n\n\t/** Cuts `clip` at `at` (inside it): the left part keeps the id, the right part is returned. */\n\tcut(track: VideoTrack, clip: VideoClip, at: number, id?: string): VideoClip {\n\t\tconst end = videoClipEnd(clip);\n\t\tconst right: any = { ...structuredClone(clip), id: id ?? freshId(`${clip.id}-`, this.ids()), start: at, duration: end - at };\n\t\tdelete right.transition;\n\t\tdelete right.fadeIn;\n\t\tdelete right.fadeInCurve;\n\t\tdelete right.animateIn;\n\t\t// Each part keeps the keys on its side, with a key at the cut so both play as the whole did.\n\t\tconst leftValues = clipAnimatables(clip);\n\t\tclipAnimatables(right).forEach((r, i) => {\n\t\t\tconst [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);\n\t\t\tif (isVideoKeyframes(l)) leftValues[i].set(l);\n\t\t\tif (isVideoKeyframes(rr)) r.set(rr);\n\t\t});\n\t\tif (!isRamped(right)) this.moveIn(right, at - clip.start);\n\t\tclip.duration = at - clip.start;\n\t\tdelete clip.fadeOut;\n\t\tdelete clip.animateOut;\n\t\tif (clip.type === 'media' || clip.type === 'sequence') delete clip.fadeOutCurve;\n\t\ttrack.clips.push(right);\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t\treturn right;\n\t}\n\n\t/** Cuts every clip on `tracks` that runs across `at`; returns the new right-hand parts. */\n\tcutAcross(tracks: readonly VideoTrack[], at: number): VideoClip[] {\n\t\tconst made: VideoClip[] = [];\n\t\tfor (const t of tracks) for (const c of [...t.clips]) if (c.start < at - VIDEO_TIME_EPSILON && videoClipEnd(c) > at + VIDEO_TIME_EPSILON) made.push(this.cut(t, c, at));\n\t\treturn made;\n\t}\n\n\t/**\n\t * Opens `amount` of empty time at `at` on `tracks`: clips across `at` are cut there and everything\n\t * from `at` on moves later. Returns the clips made by cutting.\n\t */\n\topen(tracks: readonly VideoTrack[], at: number, amount: number, except: ReadonlySet<string> = new Set()): string[] {\n\t\tconst made = this.cutAcross(tracks, at).map((c) => c.id);\n\t\tfor (const t of tracks) this.shift(t, at, amount, except);\n\t\treturn made;\n\t}\n\n\t/**\n\t * Closes `[from, to)` on `tracks`, pulling everything after it earlier. Refuses, before moving\n\t * anything, when a track still has a clip in that stretch (it would be pulled out of sync).\n\t */\n\tclose(tracks: readonly VideoTrack[], from: number, to: number) {\n\t\tfor (const t of tracks) {\n\t\t\tconst hit = videoOverlap(t, from, to);\n\t\t\tif (hit)\n\t\t\t\trefuse(\n\t\t\t\t\t`Rippling out ${formatVideoSeconds(from)}–${formatVideoSeconds(to)} would pull track \"${t.id}\" out of sync: clip \"${hit.id}\" (${formatVideoSeconds(hit.start)}–${formatVideoSeconds(videoClipEnd(hit))}) is in that stretch. Extract the range across \"${t.id}\" too (extract_range with its id in \"tracks\"), turn its sync lock off (update_track with syncLocked false) or lock it, or lift instead of rippling.`\n\t\t\t\t);\n\t\t}\n\t\tfor (const t of tracks) this.shift(t, to, from - to);\n\t}\n\n\t/**\n\t * Empties `[from, to)` on one track, leaving a gap: clips inside go, clips across an edge are\n\t * trimmed back to it, and a clip across both is cut in two around the gap.\n\t */\n\tclear(track: VideoTrack, from: number, to: number, report: { removed: string[]; trimmed: string[]; created: string[] }) {\n\t\tfor (const c of [...track.clips]) {\n\t\t\tconst start = c.start;\n\t\t\tconst end = videoClipEnd(c);\n\t\t\tif (end <= from + VIDEO_TIME_EPSILON || start >= to - VIDEO_TIME_EPSILON) continue;\n\t\t\tconst coversHead = start >= from - VIDEO_TIME_EPSILON;\n\t\t\tconst coversTail = end <= to + VIDEO_TIME_EPSILON;\n\t\t\tif (coversHead && coversTail) {\n\t\t\t\ttrack.clips = track.clips.filter((x) => x !== c);\n\t\t\t\treport.removed.push(c.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!coversHead && !coversTail) report.created.push(this.cut(track, c, to).id);\n\t\t\telse if (coversHead) {\n\t\t\t\t// The head goes: start later in the source; a transition in has no cut left to play over.\n\t\t\t\tthis.trimHead(c, to - start);\n\t\t\t\tc.start = to;\n\t\t\t\tc.duration = end - to;\n\t\t\t\tdelete c.transition;\n\t\t\t\treport.trimmed.push(c.id);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tc.duration = from - start;\n\t\t\tif (!report.trimmed.includes(c.id)) report.trimmed.push(c.id);\n\t\t}\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t}\n\n\t/** Clamps a slip, roll or slide to `[lo, hi]`, refusing when it cannot move that way at all. */\n\tclamp(delta: number, lo: number, hi: number, why: () => string): number {\n\t\tconst applied = Math.min(Math.max(delta, Math.min(0, lo)), Math.max(0, hi));\n\t\tif (Math.abs(delta) > VIDEO_TIME_EPSILON && Math.abs(applied) <= VIDEO_TIME_EPSILON) refuse(why());\n\t\treturn Math.abs(applied) <= VIDEO_TIME_EPSILON ? 0 : applied;\n\t}\n\n\t/** Places a clip on a track by `mode`, returning what it cut, trimmed, removed and rippled. */\n\tplace(track: VideoTrack, clip: VideoClip, mode: VideoPlacementMode, outcome: VideoEditOutcome) {\n\t\tif (mode === 'insert') {\n\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\toutcome.created = this.open(tracks, clip.start, clip.duration);\n\t\t\toutcome.rippled = tracks.map((t) => t.id);\n\t\t} else if (mode === 'overwrite') {\n\t\t\tconst report = { removed: [] as string[], trimmed: [] as string[], created: [] as string[] };\n\t\t\tthis.clear(track, clip.start, videoClipEnd(clip), report);\n\t\t\tObject.assign(outcome, report);\n\t\t} else this.fits(track, clip.start, clip.duration, new Set([clip.id]), `Clip \"${clip.id}\"`);\n\t\ttrack.clips.push(clip);\n\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t}\n\n\tplacement(mode: unknown, ripple?: boolean): VideoPlacementMode {\n\t\tif (mode !== undefined && !VIDEO_PLACEMENT_MODES.includes(mode as VideoPlacementMode)) refuse(`\"mode\" must be ${VIDEO_PLACEMENT_MODES.map((m) => `\"${m}\"`).join(', ')}.`);\n\t\tif (ripple && mode !== undefined && mode !== 'insert') refuse(`\"ripple\" means mode \"insert\", so it cannot go with mode \"${mode}\"; pass one or the other.`);\n\t\treturn (mode as VideoPlacementMode | undefined) ?? (ripple ? 'insert' : 'refuse');\n\t}\n\n\trun(e: VideoEdit) {\n\t\tfor (const k of TIME_FIELDS[e.op] ?? []) {\n\t\t\tconst v = (e as Record<string, unknown>)[k];\n\t\t\tif (typeof v !== 'number' || !Number.isFinite(v)) refuse(`\"${k}\" must be a number of milliseconds.`);\n\t\t\tif (k !== 'delta' && (v as number) < 0) refuse(`\"${k}\" must be a time of at least 0.`);\n\t\t}\n\t\tswitch (e.op) {\n\t\t\tcase 'set_settings': {\n\t\t\t\tconst { op: _op, ...settings } = e;\n\t\t\t\tfor (const [k, v] of Object.entries(settings)) {\n\t\t\t\t\tif (!['name', 'width', 'height', 'fps', 'background'].includes(k)) refuse(`\"${k}\" is not a setting; use name, width, height, fps or background.`);\n\t\t\t\t\tif (v !== undefined) (this.p as any)[k] = v;\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op });\n\t\t\t}\n\t\t\tcase 'add_asset': {\n\t\t\t\tconst ids = this.p.assets.map((a) => a.id);\n\t\t\t\tif (e.asset.id !== undefined && ids.includes(e.asset.id)) refuse(`There is already an asset \"${e.asset.id}\"; pick another id or use update_asset.`);\n\t\t\t\tconst base = slug(e.asset.name ?? e.asset.src ?? '') || e.asset.kind || 'asset';\n\t\t\t\tconst unique = e.asset.id ?? (ids.includes(base) ? freshId(`${base}-`, ids) : base);\n\t\t\t\tthis.p.assets.push(parseVideoAsset({ ...e.asset, id: unique }));\n\t\t\t\treturn this.outcomes.push({ op: e.op, asset: unique });\n\t\t\t}\n\t\t\tcase 'update_asset': {\n\t\t\t\tconst asset = this.asset(e.asset);\n\t\t\t\tif (e.set && 'id' in e.set) refuse('An asset\\'s id cannot be changed.');\n\t\t\t\tconst next: any = { ...asset, ...e.set };\n\t\t\t\tfor (const k of e.remove ?? []) delete next[k];\n\t\t\t\tthis.p.assets[this.p.assets.indexOf(asset)] = parseVideoAsset(next);\n\t\t\t\treturn this.outcomes.push({ op: e.op, asset: asset.id });\n\t\t\t}\n\t\t\tcase 'remove_asset': {\n\t\t\t\tconst asset = this.asset(e.asset);\n\t\t\t\tconst users = allVideoTracks(this.p).flatMap(({ track: t }) => t.clips.filter((c) => c.type === 'media' && c.asset === asset.id).map((c) => c.id));\n\t\t\t\tif (users.length) refuse(`Asset \"${asset.id}\" is used by clip${users.length > 1 ? 's' : ''} ${users.map((u) => `\"${u}\"`).join(', ')}; delete ${users.length > 1 ? 'them' : 'it'} first.`);\n\t\t\t\tthis.p.assets = this.p.assets.filter((a) => a !== asset);\n\t\t\t\treturn this.outcomes.push({ op: e.op, asset: asset.id });\n\t\t\t}\n\t\t\tcase 'add_track': {\n\t\t\t\tconst ids = this.p.tracks.map((t) => t.id);\n\t\t\t\tif (e.track.id && ids.includes(e.track.id)) refuse(`There is already a track \"${e.track.id}\".`);\n\t\t\t\tconst id = e.track.id ?? freshId(e.track.kind === 'video' ? 'v' : 'a', ids);\n\t\t\t\tconst n = this.p.tracks.filter((t) => t.kind === e.track.kind).length + 1;\n\t\t\t\tconst track: VideoTrack = { name: `${e.track.kind === 'video' ? 'Video' : 'Audio'} ${n}`, ...e.track, id, clips: [] };\n\t\t\t\t// By default a video track goes above the other video tracks, an audio track below everything.\n\t\t\t\tconst at = e.at ?? (e.track.kind === 'video' ? 0 : this.p.tracks.length);\n\t\t\t\tif (at > this.p.tracks.length) refuse(`\"at\" is ${at} but there are only ${this.p.tracks.length} tracks; use 0–${this.p.tracks.length}.`);\n\t\t\t\tthis.p.tracks.splice(at, 0, track);\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: id });\n\t\t\t}\n\t\t\tcase 'update_track': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tfor (const k of Object.keys(e.set ?? {})) if (!TRACK_FIELD_KEYS.includes(k)) refuse(`\"${k}\" cannot be set on a track; use ${TRACK_FIELD_KEYS.join(', ')}.`);\n\t\t\t\tObject.assign(track, e.set);\n\t\t\t\tfor (const k of e.remove ?? []) delete (track as any)[k];\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id });\n\t\t\t}\n\t\t\tcase 'remove_track': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tthis.p.tracks = this.p.tracks.filter((t) => t !== track);\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id, clips: track.clips.map((c) => c.id) });\n\t\t\t}\n\t\t\tcase 'move_track': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tif (e.to < 0 || e.to >= this.p.tracks.length) refuse(`\"to\" must be 0–${this.p.tracks.length - 1}.`);\n\t\t\t\tthis.p.tracks.splice(this.p.tracks.indexOf(track), 1);\n\t\t\t\tthis.p.tracks.splice(e.to, 0, track);\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id });\n\t\t\t}\n\t\t\tcase 'add_clip': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst mode = this.placement(e.mode, e.ripple);\n\t\t\t\tconst draft: any = { ...e.clip, type: e.clip.type ?? 'media' };\n\t\t\t\tthis.kindFits(track, draft);\n\t\t\t\tconst ids = this.ids();\n\t\t\t\tif (draft.id !== undefined && ids.includes(draft.id)) refuse(`There is already a clip \"${draft.id}\".`);\n\t\t\t\tdraft.id ??= freshId('c', ids);\n\t\t\t\tif (draft.duration === undefined) {\n\t\t\t\t\tif (draft.type === 'media') {\n\t\t\t\t\t\tconst asset = this.asset(draft.asset);\n\t\t\t\t\t\tif (asset.kind === 'image') draft.duration = VIDEO_DEFAULT_STILL_DURATION;\n\t\t\t\t\t\telse if (asset.duration === undefined) refuse(`Asset \"${asset.id}\" has no duration, so give the clip a \"duration\".`);\n\t\t\t\t\t\telse draft.duration = (asset.duration - (draft.in ?? 0)) / (draft.speed ?? 1);\n\t\t\t\t\t\tif (!(draft.duration > 0)) refuse(`\"in\" (${draft.in} ms) is at or past the end of \"${asset.id}\" (${asset.duration} ms).`);\n\t\t\t\t\t} else if (draft.type === 'sequence') {\n\t\t\t\t\t\tconst length = videoSequenceDuration(videoSequenceMap(this.p).get(draft.sequence)!) - (draft.in ?? 0);\n\t\t\t\t\t\tif (!(length > 0)) refuse(`\"in\" (${draft.in ?? 0} ms) is at or past the end of sequence \"${draft.sequence}\".`);\n\t\t\t\t\t\tdraft.duration = length;\n\t\t\t\t\t} else draft.duration = VIDEO_DEFAULT_TITLE_DURATION;\n\t\t\t\t}\n\t\t\t\tconst end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);\n\t\t\t\tdraft.start ??= end;\n\t\t\t\tconst clip = parseVideoClip(draft, 'clip');\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, clip: clip.id, track: track.id };\n\t\t\t\tthis.place(track, clip, mode, outcome);\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'move_clip': {\n\t\t\t\tconst from = this.clip(e.clip);\n\t\t\t\tthis.unlocked(from.track);\n\t\t\t\tconst to = e.track !== undefined ? this.track(e.track) : from.track;\n\t\t\t\tthis.unlocked(to);\n\t\t\t\tif (to !== from.track) this.kindFits(to, from.clip);\n\t\t\t\tconst mode = this.placement(e.mode);\n\t\t\t\tconst start = e.start ?? from.clip.start;\n\t\t\t\tif (start < 0) refuse('A clip cannot start before 0.');\n\t\t\t\t// Lift it out (leaving a gap where it was), then place it as if it were new.\n\t\t\t\tfrom.track.clips.splice(from.index, 1);\n\t\t\t\tfrom.clip.start = start;\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, clip: from.clip.id, track: to.id };\n\t\t\t\tthis.place(to, from.clip, mode, outcome);\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'trim_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst speed = videoClipSpeed(clip);\n\t\t\t\tconst end = videoClipEnd(clip);\n\t\t\t\tconst minimum = 1000 / this.p.fps;\n\t\t\t\tconst assets = videoAssetMap(this.p);\n\t\t\t\tif (e.edge === 'start') {\n\t\t\t\t\tconst delta = e.to - clip.start;\n\t\t\t\t\tif (clip.duration - delta < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the start to ${formatVideoSeconds(e.to)} would leave \"${clip.id}\" shorter than a frame; it ends at ${formatVideoSeconds(end)}.`);\n\t\t\t\t\t// A ramp's keys carry its source: trimming moves them (and holds the first frame past them).\n\t\t\t\t\tif ((clip.type === 'media' && !isRamped(clip)) || clip.type === 'sequence') {\n\t\t\t\t\t\tconst sourceIn = (clip.in ?? 0) + delta * speed;\n\t\t\t\t\t\tif (sourceIn < -VIDEO_TIME_EPSILON) refuse(`\"${clip.id}\" starts ${Math.round((clip.in ?? 0) / speed)} ms into its source, so its start can move at most that far earlier (to ${formatVideoSeconds(clip.start - (clip.in ?? 0) / speed)}).`);\n\t\t\t\t\t\tclip.in = Math.max(0, sourceIn);\n\t\t\t\t\t\tif (clip.in === 0) delete clip.in;\n\t\t\t\t\t}\n\t\t\t\t\t// Keys stay on the picture they were set on, so they move by the trim.\n\t\t\t\t\tretimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));\n\t\t\t\t\tif (e.ripple) {\n\t\t\t\t\t\t// Ripple at the edit point: the head comes out (or goes in) there, on every sync-locked track.\n\t\t\t\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\t\t\t\tif (delta > 0) {\n\t\t\t\t\t\t\tclip.start += delta;\n\t\t\t\t\t\t\tclip.duration -= delta;\n\t\t\t\t\t\t\tthis.close(tracks, clip.start - delta, clip.start);\n\t\t\t\t\t\t} else if (delta < 0) {\n\t\t\t\t\t\t\tconst created = this.open(tracks, clip.start, -delta, new Set([clip.id]));\n\t\t\t\t\t\t\tclip.duration -= delta;\n\t\t\t\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id), ...(created.length ? { created } : {}) });\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id) });\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (e.to < 0) refuse('A clip cannot start before 0.');\n\t\t\t\t\t\tif (delta < 0) this.fits(track, e.to, -delta, new Set([clip.id]), `Extending \"${clip.id}\"`);\n\t\t\t\t\t\tclip.start = e.to;\n\t\t\t\t\t\tclip.duration -= delta;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst duration = e.to - clip.start;\n\t\t\t\t\tif (duration < minimum - VIDEO_TIME_EPSILON) refuse(`Trimming the end to ${formatVideoSeconds(e.to)} would leave \"${clip.id}\" shorter than a frame; it starts at ${formatVideoSeconds(clip.start)}.`);\n\t\t\t\t\tif (clip.type === 'media' && !isRamped(clip)) {\n\t\t\t\t\t\tconst asset = assets.get(clip.asset);\n\t\t\t\t\t\tconst out = (clip.in ?? 0) + duration * speed;\n\t\t\t\t\t\tif (asset?.duration !== undefined && out > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\t\trefuse(`\"${clip.id}\" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where \"${asset.id}\" runs out.`);\n\t\t\t\t\t}\n\t\t\t\t\tif (clip.type === 'sequence') {\n\t\t\t\t\t\tconst seq = videoSequenceMap(this.p).get(clip.sequence);\n\t\t\t\t\t\tconst length = seq ? videoSequenceDuration(seq) : Infinity;\n\t\t\t\t\t\tif ((clip.in ?? 0) + duration > length + VIDEO_TIME_EPSILON) refuse(`\"${clip.id}\" can end at most at ${formatVideoSeconds(clip.start + length - (clip.in ?? 0))}, where sequence \"${clip.sequence}\" ends.`);\n\t\t\t\t\t}\n\t\t\t\t\tif (e.ripple) {\n\t\t\t\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\t\t\t\tlet created: string[] = [];\n\t\t\t\t\t\tif (e.to < end) {\n\t\t\t\t\t\t\tclip.duration = duration;\n\t\t\t\t\t\t\tthis.close(tracks, e.to, end);\n\t\t\t\t\t\t} else if (e.to > end) {\n\t\t\t\t\t\t\tcreated = this.open(tracks, end, e.to - end, new Set([clip.id]));\n\t\t\t\t\t\t\tclip.duration = duration;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id), ...(created.length ? { created } : {}) });\n\t\t\t\t\t}\n\t\t\t\t\tif (e.to > end) this.fits(track, end, e.to - end, new Set([clip.id]), `Extending \"${clip.id}\"`);\n\t\t\t\t\tclip.duration = duration;\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'split_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst end = videoClipEnd(clip);\n\t\t\t\tconst minimum = 1000 / this.p.fps;\n\t\t\t\tif (e.at < clip.start + minimum - VIDEO_TIME_EPSILON || e.at > end - minimum + VIDEO_TIME_EPSILON) refuse(`${formatVideoSeconds(e.at)} is not inside \"${clip.id}\" (${formatVideoSeconds(clip.start)}–${formatVideoSeconds(end)}), at least a frame from either end.`);\n\t\t\t\tif (e.id !== undefined && this.ids().includes(e.id)) refuse(`There is already a clip \"${e.id}\".`);\n\t\t\t\tconst right = this.cut(track, clip, e.at, e.id);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: [clip.id, right.id], clip: right.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'delete_clips': {\n\t\t\t\tconst found = [...new Set(e.clips)].map((id) => this.clip(id));\n\t\t\t\tfor (const f of found) this.unlocked(f.track);\n\t\t\t\tfor (const track of new Set(found.map((f) => f.track))) track.clips = track.clips.filter((c) => !found.some((f) => f.clip === c));\n\t\t\t\tif (!e.ripple) return this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id) });\n\t\t\t\t// A track out of sync lock closes only its own gaps; the gaps of sync-locked tracks are\n\t\t\t\t// merged into spans closed on every sync-locked track, right to left so earlier spans stay put.\n\t\t\t\tconst rippled = new Set<VideoTrack>();\n\t\t\t\tconst groups = [...new Set(found.map((f) => f.track))].filter((t) => !videoTrackSyncLocked(t)).map((t) => [t]);\n\t\t\t\tif (found.some((f) => videoTrackSyncLocked(f.track))) groups.push(found.filter((f) => videoTrackSyncLocked(f.track)).map((f) => f.track));\n\t\t\t\tfor (const sources of groups) {\n\t\t\t\t\tconst tracks = this.rippleTracks(sources);\n\t\t\t\t\ttracks.forEach((t) => rippled.add(t));\n\t\t\t\t\tconst spans = mergeSpans(found.filter((f) => sources.includes(f.track)).map((f) => [f.clip.start, videoClipEnd(f.clip)]));\n\t\t\t\t\tfor (const [from, to] of spans.reverse()) this.close(tracks, from, to);\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id), rippled: this.p.tracks.filter((t) => rippled.has(t)).map((t) => t.id) });\n\t\t\t}\n\t\t\tcase 'update_clip': {\n\t\t\t\tconst at = this.clip(e.clip);\n\t\t\t\tthis.unlocked(at.track);\n\t\t\t\tconst set = { ...(e.set ?? {}) };\n\t\t\t\tfor (const k of ['id', 'type']) if (k in set) refuse(`A clip's ${k} cannot be changed; delete it and add a new one.`);\n\t\t\t\tfor (const k of e.remove ?? []) if (!OPTIONAL_CLIP_FIELDS.includes(k)) refuse(`\"${k}\" cannot be removed; removable fields are ${OPTIONAL_CLIP_FIELDS.join(', ')}.`);\n\t\t\t\t// A new speed keeps the stretch of source and changes the length, as in an NLE's rate stretch.\n\t\t\t\tif ('speed' in set && (typeof set.speed !== 'number' || !(set.speed > 0))) refuse('\"speed\" must be a number greater than 0 (1 = normal speed).');\n\t\t\t\tif ('speed' in set && isRamped(at.clip)) refuse(`Clip \"${at.clip.id}\" has a speed ramp, which sets its speed; change the ramp, or remove it first.`);\n\t\t\t\tif ('timeRemap' in set) refuse('Set a speed ramp with set_time_remap, which checks it against the media and moves later clips if asked.');\n\t\t\t\tlet stretch = 1;\n\t\t\t\tif (at.clip.type === 'media' && !('duration' in set)) {\n\t\t\t\t\tconst was = videoClipSpeed(at.clip);\n\t\t\t\t\tconst now = 'speed' in set ? (set.speed as number) : e.remove?.includes('speed') ? 1 : was;\n\t\t\t\t\tif (now !== was) {\n\t\t\t\t\t\tset.duration = (at.clip.duration * was) / now;\n\t\t\t\t\t\tstretch = was / now;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst next: any = { ...structuredClone(at.clip), ...set };\n\t\t\t\t// A rate stretch keeps keys on the same frames of source.\n\t\t\t\tif (stretch !== 1) retimeClipKeys(next, (v) => retimeVideoAnimatable(v, 0, stretch));\n\t\t\t\tfor (const k of e.remove ?? []) delete next[k];\n\t\t\t\tconst clip = parseVideoClip(next, 'set');\n\t\t\t\tif (clip.type === 'media' && clip.asset !== (at.clip as any).asset) this.kindFits(at.track, clip);\n\t\t\t\tif (clip.start !== at.clip.start || clip.duration !== at.clip.duration) this.fits(at.track, clip.start, clip.duration, new Set([clip.id]), `Clip \"${clip.id}\"`);\n\t\t\t\tat.track.clips[at.index] = clip;\n\t\t\t\tat.track.clips.sort((a, b) => a.start - b.start);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: at.track.id });\n\t\t\t}\n\t\t\tcase 'set_time_remap': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (clip.type === 'sequence') refuse(`Clip \"${clip.id}\" is a compound clip, which plays its sequence at normal speed; ramp the clips inside the sequence instead.`);\n\t\t\t\tif (clip.type !== 'media') refuse(`Clip \"${clip.id}\" is a ${clip.type === 'text' ? 'title' : clip.type === 'effect' ? 'effect clip' : 'colour'}, so it has no source to ramp; only video and audio clips change speed.`);\n\t\t\t\tconst asset = this.asset((clip as VideoMediaClip).asset);\n\t\t\t\tif (asset.kind === 'image') refuse(`Clip \"${clip.id}\" is a still (\"${asset.id}\"), so it has no motion to ramp.`);\n\t\t\t\tconst media = clip as VideoMediaClip;\n\t\t\t\tconst end = videoClipEnd(media);\n\t\t\t\tconst duration = e.duration ?? media.duration;\n\t\t\t\tif (!(duration >= 1000 / this.p.fps - VIDEO_TIME_EPSILON)) refuse(`\"duration\" must be at least a frame (${Math.ceil(1000 / this.p.fps)} ms).`);\n\t\t\t\tif (e.keys === null) {\n\t\t\t\t\tif (!media.timeRemap) refuse(`Clip \"${clip.id}\" has no speed ramp to remove.`);\n\t\t\t\t\t// Play on at normal speed from where the ramp started.\n\t\t\t\t\tconst from = (media.in ?? 0) + media.timeRemap!.keys[0].v;\n\t\t\t\t\tif (asset.duration !== undefined && from + duration > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\trefuse(`At normal speed from ${Math.round(from)} ms, \"${clip.id}\" would run past the end of \"${asset.id}\" (${asset.duration} ms); give a duration of at most ${Math.floor(asset.duration - from)} ms.`);\n\t\t\t\t\tdelete media.timeRemap;\n\t\t\t\t\tmedia.in = from;\n\t\t\t\t\tif (media.in < 1e-9) delete media.in;\n\t\t\t\t} else {\n\t\t\t\t\tif (!Array.isArray(e.keys) || !e.keys.length) refuse('\"keys\" must be a list of at least one { t, v, ease } (or null to remove the ramp).');\n\t\t\t\t\tconst remap = parseVideoTimeRemap({ keys: e.keys }, undefined, 'keys')!;\n\t\t\t\t\tremap.keys = sortVideoKeys(remap.keys);\n\t\t\t\t\tcheckVideoTimeRemap(remap, 'keys');\n\t\t\t\t\tconst span = videoTimeRemapSpan(remap);\n\t\t\t\t\tif (asset.duration !== undefined && (media.in ?? 0) + span.to > asset.duration + VIDEO_TIME_EPSILON)\n\t\t\t\t\t\trefuse(`The ramp plays ${Math.round(span.to)} ms of source from the in point (${media.in ?? 0} ms), past the end of \"${asset.id}\" (${asset.duration} ms); it can use at most ${Math.floor(asset.duration - (media.in ?? 0))} ms.`);\n\t\t\t\t\tmedia.timeRemap = remap;\n\t\t\t\t\tdelete media.speed;\n\t\t\t\t}\n\t\t\t\tconst newEnd = media.start + duration;\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, clip: clip.id, track: track.id };\n\t\t\t\tif (Math.abs(newEnd - end) > VIDEO_TIME_EPSILON) {\n\t\t\t\t\tif (e.ripple) {\n\t\t\t\t\t\tconst tracks = this.rippleTracks([track]);\n\t\t\t\t\t\tif (newEnd < end) {\n\t\t\t\t\t\t\tmedia.duration = duration;\n\t\t\t\t\t\t\tthis.close(tracks, newEnd, end);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconst created = this.open(tracks, end, newEnd - end, new Set([clip.id]));\n\t\t\t\t\t\t\tmedia.duration = duration;\n\t\t\t\t\t\t\tif (created.length) outcome.created = created;\n\t\t\t\t\t\t}\n\t\t\t\t\t\toutcome.rippled = tracks.map((t) => t.id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (newEnd > end) this.fits(track, end, newEnd - end, new Set([clip.id]), `Lengthening \"${clip.id}\"`);\n\t\t\t\t\t\tmedia.duration = duration;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'set_transition': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (e.transition === null) delete clip.transition;\n\t\t\t\telse clip.transition = parseVideoTransition(e.transition, 'transition');\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'slip_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (clip.type !== 'media' && clip.type !== 'sequence') refuse(`Clip \"${clip.id}\" is a ${clip.type === 'text' ? 'title' : clip.type === 'effect' ? 'effect clip' : 'colour'}, so it has no source to slip; only video, audio and compound clips slip.`);\n\t\t\t\tconst source = clip.type === 'sequence' ? `sequence \"${clip.sequence}\"` : `\"${this.asset((clip as { asset: string }).asset).id}\"`;\n\t\t\t\tif (clip.type === 'media' && this.asset(clip.asset).kind === 'image') refuse(`Clip \"${clip.id}\" is a still (\"${clip.asset}\"), so it has no source to slip; only video and audio clips slip.`);\n\t\t\t\t// Keep the media a transition at either edge plays past the cut.\n\t\t\t\tconst handles = videoClipHandles(clip, videoAssetMap(this.p), videoSequenceMap(this.p));\n\t\t\t\tconst { lead, tail } = videoClipOverhang(track, clip);\n\t\t\t\tconst delta = this.clamp(e.delta, lead - handles.before, handles.after - tail, () =>\n\t\t\t\t\te.delta > 0\n\t\t\t\t\t\t? `\"${clip.id}\" already plays to the end of ${source}${tail ? ` (less the ${tail} ms the transition after it needs)` : ''}, so it cannot slip later; slip it earlier (a negative delta).`\n\t\t\t\t\t\t: `\"${clip.id}\" already starts at the beginning of ${source}${lead ? ` (plus the ${lead} ms its transition needs)` : ''}, so it cannot slip earlier; slip it later (a positive delta).`\n\t\t\t\t);\n\t\t\t\tthis.moveIn(clip, delta);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, delta });\n\t\t\t}\n\t\t\tcase 'roll_edit': {\n\t\t\t\tconst left = this.clip(e.left);\n\t\t\t\tconst right = this.clip(e.right);\n\t\t\t\tif (left.track !== right.track) refuse(`\"${e.left}\" is on track \"${left.track.id}\" and \"${e.right}\" on \"${right.track.id}\"; a roll moves the cut between two clips on one track.`);\n\t\t\t\tconst track = left.track;\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst L = left.clip;\n\t\t\t\tconst R = right.clip;\n\t\t\t\tif (Math.abs(videoClipEnd(L) - R.start) > VIDEO_TIME_EPSILON) {\n\t\t\t\t\tif (Math.abs(videoClipEnd(R) - L.start) <= VIDEO_TIME_EPSILON) refuse(`\"${R.id}\" comes before \"${L.id}\"; pass left \"${R.id}\" and right \"${L.id}\".`);\n\t\t\t\t\trefuse(`\"${L.id}\" ends at ${formatVideoSeconds(videoClipEnd(L))} but \"${R.id}\" starts at ${formatVideoSeconds(R.start)}; a roll needs two clips that meet at a cut (use trim_clip to move one edge).`);\n\t\t\t\t}\n\t\t\t\tconst assets = videoAssetMap(this.p);\n\t\t\t\tconst lo = Math.max(-(L.duration - this.frame), -(videoClipHandles(R, assets, videoSequenceMap(this.p)).before - videoClipOverhang(track, R).lead));\n\t\t\t\tconst hi = Math.min(R.duration - this.frame, videoClipHandles(L, assets, videoSequenceMap(this.p)).after - videoClipOverhang(track, L).tail);\n\t\t\t\tconst delta = this.clamp(e.delta, lo, hi, () =>\n\t\t\t\t\te.delta > 0\n\t\t\t\t\t\t? `The cut cannot move later: ${R.duration - this.frame < 1 ? `\"${R.id}\" is already a frame long` : `\"${L.id}\" has no media after its out point`}. Roll it earlier (a negative delta).`\n\t\t\t\t\t\t: `The cut cannot move earlier: ${L.duration - this.frame < 1 ? `\"${L.id}\" is already a frame long` : `\"${R.id}\" has no media before its in point`}. Roll it later (a positive delta).`\n\t\t\t\t);\n\t\t\t\tL.duration += delta;\n\t\t\t\tR.start += delta;\n\t\t\t\tR.duration -= delta;\n\t\t\t\tthis.trimHead(R, delta);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: R.id, clips: [L.id, R.id], track: track.id, delta });\n\t\t\t}\n\t\t\tcase 'slide_clip': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tconst before = videoClipBefore(track, clip);\n\t\t\t\tconst after = videoClipAfter(track, clip);\n\t\t\t\tif (!before && !after) refuse(`\"${clip.id}\" has no clip right before or after it on track \"${track.id}\", so there is nothing to slide against; use move_clip.`);\n\t\t\t\tconst assets = videoAssetMap(this.p);\n\t\t\t\tconst end = videoClipEnd(clip);\n\t\t\t\tconst others = track.clips.filter((c) => c !== clip);\n\t\t\t\t// Without a neighbour on one side the clip moves through the gap there, up to the next clip.\n\t\t\t\tconst room = {\n\t\t\t\t\tearlier: before ? before.duration - this.frame : clip.start - others.filter((c) => videoClipEnd(c) <= clip.start + VIDEO_TIME_EPSILON).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0),\n\t\t\t\t\tlater: after ? after.duration - this.frame : others.filter((c) => c.start >= end - VIDEO_TIME_EPSILON).reduce((m, c) => Math.min(m, c.start - end), Infinity)\n\t\t\t\t};\n\t\t\t\tconst lo = -Math.min(room.earlier, after ? videoClipHandles(after, assets, videoSequenceMap(this.p)).before - videoClipOverhang(track, after).lead : Infinity);\n\t\t\t\tconst hi = Math.min(room.later, before ? videoClipHandles(before, assets, videoSequenceMap(this.p)).after - videoClipOverhang(track, before).tail : Infinity);\n\t\t\t\tconst delta = this.clamp(e.delta, lo, hi, () =>\n\t\t\t\t\te.delta > 0\n\t\t\t\t\t\t? `\"${clip.id}\" cannot slide later: ${room.later < 1 ? (after ? `\"${after.id}\" is already a frame long` : 'the next clip is right after it') : `\"${before!.id}\" has no media after its out point to fill in behind it`}. Slide it earlier (a negative delta).`\n\t\t\t\t\t\t: `\"${clip.id}\" cannot slide earlier: ${room.earlier < 1 ? (before ? `\"${before.id}\" is already a frame long` : 'it is against the clip before it or the start') : `\"${after!.id}\" has no media before its in point to fill in behind it`}. Slide it later (a positive delta).`\n\t\t\t\t);\n\t\t\t\tclip.start += delta;\n\t\t\t\tif (before) before.duration += delta;\n\t\t\t\tif (after) {\n\t\t\t\t\tafter.start += delta;\n\t\t\t\t\tafter.duration -= delta;\n\t\t\t\t\tthis.trimHead(after, delta);\n\t\t\t\t}\n\t\t\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, clips: [before?.id, clip.id, after?.id].filter((x): x is string => !!x), track: track.id, delta });\n\t\t\t}\n\t\t\tcase 'lift_range':\n\t\t\tcase 'extract_range': {\n\t\t\t\tthis.range(e.from, e.to);\n\t\t\t\tconst tracks = this.rangeTracks(e.tracks);\n\t\t\t\tconst report = { removed: [] as string[], trimmed: [] as string[], created: [] as string[] };\n\t\t\t\tfor (const t of tracks) this.clear(t, e.from, e.to, report);\n\t\t\t\tconst outcome: VideoEditOutcome = { op: e.op, ...report };\n\t\t\t\tif (e.op === 'extract_range') {\n\t\t\t\t\tconst rippled = this.rippleTracks(tracks);\n\t\t\t\t\tthis.close(rippled, e.from, e.to);\n\t\t\t\t\toutcome.rippled = rippled.map((t) => t.id);\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push(outcome);\n\t\t\t}\n\t\t\tcase 'split_at': {\n\t\t\t\tconst tracks = this.rangeTracks(e.tracks);\n\t\t\t\tconst lefts = tracks.flatMap((t) => t.clips.filter((c) => c.start < e.at - VIDEO_TIME_EPSILON && videoClipEnd(c) > e.at + VIDEO_TIME_EPSILON).map((c) => c.id));\n\t\t\t\tif (!lefts.length) refuse(`No clip runs across ${formatVideoSeconds(e.at)} on ${e.tracks ? `track${tracks.length > 1 ? 's' : ''} ${tracks.map((t) => `\"${t.id}\"`).join(', ')}` : 'the unlocked tracks'}, so there is nothing to split there.`);\n\t\t\t\tconst created = this.cutAcross(tracks, e.at).map((c) => c.id);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: lefts, created });\n\t\t\t}\n\t\t\tcase 'set_keyframes': {\n\t\t\t\tconst { track, clip } = this.clip(e.clip);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tif (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`\"${e.property}\" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(', ')}.`);\n\t\t\t\tif (e.property === 'volume' && clip.type !== 'media' && clip.type !== 'sequence') refuse(`Clip \"${clip.id}\" is a ${clip.type} clip and has no volume; volume keys go on media and compound clips.`);\n\t\t\t\tconst limits = VIDEO_KEYFRAME_LIMITS[e.property];\n\t\t\t\tconst transform = (VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(e.property);\n\t\t\t\tconst c = clip as any;\n\t\t\t\tconst current: VideoAnimatable | undefined = transform ? c.transform?.[e.property] : c[e.property];\n\t\t\t\tlet value: VideoAnimatable | undefined;\n\t\t\t\tif (e.keys === null) {\n\t\t\t\t\t// Stop animating: keep what it shows at the clip's start.\n\t\t\t\t\tconst v = videoValueAt(current, 0, limits.default);\n\t\t\t\t\tvalue = isVideoKeyframes(current) ? (v === limits.default ? undefined : v) : current;\n\t\t\t\t} else {\n\t\t\t\t\tif (!Array.isArray(e.keys) || !e.keys.length) refuse('\"keys\" must list at least one key { t, v }; pass null to remove the keyframes.');\n\t\t\t\t\tlet sorted: VideoKey[] = [];\n\t\t\t\t\ttry {\n\t\t\t\t\t\tsorted = sortVideoKeys(e.keys);\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\trefuse((error as Error).message);\n\t\t\t\t\t}\n\t\t\t\t\tvalue = parseVideoAnimatable({ keys: sorted }, e.property, limits);\n\t\t\t\t}\n\t\t\t\tif (transform) {\n\t\t\t\t\tc.transform = { ...c.transform, [e.property]: value };\n\t\t\t\t\tif (value === undefined) delete c.transform[e.property];\n\t\t\t\t\tif (!Object.keys(c.transform).length) delete c.transform;\n\t\t\t\t} else if (value === undefined) delete c[e.property];\n\t\t\t\telse c[e.property] = value;\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });\n\t\t\t}\n\t\t\tcase 'close_gaps': {\n\t\t\t\tconst track = this.track(e.track);\n\t\t\t\tthis.unlocked(track);\n\t\t\t\tlet at = 0;\n\t\t\t\tconst moved: string[] = [];\n\t\t\t\tfor (const clip of sortedVideoClips(track)) {\n\t\t\t\t\tif (clip.start >= (e.from ?? 0) - VIDEO_TIME_EPSILON && clip.start > at + VIDEO_TIME_EPSILON) {\n\t\t\t\t\t\tclip.start = Math.max(at, e.from ?? 0);\n\t\t\t\t\t\tmoved.push(clip.id);\n\t\t\t\t\t}\n\t\t\t\t\tat = Math.max(at, videoClipEnd(clip));\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, track: track.id, clips: moved });\n\t\t\t}\n\t\t\tcase 'add_marker': {\n\t\t\t\tconst markers = (this.p.markers ??= []);\n\t\t\t\tif (e.marker.id !== undefined && markers.some((m) => m.id === e.marker.id)) refuse(`There is already a marker \"${e.marker.id}\".`);\n\t\t\t\tconst marker = parseVideoMarker({ ...e.marker, id: e.marker.id ?? freshId('m', markers.map((m) => m.id)) });\n\t\t\t\tmarkers.push(marker);\n\t\t\t\tmarkers.sort((a, b) => a.time - b.time);\n\t\t\t\treturn this.outcomes.push({ op: e.op, marker: marker.id });\n\t\t\t}\n\t\t\tcase 'update_marker': {\n\t\t\t\tconst markers = this.p.markers ?? [];\n\t\t\t\tconst i = markers.findIndex((m) => m.id === e.marker);\n\t\t\t\tif (i < 0) refuse(`There is no marker ${namedWithSuggestion(e.marker, markers.map((m) => m.id))}.`);\n\t\t\t\tif (e.set && 'id' in e.set) refuse('A marker\\'s id cannot be changed.');\n\t\t\t\tconst next: any = { ...markers[i], ...e.set };\n\t\t\t\tfor (const k of e.remove ?? []) delete next[k];\n\t\t\t\tmarkers[i] = parseVideoMarker(next);\n\t\t\t\tmarkers.sort((a, b) => a.time - b.time);\n\t\t\t\treturn this.outcomes.push({ op: e.op, marker: e.marker });\n\t\t\t}\n\t\t\tcase 'remove_marker': {\n\t\t\t\tconst markers = this.p.markers ?? [];\n\t\t\t\tif (!markers.some((m) => m.id === e.marker)) refuse(`There is no marker ${namedWithSuggestion(e.marker, markers.map((m) => m.id))}.`);\n\t\t\t\tthis.p.markers = markers.filter((m) => m.id !== e.marker);\n\t\t\t\tif (!this.p.markers.length) delete this.p.markers;\n\t\t\t\treturn this.outcomes.push({ op: e.op, marker: e.marker });\n\t\t\t}\n\t\t\tcase 'replace_project': {\n\t\t\t\tthis.p = parseVideoProject(e.project);\n\t\t\t\treturn this.outcomes.push({ op: e.op });\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\trefuse(`Unknown op ${namedWithSuggestion(String((e as { op: unknown }).op), VIDEO_EDIT_OPS)}; the ops are ${VIDEO_EDIT_OPS.join(', ')}.`);\n\t\t}\n\t}\n}\n\n/**\n * Applies edits in order, all or nothing: the first that fails (an unknown id, an overlap, a trim\n * past the end of the source, a locked track) stops the lot and the project is unchanged. Edits\n * may not add errors (`checkVideoProject`); ones already there (from a loaded file) do not block.\n * With several edits, errors name the op (`op 2 (trim_clip): …`).\n */\nexport function applyVideoEdits(project: VideoProject, edits: VideoEdit | readonly VideoEdit[]): VideoEditResult {\n\tconst list = Array.isArray(edits) ? edits : [edits as VideoEdit];\n\tif (!list.length) return { ok: true, project, issues: checkVideoProject(project), outcomes: [] };\n\tconst editor = new Editor(structuredClone(project));\n\tfor (let i = 0; i < list.length; i++) {\n\t\tconst edit = list[i];\n\t\ttry {\n\t\t\teditor.run(edit);\n\t\t} catch (error) {\n\t\t\tconst message = (error as Error).message;\n\t\t\treturn { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${edit?.op}): ` : ''}${message}${/Nothing was changed\\.$/.test(message) ? '' : ' Nothing was changed.'}` };\n\t\t}\n\t}\n\tlet next: VideoProject;\n\ttry {\n\t\tnext = parseVideoProject(editor.p);\n\t} catch (error) {\n\t\treturn { ok: false, error: `${(error as Error).message} Nothing was changed.` };\n\t}\n\tconst before = new Set(checkVideoProject(project).filter((i) => i.severity === 'error').map(videoIssueKey));\n\tconst issues = checkVideoProject(next);\n\tconst added = issues.find((i) => i.severity === 'error' && !before.has(videoIssueKey(i)));\n\tif (added) return { ok: false, error: `That would leave an error: ${added.message} Nothing was changed.` };\n\tif (JSON.stringify(next) === JSON.stringify(project)) return { ok: true, project, issues, outcomes: editor.outcomes };\n\treturn { ok: true, project: next, issues, outcomes: editor.outcomes };\n}\n","import { videoClipSoundGain } from './evaluate.js';\nimport type { VideoAsset, VideoMediaClip } from './types.js';\n\n/**\n * Sound levels of a piece of audio, small enough to keep for every asset: a pyramid of min / max\n * sample values. Level 0 holds one min / max pair per `blockSize` samples; each level above merges\n * four blocks of the one below, so the min / max of any stretch costs a handful of reads at any\n * zoom. Channels are folded together (the loudest of them counts). Plain data: hosts can compute\n * it once (`buildVideoAudioPeaks`) and store it.\n */\nexport interface VideoAudioPeaks {\n\t/** Samples per second of the decoded audio. */\n\tsampleRate: number;\n\t/** Samples in the audio (per channel). */\n\tlength: number;\n\t/** Samples per level-0 block. */\n\tblockSize: number;\n\t/** `levels[k]` is `[min, max, min, max, …]`, one pair per block of `blockSize × 4^k` samples. */\n\tlevels: Float32Array[];\n}\n\n/** Samples per level-0 block (well under a millisecond at common rates). */\nexport const VIDEO_PEAK_BLOCK = 32;\n\n/** Builds the peak pyramid for decoded audio (one `Float32Array` of samples per channel). */\nexport function buildVideoAudioPeaks(channels: readonly Float32Array[], sampleRate: number, blockSize = VIDEO_PEAK_BLOCK): VideoAudioPeaks {\n\tif (!(sampleRate > 0)) throw new Error('The sample rate must be above 0.');\n\tconst length = channels.reduce((n, c) => Math.max(n, c.length), 0);\n\tconst blocks = Math.max(1, Math.ceil(length / blockSize));\n\tconst level0 = new Float32Array(blocks * 2);\n\tfor (let b = 0; b < blocks; b++) {\n\t\tlet lo = Infinity;\n\t\tlet hi = -Infinity;\n\t\tconst end = Math.min(length, (b + 1) * blockSize);\n\t\tfor (const channel of channels) {\n\t\t\tfor (let i = b * blockSize; i < Math.min(end, channel.length); i++) {\n\t\t\t\tconst v = channel[i];\n\t\t\t\tif (!Number.isFinite(v)) continue;\n\t\t\t\tif (v < lo) lo = v;\n\t\t\t\tif (v > hi) hi = v;\n\t\t\t}\n\t\t}\n\t\tlevel0[2 * b] = lo === Infinity ? 0 : lo;\n\t\tlevel0[2 * b + 1] = hi === -Infinity ? 0 : hi;\n\t}\n\tconst levels = [level0];\n\twhile (levels[levels.length - 1].length > 2) {\n\t\tconst below = levels[levels.length - 1];\n\t\tconst n = below.length / 2;\n\t\tconst up = new Float32Array(Math.ceil(n / 4) * 2);\n\t\tfor (let j = 0; j < up.length / 2; j++) {\n\t\t\tlet lo = Infinity;\n\t\t\tlet hi = -Infinity;\n\t\t\tfor (let i = 4 * j; i < Math.min(n, 4 * j + 4); i++) {\n\t\t\t\tlo = Math.min(lo, below[2 * i]);\n\t\t\t\thi = Math.max(hi, below[2 * i + 1]);\n\t\t\t}\n\t\t\tup[2 * j] = lo;\n\t\t\tup[2 * j + 1] = hi;\n\t\t}\n\t\tlevels.push(up);\n\t}\n\treturn { sampleRate, length, blockSize, levels };\n}\n\n/** Length of the audio (ms). */\nexport const videoAudioPeaksDuration = (peaks: VideoAudioPeaks): number => (peaks.length * 1000) / peaks.sampleRate;\n\n/**\n * The lowest and highest sample between two times (ms) in the audio, to the nearest block (so a\n * stretch can read up to a block wider). `{ min: 0, max: 0 }` outside the audio.\n */\nexport function videoAudioPeakRange(peaks: VideoAudioPeaks, from: number, to: number): { min: number; max: number } {\n\tconst blocks = peaks.levels[0].length / 2;\n\tlet a = Math.max(0, Math.floor((from * peaks.sampleRate) / 1000 / peaks.blockSize));\n\tlet b = Math.min(blocks, Math.ceil((to * peaks.sampleRate) / 1000 / peaks.blockSize));\n\tif (b <= a) {\n\t\t// Narrower than a block: the block it falls in.\n\t\tif (a >= blocks || to < 0) return { min: 0, max: 0 };\n\t\tb = a + 1;\n\t}\n\tlet min = Infinity;\n\tlet max = -Infinity;\n\tconst take = (level: Float32Array, i: number) => {\n\t\tif (2 * i + 1 >= level.length) return;\n\t\tif (level[2 * i] < min) min = level[2 * i];\n\t\tif (level[2 * i + 1] > max) max = level[2 * i + 1];\n\t};\n\t// Whole blocks at the edges of each level, then up a level for the aligned middle.\n\tfor (let k = 0; a < b; k++) {\n\t\tconst level = peaks.levels[k];\n\t\tif (k === peaks.levels.length - 1) {\n\t\t\tfor (let i = a; i < b; i++) take(level, i);\n\t\t\tbreak;\n\t\t}\n\t\twhile (a < b && a % 4) take(level, a++);\n\t\twhile (a < b && b % 4) take(level, --b);\n\t\ta /= 4;\n\t\tb /= 4;\n\t}\n\treturn min === Infinity ? { min: 0, max: 0 } : { min, max };\n}\n\n/** `columns` min / max pairs (`[min, max, …]`) evenly across `from`..`to` ms, e.g. one per pixel. */\nexport function videoAudioPeakSpans(peaks: VideoAudioPeaks, from: number, to: number, columns: number): Float32Array {\n\tconst n = Math.max(0, Math.floor(columns));\n\tconst out = new Float32Array(n * 2);\n\tconst step = (to - from) / Math.max(1, n);\n\tfor (let c = 0; c < n; c++) {\n\t\tconst { min, max } = videoAudioPeakRange(peaks, from + c * step, from + (c + 1) * step);\n\t\tout[2 * c] = min;\n\t\tout[2 * c + 1] = max;\n\t}\n\treturn out;\n}\n\n/**\n * What a media clip plays as `columns` min / max pairs across its length: each column reads the\n * stretch of source under it (through the in point and speed) and is scaled by the clip's volume and\n * fades, so the waveform shows what is heard.\n */\nexport function videoClipWaveform(clip: VideoMediaClip, peaks: VideoAudioPeaks, columns: number): Float32Array {\n\tconst n = Math.max(0, Math.floor(columns));\n\tconst out = new Float32Array(n * 2);\n\tconst speed = clip.speed ?? 1;\n\tconst start = clip.in ?? 0;\n\tconst step = clip.duration / Math.max(1, n);\n\tfor (let c = 0; c < n; c++) {\n\t\tconst { min, max } = videoAudioPeakRange(peaks, start + c * step * speed, start + (c + 1) * step * speed);\n\t\tconst gain = videoClipSoundGain(clip, (c + 0.5) * step);\n\t\tout[2 * c] = min * gain;\n\t\tout[2 * c + 1] = max * gain;\n\t}\n\treturn out;\n}\n\n/**\n * An SVG path (`d`) of a waveform filling `width` × `height`: the top edge along the maxima, back\n * along the minima. Values are clamped to ±1, so anything louder than full scale touches the edge.\n */\nexport function videoWaveformPath(spans: Float32Array, width: number, height: number): string {\n\tconst n = spans.length / 2;\n\tif (!n || width <= 0 || height <= 0) return '';\n\tconst mid = height / 2;\n\tconst y = (v: number) => (mid - Math.max(-1, Math.min(1, v)) * mid).toFixed(1);\n\tconst x = (i: number) => ((i + 0.5) * (width / n)).toFixed(1);\n\t// Keep at least a hairline where it is silent.\n\tconst floor = 0.5 / mid;\n\tconst top: string[] = [];\n\tconst bottom: string[] = [];\n\tfor (let i = 0; i < n; i++) top.push(`${i ? 'L' : 'M'}${x(i)} ${y(Math.max(spans[2 * i + 1], floor))}`);\n\tfor (let i = n - 1; i >= 0; i--) bottom.push(`L${x(i)} ${y(Math.min(spans[2 * i], -floor))}`);\n\treturn `${top.join('')}${bottom.join('')}Z`;\n}\n\n/**\n * The SVG path of a media clip's waveform drawn `width` × `height` pixels (one column per 2 px), as\n * `videoClipWaveform` reads it: what timelines draw inside an audio clip.\n */\nexport function videoClipWaveformPath(clip: VideoMediaClip, peaks: VideoAudioPeaks, width: number, height: number): string {\n\tconst columns = Math.max(1, Math.min(2000, Math.ceil(width / 2)));\n\treturn videoWaveformPath(videoClipWaveform(clip, peaks, columns), width, height);\n}\n\n/** A sample value as dBFS, floored at -100 (silence). */\nexport const videoPeakDb = (value: number): number => (Math.abs(value) > 1e-5 ? Math.max(-100, Math.round(200 * Math.log10(Math.abs(value))) / 10) : -100);\n\nexport interface VideoAudioLevel {\n\t/** Start of the bucket (ms, in the clock the caller asked in). */\n\tstart: number;\n\tend: number;\n\t/** Peak level in dBFS (0 = full scale, -100 = silence). */\n\tpeakDb: number;\n}\n\n/** Peak level in `buckets` equal stretches of `from`..`to` ms of the audio. */\nexport function videoAudioLevels(peaks: VideoAudioPeaks, from: number, to: number, buckets: number): VideoAudioLevel[] {\n\tconst spans = videoAudioPeakSpans(peaks, from, to, buckets);\n\tconst step = (to - from) / Math.max(1, buckets);\n\treturn Array.from({ length: spans.length / 2 }, (_, i) => ({ start: from + i * step, end: from + (i + 1) * step, peakDb: videoPeakDb(Math.max(Math.abs(spans[2 * i]), Math.abs(spans[2 * i + 1]))) }));\n}\n\nexport interface VideoSilenceOptions {\n\t/** Quieter than this peak level counts as silence (dBFS, default -40). */\n\tthresholdDb?: number;\n\t/** Shortest silence to report (ms, default 300). */\n\tminDuration?: number;\n\t/** Resolution of the scan (ms, default 10). */\n\tresolution?: number;\n}\n\n/** Stretches of `from`..`to` ms of the audio that stay below a level, at least `minDuration` long. */\nexport function findVideoAudioSilences(peaks: VideoAudioPeaks, from: number, to: number, options: VideoSilenceOptions = {}): { start: number; end: number }[] {\n\tconst threshold = options.thresholdDb ?? -40;\n\tconst minDuration = options.minDuration ?? 300;\n\tconst resolution = Math.max(1, options.resolution ?? 10);\n\tconst buckets = Math.max(1, Math.ceil((to - from) / resolution));\n\tconst out: { start: number; end: number }[] = [];\n\tlet run: number | null = null;\n\tconst levels = videoAudioLevels(peaks, from, to, buckets);\n\tlevels.forEach((l, i) => {\n\t\tconst quiet = l.peakDb < threshold;\n\t\tif (quiet && run === null) run = l.start;\n\t\tif ((!quiet || i === levels.length - 1) && run !== null) {\n\t\t\tconst end = quiet ? l.end : l.start;\n\t\t\tif (end - run >= minDuration) out.push({ start: run, end });\n\t\t\trun = null;\n\t\t}\n\t});\n\treturn out;\n}\n\n/** Fetches and decodes a media file's sound into peaks, in a browser (Web Audio). */\nexport async function decodeVideoAudioPeaks(src: string, options: { signal?: AbortSignal } = {}): Promise<VideoAudioPeaks> {\n\tconst Offline = (globalThis as { OfflineAudioContext?: typeof OfflineAudioContext }).OfflineAudioContext;\n\tif (!Offline || typeof fetch === 'undefined') throw new Error('Audio can only be decoded in a browser with Web Audio.');\n\tconst response = await fetch(src, options.signal ? { signal: options.signal } : {});\n\tif (!response.ok) throw new Error(`\"${src}\" could not be loaded (${response.status}).`);\n\tconst data = await response.arrayBuffer();\n\tlet buffer: AudioBuffer;\n\ttry {\n\t\tbuffer = await new Offline(1, 1, 44100).decodeAudioData(data);\n\t} catch {\n\t\tthrow new Error(`\"${src}\" has no sound this browser can decode.`);\n\t}\n\treturn buildVideoAudioPeaks(Array.from({ length: buffer.numberOfChannels }, (_, i) => buffer.getChannelData(i)), buffer.sampleRate);\n}\n\n/** Loads the peaks of an asset's sound. */\nexport type VideoAudioPeaksLoader = (asset: VideoAsset) => Promise<VideoAudioPeaks>;\n\n/**\n * Peaks per asset, loaded once each (by `src`) and shared by the timeline and agent tools.\n * `get` returns what is ready and starts loading the rest; `subscribe` hears when more is ready.\n */\nexport class VideoAudioPeaksCache {\n\tprivate readonly ready = new Map<string, VideoAudioPeaks>();\n\tprivate readonly pending = new Map<string, Promise<VideoAudioPeaks>>();\n\tprivate readonly failed = new Map<string, string>();\n\tprivate readonly listeners = new Set<() => void>();\n\n\tconstructor(private readonly loader: VideoAudioPeaksLoader = (asset) => decodeVideoAudioPeaks(asset.src)) {}\n\n\t/** The peaks if ready, without starting to load them. */\n\tpeek(asset: VideoAsset): VideoAudioPeaks | undefined {\n\t\treturn this.ready.get(asset.src);\n\t}\n\n\t/** The peaks if ready; otherwise `undefined`, and loading starts (once) unless it failed. */\n\tget(asset: VideoAsset): VideoAudioPeaks | undefined {\n\t\tconst peaks = this.ready.get(asset.src);\n\t\tif (!peaks && !this.failed.has(asset.src)) void this.load(asset).catch(() => {});\n\t\treturn peaks;\n\t}\n\n\t/** Why loading an asset's peaks failed, if it did. */\n\terror(asset: VideoAsset): string | undefined {\n\t\treturn this.failed.get(asset.src);\n\t}\n\n\t/** The peaks, loading them if needed. Rejects with a readable error. */\n\tload(asset: VideoAsset): Promise<VideoAudioPeaks> {\n\t\tconst ready = this.ready.get(asset.src);\n\t\tif (ready) return Promise.resolve(ready);\n\t\tconst failed = this.failed.get(asset.src);\n\t\tif (failed) return Promise.reject(new Error(failed));\n\t\tlet pending = this.pending.get(asset.src);\n\t\tif (!pending) {\n\t\t\tpending = this.loader(asset).then(\n\t\t\t\t(peaks) => {\n\t\t\t\t\tthis.pending.delete(asset.src);\n\t\t\t\t\tthis.ready.set(asset.src, peaks);\n\t\t\t\t\tthis.emit();\n\t\t\t\t\treturn peaks;\n\t\t\t\t},\n\t\t\t\t(e: unknown) => {\n\t\t\t\t\tthis.pending.delete(asset.src);\n\t\t\t\t\tconst message = `The sound of \"${asset.id}\" could not be read: ${e instanceof Error ? e.message : String(e)}`;\n\t\t\t\t\tthis.failed.set(asset.src, message);\n\t\t\t\t\tthis.emit();\n\t\t\t\t\tthrow new Error(message);\n\t\t\t\t}\n\t\t\t);\n\t\t\tthis.pending.set(asset.src, pending);\n\t\t}\n\t\treturn pending;\n\t}\n\n\t/** Uses peaks the host already has (computed earlier or stored). */\n\tset(src: string, peaks: VideoAudioPeaks): void {\n\t\tthis.ready.set(src, peaks);\n\t\tthis.failed.delete(src);\n\t\tthis.emit();\n\t}\n\n\tsubscribe(fn: () => void): () => void {\n\t\tthis.listeners.add(fn);\n\t\treturn () => this.listeners.delete(fn);\n\t}\n\n\tprivate emit() {\n\t\tfor (const fn of this.listeners) fn();\n\t}\n}\n","/**\n * Colour scopes: a histogram, a luma waveform and a vectorscope, worked out from a frame's pixels,\n * plus exposure and clipping figures. Pure functions on RGBA pixels as `ImageData` holds them, so\n * the same numbers come from a preview canvas, a decoded export frame or a test.\n *\n * Luma and chroma use the Rec. 709 weights on the gamma-encoded values (Y′, Cb, Cr), as broadcast\n * scopes do: luma 0 is black and 1 white; Cb and Cr run from −0.5 to 0.5.\n */\n\n/** RGBA pixels, 4 values (0–255) a pixel, rows top to bottom: an `ImageData` fits. */\nexport interface VideoPixels {\n\twidth: number;\n\theight: number;\n\tdata: ArrayLike<number>;\n}\n\nexport type VideoScopeKind = 'histogram' | 'waveform' | 'vectorscope';\nexport const VIDEO_SCOPE_KINDS = ['histogram', 'waveform', 'vectorscope'] as const;\n\n/** Rec. 709 luma of gamma-encoded RGB (each 0–1). */\nexport const videoLuma = (r: number, g: number, b: number): number => 0.2126 * r + 0.7152 * g + 0.0722 * b;\n\n/** Rec. 709 chroma (Cb, Cr), each −0.5 to 0.5, of gamma-encoded RGB (each 0–1). */\nexport function videoChroma(r: number, g: number, b: number): [number, number] {\n\tconst y = videoLuma(r, g, b);\n\treturn [(b - y) / 1.8556, (r - y) / 1.5748];\n}\n\nexport interface VideoScopeOptions {\n\t/**\n\t * Read at most about this many pixels, evenly spaced (default 262 144, a 512 × 512 picture), so\n\t * a large frame costs no more than a small one. Counts are of the pixels read.\n\t */\n\tmaxPixels?: number;\n}\n\n/** Calls `visit(r, g, b, x, y)` (0–255 values) for evenly spaced pixels, at most about `maxPixels`. */\nfunction eachPixel(pixels: VideoPixels, maxPixels: number | undefined, visit: (r: number, g: number, b: number, x: number, y: number) => void): number {\n\tconst { width, height, data } = pixels;\n\tif (!(width > 0 && height > 0) || data.length < width * height * 4) throw new Error(`The pixels are not ${width} × ${height} RGBA: there are ${data.length} values, and ${width * height * 4} are needed.`);\n\tconst step = Math.max(1, Math.sqrt((width * height) / Math.max(1, maxPixels ?? 262_144)));\n\tlet count = 0;\n\tfor (let fy = step / 2; fy < height; fy += step) {\n\t\tconst y = Math.floor(fy);\n\t\tfor (let fx = step / 2; fx < width; fx += step) {\n\t\t\tconst x = Math.floor(fx);\n\t\t\tconst i = (y * width + x) * 4;\n\t\t\tvisit(data[i], data[i + 1], data[i + 2], x, y);\n\t\t\tcount++;\n\t\t}\n\t}\n\treturn count;\n}\n\n/** How many pixels fall in each of `bins` equal steps of red, green, blue and luma (bin 0 is darkest). */\nexport interface VideoHistogram {\n\tbins: number;\n\tr: number[];\n\tg: number[];\n\tb: number[];\n\tluma: number[];\n\t/** Pixels counted. */\n\ttotal: number;\n}\n\n// The nudge keeps a grey whose luma comes out a hair under its value (39.99999…) in its own step.\nconst binOf = (v: number, bins: number) => Math.min(bins - 1, Math.floor(((v + 1e-6) / 256) * bins));\n\nexport function videoHistogram(pixels: VideoPixels, options: VideoScopeOptions & { bins?: number } = {}): VideoHistogram {\n\tconst bins = Math.max(1, Math.min(256, Math.round(options.bins ?? 256)));\n\tconst out: VideoHistogram = { bins, r: Array(bins).fill(0), g: Array(bins).fill(0), b: Array(bins).fill(0), luma: Array(bins).fill(0), total: 0 };\n\tout.total = eachPixel(pixels, options.maxPixels, (r, g, b) => {\n\t\tout.r[binOf(r, bins)]++;\n\t\tout.g[binOf(g, bins)]++;\n\t\tout.b[binOf(b, bins)]++;\n\t\tout.luma[binOf(videoLuma(r, g, b), bins)]++;\n\t});\n\treturn out;\n}\n\n/**\n * A luma waveform: the picture split into `columns` vertical strips, left to right, each counting\n * its pixels at each of `levels` luma steps. `data[column * levels + level]`; level 0 is black.\n */\nexport interface VideoWaveform {\n\tcolumns: number;\n\tlevels: number;\n\tdata: Uint32Array;\n\t/** The largest count, for scaling a drawing. */\n\tpeak: number;\n}\n\nexport function videoLumaWaveform(pixels: VideoPixels, options: VideoScopeOptions & { columns?: number; levels?: number } = {}): VideoWaveform {\n\tconst columns = Math.max(1, Math.min(pixels.width, Math.round(options.columns ?? Math.min(pixels.width, 256))));\n\tconst levels = Math.max(2, Math.min(256, Math.round(options.levels ?? 128)));\n\tconst data = new Uint32Array(columns * levels);\n\tlet peak = 0;\n\teachPixel(pixels, options.maxPixels, (r, g, b, x) => {\n\t\tconst c = Math.min(columns - 1, Math.floor((x / pixels.width) * columns));\n\t\tconst i = c * levels + binOf(videoLuma(r, g, b), levels);\n\t\tif (++data[i] > peak) peak = data[i];\n\t});\n\treturn { columns, levels, data, peak };\n}\n\n/**\n * A vectorscope: pixels counted on a `size` × `size` grid by chroma, Cb across (left −0.5, right\n * +0.5) and Cr up (top +0.5). The centre is grey; the further out, the more saturated; the angle\n * is the hue. `data[row * size + column]`.\n */\nexport interface VideoVectorscope {\n\tsize: number;\n\tdata: Uint32Array;\n\tpeak: number;\n}\n\n/** Where chroma (Cb, Cr) falls on a vectorscope of `size` pixels, as `[column, row]`. */\nexport const videoVectorscopePoint = (cb: number, cr: number, size: number): [number, number] => [(cb + 0.5) * (size - 1), (0.5 - cr) * (size - 1)];\n\nexport function videoVectorscope(pixels: VideoPixels, options: VideoScopeOptions & { size?: number } = {}): VideoVectorscope {\n\tconst size = Math.max(16, Math.min(512, Math.round(options.size ?? 128)));\n\tconst data = new Uint32Array(size * size);\n\tlet peak = 0;\n\teachPixel(pixels, options.maxPixels, (r, g, b) => {\n\t\tconst [cb, cr] = videoChroma(r / 255, g / 255, b / 255);\n\t\tconst [x, y] = videoVectorscopePoint(cb, cr, size);\n\t\tconst i = Math.round(y) * size + Math.round(x);\n\t\tif (++data[i] > peak) peak = data[i];\n\t});\n\treturn { size, data, peak };\n}\n\n/** Exposure and colour figures for a frame (see `videoExposureStats`). Luma and levels run 0–1. */\nexport interface VideoExposureStats {\n\t/** Pixels read. */\n\tpixels: number;\n\tluma: { mean: number; median: number; min: number; max: number; p1: number; p99: number };\n\t/** Share (0–1) of pixels crushed to black (luma at or below `shadowLevel`) or blown to white (at or above `highlightLevel`). */\n\tclipped: { shadows: number; highlights: number; r: number; g: number; b: number };\n\t/** Mean and highest chroma, 0 (grey) to 1 (a full primary). */\n\tsaturation: { mean: number; max: number };\n\t/** The mean colour as `#rrggbb`: far from grey means a colour cast. */\n\taverage: string;\n}\n\nexport interface VideoExposureOptions extends VideoScopeOptions {\n\t/** Luma (0–1) at or below which a pixel counts as crushed (default 0.02). */\n\tshadowLevel?: number;\n\t/** Luma or channel (0–1) at or above which a pixel counts as blown (default 0.98). */\n\thighlightLevel?: number;\n}\n\nconst round3 = (v: number) => Math.round(v * 1000) / 1000;\nconst hex2 = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');\n\nexport function videoExposureStats(pixels: VideoPixels, options: VideoExposureOptions = {}): VideoExposureStats {\n\tconst low = (options.shadowLevel ?? 0.02) * 255;\n\tconst high = (options.highlightLevel ?? 0.98) * 255;\n\tconst lumaBins = new Uint32Array(256);\n\tlet sumY = 0;\n\tlet sumR = 0;\n\tlet sumG = 0;\n\tlet sumB = 0;\n\tlet sumSat = 0;\n\tlet maxSat = 0;\n\tlet shadows = 0;\n\tlet highlights = 0;\n\tlet cr = 0;\n\tlet cg = 0;\n\tlet cbl = 0;\n\tconst total = eachPixel(pixels, options.maxPixels, (r, g, b) => {\n\t\tconst y = videoLuma(r, g, b);\n\t\tlumaBins[Math.min(255, Math.round(y))]++;\n\t\tsumY += y;\n\t\tsumR += r;\n\t\tsumG += g;\n\t\tsumB += b;\n\t\tconst [u, v] = videoChroma(r / 255, g / 255, b / 255);\n\t\tconst sat = Math.min(1, Math.hypot(u, v) / 0.5);\n\t\tsumSat += sat;\n\t\tif (sat > maxSat) maxSat = sat;\n\t\tif (y <= low) shadows++;\n\t\tif (y >= high) highlights++;\n\t\tif (r >= high) cr++;\n\t\tif (g >= high) cg++;\n\t\tif (b >= high) cbl++;\n\t});\n\tconst percentile = (p: number) => {\n\t\tconst target = p * total;\n\t\tlet seen = 0;\n\t\tfor (let i = 0; i < 256; i++) {\n\t\t\tseen += lumaBins[i];\n\t\t\tif (seen >= target && seen > 0) return i / 255;\n\t\t}\n\t\treturn 1;\n\t};\n\tlet min = 0;\n\twhile (min < 255 && !lumaBins[min]) min++;\n\tlet max = 255;\n\twhile (max > 0 && !lumaBins[max]) max--;\n\treturn {\n\t\tpixels: total,\n\t\tluma: { 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)) },\n\t\tclipped: { shadows: round3(shadows / total), highlights: round3(highlights / total), r: round3(cr / total), g: round3(cg / total), b: round3(cbl / total) },\n\t\tsaturation: { mean: round3(sumSat / total), max: round3(maxSat) },\n\t\taverage: `#${hex2(sumR / total)}${hex2(sumG / total)}${hex2(sumB / total)}`\n\t};\n}\n\n/** What a scope reading says in words: exposure, clipping and colour cast worth fixing. */\nexport function videoExposureNotes(stats: VideoExposureStats): string[] {\n\tconst notes: string[] = [];\n\tconst pct = (v: number) => `${Math.round(v * 1000) / 10}%`;\n\tif (stats.clipped.shadows >= 0.05) notes.push(`${pct(stats.clipped.shadows)} of the picture is crushed to black; raise exposure or lower contrast.`);\n\tif (stats.clipped.highlights >= 0.02) notes.push(`${pct(stats.clipped.highlights)} of the picture is blown to white; lower exposure.`);\n\telse {\n\t\tconst channels = (['r', 'g', 'b'] as const).filter((k) => stats.clipped[k] >= 0.05);\n\t\tif (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.`);\n\t}\n\tif (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.`);\n\tif (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.`);\n\tif (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.`);\n\tconst [r, g, b] = [1, 3, 5].map((i) => parseInt(stats.average.slice(i, i + 2), 16) / 255);\n\tconst [u, v] = videoChroma(r, g, b);\n\tif (Math.hypot(u, v) > 0.04 && stats.saturation.mean < 0.35) {\n\t\tconst warm = v - u;\n\t\tnotes.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.`);\n\t}\n\treturn notes;\n}\n\n/** A compact reading of a frame for agents: exposure figures, notes, and coarse histogram, waveform and vectorscope summaries. */\nexport interface VideoScopeSummary {\n\tstats: VideoExposureStats;\n\tnotes: string[];\n\t/** Share of pixels (0–1) in each of `bins` equal luma and channel steps, darkest first. */\n\thistogram: { bins: number; luma: number[]; r: number[]; g: number[]; b: number[] };\n\t/** The picture in vertical strips, left to right: each strip's luma spread (0–1). */\n\twaveform: { start: number; end: number; min: number; median: number; max: number }[];\n\t/** The chroma of the most saturated tenth of the picture: its main hue (degrees on the vectorscope, red ≈ 103°) and strength (0–1). */\n\tvectorscope: { hue: number | null; strength: number; spread: number };\n}\n\n/** Vectorscope angle (degrees, anticlockwise from +Cb) of chroma. */\nexport const videoVectorscopeAngle = (cb: number, cr: number): number => (((Math.atan2(cr, cb) * 180) / Math.PI) % 360 + 360) % 360;\n\nexport function summarizeVideoScopes(pixels: VideoPixels, options: VideoExposureOptions & { bins?: number; columns?: number } = {}): VideoScopeSummary {\n\tconst stats = videoExposureStats(pixels, options);\n\tconst bins = Math.max(2, Math.min(64, Math.round(options.bins ?? 16)));\n\tconst h = videoHistogram(pixels, { ...options, bins });\n\tconst share = (a: number[]) => a.map((v) => round3(v / h.total));\n\tconst columns = Math.max(1, Math.min(32, Math.round(options.columns ?? 8)));\n\tconst w = videoLumaWaveform(pixels, { ...options, columns, levels: 256 });\n\tconst waveform = Array.from({ length: w.columns }, (_, c) => {\n\t\tconst counts = w.data.subarray(c * 256, c * 256 + 256);\n\t\tconst n = counts.reduce((a, b) => a + b, 0);\n\t\tlet lo = 0;\n\t\twhile (lo < 255 && !counts[lo]) lo++;\n\t\tlet hi = 255;\n\t\twhile (hi > 0 && !counts[hi]) hi--;\n\t\tlet seen = 0;\n\t\tlet median = 0;\n\t\tfor (let i = 0; i < 256; i++) {\n\t\t\tseen += counts[i];\n\t\t\tif (seen >= n / 2 && n) {\n\t\t\t\tmedian = i;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn { start: round3(c / w.columns), end: round3((c + 1) / w.columns), min: round3(lo / 255), median: round3(median / 255), max: round3(hi / 255) };\n\t}).filter((_, c) => w.data.subarray(c * 256, c * 256 + 256).some((v) => v > 0));\n\t// The most saturated tenth: its mean direction is the picture's main hue.\n\tconst chroma: [number, number, number][] = [];\n\teachPixel(pixels, Math.min(options.maxPixels ?? 65_536, 65_536), (r, g, b) => {\n\t\tconst [u, v] = videoChroma(r / 255, g / 255, b / 255);\n\t\tchroma.push([u, v, Math.hypot(u, v)]);\n\t});\n\tchroma.sort((a, b) => b[2] - a[2]);\n\tconst top = chroma.slice(0, Math.max(1, Math.ceil(chroma.length / 10)));\n\tconst mu = top.reduce((s, c) => s + c[0], 0) / top.length;\n\tconst mv = top.reduce((s, c) => s + c[1], 0) / top.length;\n\tconst strength = Math.min(1, top.reduce((s, c) => s + c[2], 0) / top.length / 0.5);\n\tconst coherence = Math.hypot(mu, mv) / Math.max(1e-9, top.reduce((s, c) => s + c[2], 0) / top.length);\n\treturn {\n\t\tstats,\n\t\tnotes: videoExposureNotes(stats),\n\t\thistogram: { bins, luma: share(h.luma), r: share(h.r), g: share(h.g), b: share(h.b) },\n\t\twaveform,\n\t\tvectorscope: { hue: strength < 0.02 ? null : Math.round(videoVectorscopeAngle(mu, mv)), strength: round3(strength), spread: round3(1 - coherence) }\n\t};\n}\n\n/** Where the six 75% colour-bar targets sit on a vectorscope: name, Cb and Cr. */\nexport const VIDEO_VECTORSCOPE_TARGETS: readonly { name: string; rgb: [number, number, number]; cb: number; cr: number }[] = (\n\t[\n\t\t['R', [0.75, 0, 0]],\n\t\t['Mg', [0.75, 0, 0.75]],\n\t\t['B', [0, 0, 0.75]],\n\t\t['Cy', [0, 0.75, 0.75]],\n\t\t['G', [0, 0.75, 0]],\n\t\t['Yl', [0.75, 0.75, 0]]\n\t] as const\n).map(([name, rgb]) => {\n\tconst [cb, cr] = videoChroma(rgb[0], rgb[1], rgb[2]);\n\treturn { name, rgb: [...rgb] as [number, number, number], cb, cr };\n});\n\n/** RGBA pixels of a drawn scope, ready for `new ImageData(data, width, height)`. */\nexport interface VideoScopeImage {\n\twidth: number;\n\theight: number;\n\tdata: Uint8ClampedArray<ArrayBuffer>;\n}\n\nfunction canvas(width: number, height: number): VideoScopeImage {\n\tconst data = new Uint8ClampedArray(width * height * 4);\n\tfor (let i = 0; i < data.length; i += 4) {\n\t\tdata[i] = 12;\n\t\tdata[i + 1] = 14;\n\t\tdata[i + 2] = 18;\n\t\tdata[i + 3] = 255;\n\t}\n\treturn { width, height, data };\n}\n\nfunction plot(img: VideoScopeImage, x: number, y: number, rgb: readonly [number, number, number], alpha = 1, add = false) {\n\tconst xi = Math.round(x);\n\tconst yi = Math.round(y);\n\tif (xi < 0 || yi < 0 || xi >= img.width || yi >= img.height) return;\n\tconst i = (yi * img.width + xi) * 4;\n\tfor (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;\n}\n\nconst GRID: [number, number, number] = [70, 76, 88];\n/** Brightness for a count: log-scaled so a few pixels still show next to a large flat area. */\nconst glow = (count: number, peak: number) => (count ? 0.25 + 0.75 * (Math.log1p(count) / Math.log1p(Math.max(1, peak))) : 0);\n\n/** A histogram drawn `width` × `height`: red, green and blue added together, with luma as a white outline. */\nexport function videoHistogramImage(h: VideoHistogram, width = 256, height = 128): VideoScopeImage {\n\tconst img = canvas(width, height);\n\tfor (const q of [0.25, 0.5, 0.75]) for (let y = 0; y < height; y++) plot(img, q * (width - 1), y, GRID);\n\t// Scale to the tallest bin away from the ends, so a clipped spike does not flatten everything else.\n\tconst inner = (a: number[]) => (a.length > 2 ? a.slice(1, -1) : a);\n\tconst peak = Math.max(1, ...inner(h.r), ...inner(h.g), ...inner(h.b), ...inner(h.luma));\n\tconst height01 = (v: number) => Math.min(1, Math.sqrt(v / peak));\n\tfor (let x = 0; x < width; x++) {\n\t\tconst bin = Math.min(h.bins - 1, Math.floor((x / width) * h.bins));\n\t\tconst channels: [number[], [number, number, number]][] = [\n\t\t\t[h.r, [200, 40, 40]],\n\t\t\t[h.g, [40, 180, 40]],\n\t\t\t[h.b, [50, 80, 220]]\n\t\t];\n\t\tfor (const [a, rgb] of channels) {\n\t\t\tconst top = height - height01(a[bin]) * height;\n\t\t\tfor (let y = Math.ceil(top); y < height; y++) plot(img, x, y, rgb, 0.8, true);\n\t\t}\n\t\tplot(img, x, height - 1 - height01(h.luma[bin]) * (height - 1), [255, 255, 255]);\n\t}\n\treturn img;\n}\n\n/** A luma waveform drawn `width` × `height`: white at the top, black at the bottom, lines at each quarter. */\nexport function videoWaveformImage(w: VideoWaveform, width = 256, height = 128): VideoScopeImage {\n\tconst img = canvas(width, height);\n\tfor (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);\n\tfor (let x = 0; x < width; x++) {\n\t\tconst c = Math.min(w.columns - 1, Math.floor((x / width) * w.columns));\n\t\tfor (let y = 0; y < height; y++) {\n\t\t\tconst level = Math.min(w.levels - 1, Math.floor(((height - 1 - y) / height) * w.levels));\n\t\t\tconst a = glow(w.data[c * w.levels + level], w.peak);\n\t\t\tif (a) plot(img, x, y, [120, 255, 140], a);\n\t\t}\n\t}\n\treturn img;\n}\n\n/** A vectorscope drawn `size` × `size`, with its outer circle, the skin-tone line and the colour-bar targets. */\nexport function videoVectorscopeImage(v: VideoVectorscope, size = v.size): VideoScopeImage {\n\tconst img = canvas(size, size);\n\tconst c = (size - 1) / 2;\n\tfor (let a = 0; a < 720; a++) {\n\t\tconst t = (a / 720) * 2 * Math.PI;\n\t\tplot(img, c + Math.cos(t) * c, c - Math.sin(t) * c, GRID);\n\t}\n\t// The skin-tone (I) line, at 123° on a vectorscope.\n\tconst skin = (123 * Math.PI) / 180;\n\tfor (let r = 0; r < c; r++) plot(img, c + Math.cos(skin) * r, c - Math.sin(skin) * r, [140, 110, 90]);\n\tfor (const t of VIDEO_VECTORSCOPE_TARGETS) {\n\t\tconst [x, y] = videoVectorscopePoint(t.cb, t.cr, size);\n\t\tconst rgb = t.rgb.map((k) => 80 + k * 200) as [number, number, number];\n\t\tfor (let k = -3; k <= 3; k++) {\n\t\t\tplot(img, x + k, y - 3, rgb);\n\t\t\tplot(img, x + k, y + 3, rgb);\n\t\t\tplot(img, x - 3, y + k, rgb);\n\t\t\tplot(img, x + 3, y + k, rgb);\n\t\t}\n\t}\n\tfor (let y = 0; y < size; y++)\n\t\tfor (let x = 0; x < size; x++) {\n\t\t\tconst sy = Math.min(v.size - 1, Math.floor((y / size) * v.size));\n\t\t\tconst sx = Math.min(v.size - 1, Math.floor((x / size) * v.size));\n\t\t\tconst a = glow(v.data[sy * v.size + sx], v.peak);\n\t\t\tif (!a) continue;\n\t\t\t// Each point takes the hue it stands for, so the scope reads like a colour wheel.\n\t\t\tconst cb = sx / (v.size - 1) - 0.5;\n\t\t\tconst cr = 0.5 - sy / (v.size - 1);\n\t\t\tconst r = Math.min(1, Math.max(0, 0.6 + 1.5748 * cr));\n\t\t\tconst g = Math.min(1, Math.max(0, 0.6 - 0.1873 * cb - 0.4681 * cr));\n\t\t\tconst b = Math.min(1, Math.max(0, 0.6 + 1.8556 * cb));\n\t\t\tplot(img, x, y, [r * 255, g * 255, b * 255], a);\n\t\t}\n\treturn img;\n}\n\n/** One scope of a frame drawn `width` × `height` (a vectorscope is square: the smaller side). */\nexport function videoScopeImage(kind: VideoScopeKind, pixels: VideoPixels, width = 256, height = 128, options: VideoScopeOptions = {}): VideoScopeImage {\n\tif (kind === 'histogram') return videoHistogramImage(videoHistogram(pixels, { ...options, bins: Math.min(256, width) }), width, height);\n\tif (kind === 'waveform') return videoWaveformImage(videoLumaWaveform(pixels, { ...options, columns: Math.min(pixels.width, width), levels: Math.min(256, height) }), width, height);\n\tconst size = Math.min(width, height);\n\treturn videoVectorscopeImage(videoVectorscope(pixels, { ...options, size }), size);\n}\n\n/** A frame the preview drew: when, whether every picture in it had loaded, and its pixels. */\nexport interface VideoPreviewSnapshot {\n\ttime: number;\n\t/** False while a picture is still loading or seeking (the frame shows a placeholder or a stale picture). */\n\tcomplete: boolean;\n\tpixels: VideoPixels;\n}\n\n/**\n * Where a preview's pixels can be read, for scopes and agents: a `VideoPreviewEngine` attaches its\n * canvas and says each time it draws. `VideoEditorController.previewPixels` is one, so a preview,\n * a scopes panel and the agent tools of one editor find each other without wiring.\n */\nexport class VideoPreviewPixels {\n\tprivate reader: (() => VideoPreviewSnapshot | null) | null = null;\n\tprivate drawnAt: { time: number; complete: boolean } | null = null;\n\tprivate readonly listeners = new Set<() => void>();\n\n\t/** Gives the canvas to read (the latest preview wins). Returns a function that takes it away again. */\n\tattach(reader: () => VideoPreviewSnapshot | null): () => void {\n\t\tthis.reader = reader;\n\t\tthis.drawnAt = null;\n\t\treturn () => {\n\t\t\tif (this.reader !== reader) return;\n\t\t\tthis.reader = null;\n\t\t\tthis.drawnAt = null;\n\t\t\tthis.emit();\n\t\t};\n\t}\n\n\t/** Whether a preview is attached. */\n\tget attached(): boolean {\n\t\treturn this.reader !== null;\n\t}\n\n\t/** The preview drew the frame at `time`; `complete` is false while a picture was missing or stale. */\n\tdrawn(time: number, complete: boolean): void {\n\t\tthis.drawnAt = { time, complete };\n\t\tthis.emit();\n\t}\n\n\t/** The last frame drawn, with its pixels; `null` without a preview. Throws when the canvas cannot be read. */\n\tread(): VideoPreviewSnapshot | null {\n\t\tif (!this.reader) return null;\n\t\ttry {\n\t\t\treturn this.reader();\n\t\t} catch (e) {\n\t\t\tconst reason = e instanceof Error ? e.message : String(e);\n\t\t\tthrow 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.`);\n\t\t}\n\t}\n\n\t/** Called after each frame the preview draws. */\n\tsubscribe(listener: () => void): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t}\n\n\t/**\n\t * The frame at `time` once it is complete: now, if the last frame drawn was it, else the next\n\t * complete drawing of it. Fails after `timeout` ms (default 5000).\n\t */\n\tnext(time: number, timeout = 5000): Promise<VideoPreviewSnapshot> {\n\t\tconst matches = () => this.drawnAt && this.drawnAt.complete && Math.abs(this.drawnAt.time - time) < 0.5;\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst done = (fn: () => void) => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\toff();\n\t\t\t\tfn();\n\t\t\t};\n\t\t\tconst check = () => {\n\t\t\t\tif (!this.reader) return done(() => reject(new Error('The preview was closed before it drew the frame.')));\n\t\t\t\tif (!matches()) return;\n\t\t\t\ttry {\n\t\t\t\t\tconst snap = this.read();\n\t\t\t\t\tdone(() => (snap ? resolve(snap) : reject(new Error('The preview has no pixels to read.'))));\n\t\t\t\t} catch (e) {\n\t\t\t\t\tdone(() => reject(e));\n\t\t\t\t}\n\t\t\t};\n\t\t\tconst off = this.subscribe(check);\n\t\t\tconst 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);\n\t\t\tcheck();\n\t\t});\n\t}\n\n\tprivate emit() {\n\t\tfor (const l of [...this.listeners]) l();\n\t}\n}\n\n/** What a scopes panel shows for the preview's latest frame (see `readVideoScopesPanel`). */\nexport type VideoScopesPanelReading =\n\t| { ok: true; time: number; complete: boolean; images: Partial<Record<VideoScopeKind, VideoScopeImage>>; stats: VideoExposureStats; notes: string[] }\n\t| { ok: false; message: string };\n\n/**\n * The preview's latest frame as a scopes panel draws it: an image per scope `kinds`, each\n * `width` × `height` (vectorscopes square), and the exposure figures. Reads at most `maxPixels`\n * (default 65 536) so it keeps up with playback. Says why when there is nothing to show.\n */\nexport function readVideoScopesPanel(feed: VideoPreviewPixels, kinds: readonly VideoScopeKind[] = VIDEO_SCOPE_KINDS, options: { width?: number; height?: number; maxPixels?: number } = {}): VideoScopesPanelReading {\n\tlet snap: VideoPreviewSnapshot | null;\n\ttry {\n\t\tsnap = feed.read();\n\t} catch (e) {\n\t\treturn { ok: false, message: e instanceof Error ? e.message : String(e) };\n\t}\n\tif (!snap) return { ok: false, message: feed.attached ? 'The preview has not drawn a frame yet.' : 'Show the preview to see its scopes.' };\n\tconst width = options.width ?? 256;\n\tconst height = options.height ?? 128;\n\tconst opts = { maxPixels: options.maxPixels ?? 65_536 };\n\tconst images: Partial<Record<VideoScopeKind, VideoScopeImage>> = {};\n\tfor (const kind of kinds) images[kind] = videoScopeImage(kind, snap.pixels, width, height, opts);\n\tconst stats = videoExposureStats(snap.pixels, opts);\n\treturn { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };\n}\n","import type { VideoEdit } from './edit.js';\nimport { snapToVideoFrame, sortedVideoClips, videoAssetMap, videoClipEnd, videoClipSpeed, VIDEO_TIME_EPSILON } from './timeline.js';\nimport type { VideoProject, VideoTranscriptWord } from './types.js';\n\n/**\n * Text-based editing: an asset's transcript is a list of words timed in its source, so it stays\n * right however the clips that use it are cut. `videoTimelineTranscript` maps the words through the\n * clips onto the timeline; searches, fillers and pauses come back as timeline ranges that\n * `videoRemoveRangesEdits` cuts out in one step.\n */\n\n// ---------------------------------------------------------------------------------------------\n// Reading transcripts\n// ---------------------------------------------------------------------------------------------\n\nconst isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** `HH:MM:SS,mmm`, `HH:MM:SS.mmm` or `MM:SS.mmm` as ms. */\nfunction cueTime(s: string): number {\n\tconst m = s.trim().match(/^(?:(\\d+):)?(\\d{1,2}):(\\d{1,2})[.,](\\d{1,3})$/);\n\tif (!m) throw new Error(`\"${s.trim()}\" is not a cue time (HH:MM:SS,mmm).`);\n\treturn ((Number(m[1] ?? 0) * 60 + Number(m[2])) * 60 + Number(m[3])) * 1000 + Number(m[4].padEnd(3, '0'));\n}\n\n/** Words of a cue spread evenly across its time (cues have no word timings). */\nfunction cueWords(text: string, start: number, end: number): VideoTranscriptWord[] {\n\tconst words = text\n\t\t.replace(/<[^>]+>/g, '')\n\t\t.split(/\\s+/)\n\t\t.filter(Boolean);\n\tconst step = (end - start) / Math.max(1, words.length);\n\treturn words.map((word, i) => ({ word, start: Math.round(start + i * step), end: Math.round(start + (i + 1) * step) }));\n}\n\n/**\n * A transcript from text a speech-to-text service or captioning tool wrote: Whisper-style JSON\n * (`{ segments: [{ words: [{ word, start, end }] }] }` or `{ words: [...] }`, times in seconds, or a\n * plain `[{ word, start, end }]` array in ms), SubRip (`.srt`) or WebVTT. Captions have no word\n * timings, so their words are spread evenly across each cue (`approximate: true`). Throws when it\n * finds no words.\n */\nexport function parseVideoTranscript(input: string | unknown): { words: VideoTranscriptWord[]; approximate: boolean } {\n\tlet data: unknown = input;\n\tif (typeof input === 'string') {\n\t\tconst text = input.replace(/^/, '').trim();\n\t\tif (text.startsWith('{') || text.startsWith('[')) {\n\t\t\ttry {\n\t\t\t\tdata = JSON.parse(text);\n\t\t\t} catch {\n\t\t\t\tthrow new Error('The transcript looks like JSON but is not valid JSON.');\n\t\t\t}\n\t\t} else {\n\t\t\t// SubRip or WebVTT: blocks of an optional id, a \"start --> end\" line and text lines.\n\t\t\tconst words: VideoTranscriptWord[] = [];\n\t\t\tfor (const block of text.replace(/\\r\\n?/g, '\\n').split(/\\n{2,}/)) {\n\t\t\t\tconst lines = block.split('\\n');\n\t\t\t\tconst at = lines.findIndex((l) => l.includes('-->'));\n\t\t\t\tif (at < 0) continue;\n\t\t\t\tconst [a, b] = lines[at].split('-->');\n\t\t\t\twords.push(...cueWords(lines.slice(at + 1).join(' '), cueTime(a), cueTime(b.trim().split(/\\s+/)[0])));\n\t\t\t}\n\t\t\tif (!words.length) throw new Error('No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.');\n\t\t\treturn { words, approximate: true };\n\t\t}\n\t}\n\tconst seconds = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 1000) : NaN);\n\tconst fromList = (list: unknown[], toMs: (v: unknown) => number) =>\n\t\tlist.map((w, i) => {\n\t\t\tif (!isRecord(w)) throw new Error(`Word ${i + 1} is not an object { word, start, end }.`);\n\t\t\tconst word = String(w.word ?? w.text ?? '').trim();\n\t\t\tconst start = toMs(w.start);\n\t\t\tconst end = toMs(w.end);\n\t\t\tif (!word || !Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Word ${i + 1} needs a word and numeric start and end.`);\n\t\t\treturn { word, start, end: Math.max(start, end) };\n\t\t});\n\tlet words: VideoTranscriptWord[] = [];\n\tif (Array.isArray(data)) words = fromList(data, (v) => (typeof v === 'number' ? v : NaN));\n\telse if (isRecord(data) && Array.isArray(data.words)) words = fromList(data.words, seconds);\n\telse if (isRecord(data) && Array.isArray(data.segments)) {\n\t\tconst segments = data.segments.filter(isRecord);\n\t\tif (segments.some((s) => Array.isArray(s.words))) words = fromList(segments.flatMap((s) => (Array.isArray(s.words) ? s.words : [])), seconds);\n\t\telse {\n\t\t\tfor (const s of segments) words.push(...cueWords(String(s.text ?? ''), seconds(s.start), seconds(s.end)));\n\t\t\tif (!words.length) throw new Error('No words found in the segments.');\n\t\t\treturn { words, approximate: true };\n\t\t}\n\t}\n\tif (!words.length) throw new Error('No words found: expected Whisper-style JSON with word timings, SubRip (.srt) or WebVTT.');\n\treturn { words: words.sort((a, b) => a.start - b.start), approximate: false };\n}\n\n// ---------------------------------------------------------------------------------------------\n// Words on the timeline\n// ---------------------------------------------------------------------------------------------\n\n/** A word where it plays on the timeline. */\nexport interface VideoTimelineWord {\n\tword: string;\n\t/** Timeline times (ms). */\n\tstart: number;\n\tend: number;\n\t/** The clip that plays it. */\n\tclip: string;\n\ttrack: string;\n\t/** Its index in the asset's transcript. */\n\tindex: number;\n}\n\n/**\n * The track whose words make the timeline transcript: `track` if given, else the first audio track\n * with a transcribed clip, else the first video track with one. `null` when nothing is transcribed.\n */\nexport function videoTranscriptTrack(project: VideoProject, track?: string): string | null {\n\tif (track !== undefined) {\n\t\tif (!project.tracks.some((t) => t.id === track)) throw new Error(`There is no track \"${track}\"; the tracks are ${project.tracks.map((t) => `\"${t.id}\"`).join(', ')}.`);\n\t\treturn track;\n\t}\n\tconst assets = videoAssetMap(project);\n\tconst transcribed = (t: VideoProject['tracks'][number]) => t.clips.some((c) => c.type === 'media' && assets.get(c.asset)?.transcript?.length);\n\treturn (project.tracks.find((t) => t.kind === 'audio' && transcribed(t)) ?? project.tracks.find(transcribed))?.id ?? null;\n}\n\n/**\n * The words the timeline plays on one track, in order: each transcribed media clip's words whose\n * middle falls inside the stretch of source it plays, moved to timeline time through its in point\n * and speed and clamped to the clip.\n */\nexport function videoTimelineTranscript(project: VideoProject, options: { track?: string } = {}): VideoTimelineWord[] {\n\tconst id = videoTranscriptTrack(project, options.track);\n\tif (!id) return [];\n\tconst track = project.tracks.find((t) => t.id === id)!;\n\tconst assets = videoAssetMap(project);\n\tconst out: VideoTimelineWord[] = [];\n\tfor (const clip of sortedVideoClips(track)) {\n\t\tif (clip.type !== 'media') continue;\n\t\tconst words = assets.get(clip.asset)?.transcript;\n\t\tif (!words?.length) continue;\n\t\tconst speed = videoClipSpeed(clip);\n\t\tconst from = clip.in ?? 0;\n\t\tconst to = from + clip.duration * speed;\n\t\tconst toTimeline = (s: number) => Math.min(videoClipEnd(clip), Math.max(clip.start, clip.start + (s - from) / speed));\n\t\twords.forEach((w, index) => {\n\t\t\tconst mid = (w.start + w.end) / 2;\n\t\t\tif (mid < from || mid >= to) return;\n\t\t\tout.push({ word: w.word, start: toTimeline(w.start), end: toTimeline(w.end), clip: clip.id, track: track.id, index });\n\t\t});\n\t}\n\treturn out;\n}\n\n// ---------------------------------------------------------------------------------------------\n// Finding things to cut\n// ---------------------------------------------------------------------------------------------\n\n/** A stretch of the timeline (ms) and the words in it. */\nexport interface VideoTranscriptRange {\n\tfrom: number;\n\tto: number;\n\ttext: string;\n}\n\n/** A word as compared: lower case, without surrounding punctuation. */\nexport const normalizeVideoWord = (word: string): string => word.toLowerCase().replace(/^[^\\p{L}\\p{N}']+|[^\\p{L}\\p{N}']+$/gu, '');\n\nconst rangeOf = (words: readonly VideoTimelineWord[], i: number, n: number): VideoTranscriptRange => ({ from: words[i].start, to: words[i + n - 1].end, text: words.slice(i, i + n).map((w) => w.word).join(' ') });\n\n/** Every place a phrase is said (case and punctuation ignored), as timeline ranges. */\nexport function findVideoTranscriptText(words: readonly VideoTimelineWord[], query: string): VideoTranscriptRange[] {\n\tconst q = query.split(/\\s+/).map(normalizeVideoWord).filter(Boolean);\n\tif (!q.length) throw new Error('Search for at least one word.');\n\tconst norm = words.map((w) => normalizeVideoWord(w.word));\n\tconst out: VideoTranscriptRange[] = [];\n\tfor (let i = 0; i + q.length <= norm.length; i++) if (q.every((x, k) => norm[i + k] === x)) out.push(rangeOf(words, i, q.length));\n\treturn out;\n}\n\n/** Filler words and phrases removed by default. \"like\" and \"so\" are left out: too often meant. */\nexport const VIDEO_DEFAULT_FILLERS = ['um', 'umm', 'uh', 'uhh', 'uhm', 'erm', 'er', 'ah', 'hmm', 'mm', 'you know', 'i mean'] as const;\n\n/** Fillers in the transcript, longest phrase first (\"you know\" before \"you\"), as timeline ranges. */\nexport function findVideoFillers(words: readonly VideoTimelineWord[], fillers: readonly string[] = VIDEO_DEFAULT_FILLERS): VideoTranscriptRange[] {\n\tconst phrases = fillers\n\t\t.map((f) => f.split(/\\s+/).map(normalizeVideoWord).filter(Boolean))\n\t\t.filter((p) => p.length)\n\t\t.sort((a, b) => b.length - a.length);\n\tconst norm = words.map((w) => normalizeVideoWord(w.word));\n\tconst out: VideoTranscriptRange[] = [];\n\tfor (let i = 0; i < norm.length; ) {\n\t\tconst hit = phrases.find((p) => i + p.length <= norm.length && p.every((x, k) => norm[i + k] === x));\n\t\tif (hit) {\n\t\t\tout.push(rangeOf(words, i, hit.length));\n\t\t\ti += hit.length;\n\t\t} else i++;\n\t}\n\treturn out;\n}\n\nexport interface VideoPauseOptions {\n\t/** Shortest gap between words that counts as a pause (ms, default 700). */\n\tmin?: number;\n\t/** Silence to keep next to each word so speech does not sound clipped (ms, default 150). */\n\tkeep?: number;\n\t/** Frame rate to snap the cut inward to (default: none). */\n\tfps?: number;\n}\n\n/** Gaps between consecutive words on the same clip longer than `min`, less `keep` either side, as timeline ranges. */\nexport function findVideoTranscriptPauses(words: readonly VideoTimelineWord[], options: VideoPauseOptions = {}): VideoTranscriptRange[] {\n\tconst min = options.min ?? 700;\n\tconst keep = options.keep ?? 150;\n\tconst out: VideoTranscriptRange[] = [];\n\tfor (let i = 1; i < words.length; i++) {\n\t\tconst a = words[i - 1];\n\t\tconst b = words[i];\n\t\tif (a.clip !== b.clip || b.start - a.end < min) continue;\n\t\tlet from = a.end + keep;\n\t\tlet to = b.start - keep;\n\t\tif (options.fps) {\n\t\t\t// Snap inward, so the cut never eats into the kept silence.\n\t\t\tconst f = 1000 / options.fps;\n\t\t\tfrom = snapToVideoFrame(Math.ceil(from / f - 1e-9) * f, options.fps);\n\t\t\tto = snapToVideoFrame(Math.floor(to / f + 1e-9) * f, options.fps);\n\t\t}\n\t\tif (to - from > VIDEO_TIME_EPSILON) out.push({ from, to, text: `(pause after \"${a.word}\")` });\n\t}\n\treturn out;\n}\n\n/** Ranges sorted and merged where they touch or overlap. */\nexport function mergeVideoRanges(ranges: readonly { from: number; to: number }[]): { from: number; to: number }[] {\n\tconst sorted = ranges.filter((r) => r.to - r.from > VIDEO_TIME_EPSILON).map((r) => ({ from: r.from, to: r.to })).sort((a, b) => a.from - b.from);\n\tconst out: { from: number; to: number }[] = [];\n\tfor (const r of sorted) {\n\t\tconst last = out[out.length - 1];\n\t\tif (last && r.from <= last.to + VIDEO_TIME_EPSILON) last.to = Math.max(last.to, r.to);\n\t\telse out.push(r);\n\t}\n\treturn out;\n}\n\n/**\n * The edits that cut ranges out of the timeline and close them up: one `extract_range` per merged\n * range, last first, so earlier ranges keep their times. Apply them together as one undo step.\n */\nexport function videoRemoveRangesEdits(ranges: readonly { from: number; to: number }[], tracks?: string[]): VideoEdit[] {\n\treturn mergeVideoRanges(ranges)\n\t\t.reverse()\n\t\t.map((r) => ({ op: 'extract_range' as const, from: r.from, to: r.to, ...(tracks ? { tracks } : {}) }));\n}\n\n/** The timeline stretch from word `a` to word `b` (either order), for deleting a selection of words. */\nexport function videoWordsRange(words: readonly VideoTimelineWord[], a: number, b: number): { from: number; to: number } {\n\tconst [i, j] = a <= b ? [a, b] : [b, a];\n\treturn { from: words[i].start, to: words[j].end };\n}\n\n/** The edit that attaches a transcript file's text to an asset (see `parseVideoTranscript`). Throws a readable error for text it cannot read. */\nexport function videoTranscriptEdit(assetId: string, text: string): VideoEdit {\n\treturn { op: 'update_asset', asset: assetId, set: { transcript: parseVideoTranscript(text).words } };\n}\n\n/** The word playing at a timeline time, as its index in `words`, or -1. */\nexport function videoWordAt(words: readonly VideoTimelineWord[], time: number): number {\n\tlet lo = 0;\n\tlet hi = words.length - 1;\n\twhile (lo <= hi) {\n\t\tconst mid = (lo + hi) >> 1;\n\t\tif (words[mid].end <= time) lo = mid + 1;\n\t\telse if (words[mid].start > time) hi = mid - 1;\n\t\telse return mid;\n\t}\n\treturn -1;\n}\n","/**\n * Proxies: smaller copies of an asset's media the preview plays instead of the original, so\n * editing stays smooth on large footage. They live on the asset (`VideoAsset.proxies`); the preview\n * picks one by its preview scale; the export, EDL and agents' source times always use the original.\n */\nimport { videoShellQuote } from './export.js';\nimport type { VideoAsset, VideoAssetProxy, VideoProject } from './types.js';\n\n/**\n * How large a picture the preview plays: `full` (the original), a fraction (0.5 = half size, using\n * the smallest proxy at least that large), or `auto` (what the preview canvas shows).\n */\nexport type VideoPreviewScale = 'auto' | 'full' | number;\n/** The scales a preview-scale control offers, besides `auto` and `full`. */\nexport const VIDEO_PROXY_SCALES = [0.5, 0.25] as const;\n\n/** The proxy the preview plays at `scale` (0–1]: the smallest at least that large, else `null` (play the original). */\nexport function videoAssetProxy(asset: Pick<VideoAsset, 'proxies'> | undefined, scale: number): VideoAssetProxy | null {\n\tif (!asset?.proxies?.length || !(scale > 0) || scale >= 1) return null;\n\tlet best: VideoAssetProxy | null = null;\n\tfor (const p of asset.proxies) if (p.scale >= scale - 1e-9 && p.scale < 1 && (!best || p.scale < best.scale)) best = p;\n\treturn best;\n}\n\n/** The `src` the preview plays for an asset at `scale`: a proxy's, or the original's. */\nexport const videoPreviewSrc = (asset: VideoAsset | undefined, scale: number, fallback = asset?.src ?? ''): string => videoAssetProxy(asset, scale)?.src ?? fallback;\n\n/** A preview scale as a fraction: `full` is 1, `auto` is `shown` (the canvas's resolution, at most 1). */\nexport function resolveVideoPreviewScale(scale: VideoPreviewScale | undefined, shown: number): number {\n\tif (scale === undefined || scale === 'auto') return Math.min(1, Math.max(0, shown));\n\tif (scale === 'full') return 1;\n\treturn Math.min(1, Math.max(0, scale));\n}\n\nexport interface VideoProxyOptions {\n\t/** Size of the proxy as a fraction of the original (default 0.5). */\n\tscale?: number;\n\t/** Output file (default: the source's name with `.proxy-50.mp4`, for scale 0.5). */\n\toutput?: string;\n\t/** The path or URL ffmpeg should open (default the asset's `src`). */\n\tresolve?: (asset: VideoAsset) => string;\n\t/** Frames between keyframes (default 12): short, so seeking and scrubbing decode little. */\n\tgop?: number;\n\t/** x264 quality (default 28: small files; proxies are for editing, not viewing). */\n\tcrf?: number;\n}\n\n/** A proxy's file name next to `src`: `clip.mov` at 0.5 → `clip.proxy-50.mp4`. */\nexport function videoProxyName(src: string, scale = 0.5): string {\n\tconst clean = src.replace(/[?#].*$/, '');\n\tconst base = clean.replace(/\\.[^./\\\\]+$/, '');\n\treturn `${base}.proxy-${Math.round(scale * 100)}.mp4`;\n}\n\n/** An ffmpeg command making an asset's proxy, and the proxy entry to add once it exists. */\nexport interface VideoProxyCommand {\n\tasset: string;\n\targs: string[];\n\t/** One shell line. */\n\tcommand: string;\n\t/** The entry for `VideoAsset.proxies` (its `src` is the output; map it to a URL the preview can load if needed). */\n\tproxy: VideoAssetProxy;\n}\n\n/**\n * The ffmpeg command for a proxy of a video asset: scaled to `scale` (even sizes), H.264 with a\n * short keyframe interval (`gop`) and no B-frames so seeking is cheap, `fastdecode`, the sound\n * kept as AAC, and `faststart` so a browser can play it while it loads.\n */\nexport function videoProxyFfmpeg(asset: VideoAsset, options: VideoProxyOptions = {}): VideoProxyCommand {\n\tif (asset.kind !== 'video') throw new Error(`Asset \"${asset.id}\" is ${asset.kind === 'image' ? 'an image' : 'audio'}; only video assets have proxies.`);\n\tconst scale = options.scale ?? 0.5;\n\tif (!(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.`);\n\tconst gop = Math.max(1, Math.round(options.gop ?? 12));\n\tconst output = options.output ?? videoProxyName(asset.src, scale);\n\tconst src = options.resolve ? options.resolve(asset) : asset.src;\n\tconst size = `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`;\n\tconst args = [\n\t\t'-y',\n\t\t'-i',\n\t\tsrc,\n\t\t'-vf',\n\t\tsize,\n\t\t'-c:v',\n\t\t'libx264',\n\t\t'-preset',\n\t\t'veryfast',\n\t\t'-tune',\n\t\t'fastdecode',\n\t\t'-crf',\n\t\tString(options.crf ?? 28),\n\t\t'-g',\n\t\tString(gop),\n\t\t'-keyint_min',\n\t\tString(gop),\n\t\t'-sc_threshold',\n\t\t'0',\n\t\t'-bf',\n\t\t'0',\n\t\t'-pix_fmt',\n\t\t'yuv420p',\n\t\t...(asset.hasAudio === false ? ['-an'] : ['-c:a', 'aac', '-b:a', '128k']),\n\t\t'-movflags',\n\t\t'+faststart',\n\t\toutput\n\t];\n\tconst proxy: VideoAssetProxy = { scale, src: output, ...(asset.width && asset.height ? { width: Math.trunc((asset.width * scale) / 2) * 2, height: Math.trunc((asset.height * scale) / 2) * 2 } : {}) };\n\treturn { asset: asset.id, args, command: ['ffmpeg', ...args].map(videoShellQuote).join(' '), proxy };\n}\n\n/** Commands for every video asset (or those listed) without a proxy at `scale`. */\nexport function videoProxyPlan(project: VideoProject, options: VideoProxyOptions & { assets?: readonly string[]; output?: never } = {}): VideoProxyCommand[] {\n\tconst scale = options.scale ?? 0.5;\n\tconst wanted = options.assets ? new Set(options.assets) : null;\n\tif (options.assets) for (const id of options.assets) {\n\t\tconst a = project.assets.find((x) => x.id === id);\n\t\tif (!a) throw new Error(`There is no asset \"${id}\"; the assets are ${project.assets.map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\tif (a.kind !== 'video') throw new Error(`Asset \"${id}\" is ${a.kind === 'image' ? 'an image' : 'audio'}; only video assets have proxies.`);\n\t}\n\treturn project.assets\n\t\t.filter((a) => a.kind === 'video' && (!wanted || wanted.has(a.id)) && !a.proxies?.some((p) => Math.abs(p.scale - scale) < 1e-9))\n\t\t.map((a) => videoProxyFfmpeg(a, { ...options, scale }));\n}\n\n/** The choices a preview-size control offers, with labels: `value` round-trips through `parseVideoPreviewScale`. */\nexport const VIDEO_PREVIEW_SCALE_OPTIONS: readonly { value: string; label: string; scale: VideoPreviewScale }[] = [\n\t{ value: 'auto', label: 'Auto', scale: 'auto' },\n\t{ value: 'full', label: 'Full', scale: 'full' },\n\t...VIDEO_PROXY_SCALES.map((s) => ({ value: String(s), label: s === 0.5 ? '½' : s === 0.25 ? '¼' : `${s * 100}%`, scale: s }))\n];\n\n/** A preview-size control's value back as a scale (`auto` for anything unknown). */\nexport const parseVideoPreviewScale = (value: string): VideoPreviewScale => (value === 'full' ? 'full' : Number(value) > 0 && Number(value) <= 1 ? Number(value) : 'auto');\n\n/** Whether any asset has proxies (so a preview-size control is worth showing). */\nexport const videoProjectHasProxies = (project: VideoProject): boolean => project.assets.some((a) => !!a.proxies?.length);\n","/**\n * A cache of decoded source frames for the preview, with prefetch ahead of the playhead, after the\n * way Blender's sequencer caches strips: scrubbing and stepping show cached frames at once instead\n * of waiting for a `<video>` to seek. It is host-side and optional: give a `VideoFrameCache` to the\n * controller (`frameCache`) or the preview engine.\n *\n * Frames are keyed by the file the preview plays (the asset's `src`, or the proxy it picks) and the\n * source frame, not by clip: every clip of an asset shares them, and edits never invalidate them.\n * Eviction removes the frames furthest from the playhead, counting frames behind it (against the\n * play direction) as further, and never touches the prefetch window.\n */\nimport { videoFrameAt, videoFrameLeafLayers, type VideoLayer } from './evaluate.js';\nimport type { VideoProject } from './types.js';\n\n/** The source frame showing at a source time (ms) on an `fps` grid: a frame shows from its start. */\nexport const videoSourceFrame = (sourceTime: number, fps: number): number => Math.max(0, Math.floor((sourceTime * fps) / 1000 + 1e-6));\n\n/** The middle of a source frame (ms): where a decoder should seek, away from the frame's edges. */\nexport const videoSourceFrameTime = (frame: number, fps: number): number => ((frame + 0.5) * 1000) / fps;\n\n/** A cache key: the file and the source frame. */\nexport const videoFrameCacheKey = (src: string, frame: number): string => `${frame}@${src}`;\n\n/** A frame the preview will need: the file, the source frame and when on the timeline it shows. */\nexport interface VideoFrameRequest {\n\tkey: string;\n\tsrc: string;\n\tframe: number;\n\t/** Where to seek in the source (ms): the middle of the frame. */\n\tsourceTime: number;\n\t/** When it shows on the timeline (ms). */\n\ttime: number;\n}\n\n/** The video layers' source frames at timeline `time`, as the preview would play them. */\nexport function videoFrameRequestsAt(project: VideoProject, time: number, srcFor: (layer: VideoLayer) => string | undefined = (l) => l.src): VideoFrameRequest[] {\n\tconst out: VideoFrameRequest[] = [];\n\t// Media inside compound clips is decoded like any other.\n\tfor (const layer of videoFrameLeafLayers(videoFrameAt(project, time).layers)) {\n\t\tif (layer.type !== 'media' || layer.assetKind !== 'video') continue;\n\t\tconst src = srcFor(layer);\n\t\tif (!src) continue;\n\t\tconst frame = videoSourceFrame(layer.sourceTime ?? 0, project.fps);\n\t\tout.push({ key: videoFrameCacheKey(src, frame), src, frame, sourceTime: videoSourceFrameTime(frame, project.fps), time });\n\t}\n\treturn out;\n}\n\n/**\n * The frames to have ready from `time` over the next `window` ms (backwards with `direction` -1),\n * a frame apart, nearest first, each key once.\n */\nexport function planVideoPrefetch(project: VideoProject, time: number, options: { window?: number; direction?: 1 | -1; srcFor?: (layer: VideoLayer) => string | undefined } = {}): VideoFrameRequest[] {\n\tconst window = options.window ?? 2000;\n\tconst dir = options.direction ?? 1;\n\tconst step = 1000 / project.fps;\n\tconst seen = new Set<string>();\n\tconst out: VideoFrameRequest[] = [];\n\tfor (let i = 0; i * step <= window + 1e-6; i++) {\n\t\tconst t = time + dir * i * step;\n\t\tif (t < 0) break;\n\t\tfor (const r of videoFrameRequestsAt(project, t, options.srcFor)) {\n\t\t\tif (seen.has(r.key)) continue;\n\t\t\tseen.add(r.key);\n\t\t\tout.push(r);\n\t\t}\n\t}\n\treturn out;\n}\n\n/** A cached frame's bookkeeping for eviction. */\nexport interface VideoCacheEntryInfo {\n\tkey: string;\n\t/** When it last showed (or will show) on the timeline (ms). */\n\ttime: number;\n\tbytes: number;\n}\n\n/**\n * Which entries to drop to get under `maxBytes`: never those in the prefetch window\n * (`protect`), then the furthest from the playhead first, with frames behind it (against\n * `direction`) counted `behind` times as far (default 2), so the way you are going is kept.\n */\nexport function videoCacheEvictions(entries: readonly VideoCacheEntryInfo[], options: { playhead: number; maxBytes: number; direction?: 1 | -1; behind?: number; protect?: ReadonlySet<string> }): string[] {\n\tlet total = entries.reduce((n, e) => n + e.bytes, 0);\n\tif (total <= options.maxBytes) return [];\n\tconst dir = options.direction ?? 1;\n\tconst behind = options.behind ?? 2;\n\tconst cost = (e: VideoCacheEntryInfo) => {\n\t\tconst d = (e.time - options.playhead) * dir;\n\t\treturn d >= 0 ? d : -d * behind;\n\t};\n\tconst out: string[] = [];\n\tfor (const e of entries.filter((x) => !options.protect?.has(x.key)).sort((a, b) => cost(b) - cost(a))) {\n\t\tif (total <= options.maxBytes) break;\n\t\tout.push(e.key);\n\t\ttotal -= e.bytes;\n\t}\n\treturn out;\n}\n\n/** A decoded frame: anything `drawImage` takes, with its size (`close` is called when it is evicted). */\nexport interface VideoDecodedFrame {\n\timage: CanvasImageSource & { close?: () => void };\n\twidth: number;\n\theight: number;\n}\n\n/** Decodes the frame of `src` at a source time (ms). */\nexport type VideoFrameDecoder = (src: string, sourceTime: number) => Promise<VideoDecodedFrame>;\n\nexport interface VideoFrameCacheOptions {\n\t/** How frames are decoded for prefetch (default in a browser: `createVideoElementDecoder()`; elsewhere none, so only frames the preview hands over are kept). */\n\tdecode?: VideoFrameDecoder | null;\n\t/** Memory budget in bytes (4 a pixel; default 512 MB). */\n\tmaxBytes?: number;\n\t/** How far ahead of the playhead to prefetch (ms, default 2000; 0 turns prefetch off). */\n\tprefetch?: number;\n\t/** How much further frames behind the playhead count when evicting (default 2). */\n\tbehind?: number;\n\t/** Frames decoded at once (default 2). */\n\tconcurrency?: number;\n\t/** Copies a ready media element's current picture (default `createImageBitmap`), to keep frames the preview has shown. */\n\tcapture?: (source: CanvasImageSource) => Promise<VideoDecodedFrame | null>;\n}\n\n/** The cache's state, for a status line and agents. */\nexport interface VideoFrameCacheStats {\n\tframes: number;\n\tbytes: number;\n\tmaxBytes: number;\n\thits: number;\n\tmisses: number;\n\t/** Frames being decoded or waiting to be. */\n\tpending: number;\n\tprefetch: number;\n}\n\ninterface Entry extends VideoCacheEntryInfo {\n\tframe: VideoDecodedFrame;\n}\n\nconst defaultCapture = async (source: CanvasImageSource): Promise<VideoDecodedFrame | null> => {\n\tif (typeof createImageBitmap !== 'function') return null;\n\tconst image = await createImageBitmap(source as ImageBitmapSource);\n\treturn { image, width: image.width, height: image.height };\n};\n\n/**\n * Decoded source frames for the preview, filled ahead of the playhead and evicted by distance from\n * it (see the module notes). `VideoPreviewEngine` calls `update` as the playhead moves, draws hits\n * from `get` while paused or while an element seeks, and hands over frames it has shown.\n */\nexport class VideoFrameCache {\n\tprivate readonly entries = new Map<string, Entry>();\n\tprivate readonly inFlight = new Set<string>();\n\tprivate queue: VideoFrameRequest[] = [];\n\tprivate protect = new Set<string>();\n\tprivate readonly listeners = new Set<() => void>();\n\tprivate playhead = 0;\n\tprivate direction: 1 | -1 = 1;\n\tprivate hits = 0;\n\tprivate misses = 0;\n\tprivate bytes = 0;\n\tprivate generation = 0;\n\tprivate destroyed = false;\n\tprivate srcFor: ((layer: VideoLayer) => string | undefined) | undefined;\n\treadonly maxBytes: number;\n\treadonly prefetch: number;\n\tprivate readonly decode: VideoFrameDecoder | null;\n\n\tconstructor(private readonly options: VideoFrameCacheOptions = {}) {\n\t\tthis.maxBytes = options.maxBytes ?? 512 * 1024 * 1024;\n\t\tthis.prefetch = options.prefetch ?? 2000;\n\t\tthis.decode = options.decode !== undefined ? options.decode : typeof document !== 'undefined' ? createVideoElementDecoder() : null;\n\t}\n\n\t/** The cached frame of `src` showing at a source time (ms), on the project's `fps` grid. Counts a hit or a miss. */\n\tget(src: string, sourceTime: number, fps: number): CanvasImageSource | undefined {\n\t\tconst entry = this.entries.get(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));\n\t\tif (entry) this.hits++;\n\t\telse this.misses++;\n\t\treturn entry?.frame.image;\n\t}\n\n\t/** Whether a frame is cached, without counting it. */\n\thas(src: string, sourceTime: number, fps: number): boolean {\n\t\treturn this.entries.has(videoFrameCacheKey(src, videoSourceFrame(sourceTime, fps)));\n\t}\n\n\t/** Keeps a decoded frame (replacing any with the same key), then evicts to the budget. */\n\tput(request: Pick<VideoFrameRequest, 'key' | 'time'>, frame: VideoDecodedFrame): void {\n\t\tif (this.destroyed) return void frame.image.close?.();\n\t\tconst old = this.entries.get(request.key);\n\t\tif (old) this.drop(old);\n\t\tconst bytes = Math.max(1, frame.width * frame.height * 4);\n\t\tthis.entries.set(request.key, { key: request.key, time: request.time, bytes, frame });\n\t\tthis.bytes += bytes;\n\t\tthis.evict();\n\t\tthis.emit();\n\t}\n\n\t/** Keeps what a ready media element shows now as the frame of `src` at `sourceTime` (async; does nothing if it is cached). */\n\tcapture(src: string, sourceTime: number, fps: number, time: number, source: CanvasImageSource): void {\n\t\tconst frame = videoSourceFrame(sourceTime, fps);\n\t\tconst key = videoFrameCacheKey(src, frame);\n\t\tif (this.entries.has(key) || this.inFlight.has(key)) return;\n\t\tthis.inFlight.add(key);\n\t\tvoid (this.options.capture ?? defaultCapture)(source)\n\t\t\t.then((f) => {\n\t\t\t\tif (f) this.put({ key, time }, f);\n\t\t\t})\n\t\t\t.catch(() => {})\n\t\t\t.finally(() => this.inFlight.delete(key));\n\t}\n\n\t/**\n\t * Follows the playhead: protects the prefetch window, evicts to the budget, and queues the\n\t * window's missing frames for decoding (nearest first). `srcFor` gives the file a layer plays.\n\t */\n\tupdate(project: VideoProject, time: number, options: { playing?: boolean; direction?: 1 | -1; srcFor?: (layer: VideoLayer) => string | undefined } = {}): void {\n\t\tif (this.destroyed) return;\n\t\tthis.direction = options.direction ?? (time < this.playhead && !options.playing ? -1 : 1);\n\t\tthis.playhead = time;\n\t\tthis.srcFor = options.srcFor;\n\t\tconst plan = this.prefetch > 0 ? planVideoPrefetch(project, time, { window: this.prefetch, direction: this.direction, srcFor: options.srcFor }) : videoFrameRequestsAt(project, time, options.srcFor);\n\t\tthis.protect = new Set(plan.map((r) => r.key));\n\t\t// Frames in the window show soon: their timeline time is now where they will be needed.\n\t\tfor (const r of plan) {\n\t\t\tconst e = this.entries.get(r.key);\n\t\t\tif (e) e.time = r.time;\n\t\t}\n\t\tthis.evict();\n\t\tthis.generation++;\n\t\tthis.queue = this.decode ? plan.filter((r) => !this.entries.has(r.key) && !this.inFlight.has(r.key)) : [];\n\t\tthis.pump();\n\t}\n\n\t/**\n\t * The stretches of the timeline whose video frames are all cached, between `from` and `to` (ms),\n\t * for the files the preview last played (its `srcFor`, e.g. proxies) unless `srcFor` is given.\n\t * `step` (ms, at least a frame) samples more coarsely.\n\t */\n\tcachedRanges(project: VideoProject, options: { from?: number; to?: number; srcFor?: (layer: VideoLayer) => string | undefined; step?: number } = {}): { start: number; end: number }[] {\n\t\t// One check a frame by default; a coarser `step` (ms) suits drawing a long timeline.\n\t\tconst step = Math.max(1000 / project.fps, options.step ?? 0);\n\t\tconst from = Math.max(0, options.from ?? 0);\n\t\tconst to = options.to ?? Math.max(...project.tracks.flatMap((t) => t.clips.map((c) => c.start + c.duration)), 0);\n\t\tconst out: { start: number; end: number }[] = [];\n\t\tfor (let t = from; t < to - 1e-6; t += step) {\n\t\t\tconst requests = videoFrameRequestsAt(project, t, options.srcFor ?? this.srcFor);\n\t\t\tconst cached = requests.length > 0 && requests.every((r) => this.entries.has(r.key));\n\t\t\tconst last = out[out.length - 1];\n\t\t\tif (!cached) continue;\n\t\t\tconst end = Math.min(to, t + step);\n\t\t\tif (last && Math.abs(last.end - t) < 1e-6) last.end = end;\n\t\t\telse out.push({ start: t, end });\n\t\t}\n\t\treturn out.map((r) => ({ start: Math.round(r.start * 10) / 10, end: Math.round(r.end * 10) / 10 }));\n\t}\n\n\tstats(): VideoFrameCacheStats {\n\t\treturn { 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 };\n\t}\n\n\t/** Called when frames arrive or leave. */\n\tsubscribe(listener: () => void): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t}\n\n\t/** Drops every frame (closing them) and the queue. */\n\tclear(): void {\n\t\tfor (const e of [...this.entries.values()]) this.drop(e);\n\t\tthis.queue = [];\n\t\tthis.generation++;\n\t\tthis.emit();\n\t}\n\n\t/** Clears and stops decoding for good. */\n\tdestroy(): void {\n\t\tthis.clear();\n\t\tthis.destroyed = true;\n\t\tthis.listeners.clear();\n\t}\n\n\tprivate pump() {\n\t\tconst limit = Math.max(1, this.options.concurrency ?? 2);\n\t\twhile (this.decode && this.inFlight.size < limit && this.queue.length) {\n\t\t\tconst r = this.queue.shift()!;\n\t\t\tif (this.entries.has(r.key) || this.inFlight.has(r.key)) continue;\n\t\t\t// Stop filling once the window alone is at the budget: decoding more would only evict it.\n\t\t\tif (this.bytes >= this.maxBytes && [...this.entries.keys()].every((k) => this.protect.has(k))) {\n\t\t\t\tthis.queue = [];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tconst generation = this.generation;\n\t\t\tthis.inFlight.add(r.key);\n\t\t\tthis.decode(r.src, r.sourceTime)\n\t\t\t\t.then(\n\t\t\t\t\t(frame) => {\n\t\t\t\t\t\tthis.inFlight.delete(r.key);\n\t\t\t\t\t\t// A frame that left the window while it decoded is still worth keeping if there is room.\n\t\t\t\t\t\tif (generation === this.generation || this.bytes + frame.width * frame.height * 4 <= this.maxBytes) this.put(r, frame);\n\t\t\t\t\t\telse frame.image.close?.();\n\t\t\t\t\t},\n\t\t\t\t\t() => this.inFlight.delete(r.key)\n\t\t\t\t)\n\t\t\t\t.finally(() => this.pump());\n\t\t}\n\t}\n\n\tprivate evict() {\n\t\tconst drop = videoCacheEvictions([...this.entries.values()], { playhead: this.playhead, maxBytes: this.maxBytes, direction: this.direction, behind: this.options.behind, protect: this.protect });\n\t\tfor (const key of drop) this.drop(this.entries.get(key)!);\n\t}\n\n\tprivate drop(e: Entry) {\n\t\tthis.entries.delete(e.key);\n\t\tthis.bytes -= e.bytes;\n\t\te.frame.image.close?.();\n\t}\n\n\tprivate emit() {\n\t\tfor (const l of [...this.listeners]) l();\n\t}\n}\n\n/**\n * A browser decoder for prefetch: one hidden, muted `<video>` per file, seeking to each requested\n * time in turn and copying the picture with `createImageBitmap` once it is presented\n * (`requestVideoFrameCallback` where the browser has it, else `seeked`). Media from another origin\n * needs CORS, as for scopes.\n */\nexport function createVideoElementDecoder(options: { createVideo?: () => HTMLVideoElement; timeout?: number } = {}): VideoFrameDecoder {\n\tconst videos = new Map<string, { el: HTMLVideoElement; chain: Promise<unknown> }>();\n\tconst make =\n\t\toptions.createVideo ??\n\t\t(() => {\n\t\t\tconst el = document.createElement('video');\n\t\t\tel.muted = true;\n\t\t\tel.preload = 'auto';\n\t\t\tel.crossOrigin = 'anonymous';\n\t\t\tel.playsInline = true;\n\t\t\treturn el;\n\t\t});\n\tconst timeout = options.timeout ?? 5000;\n\tconst once = (el: HTMLVideoElement, type: string) =>\n\t\tnew Promise<void>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => reject(new Error(`The video did not ${type} within ${timeout} ms.`)), timeout);\n\t\t\tel.addEventListener(type, () => (clearTimeout(timer), resolve()), { once: true });\n\t\t\tel.addEventListener('error', () => (clearTimeout(timer), reject(new Error('The video could not be loaded.'))), { once: true });\n\t\t});\n\treturn (src, sourceTime) => {\n\t\tlet v = videos.get(src);\n\t\tif (!v) {\n\t\t\tconst el = make();\n\t\t\tel.src = src;\n\t\t\tv = { el, chain: el.readyState >= 1 ? Promise.resolve() : once(el, 'loadedmetadata') };\n\t\t\tvideos.set(src, v);\n\t\t}\n\t\tconst { el } = v;\n\t\tconst next = v.chain.then(async () => {\n\t\t\tconst presented = 'requestVideoFrameCallback' in el ? new Promise<void>((r) => (el as any).requestVideoFrameCallback(() => r())) : null;\n\t\t\tconst seeked = once(el, 'seeked');\n\t\t\tel.currentTime = sourceTime / 1000;\n\t\t\tawait seeked;\n\t\t\tif (presented) await Promise.race([presented, new Promise((r) => setTimeout(r, 100))]);\n\t\t\tconst image = await createImageBitmap(el);\n\t\t\treturn { image, width: image.width, height: image.height };\n\t\t});\n\t\tv.chain = next.catch(() => {});\n\t\treturn next;\n\t};\n}\n","import { videoFrameAt, videoFrameLeafLayers, type VideoFrame, type VideoLayer } from './evaluate.js';\nimport { drawVideoFrame, type VideoCanvasContext, type VideoScratchCanvas } from './render.js';\nimport type { VideoFrameCache } from './frame-cache.js';\nimport type { VideoLut, VideoLutCache } from './lut.js';\nimport type { VideoPreviewPixels, VideoPreviewSnapshot } from './scopes.js';\nimport { resolveVideoPreviewScale, videoPreviewSrc, type VideoPreviewScale } from './proxy.js';\nimport type { VideoProject } from './types.js';\nimport { videoEffectFrameTime, videoLayersUseEffects, type VideoEffectsEngine } from './effects.js';\n\n/** What a preview follows: a `VideoEditorController` fits. */\nexport interface VideoPreviewSource {\n\tgetState(): { project: VideoProject; time: number; playing: boolean; previewScale?: VideoPreviewScale };\n\tsubscribe(listener: () => void): () => void;\n\t/** Where to publish the drawn pixels (a controller has one), for scopes and agents. */\n\tpreviewPixels?: VideoPreviewPixels;\n\t/** Where grades' LUTs load from (a controller has one). */\n\tluts?: VideoLutCache;\n\t/** Decoded source frames with prefetch (a controller has one when the host gives it). */\n\tframeCache?: VideoFrameCache | null;\n\t/** Provider effect pictures (a controller has one; it draws nothing until a provider is set). */\n\teffectsEngine?: VideoEffectsEngine | null;\n}\n\n/** The parts of a media element the preview drives (so tests can pass fakes). */\nexport interface VideoPreviewMedia {\n\tsrc: string;\n\tcurrentTime: number;\n\tplaybackRate: number;\n\tvolume: number;\n\tmuted: boolean;\n\tpaused: boolean;\n\t/** True while a seek is under way (the element still shows the old frame). */\n\tseeking?: boolean;\n\treadyState: number;\n\tpreload: string;\n\tplay(): Promise<void> | void;\n\tpause(): void;\n\taddEventListener(type: string, listener: () => void): void;\n\tremoveEventListener(type: string, listener: () => void): void;\n\tremoveAttribute?(name: string): void;\n\tload?(): void;\n}\n\nexport interface VideoPreviewImage {\n\tsrc: string;\n\tcomplete: boolean;\n\taddEventListener(type: string, listener: () => void): void;\n}\n\nexport interface VideoPreviewOptions {\n\t/** Canvas pixels per frame pixel (default: fits the canvas's displayed width at the device pixel ratio, at most 1). */\n\tresolution?: number;\n\t/** How large a picture to play, overriding the source's `previewScale` (see `VideoPreviewScale`). */\n\tpreviewScale?: VideoPreviewScale;\n\t/** How far (ms) a playing element may drift from the timeline before it is re-seeked (default 200). */\n\ttolerance?: number;\n\t/** Makes media and image elements (default `document.createElement`). */\n\tcreateMedia?: (kind: 'video' | 'audio') => VideoPreviewMedia;\n\tcreateImage?: () => VideoPreviewImage;\n\t/** The 2D context to draw on (default `canvas.getContext('2d')`). */\n\tcontext?: VideoCanvasContext | null;\n\t/** Called after each frame is drawn. */\n\tonFrame?: (frame: VideoFrame) => void;\n\t/**\n\t * Puts an SVG filter (white balance in a grade) where the canvas's `filter: url(#id)` can find\n\t * it. Default: a hidden `<svg>` added to `document.body`. `null` leaves white balance out.\n\t */\n\tdefineSvgFilter?: ((id: string, markup: string) => void) | null;\n\t/**\n\t * Where to publish the canvas's pixels after each frame, for scopes and the `get_video_scopes`\n\t * tool (default: the source's `previewPixels`). `null` publishes nothing.\n\t */\n\tpixels?: VideoPreviewPixels | null;\n\t/**\n\t * Decoded source frames to draw while paused, scrubbing or waiting for a seek, prefetched ahead\n\t * of the playhead (default: the source's `frameCache`; `null` uses none).\n\t */\n\tframeCache?: VideoFrameCache | null;\n\t/** Where grades' LUTs load from (default: the source's `luts`). Without one, LUTs are left out. */\n\tluts?: VideoLutCache | null;\n\t/**\n\t * Makes an offscreen canvas compound clips and LUT-graded layers are drawn on (default: a\n\t * `<canvas>` with `willReadFrequently`), once per `slot` (see `VideoDrawOptions.scratch`). LUTs\n\t * run on the CPU, so a lower `resolution` keeps playback smooth.\n\t */\n\tcreateScratch?: (width: number, height: number, slot: number) => VideoScratchCanvas | null;\n\t/** Where provider effect pictures come from (default: the source's `effectsEngine`; `null` draws none). */\n\teffects?: VideoEffectsEngine | null;\n}\n\ninterface Entry {\n\tclip: string;\n\tsrc: string;\n\tmedia: VideoPreviewMedia;\n\tonReady: () => void;\n}\n\n/**\n * Plays a project in the browser: keeps one `<video>` or `<audio>` element per playing clip in step\n * with the timeline (seeking when it drifts, matching speed and gain, pausing with the editor), and\n * draws each frame (`videoFrameAt` + `drawVideoFrame`) on a canvas. Framework-free: React and Svelte\n * wrap it in `<VideoPreview>`. Elements for clips that stop playing are paused and released.\n */\nexport class VideoPreviewEngine {\n\tprivate readonly canvas: { width: number; height: number; clientWidth?: number; getContext?: (id: '2d') => unknown };\n\tprivate readonly ctx: VideoCanvasContext | null;\n\tprivate readonly media = new Map<string, Entry>();\n\tprivate readonly images = new Map<string, VideoPreviewImage>();\n\tprivate readonly unsubscribe: () => void;\n\tprivate scheduled = false;\n\tprivate destroyed = false;\n\tprivate filterHost: Element | null = null;\n\tprivate readonly filters = new Set<string>();\n\tprivate readonly detachPixels: () => void;\n\tprivate readonly pixelsTarget: VideoPreviewPixels | null;\n\tprivate drawn: { time: number; complete: boolean } | null = null;\n\tprivate complete = true;\n\tprivate readonly lutCache: VideoLutCache | null;\n\tprivate readonly frameCache: VideoFrameCache | null;\n\tprivate readonly unsubscribeFrames: () => void;\n\tprivate srcFor: (layer: VideoLayer) => string | undefined = (layer) => layer.src;\n\tprivate playing = false;\n\tprivate fps = 30;\n\tprivate readonly unsubscribeLuts: () => void;\n\tprivate readonly scratchCanvases = new Map<number, VideoScratchCanvas | null>();\n\tprivate readonly effects: VideoEffectsEngine | null;\n\tprivate readonly unsubscribeEffects: () => void;\n\n\tconstructor(\n\t\tprivate readonly source: VideoPreviewSource,\n\t\tcanvas: { width: number; height: number; clientWidth?: number; getContext?: (id: '2d') => unknown },\n\t\tprivate readonly options: VideoPreviewOptions = {}\n\t) {\n\t\tthis.canvas = canvas;\n\t\tthis.ctx = options.context !== undefined ? options.context : ((canvas.getContext?.('2d') as VideoCanvasContext | null) ?? null);\n\t\tthis.unsubscribe = source.subscribe(() => this.update());\n\t\tthis.lutCache = options.luts !== undefined ? options.luts : (source.luts ?? null);\n\t\tthis.unsubscribeLuts = this.lutCache ? this.lutCache.subscribe(() => this.redraw()) : () => {};\n\t\tthis.frameCache = options.frameCache !== undefined ? options.frameCache : (source.frameCache ?? null);\n\t\tthis.unsubscribeFrames = this.frameCache ? this.frameCache.subscribe(() => this.redraw()) : () => {};\n\t\tthis.effects = options.effects !== undefined ? options.effects : (source.effectsEngine ?? null);\n\t\tthis.unsubscribeEffects = this.effects ? this.effects.subscribe(() => this.redraw()) : () => {};\n\t\tconst pixels = options.pixels !== undefined ? options.pixels : source.previewPixels;\n\t\tthis.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {};\n\t\tthis.pixelsTarget = pixels ?? null;\n\t\tthis.update();\n\t}\n\n\t/** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */\n\treadPixels(): VideoPreviewSnapshot | null {\n\t\tconst ctx = this.ctx as (VideoCanvasContext & { getImageData?: (x: number, y: number, w: number, h: number) => ImageData }) | null;\n\t\tif (!ctx?.getImageData || !this.drawn) return null;\n\t\treturn { ...this.drawn, pixels: ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) };\n\t}\n\n\tprivate createMedia(kind: 'video' | 'audio'): VideoPreviewMedia {\n\t\tif (this.options.createMedia) return this.options.createMedia(kind);\n\t\tconst el = document.createElement(kind);\n\t\tel.preload = 'auto';\n\t\tel.crossOrigin = 'anonymous';\n\t\tif (kind === 'video') (el as HTMLVideoElement).playsInline = true;\n\t\treturn el as unknown as VideoPreviewMedia;\n\t}\n\n\tprivate image(src: string): VideoPreviewImage {\n\t\tlet img = this.images.get(src);\n\t\tif (!img) {\n\t\t\timg = this.options.createImage ? this.options.createImage() : (new Image() as unknown as VideoPreviewImage);\n\t\t\timg.addEventListener('load', () => this.redraw());\n\t\t\timg.src = src;\n\t\t\tthis.images.set(src, img);\n\t\t}\n\t\treturn img;\n\t}\n\n\tprivate entry(clip: string, src: string, kind: 'video' | 'audio'): Entry {\n\t\tconst existing = this.media.get(clip);\n\t\tif (existing && existing.src === src) return existing;\n\t\tif (existing) this.release(existing);\n\t\tconst media = this.createMedia(kind);\n\t\tconst onReady = () => this.redraw();\n\t\tmedia.addEventListener('seeked', onReady);\n\t\tmedia.addEventListener('loadeddata', onReady);\n\t\tmedia.src = src;\n\t\tconst entry = { clip, src, media, onReady };\n\t\tthis.media.set(clip, entry);\n\t\treturn entry;\n\t}\n\n\tprivate release(entry: Entry) {\n\t\tentry.media.pause();\n\t\tentry.media.removeEventListener('seeked', entry.onReady);\n\t\tentry.media.removeEventListener('loadeddata', entry.onReady);\n\t\tentry.media.removeAttribute?.('src');\n\t\tentry.media.load?.();\n\t\tthis.media.delete(entry.clip);\n\t}\n\n\t/** Brings the media elements in step with the editor and draws the frame. */\n\tupdate(): void {\n\t\tif (this.destroyed) return;\n\t\tconst state = this.source.getState();\n\t\tconst { project, time, playing } = state;\n\t\tconst frame = videoFrameAt(project, time);\n\t\t// Video plays from the proxy the preview scale picks; sound-only clips use the original.\n\t\tconst scale = resolveVideoPreviewScale(this.options.previewScale ?? state.previewScale, this.resolutionFor(project.width));\n\t\tconst assets = new Map(project.assets.map((a) => [a.id, a]));\n\t\tthis.srcFor = (layer) => videoPreviewSrc(assets.get(layer.asset ?? ''), scale, layer.src);\n\t\tthis.playing = playing;\n\t\tthis.fps = project.fps;\n\t\tthis.frameCache?.update(project, time, { playing, srcFor: this.srcFor });\n\t\tconst tolerance = (this.options.tolerance ?? 200) / 1000;\n\t\tconst active = new Set<string>();\n\t\tconst sync = (clip: string, src: string, kind: 'video' | 'audio', sourceTime: number, speed: number, gain: number | null) => {\n\t\t\tconst { media } = this.entry(clip, src, kind);\n\t\t\tactive.add(clip);\n\t\t\tconst target = sourceTime / 1000;\n\t\t\t// Browsers refuse rates below about 1/16; slower than that (a freeze) is shown by seeking a paused element.\n\t\t\tconst frozen = speed < 0.0625;\n\t\t\tif (!frozen && Math.abs(media.playbackRate - speed) > 1e-6) media.playbackRate = speed;\n\t\t\tif (gain === null) media.muted = true;\n\t\t\telse {\n\t\t\t\tmedia.muted = gain <= 0;\n\t\t\t\tmedia.volume = Math.min(1, Math.max(0, gain));\n\t\t\t}\n\t\t\tif (playing && !frozen) {\n\t\t\t\tif (media.paused || Math.abs(media.currentTime - target) > tolerance) media.currentTime = target;\n\t\t\t\tif (media.paused) void Promise.resolve(media.play()).catch(() => {});\n\t\t\t} else {\n\t\t\t\tif (!media.paused) media.pause();\n\t\t\t\tif (Math.abs(media.currentTime - target) > 1 / (2 * project.fps)) media.currentTime = target;\n\t\t\t}\n\t\t};\n\t\tconst sounding = new Map(frame.audio.map((a) => [a.clip, a]));\n\t\tfor (const layer of videoFrameLeafLayers(frame.layers)) {\n\t\t\tif (layer.type !== 'media' || layer.assetKind !== 'video' || !layer.src) continue;\n\t\t\tconst sound = sounding.get(layer.clip);\n\t\t\tsync(layer.clip, this.srcFor(layer)!, 'video', layer.sourceTime ?? 0, sound?.speed ?? 1, sound ? sound.gain : null);\n\t\t\tsounding.delete(layer.clip);\n\t\t}\n\t\t// Sound with no picture (audio tracks, hidden video tracks): an audio element is enough.\n\t\tfor (const a of sounding.values()) sync(a.clip, a.src, 'audio', a.sourceTime, a.speed, a.gain);\n\t\tfor (const entry of [...this.media.values()]) if (!active.has(entry.clip)) this.release(entry);\n\t\tthis.draw(this.drawable(project, frame));\n\t}\n\n\t/** With effects, the frame is drawn at the start of its frame, so effect pictures are cached once per frame. */\n\tprivate drawable(project: VideoProject, frame: VideoFrame): VideoFrame {\n\t\tif (!this.effects?.available || !videoLayersUseEffects(frame.layers)) return frame;\n\t\tconst at = videoEffectFrameTime(frame.time, project.fps);\n\t\treturn at === frame.time ? frame : { ...videoFrameAt(project, at), time: frame.time };\n\t}\n\n\tprivate redraw() {\n\t\tif (this.scheduled || this.destroyed) return;\n\t\tthis.scheduled = true;\n\t\tqueueMicrotask(() => {\n\t\t\tthis.scheduled = false;\n\t\t\tif (this.destroyed) return;\n\t\t\tconst { project, time } = this.source.getState();\n\t\t\tthis.draw(this.drawable(project, videoFrameAt(project, time)));\n\t\t});\n\t}\n\n\tprivate sourceFor = (layer: VideoLayer): CanvasImageSource | null => {\n\t\tif (layer.assetKind === 'image' && layer.src) {\n\t\t\tconst img = this.image(layer.src);\n\t\t\treturn img.complete ? (img as unknown as CanvasImageSource) : null;\n\t\t}\n\t\tconst entry = this.media.get(layer.clip);\n\t\tconst ready = !!entry && entry.media.readyState >= 2;\n\t\tconst cache = this.frameCache;\n\t\tconst src = entry?.src ?? this.srcFor(layer);\n\t\tconst sourceTime = layer.sourceTime ?? 0;\n\t\t// Paused, scrubbing or mid-seek: a cached frame is the right picture now; a seeking element is not.\n\t\tif (cache && src && (!this.playing || !ready || entry!.media.seeking)) {\n\t\t\tconst hit = cache.get(src, sourceTime, this.fps);\n\t\t\tif (hit) return hit;\n\t\t}\n\t\tif (!ready) return null;\n\t\tif (entry!.media.seeking) this.complete = false;\n\t\telse if (cache && !this.playing) cache.capture(entry!.src, sourceTime, this.fps, this.source.getState().time, entry!.media as unknown as CanvasImageSource);\n\t\treturn entry!.media as unknown as CanvasImageSource;\n\t};\n\n\t/** Canvas pixels per frame pixel: the option, else the displayed width at the device pixel ratio (at most 1). */\n\tprivate resolutionFor(frameWidth: number): number {\n\t\tif (this.options.resolution !== undefined) return this.options.resolution;\n\t\tconst shown = this.canvas.clientWidth || frameWidth;\n\t\tconst dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;\n\t\treturn Math.min(1, (shown * dpr) / frameWidth);\n\t}\n\n\tprivate draw(frame: VideoFrame) {\n\t\tif (!this.ctx) return;\n\t\tconst resolution = this.resolutionFor(frame.width);\n\t\tconst w = Math.max(1, Math.round(frame.width * resolution));\n\t\tconst h = Math.max(1, Math.round(frame.height * resolution));\n\t\tif (this.canvas.width !== w) this.canvas.width = w;\n\t\tif (this.canvas.height !== h) this.canvas.height = h;\n\t\tthis.complete = true;\n\t\tconst sourceFor = (layer: VideoLayer) => {\n\t\t\t// An effect clip has no media: without a provider it is a placeholder, which is complete.\n\t\t\tif (layer.type === 'effect') return null;\n\t\t\tconst source = this.sourceFor(layer);\n\t\t\tif (!source) this.complete = false;\n\t\t\treturn source;\n\t\t};\n\t\tdrawVideoFrame(this.ctx, frame, sourceFor, resolution, {\n\t\t\tdefineSvgFilter: this.defineSvgFilter,\n\t\t\tlutFor: this.lutFor,\n\t\t\tscratch: this.scratch,\n\t\t\teffects: this.effects,\n\t\t\tonEffectWaiting: () => {\n\t\t\t\tthis.complete = false;\n\t\t\t}\n\t\t});\n\t\tthis.drawn = { time: frame.time, complete: this.complete };\n\t\tthis.options.onFrame?.(frame);\n\t\tthis.pixelsTarget?.drawn(frame.time, this.complete);\n\t}\n\n\t/** A grade's LUT once loaded; while it loads (or if it fails) the frame is drawn without it and counts as incomplete. */\n\tprivate readonly lutFor = (src: string): VideoLut | undefined => {\n\t\tconst lut = this.lutCache?.get(src);\n\t\tif (!lut) this.complete = false;\n\t\treturn lut;\n\t};\n\n\tprivate readonly scratch = (width: number, height: number, slot: number): VideoScratchCanvas | null => {\n\t\tif (!this.scratchCanvases.has(slot)) {\n\t\t\tlet made: VideoScratchCanvas | null = null;\n\t\t\tif (this.options.createScratch) made = this.options.createScratch(width, height, slot);\n\t\t\telse if (typeof document !== 'undefined') {\n\t\t\t\tconst canvas = document.createElement('canvas');\n\t\t\t\tconst ctx = canvas.getContext('2d', { willReadFrequently: true });\n\t\t\t\tmade = ctx ? { canvas, ctx: ctx as unknown as VideoScratchCanvas['ctx'] } : null;\n\t\t\t}\n\t\t\tthis.scratchCanvases.set(slot, made);\n\t\t}\n\t\tconst s = this.scratchCanvases.get(slot)!;\n\t\tif (!s) return null;\n\t\tconst c = s.canvas as { width?: number; height?: number };\n\t\tif (c.width !== width) c.width = width;\n\t\tif (c.height !== height) c.height = height;\n\t\treturn s;\n\t};\n\n\tprivate readonly defineSvgFilter = (id: string, markup: string) => {\n\t\tif (this.options.defineSvgFilter === null) return;\n\t\tif (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);\n\t\tif (this.filters.has(id) || typeof document === 'undefined') return;\n\t\tif (!this.filterHost) {\n\t\t\tconst svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n\t\t\tsvg.setAttribute('width', '0');\n\t\t\tsvg.setAttribute('height', '0');\n\t\t\tsvg.setAttribute('aria-hidden', 'true');\n\t\t\tsvg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden');\n\t\t\tdocument.body.appendChild(svg);\n\t\t\tthis.filterHost = svg;\n\t\t}\n\t\tthis.filterHost.insertAdjacentHTML('beforeend', markup);\n\t\tthis.filters.add(id);\n\t};\n\n\t/** Stops following the editor and releases every media element. */\n\tdestroy(): void {\n\t\tthis.destroyed = true;\n\t\tthis.detachPixels();\n\t\tthis.unsubscribeLuts();\n\t\tthis.unsubscribeFrames();\n\t\tthis.unsubscribeEffects();\n\t\tthis.filterHost?.remove();\n\t\tthis.filterHost = null;\n\t\tthis.filters.clear();\n\t\tthis.unsubscribe();\n\t\tfor (const entry of [...this.media.values()]) this.release(entry);\n\t\tthis.images.clear();\n\t}\n}\n","import type { Clock } from '../animation/clock.js';\nimport type { AnimationLoopMode } from '../animation/evaluate.js';\nimport { AnimationPlayer } from '../animation/player.js';\nimport { HistoryManager } from '../history/HistoryManager.js';\nimport type { Command } from '../history/types.js';\nimport { VideoAudioPeaksCache, type VideoAudioPeaksLoader } from './audio.js';\nimport { checkVideoProject, type VideoIssue } from './check.js';\nimport { applyVideoEdits, type VideoEdit, type VideoEditResult } from './edit.js';\nimport { videoFrameAt, type VideoFrame } from './evaluate.js';\nimport { toVideoEdl, toVideoFfmpeg, toVideoLoudnessMeasure, type VideoEdlOptions, type VideoFfmpegExport, type VideoFfmpegOptions } from './export.js';\nimport { VideoLutCache, type VideoLutLoader } from './lut.js';\nimport { createVideoProject, parseVideoProject } from './parse.js';\nimport { VideoPreviewPixels } from './scopes.js';\nimport type { VideoPreviewScale } from './proxy.js';\nimport type { VideoFrameCache } from './frame-cache.js';\nimport { videoProjectSvg, type VideoSvgOptions } from './render.js';\nimport { snapToVideoFrame, snapVideoTime, videoClipEnd, videoClipsAt, videoProjectDuration, videoSnapPoints, findVideoClip } from './timeline.js';\nimport type { VideoClip, VideoClipEffect, VideoEffectValues, VideoPlacementMode, VideoProject } from './types.js';\nimport { EffectsSlot } from '../effects/helpers.js';\nimport { isEffectError } from '../effects/errors.js';\nimport type { EffectDescription, EffectProvider, EffectValue } from '../effects/types.js';\nimport { checkVideoEffectPlace, checkVideoEffectValues, VideoEffectsEngine, type VideoEffectPlace } from './effects.js';\nimport { isVideoKeyframes, type VideoAnimatable, type VideoKey } from './keyframes.js';\n\nexport interface VideoEditorOptions {\n\t/** The project to open (default: an empty 1920 × 1080, 30 fps project with tracks v1 and a1). */\n\tproject?: VideoProject;\n\t/** Shared undo history (default: a new one). */\n\thistory?: HistoryManager;\n\t/** Time source for playback (default: requestAnimationFrame). Pass a `ManualClock` to step frames. */\n\tclock?: Clock;\n\treadOnly?: boolean;\n\t/** Called after every change to the project (not on playback or selection). */\n\tonChange?: (project: VideoProject) => void;\n\t/**\n\t * Where sound levels for waveforms and `get_video_audio_levels` come from: a shared cache, or a\n\t * loader for one (default: fetch and decode each asset's `src` with Web Audio, in a browser).\n\t */\n\taudioPeaks?: VideoAudioPeaksCache | VideoAudioPeaksLoader;\n\t/** Where grades' LUT files (`.cube`) come from: a shared cache, or a loader (default: `fetch` each `src`). */\n\tluts?: VideoLutCache | VideoLutLoader;\n\t/** How large a picture the preview plays (default `auto`: a proxy as large as the canvas shows, if the asset has one). */\n\tpreviewScale?: VideoPreviewScale;\n\t/**\n\t * Decoded source frames with prefetch for the preview (see `VideoFrameCache`): scrubbing and\n\t * stepping then show cached frames at once. Host-side and optional (default none).\n\t */\n\tframeCache?: VideoFrameCache;\n\t/**\n\t * An effects provider (see `@pixerate/editor/effects`) for clip effects, effect transitions and\n\t * effect clips. Optional: without one the editor behaves as before, and projects that use\n\t * effects keep them, drawn as placeholders. Set or change it later with `setEffectsProvider`.\n\t */\n\teffects?: EffectProvider | null;\n}\n\n/** What `applyClipEffect` and the other effect edits return: the edit result plus what they made or changed. */\nexport type VideoEffectEditResult = (VideoEditResult & { ok: true; clip: string; effect?: VideoClipEffect }) | { ok: false; error: string };\n\n/** A snapshot of the editor. The same object is returned until something changes. */\nexport interface VideoEditorState {\n\tproject: VideoProject;\n\t/** Total length (ms): the end of the last clip. */\n\tduration: number;\n\t/** Playhead (ms). */\n\ttime: number;\n\tplaying: boolean;\n\tloop: AnimationLoopMode;\n\t/** Selected clip ids, in the order they were selected. */\n\tselection: readonly string[];\n\t/** Clips under the playhead, top track first. */\n\tcurrent: readonly string[];\n\t/** The project's problems (see `checkVideoProject`). */\n\tissues: readonly VideoIssue[];\n\treadOnly: boolean;\n\t/** Why the last edit was refused, if it was. */\n\terror: string | null;\n\tcanUndo: boolean;\n\tcanRedo: boolean;\n\tundoLabel: string;\n\tredoLabel: string;\n\t/** How large a picture the preview plays: `auto`, `full` or a fraction that picks a proxy. */\n\tpreviewScale: VideoPreviewScale;\n\t/** The registered effects provider, or `null` (effects are then drawn as placeholders and cannot be added). */\n\teffectsProvider: EffectProvider | null;\n\t/** Bumped on every change. */\n\trevision: number;\n}\n\n/**\n * Headless non-linear video editor: a `VideoProject` of assets and tracks of clips, edited with\n * undoable, all-or-nothing edits (insert, ripple, trim, split, move, transitions), played on an\n * injectable clock, with a clip selection, snapping and frames at any time. UI-agnostic: React and\n * Svelte bind it with `useVideoEditor` / `createVideoEditor`, `VideoPreviewEngine` plays it in a\n * browser and agents drive it through `createVideoEditorToolProvider`. `getState()` returns the same\n * object until something changes, so it works with `useSyncExternalStore` and Svelte runes.\n */\nexport class VideoEditorController {\n\treadonly history: HistoryManager;\n\treadonly player: AnimationPlayer;\n\t/** Sound levels per asset, loaded on demand for waveforms and agents. */\n\treadonly audioPeaks: VideoAudioPeaksCache;\n\t/** The pixels the preview last drew, for scopes and `get_video_scopes` (a `VideoPreviewEngine` fills it). */\n\treadonly previewPixels = new VideoPreviewPixels();\n\t/** Grades' LUTs, loaded on demand for the preview, SVG frames and agents. */\n\treadonly luts: VideoLutCache;\n\t/** The host's frame cache, if it gave one (the preview fills and reads it). */\n\treadonly frameCache: VideoFrameCache | null;\n\t/** The effects provider slot (see `setEffectsProvider`). */\n\treadonly effects: EffectsSlot;\n\t/** Rendered effect pictures for the preview and frame-exact exports. */\n\treadonly effectsEngine: VideoEffectsEngine;\n\tprivate project: VideoProject;\n\tprivate issues: VideoIssue[];\n\tprivate selection: string[] = [];\n\tprivate readOnly: boolean;\n\tprivate previewScale: VideoPreviewScale;\n\tprivate error: string | null = null;\n\tprivate revision = 0;\n\tprivate applying = false;\n\tprivate readonly onChange?: (project: VideoProject) => void;\n\tprivate readonly listeners = new Set<(state: VideoEditorState) => void>();\n\tprivate readonly unsubscribers: (() => void)[] = [];\n\tprivate state!: VideoEditorState;\n\n\tconstructor(options: VideoEditorOptions = {}) {\n\t\tthis.history = options.history ?? new HistoryManager();\n\t\tthis.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);\n\t\tthis.luts = options.luts instanceof VideoLutCache ? options.luts : new VideoLutCache(options.luts);\n\t\tthis.frameCache = options.frameCache ?? null;\n\t\tthis.effects = new EffectsSlot();\n\t\tif (options.effects) this.effects.set(options.effects);\n\t\tthis.effectsEngine = new VideoEffectsEngine({ slot: this.effects });\n\t\tthis.project = options.project ? parseVideoProject(options.project) : createVideoProject();\n\t\tthis.issues = checkVideoProject(this.project);\n\t\tthis.readOnly = options.readOnly ?? false;\n\t\tthis.previewScale = checkPreviewScale(options.previewScale ?? 'auto');\n\t\tthis.onChange = options.onChange;\n\t\tthis.player = new AnimationPlayer({ clock: options.clock, duration: () => videoProjectDuration(this.project), loop: 'none' });\n\t\tthis.state = this.computeState();\n\t\tthis.connect();\n\t}\n\n\tprivate connect() {\n\t\tif (this.unsubscribers.length) return;\n\t\tthis.unsubscribers.push(\n\t\t\tthis.player.subscribe(() => this.notify()),\n\t\t\tthis.history.subscribe(() => {\n\t\t\t\tif (!this.applying) this.notify();\n\t\t\t}),\n\t\t\tthis.effects.subscribe(() => this.notify())\n\t\t);\n\t}\n\n\t/** Pauses and stops listening to the player and history. Subscribing again reconnects (React StrictMode). */\n\tdestroy(): void {\n\t\tthis.player.pause();\n\t\tthis.effectsEngine.reset();\n\t\tthis.unsubscribers.splice(0).forEach((u) => u());\n\t}\n\n\tgetState = (): VideoEditorState => this.state;\n\n\tsubscribe = (listener: (state: VideoEditorState) => void): (() => void) => {\n\t\tthis.connect();\n\t\tthis.listeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.listeners.delete(listener);\n\t\t};\n\t};\n\n\t/** The current project (immutable; edit through the controller). */\n\tgetProject(): VideoProject {\n\t\treturn this.project;\n\t}\n\n\tprivate computeState(): VideoEditorState {\n\t\tconst p = this.player.getState();\n\t\tconst h = this.history.getState();\n\t\treturn {\n\t\t\tproject: this.project,\n\t\t\tduration: videoProjectDuration(this.project),\n\t\t\ttime: p.time,\n\t\t\tplaying: p.playing,\n\t\t\tloop: p.loop,\n\t\t\tselection: this.selection,\n\t\t\tcurrent: videoClipsAt(this.project, p.time).map((c) => c.clip.id),\n\t\t\tissues: this.issues,\n\t\t\treadOnly: this.readOnly,\n\t\t\terror: this.error,\n\t\t\tcanUndo: h.canUndo,\n\t\t\tcanRedo: h.canRedo,\n\t\t\tundoLabel: h.undoName,\n\t\t\tredoLabel: h.redoName,\n\t\t\tpreviewScale: this.previewScale,\n\t\t\teffectsProvider: this.effects.provider,\n\t\t\trevision: this.revision\n\t\t};\n\t}\n\n\tprivate notify() {\n\t\tthis.revision++;\n\t\tthis.state = this.computeState();\n\t\tconst state = this.state;\n\t\tthis.listeners.forEach((listener) => {\n\t\t\ttry {\n\t\t\t\tlistener(state);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error('[VideoEditorController] Error in state listener:', error);\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate setProject(project: VideoProject) {\n\t\tthis.applying = true;\n\t\ttry {\n\t\t\tthis.project = project;\n\t\t\tthis.issues = checkVideoProject(project);\n\t\t\tconst ids = new Set(project.tracks.flatMap((t) => t.clips.map((c) => c.id)));\n\t\t\tif (this.selection.some((id) => !ids.has(id))) this.selection = this.selection.filter((id) => ids.has(id));\n\t\t\tthis.player.setDuration(() => videoProjectDuration(this.project));\n\t\t} finally {\n\t\t\tthis.applying = false;\n\t\t}\n\t\tthis.notify();\n\t\tthis.onChange?.(project);\n\t}\n\n\tprivate commit(label: string, next: VideoProject, merge?: string) {\n\t\tconst before = this.project;\n\t\tthis.error = null;\n\t\tif (next === before) {\n\t\t\tthis.notify();\n\t\t\treturn;\n\t\t}\n\t\tconst command: Command = {\n\t\t\tname: label,\n\t\t\ttag: 'video-editor',\n\t\t\texecute: () => this.setProject(next),\n\t\t\tundo: () => this.setProject(before),\n\t\t\tredo: () => this.setProject(next)\n\t\t};\n\t\tif (merge) this.history.executeMerged(command, { key: merge });\n\t\telse this.history.execute(command);\n\t}\n\n\tprivate refuse(message: string): VideoEditResult {\n\t\tthis.error = message;\n\t\tthis.notify();\n\t\treturn { ok: false, error: message };\n\t}\n\n\t/** Why edits are refused now, or `null` when they are allowed. */\n\treadOnlyReason(): string | null {\n\t\treturn this.readOnly ? 'The video project is read-only.' : null;\n\t}\n\n\t/**\n\t * Applies edits as one undo step, all or nothing (see `applyVideoEdits`). With `dryRun` it\n\t * reports what would happen and changes nothing. Edits with the same `merge` key in a row\n\t * (a slider drag) fold into one undo step.\n\t */\n\tapply(edits: VideoEdit | readonly VideoEdit[], label = 'Edit video', options: { dryRun?: boolean; merge?: string } = {}): VideoEditResult {\n\t\tconst reason = this.readOnlyReason();\n\t\tif (reason) return this.refuse(reason);\n\t\tconst result = applyVideoEdits(this.project, edits);\n\t\tif (!result.ok) return options.dryRun ? result : this.refuse(result.error);\n\t\tif (!options.dryRun) this.commit(label, result.project, options.merge);\n\t\treturn result;\n\t}\n\n\t/** Opens a project (JSON or object). `clearHistory` drops the undo stack (opening a file); otherwise it is one undo step. */\n\tload(project: VideoProject | string, options: { clearHistory?: boolean } = {}): VideoEditResult {\n\t\tlet parsed: VideoProject;\n\t\ttry {\n\t\t\tparsed = parseVideoProject(project);\n\t\t} catch (error) {\n\t\t\treturn this.refuse((error as Error).message);\n\t\t}\n\t\tif (options.clearHistory) {\n\t\t\tthis.history.clear();\n\t\t\tthis.error = null;\n\t\t\tthis.selection = [];\n\t\t\tthis.setProject(parsed);\n\t\t\tthis.player.seek(0);\n\t\t\treturn { ok: true, project: parsed, issues: this.issues, outcomes: [{ op: 'replace_project' }] };\n\t\t}\n\t\treturn this.apply({ op: 'replace_project', project: parsed }, 'Replace project');\n\t}\n\n\tsetReadOnly(readOnly: boolean): void {\n\t\tif (this.readOnly === readOnly) return;\n\t\tthis.readOnly = readOnly;\n\t\tthis.notify();\n\t}\n\n\t/**\n\t * Sets how large a picture the preview plays: `full` (the originals), a fraction such as 0.5\n\t * (the smallest proxy at least that large, else the original) or `auto` (as large as the canvas\n\t * shows). A view setting, not part of the project: no undo step.\n\t */\n\tsetPreviewScale(scale: VideoPreviewScale): void {\n\t\tconst next = checkPreviewScale(scale);\n\t\tif (next === this.previewScale) return;\n\t\tthis.previewScale = next;\n\t\tthis.notify();\n\t}\n\n\t/** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */\n\tselect(clips: readonly string[] | null, options: { add?: boolean } = {}): void {\n\t\tconst known = clips?.filter((id) => findVideoClip(this.project, id)) ?? [];\n\t\tlet next: string[];\n\t\tif (options.add) {\n\t\t\tnext = [...this.selection];\n\t\t\tfor (const id of known) next = next.includes(id) ? next.filter((x) => x !== id) : [...next, id];\n\t\t} else next = [...new Set(known)];\n\t\tif (next.length === this.selection.length && next.every((id, i) => id === this.selection[i])) return;\n\t\tthis.selection = next;\n\t\tthis.notify();\n\t}\n\n\tplay(): void {\n\t\tthis.player.play();\n\t}\n\n\tpause(): void {\n\t\tthis.player.pause();\n\t}\n\n\ttoggle(): void {\n\t\tthis.player.toggle();\n\t}\n\n\t/** Moves the playhead (ms). */\n\tseek(time: number): void {\n\t\tthis.player.seek(time);\n\t}\n\n\t/** Moves the playhead by whole frames (negative steps back), pausing. */\n\tstep(frames: number): void {\n\t\tthis.player.pause();\n\t\tconst fps = this.project.fps;\n\t\tthis.player.seek(Math.max(0, snapToVideoFrame(this.player.currentTime, fps) + (frames * 1000) / fps));\n\t}\n\n\tsetLoop(loop: AnimationLoopMode): void {\n\t\tthis.player.setLoop(loop);\n\t}\n\n\tundo(): boolean {\n\t\treturn this.history.undo();\n\t}\n\n\tredo(): boolean {\n\t\treturn this.history.redo();\n\t}\n\n\t/**\n\t * `time` moved to the nearest clip edge, marker or the playhead within `threshold` ms (default:\n\t * 8 frames), else to the nearest frame. Clips in `exclude` (the ones being dragged) are ignored.\n\t */\n\tsnap(time: number, options: { exclude?: Iterable<string>; threshold?: number; playhead?: boolean } = {}): { time: number; snapped: number | null } {\n\t\tconst threshold = options.threshold ?? (8 * 1000) / this.project.fps;\n\t\tconst points = videoSnapPoints(this.project, { exclude: options.exclude, playhead: options.playhead === false ? undefined : this.player.currentTime });\n\t\tconst hit = snapVideoTime(time, points, threshold);\n\t\treturn hit.snapped !== null ? hit : { time: Math.max(0, snapToVideoFrame(time, this.project.fps)), snapped: null };\n\t}\n\n\t/** Splits the selected clips (or every clip) under the playhead there, as one undo step. */\n\tsplitAtPlayhead(): VideoEditResult {\n\t\tconst t = snapToVideoFrame(this.player.currentTime, this.project.fps);\n\t\tconst under = videoClipsAt(this.project, t).filter(({ clip }) => t > clip.start && t < videoClipEnd(clip));\n\t\tconst chosen = this.selection.length ? under.filter(({ clip }) => this.selection.includes(clip.id)) : under;\n\t\tif (!chosen.length) return this.refuse('No clip is under the playhead to split.');\n\t\treturn this.apply(chosen.map(({ clip }) => ({ op: 'split_clip' as const, clip: clip.id, at: t })), 'Split');\n\t}\n\n\t/** Deletes the selected clips; `ripple` closes the gaps they leave. */\n\tdeleteSelection(options: { ripple?: boolean } = {}): VideoEditResult {\n\t\tif (!this.selection.length) return this.refuse('Select clips to delete first.');\n\t\treturn this.apply({ op: 'delete_clips', clips: [...this.selection], ripple: options.ripple }, options.ripple ? 'Ripple delete' : 'Delete clips');\n\t}\n\n\n\t// -----------------------------------------------------------------------------------------\n\t// Effects\n\t// -----------------------------------------------------------------------------------------\n\n\t/** The registered effects provider, or `null`. */\n\tget effectsProvider(): EffectProvider | null {\n\t\treturn this.effects.provider;\n\t}\n\n\t/** Registers (or with `null` removes) the effects provider. Not an edit: no undo step. */\n\tsetEffectsProvider(provider: EffectProvider | null): void {\n\t\tthis.effects.set(provider);\n\t}\n\n\t/** Registers `provider` and returns a function that removes it again (if it is still the one set). */\n\tuseEffects(provider: EffectProvider): () => void {\n\t\treturn this.effects.use(provider);\n\t}\n\n\t/** The provider's description of an effect, checked for `place` (rejects without a provider or for an unknown id). */\n\tasync describeEffect(effect: string, place?: VideoEffectPlace): Promise<EffectDescription> {\n\t\tconst d = await this.effects.require().describe(effect);\n\t\tif (place) checkVideoEffectPlace(d, place);\n\t\treturn d;\n\t}\n\n\tprivate refused(message: string): { ok: false; error: string } {\n\t\treturn this.refuse(message) as { ok: false; error: string };\n\t}\n\n\tprivate async effectEdit(run: () => Promise<{ edit: VideoEdit; label: string; clip: string; effect?: () => VideoClipEffect | undefined }>, merge?: string): Promise<VideoEffectEditResult> {\n\t\tconst reason = this.readOnlyReason();\n\t\tif (reason) return this.refused(reason);\n\t\tlet plan;\n\t\ttry {\n\t\t\tplan = await run();\n\t\t} catch (error) {\n\t\t\tif (isEffectError(error) || error instanceof Error) return this.refused((error as Error).message);\n\t\t\tthrow error;\n\t\t}\n\t\tconst result = this.apply(plan.edit, plan.label, { merge });\n\t\tif (!result.ok) return result;\n\t\treturn { ...result, clip: plan.clip, ...(plan.effect ? { effect: plan.effect() } : {}) };\n\t}\n\n\tprivate clipOrThrow(id: string): VideoClip {\n\t\tconst at = findVideoClip(this.project, id);\n\t\tif (!at) throw new Error(`There is no clip \"${id}\"; list_applied_effects or get_video_project lists the clips.`);\n\t\treturn at.clip;\n\t}\n\n\t/**\n\t * Adds a filter or overlay effect to a clip's effect stack (at `index`, default the end: applied\n\t * last), with `values` over the effect's defaults (numbers may be keyframes). One undo step.\n\t * Returns the effect with its new id.\n\t */\n\tapplyClipEffect(clip: string, effect: string, options: { values?: VideoEffectValues; index?: number; enabled?: boolean; opacity?: VideoAnimatable } = {}): Promise<VideoEffectEditResult> {\n\t\treturn this.effectEdit(async () => {\n\t\t\tconst current = this.clipOrThrow(clip);\n\t\t\tconst d = await this.describeEffect(effect, 'clip');\n\t\t\tconst values = checkVideoEffectValues(d, options.values, { keyframes: true });\n\t\t\tconst stack = [...(current.effects ?? [])];\n\t\t\tconst taken = new Set(stack.map((e) => e.id));\n\t\t\tlet n = 1;\n\t\t\twhile (taken.has(`fx${n}`)) n++;\n\t\t\tconst added: VideoClipEffect = { id: `fx${n}`, effect: d.id, provider: this.effects.require().id, name: d.name, values };\n\t\t\tif (options.enabled === false) added.enabled = false;\n\t\t\tif (options.opacity !== undefined) added.opacity = options.opacity;\n\t\t\tconst index = options.index ?? stack.length;\n\t\t\tif (!(Number.isInteger(index) && index >= 0 && index <= stack.length)) throw new Error(`\"index\" must be a whole number from 0 to ${stack.length}.`);\n\t\t\tstack.splice(index, 0, added);\n\t\t\treturn { edit: { op: 'update_clip', clip, set: { effects: stack } }, label: `Add effect ${d.name}`, clip, effect: () => added };\n\t\t});\n\t}\n\n\t/**\n\t * Changes an effect on a clip: `values` (merged over its current ones; numbers may be\n\t * keyframes, `null` resets a control to its default), `enabled`, `opacity` or its place in the\n\t * stack (`index`). Values are checked against the provider's controls. One undo step.\n\t */\n\tupdateClipEffect(clip: string, instance: string, changes: { values?: Record<string, EffectValue | { keys: VideoKey[] } | null>; enabled?: boolean; opacity?: VideoAnimatable; index?: number }, options: { merge?: string } = {}): Promise<VideoEffectEditResult> {\n\t\treturn this.effectEdit(async () => {\n\t\t\tconst current = this.clipOrThrow(clip);\n\t\t\tconst stack = [...(current.effects ?? [])];\n\t\t\tconst i = stack.findIndex((e) => e.id === instance);\n\t\t\tif (i < 0) throw new Error(`Clip \"${clip}\" has no effect \"${instance}\"; its effects are ${stack.map((e) => `\"${e.id}\" (${e.name ?? e.effect})`).join(', ') || 'none'}.`);\n\t\t\tconst was = stack[i];\n\t\t\tconst next: VideoClipEffect = { ...was };\n\t\t\tif (changes.values) {\n\t\t\t\tconst d = await this.describeEffect(was.effect);\n\t\t\t\tconst reset = Object.keys(changes.values).filter((k) => changes.values![k] === null);\n\t\t\t\tconst given = Object.fromEntries(Object.entries(changes.values).filter(([, v]) => v !== null)) as VideoEffectValues;\n\t\t\t\tconst base = { ...(was.values ?? {}) };\n\t\t\t\tfor (const k of reset) {\n\t\t\t\t\tif (!(k in d.controls.properties)) throw new Error(`\"${k}\" is not a control of \"${d.id}\"; its controls are ${Object.keys(d.controls.properties).join(', ')}.`);\n\t\t\t\t\tbase[k] = d.defaults[k];\n\t\t\t\t}\n\t\t\t\tnext.values = checkVideoEffectValues(d, given, { base, keyframes: true });\n\t\t\t}\n\t\t\tif (changes.enabled !== undefined) {\n\t\t\t\tif (changes.enabled) delete next.enabled;\n\t\t\t\telse next.enabled = false;\n\t\t\t}\n\t\t\tif (changes.opacity !== undefined) next.opacity = changes.opacity;\n\t\t\tstack.splice(i, 1);\n\t\t\tconst index = changes.index ?? i;\n\t\t\tif (!(Number.isInteger(index) && index >= 0 && index <= stack.length)) throw new Error(`\"index\" must be a whole number from 0 to ${stack.length}.`);\n\t\t\tstack.splice(index, 0, next);\n\t\t\treturn { edit: { op: 'update_clip', clip, set: { effects: stack } }, label: `Change effect ${was.name ?? was.effect}`, clip, effect: () => next };\n\t\t}, options.merge);\n\t}\n\n\t/** Removes an effect from a clip's stack (works without a provider, so placeholders can be cleared). One undo step. */\n\tremoveClipEffect(clip: string, instance: string): VideoEffectEditResult {\n\t\tconst reason = this.readOnlyReason();\n\t\tif (reason) return this.refused(reason);\n\t\tconst at = findVideoClip(this.project, clip);\n\t\tif (!at) return this.refused(`There is no clip \"${clip}\".`);\n\t\tconst stack = at.clip.effects ?? [];\n\t\tconst was = stack.find((e) => e.id === instance);\n\t\tif (!was) return this.refused(`Clip \"${clip}\" has no effect \"${instance}\"; its effects are ${stack.map((e) => `\"${e.id}\"`).join(', ') || 'none'}.`);\n\t\tconst rest = stack.filter((e) => e !== was);\n\t\tconst result = rest.length ? this.apply({ op: 'update_clip', clip, set: { effects: rest } }, `Remove effect ${was.name ?? was.effect}`) : this.apply({ op: 'update_clip', clip, remove: ['effects'] }, `Remove effect ${was.name ?? was.effect}`);\n\t\treturn result.ok ? { ...result, clip, effect: was } : result;\n\t}\n\n\t/**\n\t * Sets a provider transition into `clip` from the clip that ends where it starts (centred on the\n\t * cut, `duration` ms, default the effect's own length or 1000). One undo step.\n\t */\n\tsetEffectTransition(clip: string, effect: string, options: { duration?: number; values?: Record<string, EffectValue> } = {}): Promise<VideoEffectEditResult> {\n\t\treturn this.effectEdit(async () => {\n\t\t\tthis.clipOrThrow(clip);\n\t\t\tconst d = await this.describeEffect(effect, 'transition');\n\t\t\tconst values = checkVideoEffectValues(d, options.values) as Record<string, EffectValue>;\n\t\t\tconst duration = options.duration ?? d.duration ?? 1000;\n\t\t\treturn { edit: { op: 'set_transition', clip, transition: { type: 'effect', duration, effect: { effect: d.id, provider: this.effects.require().id, name: d.name, values } } }, label: `Transition ${d.name}`, clip };\n\t\t});\n\t}\n\n\t/**\n\t * Adds an effect clip: a generator, title or overlay effect drawn over the whole frame for\n\t * `duration` ms (default the effect's own length, else 5 s) at `start` (default the end of the\n\t * track). One undo step. Returns the new clip's id.\n\t */\n\taddEffectClip(track: string, effect: string, options: { start?: number; duration?: number; values?: VideoEffectValues; name?: string; mode?: VideoPlacementMode; ripple?: boolean; id?: string } = {}): Promise<VideoEffectEditResult> {\n\t\treturn this.effectEdit(async () => {\n\t\t\tconst d = await this.describeEffect(effect, 'generator');\n\t\t\tconst values = checkVideoEffectValues(d, options.values, { keyframes: true });\n\t\t\tconst duration = options.duration ?? d.duration ?? 5000;\n\t\t\tconst label = `Add ${d.name}`;\n\t\t\tconst clipId = options.id ?? '';\n\t\t\treturn {\n\t\t\t\tedit: { op: 'add_clip', track, mode: options.mode, ripple: options.ripple, clip: { type: 'effect', ...(options.id ? { id: options.id } : {}), effect: d.id, provider: this.effects.require().id, name: options.name ?? d.name, values, start: options.start, duration } },\n\t\t\t\tlabel,\n\t\t\t\tclip: clipId\n\t\t\t};\n\t\t}).then((r) => (r.ok && !r.clip ? { ...r, clip: r.outcomes[0]?.clip ?? '' } : r));\n\t}\n\n\t/**\n\t * Changes the values of an effect clip's picture (`target` its id) or of the effect transition\n\t * into a clip, merged over the current ones and checked against the provider's controls.\n\t * Transition values cannot be keyframed. One undo step.\n\t */\n\tupdateEffectValues(clip: string, part: 'generator' | 'transition', values: VideoEffectValues, options: { merge?: string } = {}): Promise<VideoEffectEditResult> {\n\t\treturn this.effectEdit(async () => {\n\t\t\tconst current = this.clipOrThrow(clip);\n\t\t\tif (part === 'generator') {\n\t\t\t\tif (current.type !== 'effect') throw new Error(`Clip \"${clip}\" is not an effect clip.`);\n\t\t\t\tconst d = await this.describeEffect(current.effect);\n\t\t\t\treturn { edit: { op: 'update_clip', clip, set: { values: checkVideoEffectValues(d, values, { base: current.values ?? {}, keyframes: true }) } }, label: `Change ${current.name ?? d.name}`, clip };\n\t\t\t}\n\t\t\tconst t = current.transition;\n\t\t\tif (t?.type !== 'effect' || !t.effect) throw new Error(`Clip \"${clip}\" has no effect transition.`);\n\t\t\tconst d = await this.describeEffect(t.effect.effect);\n\t\t\tconst merged = checkVideoEffectValues(d, values, { base: t.effect.values ?? {} });\n\t\t\treturn { edit: { op: 'set_transition', clip, transition: { ...t, effect: { ...t.effect, values: merged } } }, label: `Change transition ${d.name}`, clip };\n\t\t}, options.merge);\n\t}\n\n\t/** Keys for one numeric control of a clip effect (clip-relative ms), or `null` to stop animating it (keeping its value at the clip's start). */\n\tsetClipEffectKeyframes(clip: string, instance: string, control: string, keys: VideoKey[] | null): Promise<VideoEffectEditResult> {\n\t\tconst current = findVideoClip(this.project, clip)?.clip;\n\t\tconst was = current?.effects?.find((e) => e.id === instance);\n\t\tconst value = was?.values?.[control];\n\t\tconst plain = keys === null ? (isVideoKeyframes(value) ? value.keys[0]?.v : value) : { keys: [...keys].sort((a, b) => a.t - b.t) };\n\t\treturn this.updateClipEffect(clip, instance, { values: { [control]: (plain ?? null) as EffectValue | { keys: VideoKey[] } | null } });\n\t}\n\n\t/** What plays at `time` (default: the playhead). */\n\tframe(time = this.player.currentTime): VideoFrame {\n\t\treturn videoFrameAt(this.project, time);\n\t}\n\n\t/** The frame at `time` (default: the playhead) as SVG. */\n\ttoSvg(time = this.player.currentTime, options: VideoSvgOptions = {}): string {\n\t\treturn videoProjectSvg(this.project, time, { lutFor: (src) => this.luts.peek(src), ...options });\n\t}\n\n\t/** The project as an ffmpeg command. */\n\ttoFfmpeg(options: VideoFfmpegOptions = {}): VideoFfmpegExport {\n\t\treturn toVideoFfmpeg(this.project, options);\n\t}\n\n\t/** The ffmpeg command that measures the mix's loudness (see `toVideoLoudnessMeasure`). */\n\ttoLoudnessMeasure(options: Pick<VideoFfmpegOptions, 'resolve' | 'loudness'> = {}): VideoFfmpegExport {\n\t\treturn toVideoLoudnessMeasure(this.project, options);\n\t}\n\n\t/** One track as a CMX 3600 EDL. */\n\ttoEdl(options: VideoEdlOptions = {}): string {\n\t\treturn toVideoEdl(this.project, options);\n\t}\n}\n\nfunction checkPreviewScale(scale: VideoPreviewScale): VideoPreviewScale {\n\tif (scale === 'auto' || scale === 'full') return scale;\n\tif (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)}.`);\n\treturn scale === 1 ? 'full' : scale;\n}\n","import type { VideoEdit } from './edit.js';\nimport { normalizeVideoGrade } from './grade.js';\nimport { isVideoKeyframes, removeVideoKey, setVideoKey, VIDEO_KEYFRAME_LIMITS, VIDEO_TRANSFORM_PROPERTIES, videoValueAt, type VideoAnimatable, type VideoKeyframeProperty } from './keyframes.js';\nimport { findVideoClip, formatVideoTimecode, videoClipEnd, videoClipSpeed, videoAssetMap, videoAssetHasSound } from './timeline.js';\nimport type { VideoClipAnimationEffect, VideoTransitionType, VideoAsset, VideoAssetKind, VideoBlendMode, VideoClip, VideoFadeCurve, VideoGradeLut, VideoPlacementMode, VideoProject, VideoSequence } from './types.js';\n\n// Framework-free helpers the React and Svelte timelines share, so both draw and drag alike.\n\nexport interface VideoRulerTick {\n\ttime: number;\n\t/** Pixels from the timeline's left edge. */\n\tx: number;\n\t/** Major ticks carry a label. */\n\tmajor: boolean;\n\tlabel?: string;\n}\n\nconst STEPS = [40, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000];\n\n/** Ruler ticks for `[0, duration]` at `pxPerSecond`: a major tick at least every ~80 px, labelled with timecode. */\nexport function videoRulerTicks(duration: number, pxPerSecond: number, fps: number): VideoRulerTick[] {\n\tconst pxPerMs = pxPerSecond / 1000;\n\tconst major = STEPS.find((s) => s * pxPerMs >= 80) ?? STEPS[STEPS.length - 1];\n\tconst minor = STEPS.slice()\n\t\t.reverse()\n\t\t.find((s) => s < major && major % s === 0 && s * pxPerMs >= 10);\n\tconst step = minor ?? major;\n\tconst ticks: VideoRulerTick[] = [];\n\tfor (let t = 0; t <= duration + 1e-6; t += step) {\n\t\tconst isMajor = Math.abs(t / major - Math.round(t / major)) < 1e-9;\n\t\tticks.push({ time: t, x: t * pxPerMs, major: isMajor, ...(isMajor ? { label: formatVideoTimecode(t, fps).replace(/^00:/, '') } : {}) });\n\t}\n\treturn ticks;\n}\n\n/** Fill colour a timeline uses for a clip, by what it is. */\n/** Which clips a timeline draws waveforms in: audio files only (default), anything with sound, or none. */\nexport type VideoWaveformMode = 'audio' | 'all' | 'none';\n\n/** Whether a timeline in `mode` draws a waveform in this clip. */\nexport function videoClipShowsWaveform(clip: VideoClip, asset: VideoAsset | undefined, mode: VideoWaveformMode = 'audio'): asset is VideoAsset {\n\tif (clip.type !== 'media' || mode === 'none' || !videoAssetHasSound(asset)) return false;\n\treturn mode === 'all' || asset!.kind === 'audio';\n}\n\n/** Names for the entrance and exit animations, for menus. */\nexport const VIDEO_CLIP_ANIMATION_LABELS: Readonly<Record<VideoClipAnimationEffect, string>> = {\n\tfade: 'Fade',\n\trise: 'Rise',\n\tdrop: 'Drop',\n\t'slide-left': 'Slide left',\n\t'slide-right': 'Slide right',\n\tzoom: 'Zoom',\n\tpop: 'Pop'\n};\n\n/** Names for the transitions, for menus. */\nexport const VIDEO_TRANSITION_LABELS: Readonly<Record<VideoTransitionType, string>> = {\n\tcrossfade: 'Crossfade',\n\tdip: 'Dip to colour',\n\twipe: 'Wipe',\n\tpush: 'Push',\n\tslide: 'Slide',\n\tiris: 'Iris',\n\teffect: 'Effect'\n};\n\n/** Whether a transition type takes a `direction`. */\nexport const videoTransitionHasDirection = (type: VideoTransitionType): boolean => type === 'wipe' || type === 'push' || type === 'slide';\n\n/** Names for the blend modes, for menus. */\nexport const VIDEO_BLEND_MODE_LABELS: Readonly<Record<VideoBlendMode, string>> = {\n\tnormal: 'Normal',\n\tmultiply: 'Multiply',\n\tscreen: 'Screen',\n\toverlay: 'Overlay',\n\tdarken: 'Darken',\n\tlighten: 'Lighten',\n\t'color-dodge': 'Colour dodge',\n\t'color-burn': 'Colour burn',\n\t'hard-light': 'Hard light',\n\t'soft-light': 'Soft light',\n\tdifference: 'Difference',\n\texclusion: 'Exclusion',\n\tadd: 'Add'\n};\n\n/** Names for the sound fade curves, for menus. */\nexport const VIDEO_FADE_CURVE_LABELS: Readonly<Record<VideoFadeCurve, string>> = {\n\tlinear: 'Linear',\n\t'equal-power': 'Equal power',\n\t's-curve': 'S-curve',\n\texponential: 'Exponential',\n\tlogarithmic: 'Logarithmic'\n};\n\nexport function videoClipTint(clip: VideoClip, asset?: VideoAsset): string {\n\tif (clip.type === 'text') return '#a855f7';\n\tif (clip.type === 'sequence') return '#0d9488';\n\tif (clip.type === 'color') return clip.color;\n\tif (clip.type === 'effect') return '#db2777';\n\tif (!asset) return '#ef4444';\n\treturn asset.kind === 'audio' ? '#10b981' : asset.kind === 'image' ? '#f59e0b' : '#3b82f6';\n}\n\n/** A short label for a clip on the timeline (pass the sequence a compound clip plays, for its name). */\nexport function videoClipLabel(clip: VideoClip, asset?: VideoAsset, sequence?: VideoSequence): string {\n\tif (clip.name) return clip.name;\n\tif (clip.type === 'text') return clip.text.split('\\n')[0];\n\tif (clip.type === 'color') return clip.color;\n\tif (clip.type === 'sequence') return `Compound: ${sequence?.name ?? clip.sequence}`;\n\tif (clip.type === 'effect') return `Effect: ${clip.effect}`;\n\tconst base = asset?.name ?? asset?.src.replace(/^.*[\\\\/]/, '') ?? clip.asset;\n\tconst speed = videoClipSpeed(clip);\n\treturn speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;\n}\n\nexport type VideoClipDragMode = 'move' | 'trim-start' | 'trim-end';\n\nexport interface VideoClipDragInput {\n\tclip: string;\n\tmode: VideoClipDragMode;\n\t/** How far the pointer moved along the timeline (ms). */\n\tdelta: number;\n\t/** For move: the track under the pointer (default the clip's own). */\n\ttrack?: string;\n\t/** Push later clips along instead of refusing overlaps (trims) — hold a modifier for it. */\n\tripple?: boolean;\n\t/**\n\t * For move: what to do with clips where it lands (default refuse). The timelines use Alt for\n\t * insert and Shift for overwrite.\n\t */\n\tplacement?: VideoPlacementMode;\n\t/** Snaps a time (e.g. `controller.snap(t, { exclude: [clip] }).time`); default: none. */\n\tsnap?: (time: number) => number;\n}\n\nexport interface VideoClipDragPlan {\n\t/** Where the clip would be: draw this while dragging. */\n\tstart: number;\n\tduration: number;\n\ttrack: string;\n\t/** The edit to apply on release (`null` when nothing changed). */\n\tedit: VideoEdit | null;\n}\n\n/**\n * What dragging a clip (or one of its edges) by `delta` would do: the clip's new place for drawing\n * while the pointer moves, and the edit to apply on release. Moves snap the edge nearer a snap\n * point; trims snap the edge being dragged. The edit is checked when it is applied, so an overlap\n * or a trim past the source is refused then (with the reason in the editor's state).\n */\nexport function planVideoClipDrag(project: VideoProject, input: VideoClipDragInput): VideoClipDragPlan | null {\n\tconst at = findVideoClip(project, input.clip);\n\tif (!at) return null;\n\tconst { clip, track } = at;\n\tconst snap = input.snap ?? ((t: number) => t);\n\tif (input.mode === 'move') {\n\t\tconst raw = Math.max(0, clip.start + input.delta);\n\t\t// Whichever edge snapped by less wins; an edge that did not snap does not count.\n\t\tconst head = snap(raw) - raw;\n\t\tconst tail = snap(raw + clip.duration) - (raw + clip.duration);\n\t\tconst shifts = [head, tail].filter((d) => Math.abs(d) > 1e-9);\n\t\tconst start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => (Math.abs(b) < Math.abs(a) ? b : a)) : 0));\n\t\tconst target = input.track ?? track.id;\n\t\tconst moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;\n\t\tconst mode = input.placement && input.placement !== 'refuse' ? { mode: input.placement } : {};\n\t\treturn { start, duration: clip.duration, track: target, edit: moved ? { op: 'move_clip', clip: clip.id, start, ...(target !== track.id ? { track: target } : {}), ...mode } : null };\n\t}\n\tconst minimum = 1000 / project.fps;\n\tif (input.mode === 'trim-start') {\n\t\tconst to = Math.min(snap(Math.max(0, clip.start + input.delta)), videoClipEnd(clip) - minimum);\n\t\tconst changed = Math.abs(to - clip.start) > 1e-6;\n\t\tconst plan = input.ripple ? { start: clip.start, duration: clip.duration - (to - clip.start) } : { start: to, duration: videoClipEnd(clip) - to };\n\t\treturn { ...plan, track: track.id, edit: changed ? { op: 'trim_clip', clip: clip.id, edge: 'start', to, ...(input.ripple ? { ripple: true } : {}) } : null };\n\t}\n\tconst to = Math.max(snap(videoClipEnd(clip) + input.delta), clip.start + minimum);\n\tconst changed = Math.abs(to - videoClipEnd(clip)) > 1e-6;\n\treturn { start: clip.start, duration: to - clip.start, track: track.id, edit: changed ? { op: 'trim_clip', clip: clip.id, edge: 'end', to, ...(input.ripple ? { ripple: true } : {}) } : null };\n}\n\n/** Tracks a clip may be dragged onto: the same kind as its own, unlocked. */\nexport function videoDropTracks(project: VideoProject, clipId: string): string[] {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return [];\n\treturn project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);\n}\n\n/** The label for the undo step a drag makes. */\nexport function videoDragLabel(edit: VideoEdit): string {\n\tif (edit.op === 'move_clip') return edit.mode === 'insert' ? 'Insert clip' : edit.mode === 'overwrite' ? 'Overwrite clip' : 'Move clip';\n\treturn edit.op === 'trim_clip' && edit.ripple ? 'Ripple trim' : 'Trim clip';\n}\n\n/** The edit that adds an asset as a clip where the person dropped it (or at the end of the first fitting track). */\nexport function videoAssetDropEdit(project: VideoProject, assetId: string, options: { track?: string; start?: number; ripple?: boolean; mode?: VideoPlacementMode } = {}): VideoEdit | null {\n\tconst asset = videoAssetMap(project).get(assetId);\n\tif (!asset) return null;\n\tconst kind = asset.kind === 'audio' ? 'audio' : 'video';\n\tconst track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;\n\tif (!track) return null;\n\treturn { op: 'add_clip', track, clip: { type: 'media', asset: asset.id, ...(options.start !== undefined ? { start: options.start } : {}) }, ...(options.ripple ? { ripple: true } : {}), ...(options.mode && options.mode !== 'refuse' ? { mode: options.mode } : {}) };\n}\n\n/** What `probeVideoMedia` found out about a file. */\nexport interface VideoMediaProbe {\n\tkind: VideoAssetKind;\n\tduration?: number;\n\twidth?: number;\n\theight?: number;\n}\n\n/** The asset kind for a MIME type or file name, or `null` when it is not media. */\nexport function videoAssetKindOf(typeOrName: string): VideoAssetKind | null {\n\t// URLs may carry a query or hash after the extension (`song.mp3?v=2`).\n\tconst s = typeOrName.toLowerCase().replace(/[?#].*$/, '');\n\tif (s.startsWith('video/') || /\\.(mp4|m4v|mov|webm|ogv|mkv)$/.test(s)) return 'video';\n\tif (s.startsWith('audio/') || /\\.(mp3|m4a|aac|wav|ogg|oga|flac|opus)$/.test(s)) return 'audio';\n\tif (s.startsWith('image/') || /\\.(png|jpe?g|gif|webp|avif|svg|bmp)$/.test(s)) return 'image';\n\treturn null;\n}\n\n/**\n * Reads a media file's length and size in the browser by loading its metadata into a detached\n * `<video>`, `<audio>` or `<img>`. Rejects with a readable error when it cannot be loaded.\n */\nexport function probeVideoMedia(src: string, kind: VideoAssetKind, timeout = 15000): Promise<VideoMediaProbe> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (typeof document === 'undefined') return reject(new Error('Media can only be probed in a browser.'));\n\t\tconst timer = setTimeout(() => reject(new Error(`\"${src}\" took too long to load.`)), timeout);\n\t\tconst done = (probe: VideoMediaProbe) => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve(probe);\n\t\t};\n\t\tconst failed = () => {\n\t\t\tclearTimeout(timer);\n\t\t\treject(new Error(`\"${src}\" could not be loaded as ${kind}.`));\n\t\t};\n\t\tif (kind === 'image') {\n\t\t\tconst img = new Image();\n\t\t\timg.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });\n\t\t\timg.onerror = failed;\n\t\t\timg.src = src;\n\t\t\treturn;\n\t\t}\n\t\tconst el = document.createElement(kind);\n\t\tel.preload = 'metadata';\n\t\tel.onloadedmetadata = () => {\n\t\t\tconst duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1000) : undefined;\n\t\t\tconst v = el as HTMLVideoElement;\n\t\t\tdone({ kind, ...(duration ? { duration } : {}), ...(kind === 'video' && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {}) });\n\t\t};\n\t\tel.onerror = failed;\n\t\tel.src = src;\n\t});\n}\n\n// ---------------------------------------------------------------------------------------------\n// Keyframes in an inspector\n// ---------------------------------------------------------------------------------------------\n\nconst KEY_LABELS: Record<VideoKeyframeProperty, { label: string; step: number }> = {\n\topacity: { label: 'Opacity', step: 0.05 },\n\tvolume: { label: 'Volume', step: 0.05 },\n\tx: { label: 'X', step: 1 },\n\ty: { label: 'Y', step: 1 },\n\tscale: { label: 'Scale', step: 0.05 },\n\trotation: { label: 'Rotation', step: 1 }\n};\n\n/** One animatable property of a clip as an inspector shows it at the playhead. */\nexport interface VideoKeyframeRow {\n\tproperty: VideoKeyframeProperty;\n\tlabel: string;\n\t/** Its value at the playhead (the playhead held inside the clip). */\n\tvalue: number;\n\t/** Whether it has keyframes. */\n\tkeyed: boolean;\n\t/** How many keys it has. */\n\tkeys: number;\n\t/** Whether a key sits at the playhead. */\n\tonKey: boolean;\n\t/** The playhead in clip time (ms), where new keys go. */\n\tclipTime: number;\n\tstep: number;\n\tmin?: number;\n\tmax?: number;\n}\n\nconst animatableOf = (clip: VideoClip, property: VideoKeyframeProperty): VideoAnimatable | undefined =>\n\t(VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(property) ? clip.transform?.[property as 'x'] : property === 'volume' ? (clip.type === 'media' || clip.type === 'sequence' ? clip.volume : undefined) : clip.opacity;\n\nconst playheadInClip = (clip: VideoClip, time: number) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));\n\n/**\n * The clip's animatable properties for an inspector at timeline `time`: opacity and the transform\n * for pictures, volume for sound, each with its value there and whether it is keyed.\n */\nexport function videoKeyframeRows(project: VideoProject, clipId: string, time: number): VideoKeyframeRow[] {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return [];\n\tconst { clip } = at;\n\tconst asset = clip.type === 'media' ? videoAssetMap(project).get(clip.asset) : undefined;\n\tconst visual = !(asset?.kind === 'audio' || at.track.kind === 'audio');\n\tconst audible = (clip.type === 'media' && videoAssetHasSound(asset)) || clip.type === 'sequence';\n\tconst properties: VideoKeyframeProperty[] = [...(visual ? (['opacity'] as const) : []), ...(audible ? (['volume'] as const) : []), ...(visual ? VIDEO_TRANSFORM_PROPERTIES : [])];\n\tconst clipTime = playheadInClip(clip, time);\n\treturn properties.map((property) => {\n\t\tconst prop = animatableOf(clip, property);\n\t\tconst keys = isVideoKeyframes(prop) ? prop.keys : [];\n\t\tconst limits = VIDEO_KEYFRAME_LIMITS[property];\n\t\treturn {\n\t\t\tproperty,\n\t\t\t...KEY_LABELS[property],\n\t\t\tvalue: Math.round(videoValueAt(prop, clipTime, limits.default) * 1000) / 1000,\n\t\t\tkeyed: keys.length > 0,\n\t\t\tkeys: keys.length,\n\t\t\tonKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),\n\t\t\tclipTime,\n\t\t\t...(limits.min !== undefined ? { min: limits.min } : {}),\n\t\t\t...(limits.max !== undefined ? { max: limits.max } : {})\n\t\t};\n\t});\n}\n\n/** A plain (unkeyed) value as an edit: `undefined` puts it back to its default. */\nfunction plainValueEdit(clip: VideoClip, property: VideoKeyframeProperty, value: number | undefined): VideoEdit {\n\tif (!(VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(property))\n\t\treturn value === undefined ? { op: 'update_clip', clip: clip.id, remove: [property] } : { op: 'update_clip', clip: clip.id, set: { [property]: value } };\n\tconst transform: Record<string, unknown> = { ...clip.transform, [property]: value };\n\tif (value === undefined) delete transform[property];\n\treturn Object.keys(transform).length ? { op: 'update_clip', clip: clip.id, set: { transform } } : { op: 'update_clip', clip: clip.id, remove: ['transform'] };\n}\n\n/**\n * Typing a value in an inspector: on a keyed property it sets (or moves) the key at the playhead;\n * otherwise it sets the plain value (`undefined` resets it).\n */\nexport function videoKeyframeValueEdit(project: VideoProject, clipId: string, property: VideoKeyframeProperty, time: number, value: number | undefined): VideoEdit | null {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return null;\n\tconst prop = animatableOf(at.clip, property);\n\tif (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);\n\tif (value === undefined) return null;\n\treturn { op: 'set_keyframes', clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };\n}\n\n/**\n * The keyframe toggle: adds a key at the playhead holding the current value (the first key turns\n * the plain value into keys), or removes the key there (the last one leaves its value plain).\n */\nexport function videoKeyframeToggleEdit(project: VideoProject, clipId: string, property: VideoKeyframeProperty, time: number): VideoEdit | null {\n\tconst at = findVideoClip(project, clipId);\n\tif (!at) return null;\n\tconst prop = animatableOf(at.clip, property);\n\tconst t = playheadInClip(at.clip, time);\n\tconst fallback = VIDEO_KEYFRAME_LIMITS[property].default;\n\tif (isVideoKeyframes(prop)) {\n\t\tconst key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);\n\t\tif (key) {\n\t\t\tconst rest = removeVideoKey(prop, key.t);\n\t\t\treturn isVideoKeyframes(rest) ? { op: 'set_keyframes', clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? undefined : rest);\n\t\t}\n\t}\n\tconst v = videoValueAt(prop, t, fallback);\n\treturn { op: 'set_keyframes', clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };\n}\n\n/**\n * Edits giving clips a grade LUT (keeping their other grade settings), or taking it off with\n * `null`. Clips without a grade change are left out; one `controller.apply` makes it one undo step.\n */\nexport function videoLutEdits(project: VideoProject, clips: readonly string[], lut: VideoGradeLut | null): VideoEdit[] {\n\tconst edits: VideoEdit[] = [];\n\tfor (const id of clips) {\n\t\tconst at = findVideoClip(project, id);\n\t\tif (!at) continue;\n\t\tconst { lut: current, ...rest } = at.clip.grade ?? {};\n\t\tif (!lut && !current) continue;\n\t\tconst grade = normalizeVideoGrade(lut ? { ...rest, lut } : rest);\n\t\tedits.push(grade ? { op: 'update_clip', clip: id, set: { grade } } : { op: 'update_clip', clip: id, remove: ['grade'] });\n\t}\n\treturn edits;\n}\n","import type { AgentTool, AgentToolProvider } from '../tools/registry.js';\nimport type { JsonSchema } from '../tools/schema.js';\nimport type { VideoIssue } from './check.js';\nimport type { VideoEditorController } from './controller.js';\nimport { VIDEO_EDIT_OPS, type VideoEdit, type VideoEditOutcome, type VideoEditResult } from './edit.js';\nimport { findVideoAudioSilences, videoAudioLevels } from './audio.js';\nimport { findVideoFillers, findVideoTranscriptPauses, findVideoTranscriptText, mergeVideoRanges, parseVideoTranscript, VIDEO_DEFAULT_FILLERS, videoRemoveRangesEdits, videoTimelineTranscript, videoTranscriptTrack, type VideoTranscriptRange } from './transcript.js';\nimport { videoFreezeFrameEdit, videoSpeedRampEdit, videoSpeedRampKeys } from './remap.js';\nimport { VIDEO_GRADE_CONTROLS, VIDEO_GRADE_PRESET_NAMES, videoGradeLook } from './grade.js';\nimport { videoLutEdits } from './ui.js';\nimport { videoFrameAt } from './evaluate.js';\nimport { summarizeVideoScopes, type VideoPixels } from './scopes.js';\nimport { videoAssetProxy, videoProxyPlan } from './proxy.js';\nimport { VIDEO_EASE_NAMES, VIDEO_KEYFRAME_PROPERTIES, VIDEO_TRANSFORM_PROPERTIES, type VideoAnimatable, type VideoKeyframeProperty } from './keyframes.js';\nimport { videoContactSheetSvg, videoProjectSvg } from './render.js';\nimport { allVideoTracks, findVideoClip, findVideoGaps, findVideoSequenceCycle, formatVideoSeconds, formatVideoTimecode, sortedVideoClips, VIDEO_TIME_EPSILON, videoClipEnd, videoClipSourceRange, videoProjectDuration, videoSequenceChildren, videoSequenceDuration } from './timeline.js';\nimport { VIDEO_BLEND_MODES, VIDEO_CLIP_ANIMATION_EFFECTS, VIDEO_FADE_CURVES, VIDEO_FITS, VIDEO_PLACEMENT_MODES, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoAsset, type VideoProject, type VideoSequence, type VideoTrack } from './types.js';\n\n/** Times in results, to a tenth of a millisecond. */\nconst round = (ms: number) => Math.round(ms * 10) / 10;\n\nexport interface VideoEditorToolProviderOptions {\n\t/** Provider id (unique per registry). Default `video`. */\n\tid?: string;\n\t/** Always prefix tool names with `<namespace>__`. */\n\tnamespace?: string;\n\t/** The project cannot be edited: write tools are hidden and `get_video_project` says why. A string is the reason. */\n\treadOnly?: boolean | string | (() => boolean | string);\n\t/** Turns SVG into PNG (e.g. `rasterizeSvg`), so `render_video_frame` can return an image. */\n\trasterize?: (svg: string, size: { width: number; height: number }) => Promise<{ data: string; mimeType: string }> | { data: string; mimeType: string };\n\t/**\n\t * The rendered pixels of the frame at `time` (ms), for `get_video_scopes` (e.g. a frame decoded\n\t * by ffmpeg on a server). Default: what the editor's `<VideoPreview>` draws, through\n\t * `controller.previewPixels`, seeking it there and back when `time` is not the playhead.\n\t */\n\tframePixels?: (time: number) => VideoPixels | null | Promise<VideoPixels | null>;\n\t/** How long `get_video_scopes` waits for the preview to finish drawing a frame (ms, default 5000). */\n\tframePixelsTimeout?: number;\n}\n\nconst READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;\nconst WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;\nconst WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;\nconst DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false } as const;\n\nconst object = (properties: Record<string, JsonSchema>, required: string[] = [], description?: string): JsonSchema & { type: 'object' } => ({\n\ttype: 'object' as const,\n\t...(description ? { description } : {}),\n\tproperties,\n\t...(required.length ? { required } : {}),\n\tadditionalProperties: false\n});\n\nconst MS = (what: string) => ({ type: 'number', minimum: 0, description: `${what}, in milliseconds.` }) as const;\nconst CLIP_ID = { type: 'string', minLength: 1, description: 'A clip id from get_video_project (e.g. \"c1\").' } as const;\nconst TRACK_ID = { type: 'string', minLength: 1, description: 'A track id from get_video_project (e.g. \"v1\" or \"a1\").' } as const;\nconst ASSET_ID = { type: 'string', minLength: 1, description: 'An asset id from get_video_project.' } as const;\nconst COLOR = (what: string) => ({ type: 'string', minLength: 1, description: `${what}: a CSS colour such as \"#ffcc00\".` }) as const;\nconst RIPPLE = {\n\ttype: 'boolean',\n\tdescription:\n\t\t'Push (or pull) later material along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap). When the track is sync-locked (the default), every other unlocked sync-locked track moves too: clips across the edit point are cut when time opens, and closing time is refused if another sync-locked track has a clip in that stretch.'\n} as const;\nconst MODE = {\n\ttype: 'string',\n\tenum: [...VIDEO_PLACEMENT_MODES],\n\tdescription:\n\t\t'What to do with clips already where it lands: \"refuse\" (default) fails with the next free place; \"overwrite\" cuts, trims or removes what is underneath on that track; \"insert\" cuts everything across the start and pushes later material along by its length on this track and every sync-locked track.'\n} as const;\nconst DELTA = (what: string) => ({ type: 'number', description: `${what}, in milliseconds of timeline time: positive is later, negative earlier. It is clamped to what the media and neighbours allow; the result says how far it went.` }) as const;\nconst RANGE_TRACKS = { type: 'array', items: TRACK_ID, minItems: 1, uniqueItems: true, description: 'Tracks to work on (default: every unlocked track). Locked tracks are refused.' } as const;\n\nconst TRANSFORM = object(\n\t{\n\t\tx: { type: 'number', description: 'Offset of the centre to the right of the frame\\'s centre, in pixels (default 0).' },\n\t\ty: { type: 'number', description: 'Offset of the centre below the frame\\'s centre, in pixels (default 0).' },\n\t\tscale: { type: 'number', minimum: 0, description: 'Size after fitting (1 = fitted size, default 1).' },\n\t\trotation: { type: 'number', description: 'Degrees clockwise (default 0).' }\n\t},\n\t[],\n\t'Where the picture sits after it is fitted.'\n);\nconst TRANSITION = object(\n\t{\n\t\ttype: { type: 'string', enum: VIDEO_TRANSITION_TYPES.filter((t) => t !== 'effect'), description: 'crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre). For a transition effect from the effects provider use set_video_effect_transition.' },\n\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none.' },\n\t\tcolor: COLOR('For dip: the colour passed through (default black)'),\n\t\tdirection: { type: 'string', enum: [...VIDEO_WIPE_DIRECTIONS], description: 'For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left).' },\n\t\taudioCurve: { type: 'string', enum: [...VIDEO_FADE_CURVES], description: 'Shape of the sound\\'s fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic.' }\n\t},\n\t['type', 'duration'],\n\t'A transition from the clip that ends where this one starts on the same track.'\n);\nconst LUT_SRC = { type: 'string', minLength: 1, description: 'A .cube 3D LUT file the host can load and ffmpeg can read: a URL or path, often from the user (e.g. \"luts/teal-orange.cube\").' } as const;\nconst LUT_STRENGTH = { type: 'number', minimum: 0, maximum: 1, description: 'How much of the LUT\\'s change to apply, 0 (none) to 1 (full, the default); 0.3-0.7 for a subtler look.' } as const;\nconst GRADE = object(\n\t{\n\t\t...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: 'number', minimum: c.min, maximum: c.max, description: c.description }])),\n\t\tlut: object({ src: LUT_SRC, strength: LUT_STRENGTH, name: { type: 'string', minLength: 1, description: 'A label for the look.' } }, ['src'], 'A 3D LUT applied after the other settings (set_video_lut checks the file first).')\n\t},\n\t[],\n\t'Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint), then a LUT. Leave a setting out for no change; the object replaces the clip\\'s whole grade.'\n);\nconst ANIMATION = (when: string) =>\n\tobject(\n\t\t{\n\t\t\teffect: { type: 'string', enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: 'fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards.' },\n\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms (default 500).' }\n\t\t},\n\t\t['effect'],\n\t\twhen\n\t);\nconst CLIP_COMMON: Record<string, JsonSchema> = {\n\tname: { type: 'string', description: 'A label for the clip.' },\n\topacity: { type: 'number', minimum: 0, maximum: 1, description: 'Opacity 0–1 (default 1). A number replaces any keyframes; set_video_keyframes animates it.' },\n\ttransform: TRANSFORM,\n\tfit: { type: 'string', enum: [...VIDEO_FITS], description: 'How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours).' },\n\tgrade: GRADE,\n\tanimateIn: ANIMATION('How the picture enters, from the clip\\'s start (on top of any fade in). Titles usually rise, pop or fade in.'),\n\tanimateOut: ANIMATION('How the picture leaves, ending at the clip\\'s end.'),\n\tblend: { type: 'string', enum: [...VIDEO_BLEND_MODES], description: 'How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert.' },\n\tfadeIn: MS('Fade up from nothing (picture and sound) over this long'),\n\tfadeOut: MS('Fade down to nothing over this long')\n};\nconst MEDIA_FIELDS: Record<string, JsonSchema> = {\n\tin: MS('Where in the source it starts (default 0)'),\n\tspeed: { type: 'number', minimum: 0.05, maximum: 20, description: 'Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer.' },\n\tvolume: { type: 'number', minimum: 0, maximum: 4, description: 'Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it.' },\n\tmuted: { type: 'boolean', description: 'Silence this clip (default false).' },\n\tfadeInCurve: { type: 'string', enum: [...VIDEO_FADE_CURVES], description: 'Shape of the sound\\'s fade in (default linear; the picture always fades linearly): linear, equal-power (steady loudness, best for crossfades), s-curve (gentle at both ends), exponential (slow start, natural from silence) or logarithmic (fast start).' },\n\tfadeOutCurve: { type: 'string', enum: [...VIDEO_FADE_CURVES], description: 'Shape of the sound\\'s fade out (default linear), the same choices as fadeInCurve; exponential sounds natural fading to silence.' }\n};\nconst TEXT_FIELDS: Record<string, JsonSchema> = {\n\ttext: { type: 'string', minLength: 1, description: 'The title\\'s text (\"\\\\n\" breaks lines).' },\n\tcolor: COLOR('Text colour (default white)'),\n\tfontSize: { type: 'number', exclusiveMinimum: 0, description: 'Pixels (default 64).' },\n\tfontFamily: { type: 'string', description: 'CSS font family (default sans-serif).' },\n\tfontWeight: { type: 'number', minimum: 100, maximum: 1000, description: 'CSS weight (default 700).' },\n\tbackground: COLOR('A box behind the text (default none)')\n};\nconst PLACEMENT: Record<string, JsonSchema> = {\n\tid: { type: 'string', minLength: 1, description: 'Id for the clip (default: a fresh one such as \"c3\").' },\n\tstart: MS('Where it starts on the timeline (default: the end of the track)'),\n\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length on the timeline, in ms (default: the rest of the source for media, 5000 for stills, 3000 for titles and colours).' }\n};\nconst NEW_CLIP: JsonSchema = {\n\tdescription: 'The clip: media (a stretch of an asset), text (a title) or color (a solid fill).',\n\toneOf: [\n\t\tobject({ type: { const: 'media', description: 'A stretch of an asset.' }, asset: ASSET_ID, ...PLACEMENT, ...CLIP_COMMON, ...MEDIA_FIELDS }, ['type', 'asset'], 'A media clip.'),\n\t\tobject({ type: { const: 'text', description: 'A title over the picture.' }, ...PLACEMENT, ...CLIP_COMMON, ...TEXT_FIELDS }, ['type', 'text'], 'A title.'),\n\t\tobject({ type: { const: 'color', description: 'A solid colour filling the frame.' }, color: COLOR('The fill'), ...PLACEMENT, ...CLIP_COMMON }, ['type', 'color'], 'A colour.')\n\t]\n};\nconst SETTINGS: Record<string, JsonSchema> = {\n\tname: { type: 'string', description: 'Project name.' },\n\twidth: { type: 'integer', minimum: 16, maximum: 8192, description: 'Frame width in pixels.' },\n\theight: { type: 'integer', minimum: 16, maximum: 8192, description: 'Frame height in pixels.' },\n\tfps: { type: 'number', exclusiveMinimum: 0, maximum: 240, description: 'Frames per second (e.g. 24, 25, 30, 60).' },\n\tbackground: COLOR('What shows where no clip covers the frame (default black)')\n};\nconst TRACK_FIELDS: Record<string, JsonSchema> = {\n\tname: { type: 'string', description: 'Track label.' },\n\tmuted: { type: 'boolean', description: 'Silence every clip on it.' },\n\thidden: { type: 'boolean', description: 'Leave its pictures out of the frame.' },\n\tlocked: { type: 'boolean', description: 'Refuse edits to its clips.' },\n\tsyncLocked: { type: 'boolean', description: 'Move with ripple edits on other sync-locked tracks so it stays in sync (default true). Turn off for a track that should stay put, e.g. a music bed.' },\n\tvolume: { type: 'number', minimum: 0, maximum: 4, description: 'Gain for every clip on it (1 = unchanged).' }\n};\nconst ASSET_FIELDS: Record<string, JsonSchema> = {\n\tsrc: { type: 'string', minLength: 1, description: 'URL, path or data URL the player and exporter can open.' },\n\tname: { type: 'string', description: 'Display name (e.g. the file name).' },\n\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length of the source in ms (required to check trims of video and audio).' },\n\twidth: { type: 'number', exclusiveMinimum: 0, description: 'Pixel width (video and image; default the project width).' },\n\theight: { type: 'number', exclusiveMinimum: 0, description: 'Pixel height.' },\n\thasAudio: { type: 'boolean', description: 'For video: whether it has sound (default true).' },\n\tposter: { type: 'string', description: 'For video: a still (URL) to show in rendered frames.' }\n};\n\nconst KEY_PROPERTY = {\n\ttype: 'string',\n\tenum: [...VIDEO_KEYFRAME_PROPERTIES],\n\tdescription: 'Which property: opacity (0–1), volume (media only, 0–4 gain), x / y (pixels from the frame\\'s centre, right / down), scale (1 = fitted size) or rotation (degrees clockwise).'\n} as const;\nconst KEYS: JsonSchema = {\n\ttype: 'array',\n\tminItems: 1,\n\tmaxItems: 500,\n\tdescription: 'The keys, replacing any the property had (any order, one per time). Before the first key the property holds the first value, after the last the last value.',\n\titems: object(\n\t\t{\n\t\t\tt: { type: 'number', description: 'Time in ms from the clip\\'s start (clip time, not timeline time: 0 is the clip\\'s first frame, its duration the end). Keys stay with the clip when it moves, is trimmed or split.' },\n\t\t\tv: { type: 'number', description: 'The value at that time, in the property\\'s units.' },\n\t\t\tease: {\n\t\t\t\tdescription: 'How the value moves from this key to the next (default linear): linear, hold (stays, then jumps at the next key), ease-in, ease-out, ease-in-out, or CSS cubic-bezier points [x1, y1, x2, y2] with x1 and x2 in 0–1.',\n\t\t\t\tanyOf: [\n\t\t\t\t\t{ type: 'string', enum: [...VIDEO_EASE_NAMES] },\n\t\t\t\t\t{ type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4 }\n\t\t\t\t]\n\t\t\t}\n\t\t},\n\t\t['t', 'v'],\n\t\t'One keyframe.'\n\t)\n};\n\nconst issueSummary = (issues: readonly VideoIssue[]) => ({\n\terrors: issues.filter((i) => i.severity === 'error').length,\n\twarnings: issues.filter((i) => i.severity === 'warning').length,\n\tnotes: issues.filter((i) => i.severity === 'note').length,\n\tlines: issues.map((i) => `${i.severity}: ${i.message}`)\n});\n\n/** An asset as agents see it: a transcript is summarised as its word count (get_video_transcript reads it). */\nconst videoAssetOut = (a: VideoAsset) => {\n\tconst { transcript, ...rest } = a;\n\treturn transcript ? { ...rest, transcriptWords: transcript.length } : rest;\n};\n\n/** Tracks as agents see them: each with its clips in time order, their ends and (media and compounds) the source they play. */\nconst describeVideoTracks = (tracks: readonly VideoTrack[]) =>\n\ttracks.map((t, index) => ({\n\t\tid: t.id,\n\t\tkind: t.kind,\n\t\tindex,\n\t\t...(t.name ? { name: t.name } : {}),\n\t\t...(t.muted ? { muted: true } : {}),\n\t\t...(t.hidden ? { hidden: true } : {}),\n\t\t...(t.locked ? { locked: true } : {}),\n\t\t...(t.syncLocked === false ? { syncLocked: false } : {}),\n\t\t...(t.volume !== undefined ? { volume: t.volume } : {}),\n\t\tclips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...(c.type === 'media' || c.type === 'sequence' ? { source: videoClipSourceRange(c) } : {}) }))\n\t}));\n\n/** The compound clips that play a sequence, wherever they are (the project's tracks or other sequences). */\nconst videoSequenceUsers = (project: VideoProject, id: string) =>\n\tallVideoTracks(project).flatMap(({ sequence, track }) =>\n\t\ttrack.clips.flatMap((c) => (c.type === 'sequence' && c.sequence === id ? [{ clip: c.id, ...(sequence ? { inSequence: sequence } : {}), track: track.id, start: c.start, end: videoClipEnd(c), source: videoClipSourceRange(c)! }] : []))\n\t);\n\n/** A sequence as agents see it: its length, the sequences it nests, who plays it, and (with `tracks`) its tracks and clips. */\nexport function describeVideoSequence(project: VideoProject, sequence: VideoSequence, tracks = true) {\n\tconst cycle = findVideoSequenceCycle(project, sequence.id);\n\treturn {\n\t\tid: sequence.id,\n\t\t...(sequence.name ? { name: sequence.name } : {}),\n\t\tduration: videoSequenceDuration(sequence),\n\t\tclipCount: sequence.tracks.reduce((n, t) => n + t.clips.length, 0),\n\t\tnests: videoSequenceChildren(sequence),\n\t\tusedBy: videoSequenceUsers(project, sequence.id),\n\t\t...(cycle ? { cycle } : {}),\n\t\t...(tracks ? { tracks: describeVideoTracks(sequence.tracks) } : { trackCount: sequence.tracks.length })\n\t};\n}\n\n/** A project as agents see it: settings, assets, each track's clips in order with their times, and a summary of each sequence. */\nexport function describeVideoProject(project: VideoProject) {\n\treturn {\n\t\t...(project.name ? { name: project.name } : {}),\n\t\twidth: project.width,\n\t\theight: project.height,\n\t\tfps: project.fps,\n\t\tduration: videoProjectDuration(project),\n\t\t...(project.background ? { background: project.background } : {}),\n\t\tassets: project.assets.map(videoAssetOut),\n\t\ttracks: describeVideoTracks(project.tracks),\n\t\t...(project.sequences?.length ? { sequences: project.sequences.map((q) => describeVideoSequence(project, q, false)) } : {}),\n\t\tmarkers: project.markers ?? []\n\t};\n}\n\n/**\n * Agent tools for a `VideoEditorController`: read the project (assets, tracks with clips and their\n * times, gaps, what plays at a moment), check it, render frames, export to ffmpeg or an EDL, and\n * edit it a step at a time (add assets and tracks, add / move / trim / split / delete clips with\n * overwrite, insert and sync-locked ripple, slip / roll / slide, lift / extract a range, cut across\n * tracks, transitions, markers). Every write is checked first (unknown ids with \"did you mean\", overlaps,\n * trims past the source, locked tracks), applies as one undo step, and returns what it made and\n * the resulting length and issues. Write tools are hidden while the project is read-only.\n */\nexport function createVideoEditorToolProvider(controller: VideoEditorController, options: VideoEditorToolProviderOptions = {}): AgentToolProvider {\n\tconst readOnlyReason = (): string | null => {\n\t\tconst r = typeof options.readOnly === 'function' ? options.readOnly() : options.readOnly;\n\t\tif (r) return typeof r === 'string' ? r : 'This video project is read-only.';\n\t\treturn controller.readOnlyReason();\n\t};\n\tconst project = () => controller.getProject();\n\tconst outcome = (result: VideoEditResult, extra: Record<string, unknown> = {}) => {\n\t\tif (!result.ok) throw new Error(result.error);\n\t\treturn { ...extra, duration: videoProjectDuration(result.project), issues: issueSummary(result.issues) };\n\t};\n\tconst apply = (edits: VideoEdit | VideoEdit[], label: string, extra?: (r: Extract<VideoEditResult, { ok: true }>) => Record<string, unknown>) => {\n\t\tconst result = controller.apply(edits, label);\n\t\treturn outcome(result, result.ok && extra ? extra(result) : {});\n\t};\n\tconst clipOut = (p: VideoProject, id: string) => {\n\t\tfor (const t of p.tracks) {\n\t\t\tconst c = t.clips.find((x) => x.id === id);\n\t\t\tif (c) return { track: t.id, ...c, end: videoClipEnd(c), ...(c.type === 'media' || c.type === 'sequence' ? { source: videoClipSourceRange(c) } : {}) };\n\t\t}\n\t\treturn null;\n\t};\n\n\t/** What a placement or ripple did besides the clip itself. */\n\tconst placed = (o: VideoEditOutcome) => ({\n\t\t...(o.created?.length ? { created: o.created } : {}),\n\t\t...(o.trimmed?.length ? { trimmed: o.trimmed } : {}),\n\t\t...(o.removed?.length ? { removed: o.removed } : {}),\n\t\t...(o.rippled ? { rippled: o.rippled } : {})\n\t});\n\tconst keysOut = (p: VideoProject, clip: string, property: VideoKeyframeProperty) => {\n\t\tconst c = clipOut(p, clip) as any;\n\t\tconst value: VideoAnimatable | undefined = (VIDEO_TRANSFORM_PROPERTIES as readonly string[]).includes(property) ? c?.transform?.[property] : c?.[property];\n\t\treturn typeof value === 'object' ? { keys: value.keys } : { keys: [], value: value ?? null };\n\t};\n\n\tconst getProject: AgentTool = {\n\t\tname: 'get_video_project',\n\t\ttitle: 'Read the video project',\n\t\tdescription:\n\t\t\t'Returns the whole project: frame size, fps, length (ms), assets (id, kind, src, duration), tracks top to bottom (an earlier video track draws over a later one) with each clip in time order (id, type, start, duration, end, source in/out for media and compound clips, fades, transition), a summary of each nested sequence (id, name, length, clip count, the sequences it nests, the compound clips that play it) and markers, plus the playhead, selection, issues and whether it can be edited. Compound clips (type \"sequence\") play a sequence\\'s tracks as one picture and sound; list_video_sequences reads what is inside them.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Read the video project', ...READ },\n\t\thandler: () => {\n\t\t\tconst state = controller.getState();\n\t\t\tconst reason = readOnlyReason();\n\t\t\treturn { ...describeVideoProject(state.project), playhead: state.time, previewScale: state.previewScale, selection: state.selection, issues: issueSummary(state.issues), editable: !reason, ...(reason ? { readOnlyReason: reason } : {}) };\n\t\t}\n\t};\n\n\tconst getFrame: AgentTool = {\n\t\tname: 'get_video_frame',\n\t\ttitle: 'What plays at a moment',\n\t\tdescription: 'Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe or iris) and the sounds (clip, source time, gain after volumes, fades and transitions). A compound clip is one picture with its sequence, the moment of the sequence it shows (sourceTime) and the sequence\\'s own pictures as \"layers\"; its sounds are listed with clip paths such as \"compound/inner\".',\n\t\tinputSchema: object({ time: MS('Time from the start (default: the playhead)') }),\n\t\tannotations: { title: 'What plays at a moment', ...READ },\n\t\thandler: ({ time }) => {\n\t\t\tconst frame = videoFrameAt(project(), time ?? controller.getState().time);\n\t\t\treturn { ...frame, timecode: formatVideoTimecode(frame.time, project().fps) };\n\t\t}\n\t};\n\n\tconst listSequences: AgentTool = {\n\t\tname: 'list_video_sequences',\n\t\ttitle: 'List nested sequences',\n\t\tdescription:\n\t\t\t'Lists the project\\'s nested sequences, the tracks that compound clips (type \"sequence\") play as one picture and sound, without changing anything. For each: id, name, length (ms), the sequences it nests, the compound clips that play it (clip id, the track and sequence they sit on, timeline start and end, and the stretch of the sequence they play as source in / out) and its tracks with their clips, in the same shape as get_video_project. A sequence that contains itself lists the loop as \"cycle\". Clips inside sequences can be read here but not edited yet; edit the compound clip instead (move, trim, its opacity, blend, grade, volume).',\n\t\tinputSchema: object({ sequence: { type: 'string', minLength: 1, description: 'Only this sequence id (default: every sequence).' } }),\n\t\tannotations: { title: 'List nested sequences', ...READ },\n\t\thandler: ({ sequence }) => {\n\t\t\tconst p = project();\n\t\t\tconst all = p.sequences ?? [];\n\t\t\tif (sequence !== undefined) {\n\t\t\t\tconst found = all.find((q) => q.id === sequence);\n\t\t\t\tif (!found) throw new Error(`There is no sequence \"${sequence}\"; ${all.length ? `the sequences are ${all.map((q) => `\"${q.id}\"`).join(', ')}` : 'the project has no sequences'}.`);\n\t\t\t\treturn { sequences: [describeVideoSequence(p, found)] };\n\t\t\t}\n\t\t\treturn { sequences: all.map((q) => describeVideoSequence(p, q)) };\n\t\t}\n\t};\n\n\tconst findGaps: AgentTool = {\n\t\tname: 'find_video_gaps',\n\t\ttitle: 'Find gaps',\n\t\tdescription: 'Lists empty stretches between clips (and before the first) on every track or one track, as { track, start, end } in ms. close_video_gaps removes them.',\n\t\tinputSchema: object({ track: { ...TRACK_ID, description: 'Only this track (default all).' } }),\n\t\tannotations: { title: 'Find gaps', ...READ },\n\t\thandler: ({ track }) => {\n\t\t\tif (track !== undefined && !project().tracks.some((t) => t.id === track)) throw new Error(`There is no track \"${track}\"; the tracks are ${project().tracks.map((t) => `\"${t.id}\"`).join(', ')}.`);\n\t\t\treturn { gaps: findVideoGaps(project(), track) };\n\t\t}\n\t};\n\n\tconst checkProject: AgentTool = {\n\t\tname: 'check_video_project',\n\t\ttitle: 'Check the video project',\n\t\tdescription: 'Lists the project\\'s problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades or animations longer than the clip, animations a clip\\'s own keyframes override, keyframes outside their clip, assets with no duration, unused assets.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Check the video project', ...READ },\n\t\thandler: () => {\n\t\t\tconst issues = controller.getState().issues;\n\t\t\treturn { ...issueSummary(issues), issues };\n\t\t}\n\t};\n\n\tconst renderFrame: AgentTool = {\n\t\tname: 'render_video_frame',\n\t\ttitle: 'Render a frame',\n\t\tdescription:\n\t\t\t'Draws the project at one moment (or several, as one labelled contact sheet) to check the picture, without changing it: SVG by default, PNG (format \"png\") when the host can rasterize. Images and video posters are drawn; video without a poster shows as a labelled box with its source timecode.',\n\t\tinputSchema: object({\n\t\t\ttime: MS('Time from the start (default: the playhead)'),\n\t\t\ttimes: { type: 'array', items: { type: 'number', minimum: 0 }, minItems: 2, maxItems: 16, description: 'Several moments (ms) drawn as one contact sheet instead of \"time\".' },\n\t\t\tcolumns: { type: 'integer', minimum: 1, maximum: 8, description: 'With times: frames a row (default about square).' },\n\t\t\tformat: { type: 'string', enum: ['svg', 'png'], description: 'svg (default) or png (only when the host supports it).' }\n\t\t}),\n\t\tannotations: { title: 'Render a frame', ...READ },\n\t\thandler: async ({ time, times, columns, format = 'svg' }) => {\n\t\t\tconst p = project();\n\t\t\t// LUTs are drawn from their tables (approximated in SVG), so load the ones in use first.\n\t\t\tconst lutSrcs = [...new Set(p.tracks.flatMap((t) => t.clips.map((c) => c.grade?.lut?.src).filter((x): x is string => !!x)))];\n\t\t\tconst missing = (await Promise.all(lutSrcs.map((src) => controller.luts.load(src).then(() => null, (e: Error) => e.message)))).filter((m): m is string => !!m);\n\t\t\tconst svgOptions = { lutFor: (src: string) => controller.luts.peek(src), ...(columns ? { columns } : {}) };\n\t\t\tconst svg = times ? videoContactSheetSvg(p, times, svgOptions) : videoProjectSvg(p, time ?? controller.getState().time, svgOptions);\n\t\t\tconst notes = [...missing.map((m) => `${m}; that LUT is left out of the picture.`), ...(lutSrcs.length > missing.length ? ['LUTs are drawn approximately in SVG (curves and a colour matrix); the preview and export apply them exactly.'] : [])];\n\t\t\tif (format === 'png') {\n\t\t\t\tif (!options.rasterize) throw new Error('This host cannot make PNG images; call render_video_frame with format \"svg\".');\n\t\t\t\tconst size = /viewBox=\"0 0 ([\\d.]+) ([\\d.]+)\"/.exec(svg)!;\n\t\t\t\tconst image = await options.rasterize(svg, { width: Number(size[1]), height: Number(size[2]) });\n\t\t\t\treturn { content: [{ type: 'image', data: image.data, mimeType: image.mimeType }, ...(notes.length ? [{ type: 'text' as const, text: notes.join(' ') }] : [])] };\n\t\t\t}\n\t\t\treturn { svg, ...(notes.length ? { notes } : {}) };\n\t\t}\n\t};\n\n\tconst LOUDNESS_TARGET: Record<string, JsonSchema> = {\n\t\tintegrated: { type: 'number', minimum: -70, maximum: -5, description: 'Integrated loudness to reach, in LUFS (default -14, what streaming services play at; -16 for podcasts, -23 for EBU R128 broadcast).' },\n\t\ttruePeak: { type: 'number', minimum: -9, maximum: 0, description: 'Highest true peak, in dBTP (default -1).' },\n\t\trange: { type: 'number', minimum: 1, maximum: 50, description: 'Loudness range, in LU (default 11).' }\n\t};\n\tconst exportFfmpeg: AgentTool = {\n\t\tname: 'export_video_ffmpeg',\n\t\ttitle: 'Export an ffmpeg command',\n\t\tdescription:\n\t\t\t'Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. With loudness, the mix is normalised to a target loudness (EBU R128); add loudness.measured from a measuring pass (export_video_loudness_measure) for an exact fixed gain. Fails when the project has no clips.',\n\t\tinputSchema: object({\n\t\t\toutput: { type: 'string', minLength: 1, description: 'Output file name (default output.mp4).' },\n\t\t\tcrf: { type: 'integer', minimum: 0, maximum: 51, description: 'x264 quality, lower is better (default 18).' },\n\t\t\tloudness: object(\n\t\t\t\t{\n\t\t\t\t\t...LOUDNESS_TARGET,\n\t\t\t\t\tmeasured: object(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tintegrated: { type: 'number', description: 'Measured integrated loudness, LUFS (loudnorm\\'s \"input_i\").' },\n\t\t\t\t\t\t\ttruePeak: { type: 'number', description: 'Measured true peak, dBTP (\"input_tp\").' },\n\t\t\t\t\t\t\trange: { type: 'number', description: 'Measured loudness range, LU (\"input_lra\").' },\n\t\t\t\t\t\t\tthreshold: { type: 'number', description: 'Measured gating threshold, LUFS (\"input_thresh\").' },\n\t\t\t\t\t\t\toffset: { type: 'number', description: 'Offset for the second pass, LU (\"target_offset\").' }\n\t\t\t\t\t\t},\n\t\t\t\t\t\t['integrated', 'truePeak', 'range', 'threshold', 'offset'],\n\t\t\t\t\t\t'What the measuring pass printed (the JSON at the end of its stderr), for a linear second pass that keeps the dynamics. Leave out for one-pass normalisation.'\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\t[],\n\t\t\t\t'Normalise the mix\\'s loudness (default: not normalised). An empty object uses -14 LUFS, -1 dBTP.'\n\t\t\t)\n\t\t}),\n\t\tannotations: { title: 'Export an ffmpeg command', ...READ },\n\t\thandler: ({ output, crf, loudness }) => controller.toFfmpeg({ ...(output ? { output } : {}), ...(crf !== undefined ? { crf } : {}), ...(loudness ? { loudness } : {}) })\n\t};\n\n\tconst exportLoudnessMeasure: AgentTool = {\n\t\tname: 'export_video_loudness_measure',\n\t\ttitle: 'Export a loudness measuring command',\n\t\tdescription:\n\t\t\t'Returns the ffmpeg command for the first pass of a loudness normalisation: it mixes only the sound and prints its loudness as JSON at the end of stderr (input_i, input_tp, input_lra, input_thresh, target_offset) instead of writing a file. Pass those numbers as loudness.measured (integrated, truePeak, range, threshold, offset) to export_video_ffmpeg, with the same target. Fails when nothing makes a sound.',\n\t\tinputSchema: object(LOUDNESS_TARGET),\n\t\tannotations: { title: 'Export a loudness measuring command', ...READ },\n\t\thandler: (target) => controller.toLoudnessMeasure({ loudness: target })\n\t};\n\n\tconst exportEdl: AgentTool = {\n\t\tname: 'export_video_edl',\n\t\ttitle: 'Export an EDL',\n\t\tdescription: 'Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode (drop-frame at 29.97 and 59.94 fps unless dropFrame is false), dissolves, speed changes and clip names.',\n\t\tinputSchema: object({\n\t\t\ttrack: { ...TRACK_ID, description: 'Track to export (default: the bottom video track).' },\n\t\t\ttitle: { type: 'string', description: 'TITLE line (default: the project name).' },\n\t\t\tdropFrame: { type: 'boolean', description: 'Drop-frame timecode (HH:MM:SS;FF). Default true at 29.97 and 59.94 fps, where it is the norm; ignored at other rates, which have no drop-frame form.' }\n\t\t}),\n\t\tannotations: { title: 'Export an EDL', ...READ },\n\t\thandler: ({ track, title, dropFrame }) => ({ edl: controller.toEdl({ ...(track ? { track } : {}), ...(title ? { title } : {}), ...(dropFrame !== undefined ? { dropFrame } : {}) }) })\n\t};\n\n\tconst seek: AgentTool = {\n\t\tname: 'seek_video',\n\t\ttitle: 'Show a moment',\n\t\tdescription: 'Moves the playhead the person sees to a time, a clip\\'s start or a marker, and plays or pauses. Does not change the project. Returns the playhead and the clips under it.',\n\t\tinputSchema: object({\n\t\t\ttime: MS('Time from the start'),\n\t\t\tclip: { ...CLIP_ID, description: 'Go to the start of this clip instead of \"time\".' },\n\t\t\tmarker: { type: 'string', minLength: 1, description: 'Go to this marker instead of \"time\".' },\n\t\t\tplay: { type: 'boolean', description: 'true plays from there, false pauses (default: leave as is).' }\n\t\t}),\n\t\tannotations: { title: 'Show a moment', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ time, clip, marker, play }) => {\n\t\t\tconst p = project();\n\t\t\tif (clip !== undefined) {\n\t\t\t\tconst c = clipOut(p, clip);\n\t\t\t\tif (!c) throw new Error(`There is no clip \"${clip}\".`);\n\t\t\t\tcontroller.seek(c.start);\n\t\t\t} else if (marker !== undefined) {\n\t\t\t\tconst m = (p.markers ?? []).find((x) => x.id === marker);\n\t\t\t\tif (!m) throw new Error(`There is no marker \"${marker}\"; the markers are ${(p.markers ?? []).map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\t\t\tcontroller.seek(m.time);\n\t\t\t} else if (time !== undefined) controller.seek(time);\n\t\t\tif (play === true) controller.play();\n\t\t\tif (play === false) controller.pause();\n\t\t\tconst state = controller.getState();\n\t\t\treturn { time: state.time, timecode: formatVideoTimecode(state.time, p.fps), playing: state.playing, clips: state.current };\n\t\t}\n\t};\n\n\tconst setSettings: AgentTool = {\n\t\tname: 'set_video_settings',\n\t\ttitle: 'Change project settings',\n\t\tdescription: 'Sets the name, frame size, frame rate or background colour (one undo step). Returns the length and issues.',\n\t\tinputSchema: object(SETTINGS),\n\t\tannotations: { title: 'Change project settings', ...WRITE_IDEMPOTENT },\n\t\thandler: (args) => {\n\t\t\tif (!Object.keys(args).length) throw new Error('Give at least one setting to change (name, width, height, fps, background).');\n\t\t\treturn apply({ op: 'set_settings', ...args }, 'Change settings');\n\t\t}\n\t};\n\n\tconst addAsset: AgentTool = {\n\t\tname: 'add_video_asset',\n\t\ttitle: 'Add media',\n\t\tdescription: 'Adds a video, audio or image file to the project so clips can use it (one undo step). Give the duration of video and audio so trims can be checked. Returns the asset id.',\n\t\tinputSchema: object({ id: { type: 'string', minLength: 1, description: 'Id for it (default: from its name or file name).' }, kind: { type: 'string', enum: ['video', 'audio', 'image'], description: 'What it is.' }, ...ASSET_FIELDS }, ['kind', 'src']),\n\t\tannotations: { title: 'Add media', ...WRITE },\n\t\thandler: (asset) => apply({ op: 'add_asset', asset: asset as any }, 'Add media', (r) => ({ asset: r.outcomes[0].asset }))\n\t};\n\n\tconst updateAsset: AgentTool = {\n\t\tname: 'update_video_asset',\n\t\ttitle: 'Change media details',\n\t\tdescription: '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.',\n\t\tinputSchema: 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']),\n\t\tannotations: { title: 'Change media details', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ asset, set, remove }) => apply({ op: 'update_asset', asset, set, remove }, 'Change media', (r) => ({ asset: videoAssetOut(r.project.assets.find((a) => a.id === asset)!) }))\n\t};\n\n\tconst removeAsset: AgentTool = {\n\t\tname: 'remove_video_asset',\n\t\ttitle: 'Remove media',\n\t\tdescription: 'Removes an asset no clip uses (one undo step); refused while clips use it, naming them. Returns the remaining asset ids.',\n\t\tinputSchema: object({ asset: ASSET_ID }, ['asset']),\n\t\tannotations: { title: 'Remove media', ...DESTRUCTIVE },\n\t\thandler: ({ asset }) => apply({ op: 'remove_asset', asset }, 'Remove media', (r) => ({ removed: asset, assets: r.project.assets.map((a) => a.id) }))\n\t};\n\n\tconst addTrack: AgentTool = {\n\t\tname: 'add_video_track',\n\t\ttitle: 'Add a track',\n\t\tdescription: 'Adds an empty video or audio track (one undo step). By default a video track goes on top (drawing over the others) and an audio track at the bottom. Returns its id and the track order.',\n\t\tinputSchema: object({ kind: { type: 'string', enum: ['video', 'audio'], description: 'Video (pictures, titles, colours) or audio.' }, id: { type: 'string', minLength: 1, description: 'Id (default \"v2\", \"a2\", …).' }, at: { type: 'integer', minimum: 0, description: 'Position, 0 = top.' }, ...TRACK_FIELDS }, ['kind']),\n\t\tannotations: { title: 'Add a track', ...WRITE },\n\t\thandler: ({ kind, id, at, ...fields }) => apply({ op: 'add_track', track: { kind, ...(id ? { id } : {}), ...fields }, at }, 'Add track', (r) => ({ track: r.outcomes[0].track, tracks: r.project.tracks.map((t) => t.id) }))\n\t};\n\n\tconst updateTrack: AgentTool = {\n\t\tname: 'update_video_track',\n\t\ttitle: 'Change a track',\n\t\tdescription: 'Renames, mutes, hides, locks, sync-locks or sets the volume of a track (one undo step). Returns the track\\'s settings.',\n\t\tinputSchema: object({ track: TRACK_ID, set: object(TRACK_FIELDS, [], 'Fields to replace.'), remove: { type: 'array', items: { type: 'string', enum: ['name', 'muted', 'hidden', 'locked', 'syncLocked', 'volume'] }, description: 'Fields to clear.' } }, ['track']),\n\t\tannotations: { title: 'Change a track', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ track, set, remove }) =>\n\t\t\tapply({ op: 'update_track', track, set, remove }, 'Change track', (r) => {\n\t\t\t\tconst { clips, ...t } = r.project.tracks.find((x) => x.id === track)!;\n\t\t\t\treturn { track: { ...t, clips: clips.length } };\n\t\t\t})\n\t};\n\n\tconst removeTrack: AgentTool = {\n\t\tname: 'remove_video_track',\n\t\ttitle: 'Remove a track',\n\t\tdescription: 'Deletes a track and every clip on it (one undo step; refused when locked). Returns the clips removed with it.',\n\t\tinputSchema: object({ track: TRACK_ID }, ['track']),\n\t\tannotations: { title: 'Remove a track', ...DESTRUCTIVE },\n\t\thandler: ({ track }) => apply({ op: 'remove_track', track }, 'Remove track', (r) => ({ removed: track, clips: r.outcomes[0].clips }))\n\t};\n\n\tconst moveTrack: AgentTool = {\n\t\tname: 'move_video_track',\n\t\ttitle: 'Reorder a track',\n\t\tdescription: 'Moves a track to a new position (0 = top; an earlier video track draws over later ones). Returns the track order.',\n\t\tinputSchema: object({ track: TRACK_ID, to: { type: 'integer', minimum: 0, description: 'New position, 0 = top.' } }, ['track', 'to']),\n\t\tannotations: { title: 'Reorder a track', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ track, to }) => apply({ op: 'move_track', track, to }, 'Move track', (r) => ({ tracks: r.project.tracks.map((t) => t.id) }))\n\t};\n\n\tconst addClip: AgentTool = {\n\t\tname: 'add_video_clip',\n\t\ttitle: 'Add a clip',\n\t\tdescription:\n\t\t\t'Puts a clip on a track (one undo step): media (a stretch of an asset from its \"in\" point), a title or a colour. With no start it goes at the end of the track. Where it lands on other clips, mode decides: refuse (default, with the next free place), overwrite (replace what is underneath) or insert (push later material along on every sync-locked track). Returns the clip with its end and source range, plus any clips it cut (created), trimmed or removed and the tracks it rippled.',\n\t\tinputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, mode: MODE, ripple: { type: 'boolean', description: 'Same as mode \"insert\" (kept for older callers; default false).' } }, ['track', 'clip']),\n\t\tannotations: { title: 'Add a clip', ...WRITE },\n\t\thandler: ({ track, clip, mode, ripple }) => apply({ op: 'add_clip', track, clip, mode, ripple }, mode === 'overwrite' ? 'Overwrite clip' : mode === 'insert' || ripple ? 'Insert clip' : 'Add clip', (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip!), ...placed(r.outcomes[0]) }))\n\t};\n\n\tconst moveClip: AgentTool = {\n\t\tname: 'move_video_clip',\n\t\ttitle: 'Move a clip',\n\t\tdescription:\n\t\t\t'Moves a clip to a new start and/or another track of the same kind (one undo step), leaving a gap where it was. Where it lands on other clips, mode decides: refuse (default, naming the clip and the next free place), overwrite or insert. Returns the clip, plus any clips it cut (created), trimmed or removed and the tracks it rippled.',\n\t\tinputSchema: object({ clip: CLIP_ID, start: MS('New start on the timeline (default: unchanged)'), track: { ...TRACK_ID, description: 'Track to move it to (default: its own).' }, mode: MODE }, ['clip']),\n\t\tannotations: { title: 'Move a clip', ...WRITE },\n\t\thandler: ({ clip, start, track, mode }) => {\n\t\t\tif (start === undefined && track === undefined) throw new Error('Give \"start\", \"track\" or both.');\n\t\t\treturn apply({ op: 'move_clip', clip, start, track, mode }, mode === 'overwrite' ? 'Overwrite clip' : mode === 'insert' ? 'Insert clip' : 'Move clip', (r) => ({ clip: clipOut(r.project, clip), ...placed(r.outcomes[0]) }));\n\t\t}\n\t};\n\n\tconst trimClip: AgentTool = {\n\t\tname: 'trim_video_clip',\n\t\ttitle: 'Trim a clip',\n\t\tdescription:\n\t\t\t'Moves one edge of a clip to a timeline time (one undo step): \"start\" also moves its source in point, \"end\" its out point. Refused past the ends of the source (the error says how far it can go) or below one frame. With ripple the clip keeps its start and the later clips on the track move by the change, so no gap or overlap opens. Returns the clip.',\n\t\tinputSchema: object({ clip: CLIP_ID, edge: { type: 'string', enum: ['start', 'end'], description: 'Which edge to move.' }, to: MS('Where that edge goes on the timeline'), ripple: RIPPLE }, ['clip', 'edge', 'to']),\n\t\tannotations: { title: 'Trim a clip', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, edge, to, ripple }) => apply({ op: 'trim_clip', clip, edge, to, ripple }, ripple ? 'Ripple trim' : 'Trim clip', (r) => ({ clip: clipOut(r.project, clip), ...placed(r.outcomes[0]) }))\n\t};\n\n\tconst splitClip: AgentTool = {\n\t\tname: 'split_video_clip',\n\t\ttitle: 'Split a clip',\n\t\tdescription: 'Cuts a clip in two at a timeline time (at least a frame from either end), as one undo step. The left part keeps the id, fade in and transition; the right part gets a new id and the fade out. Returns both parts.',\n\t\tinputSchema: object({ clip: CLIP_ID, at: MS('Where to cut, on the timeline'), id: { type: 'string', minLength: 1, description: 'Id for the right part (default \"<clip>-1\").' } }, ['clip', 'at']),\n\t\tannotations: { title: 'Split a clip', ...WRITE },\n\t\thandler: ({ clip, at, id }) => apply({ op: 'split_clip', clip, at, id }, 'Split clip', (r) => ({ left: clipOut(r.project, clip), right: clipOut(r.project, r.outcomes[0].clip!) }))\n\t};\n\n\tconst deleteClips: AgentTool = {\n\t\tname: 'delete_video_clips',\n\t\ttitle: 'Delete clips',\n\t\tdescription:\n\t\t\t'Removes clips (one undo step). With ripple the later material moves up to close the gaps they leave: on every sync-locked track when they were on one (refused, naming the clip, if another sync-locked track has something in a gap), else on their own track. Returns the ids removed and the tracks rippled.',\n\t\tinputSchema: object(\n\t\t\t{ clips: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clip ids.' }, ripple: { type: 'boolean', description: 'Close the gaps they leave, on every sync-locked track (default false).' } },\n\t\t\t['clips']\n\t\t),\n\t\tannotations: { title: 'Delete clips', ...DESTRUCTIVE },\n\t\thandler: ({ clips, ripple }) => apply({ op: 'delete_clips', clips, ripple }, ripple ? 'Ripple delete' : 'Delete clips', (r) => ({ removed: clips, ...(r.outcomes[0].rippled ? { rippled: r.outcomes[0].rippled } : {}) }))\n\t};\n\n\tconst updateClip: AgentTool = {\n\t\tname: 'update_video_clip',\n\t\ttitle: 'Change a clip',\n\t\tdescription:\n\t\t\t'Changes a clip\\'s properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound\\'s fade curves, speed (keeps the stretch of source, so the length changes) or asset; for compound clips in point, volume, mute and fade curves (no speed); for titles text and style; for colours the colour. Fields in \"remove\" go back to their defaults. Use move / trim for timing. Returns the clip.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclip: CLIP_ID,\n\t\t\t\tset: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: 'For media: play another asset (same in point).' }, ...TEXT_FIELDS, color: COLOR('Text colour, or a colour clip\\'s fill') }, [], 'Fields to replace (only those that fit the clip\\'s type).'),\n\t\t\t\tremove: { type: 'array', items: { type: 'string', enum: ['name', 'opacity', 'transform', 'fit', 'blend', 'grade', 'animateIn', 'animateOut', 'fadeIn', 'fadeOut', 'fadeInCurve', 'fadeOutCurve', 'in', 'speed', 'volume', 'muted', 'fontSize', 'fontFamily', 'fontWeight', 'background'] }, description: 'Fields to reset to their defaults.' }\n\t\t\t},\n\t\t\t['clip']\n\t\t),\n\t\tannotations: { title: 'Change a clip', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, set, remove }) => {\n\t\t\tconst current = clipOut(project(), clip);\n\t\t\tif (current && set) {\n\t\t\t\tconst allowed = new Set(['name', 'opacity', 'transform', 'fit', 'blend', 'grade', 'animateIn', 'animateOut', 'fadeIn', 'fadeOut', ...(current.type === 'media' ? ['in', 'speed', 'volume', 'muted', 'asset', 'fadeInCurve', 'fadeOutCurve'] : current.type === 'sequence' ? ['in', 'volume', 'muted', 'fadeInCurve', 'fadeOutCurve'] : current.type === 'text' ? Object.keys(TEXT_FIELDS) : ['color'])]);\n\t\t\t\tconst wrong = Object.keys(set).filter((k) => !allowed.has(k));\n\t\t\t\tif (wrong.length) throw new Error(`${wrong.map((k) => `\"${k}\"`).join(', ')} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(', ')}.`);\n\t\t\t}\n\t\t\treturn apply({ op: 'update_clip', clip, set, remove }, 'Change clip', (r) => ({ clip: clipOut(r.project, clip) }));\n\t\t}\n\t};\n\n\t/** Clips a grade can change: pictures, on video tracks. */\n\tconst gradeTargets = (ids: string[]) => {\n\t\tconst p = project();\n\t\tfor (const id of ids) {\n\t\t\tconst at = findVideoClip(p, id);\n\t\t\tif (!at) throw new Error(`There is no clip \"${id}\"; get_video_project lists the clips.`);\n\t\t\tconst { clip } = at;\n\t\t\tif (at.track.kind !== 'video' || (clip.type === 'media' && p.assets.find((a) => a.id === clip.asset)?.kind === 'audio'))\n\t\t\t\tthrow new Error(`Clip \"${id}\" is sound only; grades change pictures. Pass clips on video tracks.`);\n\t\t}\n\t};\n\n\tconst applyGradePreset: AgentTool = {\n\t\tname: 'apply_video_grade_preset',\n\t\ttitle: 'Apply a colour look',\n\t\tdescription:\n\t\t\t'Gives clips a named colour look at a strength (one undo step), replacing their grade settings (a LUT stays): warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip\\'s resulting grade; tweak it with update_video_clip set.grade.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclips: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clips on video tracks to grade.' },\n\t\t\t\tpreset: { type: 'string', enum: [...VIDEO_GRADE_PRESET_NAMES], description: 'The look.' },\n\t\t\t\tamount: { type: 'number', minimum: 0, maximum: 1, description: 'Strength, 0 (none) to 1 (full, the default); 0.5 is a subtler version.' }\n\t\t\t},\n\t\t\t['clips', 'preset']\n\t\t),\n\t\tannotations: { title: 'Apply a colour look', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clips, preset, amount }) => {\n\t\t\tgradeTargets(clips);\n\t\t\t// A clip's LUT stays: the look replaces only the grade's settings.\n\t\t\tconst edits: VideoEdit[] = clips.map((clip: string) => {\n\t\t\t\tconst grade = videoGradeLook(findVideoClip(project(), clip)!.clip.grade, preset, amount ?? 1);\n\t\t\t\treturn grade ? { op: 'update_clip', clip, set: { grade } } : { op: 'update_clip', clip, remove: ['grade'] };\n\t\t\t});\n\t\t\treturn apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id: string) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));\n\t\t}\n\t};\n\n\tconst copyGrade: AgentTool = {\n\t\tname: 'copy_video_grade',\n\t\ttitle: 'Copy a colour grade',\n\t\tdescription:\n\t\t\t'Gives clips the same colour grade as another clip (one undo step), to match shots in a scene; a source with no grade clears theirs. Returns the grade copied and the clips changed.',\n\t\tinputSchema: object({ from: { ...CLIP_ID, description: 'The clip whose grade to copy.' }, to: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clips on video tracks to give it to.' } }, ['from', 'to']),\n\t\tannotations: { title: 'Copy a colour grade', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ from, to }) => {\n\t\t\tgradeTargets([from, ...to]);\n\t\t\tconst grade = findVideoClip(project(), from)!.clip.grade;\n\t\t\tconst edits: VideoEdit[] = to.map((clip: string) => (grade ? { op: 'update_clip', clip, set: { grade: { ...grade } } } : { op: 'update_clip', clip, remove: ['grade'] }));\n\t\t\treturn apply(edits, 'Copy grade', () => ({ grade: grade ?? null, clips: to }));\n\t\t}\n\t};\n\n\n\tconst setLut: AgentTool = {\n\t\tname: 'set_video_lut',\n\t\ttitle: 'Apply a LUT',\n\t\tdescription:\n\t\t\t'Gives clips a 3D LUT (a .cube file) at a strength, applied after the rest of their grade (one undo step); their other grade settings stay. Reads the file first and fails, changing nothing, if it cannot be loaded or is not a 3D .cube. Returns the LUT\\'s title and size and each clip\\'s resulting grade. Remove it with remove_video_lut.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclips: { type: 'array', items: CLIP_ID, minItems: 1, uniqueItems: true, description: 'Clips on video tracks to grade.' },\n\t\t\t\tsrc: LUT_SRC,\n\t\t\t\tstrength: LUT_STRENGTH,\n\t\t\t\tname: { type: 'string', minLength: 1, description: 'A label for the look (default the file\\'s TITLE).' }\n\t\t\t},\n\t\t\t['clips', 'src']\n\t\t),\n\t\tannotations: { title: 'Apply a LUT', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n\t\thandler: async ({ clips, src, strength, name }) => {\n\t\t\tif (strength === 0) throw new Error('A strength of 0 changes nothing; pass a strength above 0, or remove the LUT with remove_video_lut.');\n\t\t\tgradeTargets(clips);\n\t\t\tconst lut = await controller.luts.load(src);\n\t\t\tconst label = name ?? lut.title;\n\t\t\tconst setting = { src, ...(strength !== undefined && strength !== 1 ? { strength } : {}), ...(label ? { name: label } : {}) };\n\t\t\treturn apply(videoLutEdits(project(), clips, setting), 'Apply LUT', (r) => ({ lut: { src, ...(lut.title ? { title: lut.title } : {}), size: lut.size }, clips: clips.map((id: string) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));\n\t\t}\n\t};\n\n\tconst removeLut: AgentTool = {\n\t\tname: 'remove_video_lut',\n\t\ttitle: 'Remove a LUT',\n\t\tdescription: 'Takes the LUT off clips\\' grades (one undo step), keeping their other grade settings. Fails if none of them has one. Returns each clip\\'s resulting grade.',\n\t\tinputSchema: object({ clips: { type: 'array', items: CLIP_ID, minItems: 1, uniqueItems: true, description: 'Clips whose LUT to remove.' } }, ['clips']),\n\t\tannotations: { title: 'Remove a LUT', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clips }) => {\n\t\t\tgradeTargets(clips);\n\t\t\tconst p = project();\n\t\t\tconst withLut = clips.filter((id: string) => findVideoClip(p, id)!.clip.grade?.lut);\n\t\t\tif (!withLut.length) throw new Error(`None of ${clips.map((c: string) => `\"${c}\"`).join(', ')} has a LUT; get_video_project shows each clip's grade.`);\n\t\t\treturn apply(videoLutEdits(p, withLut, null), 'Remove LUT', (r) => ({ clips: clips.map((id: string) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));\n\t\t}\n\t};\n\n\t// -----------------------------------------------------------------------------------------\n\t// Proxies\n\t// -----------------------------------------------------------------------------------------\n\n\tconst 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.' } as const;\n\tconst planProxies: AgentTool = {\n\t\tname: 'plan_video_proxies',\n\t\ttitle: 'Plan proxy files',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object({\n\t\t\tscale: PROXY_SCALE,\n\t\t\tassets: { type: 'array', items: ASSET_ID, minItems: 1, uniqueItems: true, description: 'Only these video assets (default: every video asset without a proxy at this scale).' },\n\t\t\tgop: { type: 'integer', minimum: 1, maximum: 300, description: 'Frames between keyframes (default 12): fewer makes seeking cheaper and files larger.' },\n\t\t\tcrf: { type: 'integer', minimum: 0, maximum: 51, description: 'x264 quality, lower is better (default 28: proxies are for editing).' }\n\t\t}),\n\t\tannotations: { title: 'Plan proxy files', ...READ },\n\t\thandler: ({ scale, assets, gop, crf }) => {\n\t\t\tconst commands = videoProxyPlan(project(), { scale, assets, gop, crf });\n\t\t\treturn { scale: scale ?? 0.5, commands, ...(commands.length ? {} : { note: 'Every video asset already has a proxy at this scale.' }) };\n\t\t}\n\t};\n\n\tconst setProxies: AgentTool = {\n\t\tname: 'set_video_proxies',\n\t\ttitle: 'Attach proxies',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tasset: { ...ASSET_ID, description: 'A video asset id from get_video_project.' },\n\t\t\t\tproxies: {\n\t\t\t\t\ttype: 'array',\n\t\t\t\t\tmaxItems: 8,\n\t\t\t\t\tdescription: 'Its proxies, one per scale (empty removes them all).',\n\t\t\t\t\titems: 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.')\n\t\t\t\t}\n\t\t\t},\n\t\t\t['asset', 'proxies']\n\t\t),\n\t\tannotations: { title: 'Attach proxies', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ asset, proxies }) => {\n\t\t\tconst a = project().assets.find((x) => x.id === asset);\n\t\t\tif (!a) throw new Error(`There is no asset \"${asset}\"; the assets are ${project().assets.map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\t\tif (a.kind !== 'video') throw new Error(`Asset \"${asset}\" is ${a.kind === 'image' ? 'an image' : 'audio'}; only video assets have proxies.`);\n\t\t\tconst edit: VideoEdit = proxies.length ? { op: 'update_asset', asset, set: { proxies } } : { op: 'update_asset', asset, remove: ['proxies'] };\n\t\t\treturn apply(edit, proxies.length ? 'Attach proxies' : 'Remove proxies', (r) => ({ asset: videoAssetOut(r.project.assets.find((x) => x.id === asset)!) }));\n\t\t}\n\t};\n\n\tconst setPreviewScale: AgentTool = {\n\t\tname: 'set_video_preview_scale',\n\t\ttitle: 'Set the preview size',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object(\n\t\t\t{ 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).' } },\n\t\t\t['scale']\n\t\t),\n\t\tannotations: { title: 'Set the preview size', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ scale }) => {\n\t\t\tcontroller.setPreviewScale(scale);\n\t\t\tconst s = controller.getState().previewScale;\n\t\t\tconst fixed = s === 'full' ? 1 : typeof s === 'number' ? s : null;\n\t\t\tconst plays = fixed === null ? undefined : project().assets.filter((a) => a.kind === 'video').map((a) => ({ asset: a.id, src: videoAssetProxy(a, fixed)?.src ?? a.src, proxy: !!videoAssetProxy(a, fixed) }));\n\t\t\treturn { 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.' }) };\n\t\t}\n\t};\n\n\tconst previewCache: AgentTool = {\n\t\tname: 'get_video_preview_cache',\n\t\ttitle: 'Read the preview\\'s frame cache',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: 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)') }),\n\t\tannotations: { title: 'Read the preview\\'s frame cache', ...READ },\n\t\thandler: ({ from, to }) => {\n\t\t\tconst cache = controller.frameCache;\n\t\t\tif (!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.' };\n\t\t\tif (from !== undefined && to !== undefined && !(to > from)) throw new Error(`\"to\" (${to} ms) must be after \"from\" (${from} ms).`);\n\t\t\tconst ranges = cache.cachedRanges(project(), { from, to });\n\t\t\treturn { enabled: true, ...cache.stats(), cachedRanges: ranges };\n\t\t}\n\t};\n\n\t// -----------------------------------------------------------------------------------------\n\t// Text-based editing\n\t// -----------------------------------------------------------------------------------------\n\n\tconst 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).' };\n\tconst RANGE_OUT = (r: VideoTranscriptRange) => ({ from: round(r.from), to: round(r.to), text: r.text });\n\tconst timelineWords = (track?: string) => {\n\t\tconst words = videoTimelineTranscript(project(), { track });\n\t\tif (!words.length) throw new Error(track ? `No clip on track \"${track}\" plays a transcribed asset; attach one with set_video_transcript.` : 'No clip plays a transcribed asset; attach a transcript to the media with set_video_transcript first.');\n\t\treturn words;\n\t};\n\n\tconst setTranscript: AgentTool = {\n\t\tname: 'set_video_transcript',\n\t\ttitle: 'Attach a transcript',\n\t\tdescription:\n\t\t\t'Attaches what is said in a video or audio asset, word by word, so the edit can be cut by text (one undo step). Pass \"text\" (Whisper-style JSON with word timings, SubRip or WebVTT, as the file says it) or \"words\" ([{ word, start, end }] in ms of the source). Captions have no word timings, so their words are spread across each cue (approximate). Replaces any transcript the asset had. Returns the word count, whether timings are approximate, and the first and last words.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tasset: { ...ASSET_ID, description: 'A video or audio asset.' },\n\t\t\t\ttext: { type: 'string', minLength: 1, description: 'The transcript file\\'s contents: Whisper-style JSON (times in seconds), .srt or .vtt. Pass this or words.' },\n\t\t\t\twords: { type: 'array', minItems: 1, items: object({ word: { type: 'string', minLength: 1, description: 'The word as said.' }, start: { type: 'number', minimum: 0, description: 'Start in ms of the source.' }, end: { type: 'number', minimum: 0, description: 'End in ms of the source.' } }, ['word', 'start', 'end'], 'One word.'), description: 'Words with source times in ms. Pass this or text.' }\n\t\t\t},\n\t\t\t['asset']\n\t\t),\n\t\tannotations: { title: 'Attach a transcript', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ asset, text, words }) => {\n\t\t\tif ((text === undefined) === (words === undefined)) throw new Error('Pass either \"text\" (a transcript file\\'s contents) or \"words\", not both.');\n\t\t\tconst a = project().assets.find((x) => x.id === asset);\n\t\t\tif (!a) throw new Error(`There is no asset \"${asset}\"; the assets are ${project().assets.map((x) => `\"${x.id}\"`).join(', ') || 'none'}.`);\n\t\t\tif (a.kind === 'image') throw new Error(`Asset \"${asset}\" is an image and has no sound to transcribe.`);\n\t\t\tconst parsed = text !== undefined ? parseVideoTranscript(text) : { words, approximate: false };\n\t\t\tconst list = parsed.words as { word: string; start: number; end: number }[];\n\t\t\treturn apply({ op: 'update_asset', asset, set: { transcript: list } }, 'Attach transcript', () => ({\n\t\t\t\tasset,\n\t\t\t\twords: list.length,\n\t\t\t\tapproximate: parsed.approximate,\n\t\t\t\tfirst: list[0],\n\t\t\t\tlast: list[list.length - 1],\n\t\t\t\t...(a.duration !== undefined && list[list.length - 1].end > a.duration + VIDEO_TIME_EPSILON ? { warning: `The transcript runs to ${list[list.length - 1].end} ms, past the asset's ${a.duration} ms: is it for this asset?` } : {})\n\t\t\t}));\n\t\t}\n\t};\n\n\tconst getTranscript: AgentTool = {\n\t\tname: 'get_video_transcript',\n\t\ttitle: 'Read the transcript',\n\t\tdescription:\n\t\t\t'Returns what the timeline says, in order, from the transcripts of the media its clips play on one track: the text, and each word with its timeline start and end (ms) and the clip that plays it, so ranges can go straight to remove_video_ranges or split_video_at. Words cut out of the edit are not included.',\n\t\tinputSchema: object({\n\t\t\ttrack: TRANSCRIPT_TRACK,\n\t\t\tfrom: MS('Only words from this timeline time (default 0)'),\n\t\t\tto: MS('Only words up to this timeline time (default the end)'),\n\t\t\tlimit: { type: 'integer', minimum: 1, maximum: 5000, description: 'Most words to return (default 1000); \"more\" says if there are others.' }\n\t\t}),\n\t\tannotations: { title: 'Read the transcript', ...READ },\n\t\thandler: ({ track, from, to, limit }) => {\n\t\t\tconst all = timelineWords(track).filter((w) => w.end > (from ?? 0) && w.start < (to ?? Infinity));\n\t\t\tconst words = all.slice(0, limit ?? 1000);\n\t\t\treturn { track: words[0]?.track ?? videoTranscriptTrack(project(), track), text: words.map((w) => w.word).join(' '), words: words.map((w) => ({ word: w.word, start: round(w.start), end: round(w.end), clip: w.clip })), more: all.length > words.length };\n\t\t}\n\t};\n\n\tconst findText: AgentTool = {\n\t\tname: 'find_video_transcript_text',\n\t\ttitle: 'Find words in the transcript',\n\t\tdescription: 'Lists every place a word or phrase is said on the timeline (case and punctuation ignored) as { from, to, text } ranges in timeline ms, ready for remove_video_ranges, split_video_at or seek_video.',\n\t\tinputSchema: object({ query: { type: 'string', minLength: 1, description: 'The word or phrase.' }, track: TRANSCRIPT_TRACK }, ['query']),\n\t\tannotations: { title: 'Find words in the transcript', ...READ },\n\t\thandler: ({ query, track }) => ({ matches: findVideoTranscriptText(timelineWords(track), query).map(RANGE_OUT) })\n\t};\n\n\tconst findFillers: AgentTool = {\n\t\tname: 'find_video_fillers',\n\t\ttitle: 'Find filler words',\n\t\tdescription: `Lists filler words and phrases on the timeline as { from, to, text } ranges in timeline ms, longest phrase first so \"you know\" is found whole. Default fillers: ${VIDEO_DEFAULT_FILLERS.join(', ')} (\"like\" and \"so\" are left out as too often meant). Check them, then pass the ones to cut to remove_video_ranges.`,\n\t\tinputSchema: object({ track: TRANSCRIPT_TRACK, fillers: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 }, description: 'Words or phrases to treat as fillers instead of the defaults.' } }),\n\t\tannotations: { title: 'Find filler words', ...READ },\n\t\thandler: ({ track, fillers }) => {\n\t\t\tconst found = findVideoFillers(timelineWords(track), fillers);\n\t\t\treturn { fillers: found.map(RANGE_OUT), total: round(found.reduce((n, r) => n + r.to - r.from, 0)) };\n\t\t}\n\t};\n\n\tconst findPauses: AgentTool = {\n\t\tname: 'find_video_pauses',\n\t\ttitle: 'Find pauses between words',\n\t\tdescription:\n\t\t\t'Lists the gaps between consecutive words on the timeline (within one clip) longer than \"min\", less \"keep\" next to each word so speech is not clipped, snapped inward to frames, as { from, to } ranges in timeline ms ready for remove_video_ranges. Uses word timings; find_video_silences measures the sound instead.',\n\t\tinputSchema: object({\n\t\t\ttrack: TRANSCRIPT_TRACK,\n\t\t\tmin: { type: 'number', exclusiveMinimum: 0, description: 'Shortest gap that counts, in ms (default 700).' },\n\t\t\tkeep: { type: 'number', minimum: 0, description: 'Silence to keep after the word before and before the word after, in ms (default 150).' }\n\t\t}),\n\t\tannotations: { title: 'Find pauses between words', ...READ },\n\t\thandler: ({ track, min, keep }) => {\n\t\t\tconst found = findVideoTranscriptPauses(timelineWords(track), { min, keep, fps: project().fps });\n\t\t\treturn { pauses: found.map(RANGE_OUT), total: round(found.reduce((n, r) => n + r.to - r.from, 0)) };\n\t\t}\n\t};\n\n\tconst removeRanges: AgentTool = {\n\t\tname: 'remove_video_ranges',\n\t\ttitle: 'Cut out stretches',\n\t\tdescription:\n\t\t\t'Cuts stretches of the timeline out and closes them up, as one undo step: the ranges are merged where they touch and extracted from the last to the first, so the times from find_video_fillers, find_video_pauses, find_video_transcript_text or find_video_silences can be passed as they are. Like extract_video_range, every sync-locked track moves with the cut. Returns how many stretches and how much time were removed and the new length.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tranges: { type: 'array', minItems: 1, maxItems: 500, items: object({ from: MS('Start, on the timeline'), to: MS('End, on the timeline'), text: { type: 'string', description: 'Ignored: the words in the stretch, as the find tools return them.' } }, ['from', 'to'], 'A stretch to remove.'), description: 'The stretches, in any order (timeline ms).' },\n\t\t\t\ttracks: { type: 'array', items: TRACK_ID, minItems: 1, description: 'Tracks to cut (default every unlocked track).' }\n\t\t\t},\n\t\t\t['ranges']\n\t\t),\n\t\tannotations: { title: 'Cut out stretches', ...DESTRUCTIVE },\n\t\thandler: ({ ranges, tracks }) => {\n\t\t\tconst bad = (ranges as { from: number; to: number }[]).findIndex((r) => !(r.to > r.from));\n\t\t\tif (bad >= 0) throw new Error(`Range ${bad + 1} is empty or backwards (${ranges[bad].from}–${ranges[bad].to} ms); \"to\" must be after \"from\".`);\n\t\t\tconst merged = mergeVideoRanges(ranges);\n\t\t\tconst before = videoProjectDuration(project());\n\t\t\treturn apply(videoRemoveRangesEdits(merged, tracks), merged.length > 1 ? `Cut ${merged.length} stretches` : 'Cut stretch', (r) => ({ removed: merged.length, time: round(before - videoProjectDuration(r.project)), duration: videoProjectDuration(r.project) }));\n\t\t}\n\t};\n\n\t// -----------------------------------------------------------------------------------------\n\t// Speed ramps and freeze frames\n\t// -----------------------------------------------------------------------------------------\n\n\t/** A media clip a ramp can change, with its asset. */\n\tconst rampTarget = (id: string) => {\n\t\tconst at = findVideoClip(project(), id);\n\t\tif (!at) throw new Error(`There is no clip \"${id}\"; get_video_project lists the clips.`);\n\t\tconst { clip } = at;\n\t\tif (clip.type === 'sequence') throw new Error(`Clip \"${id}\" is a compound clip, which plays its sequence at normal speed; change the speed of the clips inside the sequence instead.`);\n\t\tif (clip.type !== 'media') throw new Error(`Clip \"${id}\" is a ${clip.type === 'text' ? 'title' : clip.type === 'effect' ? 'effect clip' : 'colour'}, so it has no source to change the speed of; use a video or audio clip.`);\n\t\tif (project().assets.find((a) => a.id === clip.asset)?.kind === 'image') throw new Error(`Clip \"${id}\" is a still, so it has no motion to change the speed of.`);\n\t\treturn clip;\n\t};\n\tconst rampOut = (r: Extract<VideoEditResult, { ok: true }>, id: string) => {\n\t\tconst outcome = r.outcomes[r.outcomes.length - 1];\n\t\treturn { clip: clipOut(r.project, id), ...(outcome.rippled ? { rippled: outcome.rippled } : {}), duration: videoProjectDuration(r.project) };\n\t};\n\tconst RIPPLE_RAMP = { type: 'boolean', description: 'Move later material on every sync-locked track by the change in length (default true); false refuses if the clip would overlap the next one.' } as const;\n\n\tconst setSpeedRamp: AgentTool = {\n\t\tname: 'set_video_speed_ramp',\n\t\ttitle: 'Ramp the speed',\n\t\tdescription:\n\t\t\t'Plays a video or audio clip through stretches of speed, from its first frame: e.g. [{ duration: 1500, speed: 1 }, { duration: 500, speed: 1, toSpeed: 0.25 }, { duration: 2000, speed: 0.25 }] for a slow-motion ramp. \"toSpeed\" glides evenly to a new speed over the stretch; speed 0 is a freeze frame. The clip\\'s length becomes the stretches\\' total (one undo step). Replaces any ramp or speed it had. Returns the clip (with its timeRemap keys and source range).',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclip: CLIP_ID,\n\t\t\t\tsegments: {\n\t\t\t\t\ttype: 'array',\n\t\t\t\t\tminItems: 1,\n\t\t\t\t\tmaxItems: 100,\n\t\t\t\t\tdescription: 'The stretches in order, from the clip\\'s start.',\n\t\t\t\t\titems: object(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length of the stretch on the timeline, in ms.' },\n\t\t\t\t\t\t\tspeed: { type: 'number', minimum: 0, maximum: 20, description: 'Speed at its start: 1 normal, 0.5 half speed (slow motion), 2 double, 0 a freeze frame.' },\n\t\t\t\t\t\t\ttoSpeed: { type: 'number', minimum: 0, maximum: 20, description: 'Glide evenly to this speed by the stretch\\'s end (default: stay at speed). A ramp between two speeds.' }\n\t\t\t\t\t\t},\n\t\t\t\t\t\t['duration', 'speed'],\n\t\t\t\t\t\t'One stretch.'\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\tripple: RIPPLE_RAMP\n\t\t\t},\n\t\t\t['clip', 'segments']\n\t\t),\n\t\tannotations: { title: 'Ramp the speed', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, segments, ripple }) => {\n\t\t\t// Starts from the moment of source the clip shows at its first frame now.\n\t\t\tconst edit = videoSpeedRampEdit(rampTarget(clip), segments, ripple ?? true);\n\t\t\treturn apply(edit, 'Speed ramp', (r) => ({ ...rampOut(r, clip), source: Math.round(videoSpeedRampKeys(segments).source) }));\n\t\t}\n\t};\n\n\tconst addFreeze: AgentTool = {\n\t\tname: 'add_video_freeze_frame',\n\t\ttitle: 'Add a freeze frame',\n\t\tdescription:\n\t\t\t'Holds the frame a video clip shows at a timeline time for a while, then plays on (one undo step): the clip gets longer by the hold, and with ripple (default) everything after it on sync-locked tracks moves along. Works on clips with or without a speed ramp. Returns the clip.',\n\t\tinputSchema: object(\n\t\t\t{\n\t\t\t\tclip: CLIP_ID,\n\t\t\t\tat: MS('Timeline time of the frame to hold (inside the clip)'),\n\t\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'How long to hold it, in ms.' },\n\t\t\t\tripple: RIPPLE_RAMP\n\t\t\t},\n\t\t\t['clip', 'at', 'duration']\n\t\t),\n\t\tannotations: { title: 'Add a freeze frame', ...WRITE },\n\t\thandler: ({ clip, at, duration, ripple }) => {\n\t\t\tconst c = rampTarget(clip);\n\t\t\tconst t = at - c.start;\n\t\t\tif (t < 0 || t > c.duration) throw new Error(`${formatVideoSeconds(at)} is outside clip \"${clip}\" (${formatVideoSeconds(c.start)}–${formatVideoSeconds(videoClipEnd(c))}); pick a time inside it.`);\n\t\t\treturn apply(videoFreezeFrameEdit(c, t, duration, ripple ?? true), 'Freeze frame', (r) => rampOut(r, clip));\n\t\t}\n\t};\n\n\tconst removeRamp: AgentTool = {\n\t\tname: 'remove_video_speed_ramp',\n\t\ttitle: 'Remove a speed ramp',\n\t\tdescription: 'Puts a ramped clip back to normal speed from the moment its ramp started (one undo step), keeping its length unless \"duration\" is given. Returns the clip.',\n\t\tinputSchema: object({ clip: CLIP_ID, duration: { type: 'number', exclusiveMinimum: 0, description: 'New length in ms (default: unchanged).' }, ripple: RIPPLE_RAMP }, ['clip']),\n\t\tannotations: { title: 'Remove a speed ramp', ...WRITE },\n\t\thandler: ({ clip, duration, ripple }) => {\n\t\t\trampTarget(clip);\n\t\t\treturn apply({ op: 'set_time_remap', clip, keys: null, ...(duration !== undefined ? { duration } : {}), ripple: ripple ?? true }, 'Remove speed ramp', (r) => rampOut(r, clip));\n\t\t}\n\t};\n\n\tconst setTransition: AgentTool = {\n\t\tname: 'set_video_transition',\n\t\ttitle: 'Set a transition',\n\t\tdescription:\n\t\t\t'Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). Every type but dip plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.',\n\t\tinputSchema: object({ clip: { ...CLIP_ID, description: 'The incoming clip (the one after the cut).' }, transition: { anyOf: [TRANSITION, { type: 'null' }], description: 'The transition, or null to remove it.' } }, ['clip', 'transition']),\n\t\tannotations: { title: 'Set a transition', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, transition }) => apply({ op: 'set_transition', clip, transition }, transition ? 'Set transition' : 'Remove transition', (r) => ({ clip: clipOut(r.project, clip) }))\n\t};\n\n\tconst slipClip: AgentTool = {\n\t\tname: 'slip_video_clip',\n\t\ttitle: 'Slip a clip',\n\t\tdescription:\n\t\t\t'Shows a different stretch of a video or audio clip\\'s source without moving it on the timeline: its in and out points both move by delta (one undo step). Clamped to the media past either end, keeping what its transitions need. Returns the clip (with its new source range) and the delta applied.',\n\t\tinputSchema: object({ clip: CLIP_ID, delta: DELTA('How far to move the source (positive shows later media)') }, ['clip', 'delta']),\n\t\tannotations: { title: 'Slip a clip', ...WRITE },\n\t\thandler: ({ clip, delta }) => apply({ op: 'slip_clip', clip, delta }, 'Slip clip', (r) => ({ clip: clipOut(r.project, clip), delta: r.outcomes[0].delta }))\n\t};\n\n\tconst rollEdit: AgentTool = {\n\t\tname: 'roll_video_edit',\n\t\ttitle: 'Roll a cut',\n\t\tdescription:\n\t\t\t'Moves the cut between two clips that meet on one track (one undo step): the left one\\'s out point and the right one\\'s in point move together, so nothing else moves and no gap opens. Clamped to each clip\\'s media and at least a frame of each. Returns both clips, the new cut time and the delta applied.',\n\t\tinputSchema: object({ left: { ...CLIP_ID, description: 'The clip before the cut (from get_video_project).' }, right: { ...CLIP_ID, description: 'The clip after the cut, starting where \"left\" ends.' }, delta: DELTA('How far to move the cut') }, ['left', 'right', 'delta']),\n\t\tannotations: { title: 'Roll a cut', ...WRITE },\n\t\thandler: ({ left, right, delta }) =>\n\t\t\tapply({ op: 'roll_edit', left, right, delta }, 'Roll edit', (r) => {\n\t\t\t\tconst after = clipOut(r.project, right)!;\n\t\t\t\treturn { left: clipOut(r.project, left), right: after, cut: after.start, delta: r.outcomes[0].delta };\n\t\t\t})\n\t};\n\n\tconst slideClip: AgentTool = {\n\t\tname: 'slide_video_clip',\n\t\ttitle: 'Slide a clip',\n\t\tdescription:\n\t\t\t'Moves a clip along its track keeping its content, trimming the clips either side so the stretch stays gapless (one undo step): the one before gets longer as the one after gets shorter, or the other way. Without a neighbour on one side it moves through the gap there. Clamped to the neighbours\\' media and at least a frame of each. Returns the clip, its neighbours and the delta applied.',\n\t\tinputSchema: object({ clip: CLIP_ID, delta: DELTA('How far to move it') }, ['clip', 'delta']),\n\t\tannotations: { title: 'Slide a clip', ...WRITE },\n\t\thandler: ({ clip, delta }) =>\n\t\t\tapply({ op: 'slide_clip', clip, delta }, 'Slide clip', (r) => {\n\t\t\t\tconst ids = r.outcomes[0].clips!;\n\t\t\t\tconst i = ids.indexOf(clip);\n\t\t\t\treturn { clip: clipOut(r.project, clip), ...(i > 0 ? { before: clipOut(r.project, ids[0]) } : {}), ...(i < ids.length - 1 ? { after: clipOut(r.project, ids[ids.length - 1]) } : {}), delta: r.outcomes[0].delta };\n\t\t\t})\n\t};\n\n\tconst rangeArgs = object({ from: MS('Start of the range on the timeline'), to: MS('End of the range on the timeline (after \"from\")'), tracks: RANGE_TRACKS }, ['from', 'to']);\n\tconst rangeOut = (o: VideoEditOutcome) => ({ removed: o.removed ?? [], trimmed: o.trimmed ?? [], created: o.created ?? [], ...(o.rippled ? { rippled: o.rippled } : {}) });\n\n\tconst liftRange: AgentTool = {\n\t\tname: 'lift_video_range',\n\t\ttitle: 'Lift a range',\n\t\tdescription:\n\t\t\t'Empties a stretch of time on some or all tracks, leaving a gap (one undo step): clips inside are deleted, clips across an edge are trimmed to it, and a clip across both is cut in two. Returns the clips removed, trimmed and created (right-hand parts of cuts).',\n\t\tinputSchema: rangeArgs,\n\t\tannotations: { title: 'Lift a range', ...DESTRUCTIVE },\n\t\thandler: ({ from, to, tracks }) => apply({ op: 'lift_range', from, to, tracks }, 'Lift', (r) => rangeOut(r.outcomes[0]))\n\t};\n\n\tconst extractRange: AgentTool = {\n\t\tname: 'extract_video_range',\n\t\ttitle: 'Extract a range',\n\t\tdescription:\n\t\t\t'Removes a stretch of time and closes it up (one undo step): like lift, then everything after it moves earlier by its length on those tracks and every sync-locked track. Refused, naming the clip, when a sync-locked track left out of \"tracks\" has something in the range. Returns the clips removed, trimmed and created and the tracks that moved.',\n\t\tinputSchema: rangeArgs,\n\t\tannotations: { title: 'Extract a range', ...DESTRUCTIVE },\n\t\thandler: ({ from, to, tracks }) => apply({ op: 'extract_range', from, to, tracks }, 'Extract', (r) => rangeOut(r.outcomes[0]))\n\t};\n\n\tconst splitAt: AgentTool = {\n\t\tname: 'split_video_at',\n\t\ttitle: 'Cut across tracks',\n\t\tdescription:\n\t\t\t'Cuts every clip that runs across a time on some or all tracks (a razor through the timeline), as one undo step. Each left part keeps its id, fade in and transition; each right part gets a new id and the fade out. Refused when no clip runs across that time. Returns the pairs { left, right }.',\n\t\tinputSchema: object({ at: MS('Where to cut, on the timeline'), tracks: RANGE_TRACKS }, ['at']),\n\t\tannotations: { title: 'Cut across tracks', ...WRITE },\n\t\thandler: ({ at, tracks }) =>\n\t\t\tapply({ op: 'split_at', at, tracks }, 'Split', (r) => {\n\t\t\t\tconst o = r.outcomes[0];\n\t\t\t\treturn { cuts: o.clips!.map((left, i) => ({ left: clipOut(r.project, left), right: clipOut(r.project, o.created![i]) })) };\n\t\t\t})\n\t};\n\n\tconst setKeyframes: AgentTool = {\n\t\tname: 'set_video_keyframes',\n\t\ttitle: 'Animate a clip property',\n\t\tdescription:\n\t\t\t'Animates one property of a clip with keyframes (one undo step), replacing any keys it had: opacity, volume, or the transform\\'s x, y, scale and rotation. Times are clip time (ms from the clip\\'s start), so a key at 0 is the clip\\'s first frame; get_video_project gives each clip\\'s duration. Plays in the preview, renders and the ffmpeg export. Returns the property\\'s keys, sorted.',\n\t\tinputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY, keys: KEYS }, ['clip', 'property', 'keys']),\n\t\tannotations: { title: 'Animate a clip property', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, property, keys }) => apply({ op: 'set_keyframes', clip, property, keys }, `Keyframe ${property}`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))\n\t};\n\n\tconst removeKeyframes: AgentTool = {\n\t\tname: 'remove_video_keyframes',\n\t\ttitle: 'Stop animating a clip property',\n\t\tdescription:\n\t\t\t'Removes all of a property\\'s keyframes (one undo step), leaving it at the value it had at the clip\\'s start. Returns the remaining keys (none) and that value (null for the default).',\n\t\tinputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY }, ['clip', 'property']),\n\t\tannotations: { title: 'Stop animating a clip property', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, property }) => apply({ op: 'set_keyframes', clip, property, keys: null }, `Remove ${property} keyframes`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))\n\t};\n\n\tconst closeGaps: AgentTool = {\n\t\tname: 'close_video_gaps',\n\t\ttitle: 'Close gaps',\n\t\tdescription: 'Slides the clips on a track earlier so no gaps are left between them (from a time on, default the start), as one undo step. Returns the clips that moved.',\n\t\tinputSchema: object({ track: TRACK_ID, from: MS('Only close gaps after this time (default 0)') }, ['track']),\n\t\tannotations: { title: 'Close gaps', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ track, from }) => apply({ op: 'close_gaps', track, from }, 'Close gaps', (r) => ({ moved: r.outcomes[0].clips }))\n\t};\n\n\tconst addMarker: AgentTool = {\n\t\tname: 'add_video_marker',\n\t\ttitle: 'Add a marker',\n\t\tdescription: 'Adds a named marker at a time (one undo step), e.g. a beat or a note for review. Clip edges snap to markers. Returns its id.',\n\t\tinputSchema: object({ time: MS('Where it goes'), label: { type: 'string', description: 'What it marks.' }, color: COLOR('Marker colour'), id: { type: 'string', minLength: 1, description: 'Id (default \"m1\", \"m2\", …).' } }, ['time']),\n\t\tannotations: { title: 'Add a marker', ...WRITE },\n\t\thandler: (marker) => apply({ op: 'add_marker', marker: marker as any }, 'Add marker', (r) => ({ marker: r.outcomes[0].marker }))\n\t};\n\n\tconst removeMarker: AgentTool = {\n\t\tname: 'remove_video_marker',\n\t\ttitle: 'Remove a marker',\n\t\tdescription: 'Removes a marker by id (one undo step). Returns the remaining markers.',\n\t\tinputSchema: object({ marker: { type: 'string', minLength: 1, description: 'Marker id from get_video_project.' } }, ['marker']),\n\t\tannotations: { title: 'Remove a marker', ...DESTRUCTIVE },\n\t\thandler: ({ marker }) => apply({ op: 'remove_marker', marker }, 'Remove marker', (r) => ({ markers: r.project.markers ?? [] }))\n\t};\n\n\tconst EDIT_OP: JsonSchema = {\n\t\ttype: 'object',\n\t\tdescription: `One edit: { \"op\": one of ${VIDEO_EDIT_OPS.filter((o) => o !== 'replace_project').join(', ')}, …its fields }. Fields match the single tools: add_clip { track, clip, mode, ripple }, move_clip { clip, start, track, mode }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, set_time_remap { clip, keys (clip ms → source ms after the in point; null removes), duration, ripple }, slip_clip { clip, delta }, roll_edit { left, right, delta }, slide_clip { clip, delta }, lift_range { from, to, tracks }, extract_range { from, to, tracks }, split_at { at, tracks }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, … }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,\n\t\tproperties: { op: { type: 'string', enum: VIDEO_EDIT_OPS.filter((o) => o !== 'replace_project'), description: 'Which edit.' } },\n\t\trequired: ['op']\n\t};\n\n\tconst editVideo: AgentTool = {\n\t\tname: 'edit_video',\n\t\ttitle: 'Make several edits at once',\n\t\tdescription:\n\t\t\t'Applies several edits in order as one undo step, all or nothing: if one fails nothing changes and the error names the op (\"op 2 (trim_clip): …\"). Use it for a rough cut (assets, then clips) or anything that only makes sense together. Returns each op\\'s outcome (the clip, clips, track, asset or marker it made or changed), the length and issues. With dry_run it only reports them.',\n\t\tinputSchema: object({ ops: { type: 'array', items: EDIT_OP, minItems: 1, maxItems: 200, description: 'Edits, applied in order.' }, dry_run: { type: 'boolean', description: 'Check without applying (default false).' } }, ['ops']),\n\t\tannotations: { title: 'Make several edits at once', ...WRITE },\n\t\thandler: ({ ops, dry_run = false }) => {\n\t\t\tif (ops.some((o: { op: string }) => o.op === 'replace_project')) throw new Error('edit_video cannot replace the whole project; use the individual ops.');\n\t\t\tconst result = controller.apply(ops as VideoEdit[], 'Edit video', { dryRun: dry_run });\n\t\t\treturn outcome(result, result.ok ? { outcomes: result.outcomes, ...(dry_run ? { dry_run: true, hint: 'Nothing was applied; call again without dry_run to apply.' } : {}) } : {});\n\t\t}\n\t};\n\n\tconst undo: AgentTool = {\n\t\tname: 'undo_video',\n\t\ttitle: 'Undo a video edit',\n\t\tdescription: 'Undoes the last edit. Returns whether something was undone and its label.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Undo a video edit', ...WRITE },\n\t\thandler: () => {\n\t\t\tconst label = controller.getState().undoLabel;\n\t\t\tconst undone = controller.undo();\n\t\t\treturn { undone, ...(undone ? { label } : {}) };\n\t\t}\n\t};\n\n\tconst redo: AgentTool = {\n\t\tname: 'redo_video',\n\t\ttitle: 'Redo a video edit',\n\t\tdescription: 'Redoes the last undone edit. Returns whether something was redone and its label.',\n\t\tinputSchema: object({}),\n\t\tannotations: { title: 'Redo a video edit', ...WRITE },\n\t\thandler: () => {\n\t\t\tconst label = controller.getState().redoLabel;\n\t\t\tconst redone = controller.redo();\n\t\t\treturn { redone, ...(redone ? { label } : {}) };\n\t\t}\n\t};\n\n\t/** The source stretch a level or silence query reads, and how to map its times back. */\n\tconst soundRange = (args: { clip?: string; asset?: string; from?: number; to?: number }) => {\n\t\tconst p = project();\n\t\tif ((args.clip === undefined) === (args.asset === undefined)) throw new Error('Pass either \"clip\" (a media clip, times on the timeline) or \"asset\" (times in the source), not both.');\n\t\tlet asset: VideoAsset | undefined;\n\t\tlet toSource = (t: number) => t;\n\t\tlet toOut = (t: number) => t;\n\t\tlet from: number;\n\t\tlet to: number;\n\t\tif (args.clip !== undefined) {\n\t\t\tconst found = findVideoClip(p, args.clip);\n\t\t\tif (!found) throw new Error(`There is no clip \"${args.clip}\"; get_video_project lists the clips.`);\n\t\t\tconst c = found.clip;\n\t\t\tif (c.type === 'sequence') throw new Error(`Clip \"${c.id}\" is a compound clip; its sound is the mix of its sequence \"${c.sequence}\", so read the levels of the media in it (list_video_sequences lists its clips and assets).`);\n\t\t\tif (c.type !== 'media') throw new Error(`Clip \"${c.id}\" is a ${c.type} clip and has no sound; pass a media clip.`);\n\t\t\tasset = p.assets.find((a) => a.id === c.asset);\n\t\t\tconst speed = c.speed ?? 1;\n\t\t\ttoSource = (t: number) => (c.in ?? 0) + (t - c.start) * speed;\n\t\t\ttoOut = (s: number) => c.start + (s - (c.in ?? 0)) / speed;\n\t\t\tfrom = Math.max(c.start, args.from ?? c.start);\n\t\t\tto = Math.min(videoClipEnd(c), args.to ?? videoClipEnd(c));\n\t\t} else {\n\t\t\tasset = p.assets.find((a) => a.id === args.asset);\n\t\t\tif (!asset) throw new Error(`There is no asset \"${args.asset}\"; the assets are ${p.assets.map((a) => `\"${a.id}\"`).join(', ') || 'none'}.`);\n\t\t\tfrom = args.from ?? 0;\n\t\t\tto = args.to ?? asset.duration ?? 0;\n\t\t}\n\t\tif (!asset) throw new Error(`Clip \"${args.clip}\" uses a missing asset.`);\n\t\tif (asset.kind === 'image' || asset.hasAudio === false) throw new Error(`Asset \"${asset.id}\" has no sound.`);\n\t\tif (!(to > from)) throw new Error(`The stretch to read is empty (${from}–${to} ms); \"to\" must be after \"from\"${args.clip !== undefined ? ' and both inside the clip' : asset.duration === undefined ? ', and the asset has no duration, so pass \"to\"' : ''}.`);\n\t\treturn { asset, from, to, toSource, toOut };\n\t};\n\tconst SOUND_TARGET = {\n\t\tclip: { ...CLIP_ID, description: 'A media clip: read the stretch of source it plays, with times on the timeline. Pass this or asset.' },\n\t\tasset: { ...ASSET_ID, description: 'A video or audio asset: read its source, with times in the source. Pass this or clip.' },\n\t\tfrom: MS('Start of the stretch (default: the clip\\'s start, or 0 for an asset)'),\n\t\tto: MS('End of the stretch (default: the clip\\'s end, or the asset\\'s duration)')\n\t};\n\tconst SOUND_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } as const;\n\n\tconst audioLevels: AgentTool = {\n\t\tname: 'get_video_audio_levels',\n\t\ttitle: 'Read sound levels',\n\t\tdescription:\n\t\t\t'Measures how loud the source sound is over a clip or an asset, without changing anything: the peak level in dBFS (0 = full scale, -100 = silence) for each of \"buckets\" equal stretches, plus the loudest peak. Levels are the recording as it is, before clip volume and fades. Use it to find where speech or a beat starts, or to judge whether a clip is too loud. The first call for an asset decodes its file, which can take a moment.',\n\t\tinputSchema: object({ ...SOUND_TARGET, buckets: { type: 'integer', minimum: 1, maximum: 500, description: 'How many equal stretches to measure (default 50).' } }),\n\t\tannotations: { title: 'Read sound levels', ...SOUND_ANNOTATIONS },\n\t\thandler: async (args) => {\n\t\t\tconst r = soundRange(args);\n\t\t\tconst peaks = await controller.audioPeaks.load(r.asset);\n\t\t\tconst levels = videoAudioLevels(peaks, r.toSource(r.from), r.toSource(r.to), args.buckets ?? 50).map((l) => ({ start: round(r.toOut(l.start)), end: round(r.toOut(l.end)), peakDb: l.peakDb }));\n\t\t\treturn { asset: r.asset.id, from: r.from, to: r.to, peakDb: Math.max(...levels.map((l) => l.peakDb)), levels };\n\t\t}\n\t};\n\n\tconst findSilences: AgentTool = {\n\t\tname: 'find_video_silences',\n\t\ttitle: 'Find silences',\n\t\tdescription:\n\t\t\t'Lists the stretches of a clip or an asset whose source sound stays below a level for at least a minimum length, as { start, end } ms (timeline times for a clip, so they can go straight to extract_video_range or split_video_at; source times for an asset). Use it to cut pauses, dead air or the gaps between takes. The first call for an asset decodes its file.',\n\t\tinputSchema: object({\n\t\t\t...SOUND_TARGET,\n\t\t\tthresholdDb: { type: 'number', minimum: -100, maximum: 0, description: 'Quieter than this peak level counts as silence, in dBFS (default -40; -50 for a quiet room, -30 over background noise).' },\n\t\t\tminDuration: { type: 'number', exclusiveMinimum: 0, description: 'Shortest silence to report, in ms (default 300).' }\n\t\t}),\n\t\tannotations: { title: 'Find silences', ...SOUND_ANNOTATIONS },\n\t\thandler: async (args) => {\n\t\t\tconst r = soundRange(args);\n\t\t\tconst peaks = await controller.audioPeaks.load(r.asset);\n\t\t\tconst silences = findVideoAudioSilences(peaks, r.toSource(r.from), r.toSource(r.to), { thresholdDb: args.thresholdDb, minDuration: args.minDuration }).map((x) => ({ start: round(r.toOut(x.start)), end: round(r.toOut(x.end)) }));\n\t\t\treturn { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n, x) => n + x.end - x.start, 0)) };\n\t\t}\n\t};\n\n\n\t/** The pixels of the frame at `time`: the host's, or the preview's (seeking it there and back). */\n\tconst pixelsAt = async (time: number): Promise<VideoPixels> => {\n\t\tif (options.framePixels) {\n\t\t\tconst px = await options.framePixels(time);\n\t\t\tif (!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.`);\n\t\t\treturn px;\n\t\t}\n\t\tconst feed = controller.previewPixels;\n\t\tif (!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.');\n\t\tconst state = controller.getState();\n\t\tif (Math.abs(time - state.time) < 0.5) return (await feed.next(state.time, options.framePixelsTimeout)).pixels;\n\t\tconst { time: before, playing } = state;\n\t\tif (playing) controller.pause();\n\t\tcontroller.seek(time);\n\t\ttry {\n\t\t\treturn (await feed.next(controller.getState().time, options.framePixelsTimeout)).pixels;\n\t\t} finally {\n\t\t\tcontroller.seek(before);\n\t\t\tif (playing) controller.play();\n\t\t}\n\t};\n\n\tconst getScopes: AgentTool = {\n\t\tname: 'get_video_scopes',\n\t\ttitle: 'Measure a frame with scopes',\n\t\tdescription:\n\t\t\t'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.',\n\t\tinputSchema: object({\n\t\t\ttime: MS('Time from the start to measure (default: the playhead). Another time is shown briefly and the playhead put back'),\n\t\t\tbins: { type: 'integer', minimum: 2, maximum: 64, description: 'Histogram steps, darkest first (default 16).' },\n\t\t\tcolumns: { type: 'integer', minimum: 1, maximum: 32, description: 'Waveform strips across the picture, left to right (default 8).' }\n\t\t}),\n\t\tannotations: { title: 'Measure a frame with scopes', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },\n\t\thandler: async ({ time, bins, columns }) => {\n\t\t\tconst p = project();\n\t\t\tconst end = videoProjectDuration(p);\n\t\t\tconst t = time ?? controller.getState().time;\n\t\t\tif (time !== undefined && time > end) throw new Error(`Time ${time} ms is past the end of the project (${end} ms).`);\n\t\t\tconst pixels = await pixelsAt(t);\n\t\t\treturn { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };\n\t\t}\n\t};\n\n\tconst reads = [getProject, listSequences, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, previewCache, planProxies, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek, setPreviewScale];\n\tconst 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];\n\n\treturn {\n\t\tid: options.id ?? 'video',\n\t\tnamespace: options.namespace,\n\t\ttools: () => (readOnlyReason() ? reads : [...reads, ...writes]),\n\t\tsubscribe: (notify) => {\n\t\t\tlet last = readOnlyReason();\n\t\t\treturn controller.subscribe(() => {\n\t\t\t\tconst now = readOnlyReason();\n\t\t\t\tif (now !== last) {\n\t\t\t\t\tlast = now;\n\t\t\t\t\tnotify();\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t};\n}\n","/**\n * Agent tools for provider effects in the video editor: the shared effect tools\n * (`search_effects`, `describe_effect`, `list_applied_effects`, `apply_effect`, `update_effect`,\n * `remove_effect`) on clips, plus `add_video_effect_clip`, `set_video_effect_transition` and\n * `set_video_effect_keyframes`.\n */\n\nimport { createEffectToolProvider, type AppliedEffectInfo, type EffectChangeResult, type EffectTargetEffects } from '../effects/tools.js';\nimport type { AgentTool, AgentToolProvider } from '../tools/registry.js';\nimport type { EffectValue } from '../effects/types.js';\nimport type { VideoEditorController, VideoEffectEditResult } from './controller.js';\nimport { isVideoKeyframes, VIDEO_EASE_NAMES, type VideoKey } from './keyframes.js';\nimport { VIDEO_PLACEMENT_MODES, type VideoClip, type VideoEffectValues } from './types.js';\nimport { findVideoClip } from './timeline.js';\n\nexport interface VideoEffectsToolProviderOptions {\n\t/** Provider id (unique per registry). Default `video-effects`. */\n\tid?: string;\n\t/** Always prefix tool names with `<namespace>__`. */\n\tnamespace?: string;\n\t/** The project cannot be edited: write tools are hidden. A string is the reason. */\n\treadOnly?: boolean | string | (() => boolean | string);\n}\n\n/** A clip's effects as the tools report them: its effect clip picture, its effect transition, then its stack. */\nexport function videoClipAppliedEffects(clip: VideoClip): AppliedEffectInfo[] {\n\tconst out: AppliedEffectInfo[] = [];\n\tconst ref = (r: { effect: string; name?: string; provider?: string }) => ({ effect: r.effect, ...(r.name ? { name: r.name } : {}), ...(r.provider ? { provider: r.provider } : {}) });\n\tif (clip.type === 'effect') out.push({ id: 'generator', ...ref(clip), role: 'effect clip', enabled: true, values: clip.values ?? {} });\n\tif (clip.transition?.type === 'effect' && clip.transition.effect) out.push({ id: 'transition', ...ref(clip.transition.effect), role: 'transition', enabled: true, values: { ...(clip.transition.effect.values ?? {}) }, duration: clip.transition.duration } as AppliedEffectInfo);\n\t(clip.effects ?? []).forEach((e, index) => out.push({ id: e.id, ...ref(e), role: 'clip effect', enabled: e.enabled !== false, ...(e.opacity !== undefined ? { opacity: e.opacity } : {}), values: e.values ?? {}, index }));\n\treturn out;\n}\n\nconst MS = (what: string) => ({ type: 'number', minimum: 0, description: `${what}, in milliseconds.` }) as const;\nconst KEYS = {\n\ttype: 'array',\n\tminItems: 1,\n\titems: {\n\t\ttype: 'object',\n\t\tproperties: {\n\t\t\tt: { type: 'number', description: 'Clip-relative time in ms (0 = the clip\\'s start).' },\n\t\t\tv: { type: 'number', description: 'The control\\'s value at t, within its range from describe_effect.' },\n\t\t\tease: { anyOf: [{ type: 'string', enum: [...VIDEO_EASE_NAMES] }, { type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4 }], description: 'How the value moves to the next key: linear (default), hold, ease-in, ease-out, ease-in-out, or CSS cubic-bezier [x1, y1, x2, y2].' }\n\t\t},\n\t\trequired: ['t', 'v'],\n\t\tadditionalProperties: false\n\t},\n\tdescription: 'Keys in any order (sorted for you), one per time.'\n} as const;\n\n/** The effect tools for a video editor (see the module comment). */\nexport function createVideoEffectsToolProvider(controller: VideoEditorController, options: VideoEffectsToolProviderOptions = {}): AgentToolProvider {\n\tconst readOnlyReason = (): string | null => {\n\t\tconst ro = typeof options.readOnly === 'function' ? options.readOnly() : options.readOnly;\n\t\tif (ro) return typeof ro === 'string' ? ro : 'The video project is read-only.';\n\t\treturn controller.readOnlyReason();\n\t};\n\tconst clipOf = (id: string): VideoClip => {\n\t\tconst at = findVideoClip(controller.getProject(), id);\n\t\tif (!at) {\n\t\t\tconst ids = controller.getProject().tracks.flatMap((t) => t.clips.map((c) => c.id));\n\t\t\tthrow new Error(`There is no clip \"${id}\"; the clips are ${ids.map((c) => `\"${c}\"`).join(', ') || 'none'} (get_video_project lists them).`);\n\t\t}\n\t\treturn at.clip;\n\t};\n\tconst result = (r: VideoEffectEditResult, target: string, instance?: string, extra: Record<string, unknown> = {}): EffectChangeResult => {\n\t\tif (!r.ok) throw new Error(r.error);\n\t\tconst effects = videoClipAppliedEffects(clipOf(target));\n\t\tconst effect = effects.find((e) => e.id === (instance ?? r.effect?.id)) ?? (r.effect ? { id: r.effect.id, effect: r.effect.effect, role: 'clip effect', enabled: r.effect.enabled !== false, values: r.effect.values ?? {} } : effects[0]);\n\t\treturn { target, effect: effect!, effects, ...extra };\n\t};\n\n\treturn createEffectToolProvider({\n\t\tid: options.id ?? 'video-effects',\n\t\tnamespace: options.namespace,\n\t\ttarget: { noun: 'clip', id: 'A clip id from get_video_project (e.g. \"c1\").' },\n\t\tapplyUses: ['filter', 'overlay'],\n\t\tapplyDetails:\n\t\t\t'Filters change the clip\\'s picture and overlays draw over it, in stack order, on the picture as it comes from the source, before the clip\\'s grade, placement, opacity and blend; numbers may be keyframed (clip-relative ms). Transitions go through set_video_effect_transition and generators or titles through add_video_effect_clip. The preview renders them frame by frame; the ffmpeg export then needs rendered frames (see export_video_ffmpeg).',\n\t\tprovider: () => controller.effectsProvider,\n\t\treadOnlyReason,\n\t\tlist: (target) => {\n\t\t\tconst clips: VideoClip[] = target ? [clipOf(target)] : controller.getProject().tracks.flatMap((t) => t.clips);\n\t\t\treturn clips\n\t\t\t\t.map((c): EffectTargetEffects => ({ target: c.id, ...(c.name ? { name: c.name } : {}), effects: videoClipAppliedEffects(c) }))\n\t\t\t\t.filter((t) => target || t.effects.length);\n\t\t},\n\t\tapply: async (target, effect, args) => {\n\t\t\tclipOf(target);\n\t\t\treturn result(await controller.applyClipEffect(target, effect, { values: args.values as VideoEffectValues | undefined, index: args.index, enabled: args.enabled, opacity: args.opacity }), target);\n\t\t},\n\t\tupdate: async (target, instance, changes) => {\n\t\t\tclipOf(target);\n\t\t\tif (instance === 'generator' || instance === 'transition') {\n\t\t\t\tconst extra = (['enabled', 'opacity', 'index'] as const).filter((k) => changes[k] !== undefined);\n\t\t\t\tif (extra.length) throw new Error(`An ${instance === 'generator' ? 'effect clip' : 'effect transition'} only takes values; change its ${extra.join(', ')} with update_video_clip${instance === 'transition' ? ' or set_video_effect_transition' : ''}.`);\n\t\t\t\tconst values = Object.fromEntries(Object.entries(changes.values ?? {}).filter(([, v]) => v !== null)) as VideoEffectValues;\n\t\t\t\treturn result(await controller.updateEffectValues(target, instance, values), target, instance);\n\t\t\t}\n\t\t\treturn result(await controller.updateClipEffect(target, instance, changes as Parameters<VideoEditorController['updateClipEffect']>[2]), target, instance);\n\t\t},\n\t\tremove: async (target, instance) => {\n\t\t\tconst clip = clipOf(target);\n\t\t\tconst was = videoClipAppliedEffects(clip).find((e) => e.id === instance);\n\t\t\tif (!was) throw new Error(`Clip \"${target}\" has no effect \"${instance}\"; its effects are ${videoClipAppliedEffects(clip).map((e) => `\"${e.id}\"`).join(', ') || 'none'}.`);\n\t\t\tif (instance === 'generator') throw new Error(`\"${target}\" is an effect clip: its effect is the clip itself. Delete the clip with delete_video_clips.`);\n\t\t\tif (instance === 'transition') {\n\t\t\t\tconst r = controller.apply({ op: 'set_transition', clip: target, transition: null }, 'Remove transition');\n\t\t\t\tif (!r.ok) throw new Error(r.error);\n\t\t\t\treturn { target, effect: was, effects: videoClipAppliedEffects(clipOf(target)) };\n\t\t\t}\n\t\t\tconst r = controller.removeClipEffect(target, instance);\n\t\t\tif (!r.ok) throw new Error(r.error);\n\t\t\treturn { target, effect: was, effects: videoClipAppliedEffects(clipOf(target)) };\n\t\t},\n\t\tsubscribe: (notify) => controller.subscribe(() => notify()),\n\t\textraTools: (ctx): AgentTool[] => [\n\t\t\t{\n\t\t\t\tname: 'add_video_effect_clip',\n\t\t\t\ttitle: 'Add an effect clip',\n\t\t\t\tdescription:\n\t\t\t\t\t'Adds a clip drawn by a provider generator, title or overlay effect (search_effects with use generator, title or overlay), filling the frame like a colour clip; transparent effects show the tracks beneath. It can be moved, trimmed, faded, graded and given clip effects like any clip. One undo step. Returns the new clip id and its effect. The ffmpeg export then needs rendered frames.',\n\t\t\t\tinputSchema: {\n\t\t\t\t\ttype: 'object',\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\ttrack: { type: 'string', minLength: 1, description: 'A video track id from get_video_project (e.g. \"v1\"); earlier tracks draw on top.' },\n\t\t\t\t\t\teffect: ctx.effectIdSchema(['generator', 'title', 'overlay']),\n\t\t\t\t\t\tstart: MS('Where it starts on the timeline (default: the end of the track)'),\n\t\t\t\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms (default: the effect\\'s own length, else 5000).' },\n\t\t\t\t\t\tvalues: ctx.valuesSchema('Control values over the effect\\'s defaults', true),\n\t\t\t\t\t\tname: { type: 'string', minLength: 1, description: 'A label for the clip (default: the effect\\'s name).' },\n\t\t\t\t\t\tmode: { type: 'string', enum: [...VIDEO_PLACEMENT_MODES], description: 'What to do with clips already there: refuse (default), overwrite or insert.' }\n\t\t\t\t\t},\n\t\t\t\t\trequired: ['track', 'effect'],\n\t\t\t\t\tadditionalProperties: false\n\t\t\t\t},\n\t\t\t\tannotations: { title: 'Add an effect clip', ...ctx.annotations.write },\n\t\t\t\thandler: async (args) => {\n\t\t\t\t\tctx.requireProvider();\n\t\t\t\t\tconst r = await controller.addEffectClip(args.track, args.effect, { start: args.start, duration: args.duration, values: args.values, name: args.name, mode: args.mode });\n\t\t\t\t\tif (!r.ok) throw new Error(r.error);\n\t\t\t\t\treturn { clip: r.clip, track: args.track, effects: videoClipAppliedEffects(clipOf(r.clip)) };\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'set_video_effect_transition',\n\t\t\t\ttitle: 'Set an effect transition',\n\t\t\t\tdescription:\n\t\t\t\t\t'Sets a provider transition effect (search_effects with use transition) into a clip from the clip that ends where it starts on the same track, centred on the cut; like a crossfade it needs half its length in media past the cut on both sides. Replaces any transition there. One undo step. Returns the clip\\'s effects. Remove it with remove_effect (effect \"transition\") or set_video_transition null.',\n\t\t\t\tinputSchema: {\n\t\t\t\t\ttype: 'object',\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\tclip: { type: 'string', minLength: 1, description: 'The incoming clip (the one after the cut), from get_video_project.' },\n\t\t\t\t\t\teffect: ctx.effectIdSchema(['transition']),\n\t\t\t\t\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms, centred on the cut (default: the effect\\'s own length, else 1000).' },\n\t\t\t\t\t\tvalues: ctx.valuesSchema('Control values over the effect\\'s defaults (plain values; transitions cannot be keyframed)')\n\t\t\t\t\t},\n\t\t\t\t\trequired: ['clip', 'effect'],\n\t\t\t\t\tadditionalProperties: false\n\t\t\t\t},\n\t\t\t\tannotations: { title: 'Set an effect transition', ...ctx.annotations.writeIdempotent },\n\t\t\t\thandler: async (args) => {\n\t\t\t\t\tctx.requireProvider();\n\t\t\t\t\tconst r = await controller.setEffectTransition(args.clip, args.effect, { duration: args.duration, values: args.values as Record<string, EffectValue> | undefined });\n\t\t\t\t\tif (!r.ok) throw new Error(r.error);\n\t\t\t\t\treturn { clip: args.clip, transition: clipOf(args.clip).transition, effects: videoClipAppliedEffects(clipOf(args.clip)) };\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'set_video_effect_keyframes',\n\t\t\t\ttitle: 'Keyframe an effect control',\n\t\t\t\tdescription:\n\t\t\t\t\t'Animates one number control of a clip effect or an effect clip with keys at clip-relative times (ms), or with keys null stops animating it, keeping its value at the clip\\'s start. Values are checked against the control\\'s range from describe_effect. One undo step. Returns the effect.',\n\t\t\t\tinputSchema: {\n\t\t\t\t\ttype: 'object',\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\tclip: { type: 'string', minLength: 1, description: 'A clip id from list_applied_effects.' },\n\t\t\t\t\t\teffect: { type: 'string', minLength: 1, description: 'The effect\\'s id on the clip (\"fx1\", or \"generator\" for an effect clip\\'s own effect).' },\n\t\t\t\t\t\tcontrol: { type: 'string', minLength: 1, description: 'A number control name from describe_effect.' },\n\t\t\t\t\t\tkeys: { anyOf: [KEYS, { type: 'null' }], description: 'The keys, or null to stop animating the control.' }\n\t\t\t\t\t},\n\t\t\t\t\trequired: ['clip', 'effect', 'control', 'keys'],\n\t\t\t\t\tadditionalProperties: false\n\t\t\t\t},\n\t\t\t\tannotations: { title: 'Keyframe an effect control', ...ctx.annotations.writeIdempotent },\n\t\t\t\thandler: async (args) => {\n\t\t\t\t\tctx.requireProvider();\n\t\t\t\t\tconst keys = args.keys as VideoKey[] | null;\n\t\t\t\t\tlet r: VideoEffectEditResult;\n\t\t\t\t\tif (args.effect === 'generator') {\n\t\t\t\t\t\tconst clip = clipOf(args.clip);\n\t\t\t\t\t\tif (clip.type !== 'effect') throw new Error(`Clip \"${args.clip}\" is not an effect clip; give the id of one of its effects (list_applied_effects).`);\n\t\t\t\t\t\tconst was = clip.values?.[args.control];\n\t\t\t\t\t\tconst plain = keys === null ? (isVideoKeyframes(was) ? was.keys[0]?.v : was) : { keys: [...keys].sort((a, b) => a.t - b.t) };\n\t\t\t\t\t\tif (plain === undefined) throw new Error(`\"${args.control}\" has no value to keep; give keys instead.`);\n\t\t\t\t\t\tr = await controller.updateEffectValues(args.clip, 'generator', { [args.control]: plain } as VideoEffectValues);\n\t\t\t\t\t} else r = await controller.setClipEffectKeyframes(args.clip, args.effect, args.control, keys);\n\t\t\t\t\tif (!r.ok) throw new Error(r.error);\n\t\t\t\t\treturn { clip: args.clip, effect: videoClipAppliedEffects(clipOf(args.clip)).find((e) => e.id === args.effect) };\n\t\t\t\t}\n\t\t\t}\n\t\t]\n\t});\n}\n"]}
|