@pixerate/editor 0.79.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/editor/editor/packages/core/dist/video-editor/index.cjs","../../src/video-editor/types.ts","../../src/video-editor/keyframes.ts","../../src/video-editor/parse.ts","../../src/video-editor/timeline.ts","../../src/video-editor/check.ts","../../src/video-editor/edit.ts","../../src/video-editor/evaluate.ts","../../src/video-editor/render.ts","../../src/video-editor/export.ts","../../src/video-editor/preview.ts","../../src/video-editor/controller.ts","../../src/video-editor/tools.ts","../../src/video-editor/ui.ts"],"names":["n"],"mappings":"AAAA;AACE;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B;AACE;AACF,yDAA8B;AAC9B,iCAA8B;AAC9B,iCAA8B;AAC9B,iCAA8B;AAC9B;AACA;AC+CO,IAAM,WAAA,EAAa,CAAC,SAAA,EAAW,OAAA,EAAS,MAAA,EAAQ,MAAM,CAAA;AAGtD,IAAM,uBAAA,EAAyB,CAAC,WAAA,EAAa,KAAA,EAAO,MAAM,CAAA;AAE1D,IAAM,sBAAA,EAAwB,CAAC,MAAA,EAAQ,OAAA,EAAS,IAAA,EAAM,MAAM,CAAA;AA2E5D,IAAM,iBAAA,EAAmB,CAAC,OAAA,EAAS,MAAA,EAAQ,OAAO,CAAA;AD1HzD;AACA;AERO,IAAM,iBAAA,EAAmB,CAAC,QAAA,EAAU,MAAA,EAAQ,SAAA,EAAW,UAAA,EAAY,aAAa,CAAA;AAoBhF,IAAM,0BAAA,EAA4B,CAAC,SAAA,EAAW,QAAA,EAAU,GAAA,EAAK,GAAA,EAAK,OAAA,EAAS,UAAU,CAAA;AACrF,IAAM,2BAAA,EAA6B,CAAC,GAAA,EAAK,GAAA,EAAK,OAAA,EAAS,UAAU,CAAA;AAGjE,IAAM,sBAAA,EAAwG;AAAA,EACpH,OAAA,EAAS,EAAE,OAAA,EAAS,CAAA,EAAG,GAAA,EAAK,CAAA,EAAG,GAAA,EAAK,EAAE,CAAA;AAAA,EACtC,MAAA,EAAQ,EAAE,OAAA,EAAS,CAAA,EAAG,GAAA,EAAK,CAAA,EAAG,GAAA,EAAK,EAAE,CAAA;AAAA,EACrC,CAAA,EAAG,EAAE,OAAA,EAAS,EAAE,CAAA;AAAA,EAChB,CAAA,EAAG,EAAE,OAAA,EAAS,EAAE,CAAA;AAAA,EAChB,KAAA,EAAO,EAAE,OAAA,EAAS,CAAA,EAAG,GAAA,EAAK,EAAE,CAAA;AAAA,EAC5B,QAAA,EAAU,EAAE,OAAA,EAAS,EAAE;AACxB,CAAA;AAEA,IAAM,MAAA,EAA0F;AAAA,EAC/F,SAAA,EAAW,CAAC,IAAA,EAAM,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AAAA,EACzB,UAAA,EAAY,CAAC,CAAA,EAAG,CAAA,EAAG,IAAA,EAAM,CAAC,CAAA;AAAA,EAC1B,aAAA,EAAe,CAAC,IAAA,EAAM,CAAA,EAAG,IAAA,EAAM,CAAC;AACjC,CAAA;AAEO,IAAM,iBAAA,EAAmB,CAAC,CAAA,EAAA,GAAoC,OAAO,EAAA,IAAM,SAAA,GAAY,EAAA,IAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAS,CAAA,CAAqB,IAAI,CAAA;AAG7I,SAAS,eAAA,CAAgB,IAAA,EAAsE;AACrG,EAAA,GAAA,CAAI,KAAA,IAAS,KAAA,EAAA,GAAa,KAAA,IAAS,SAAA,GAAY,KAAA,IAAS,MAAA,EAAQ,OAAO,IAAA;AACvE,EAAA,OAAO,KAAA,CAAM,OAAA,CAAQ,IAAI,EAAA,EAAI,KAAA,EAAO,KAAA,CAAM,IAAI,CAAA;AAC/C;AAEA,IAAM,IAAA,EAAM,CAAC,CAAA,EAAW,CAAA,EAAW,CAAA,EAAA,GAAc,EAAA,EAAI,EAAA,EAAA,CAAK,EAAA,EAAI,CAAA,EAAA,EAAA,CAAM,EAAA,EAAI,CAAA,EAAA,EAAK,EAAA,EAAI,EAAA,EAAI,EAAA,EAAA,CAAK,EAAA,EAAI,CAAA,EAAA,EAAK,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,EAAA,EAAI,CAAA;AACnH,IAAM,SAAA,EAAW,CAAC,CAAA,EAAW,CAAA,EAAW,CAAA,EAAA,GAAc,EAAA,EAAI,EAAA,EAAA,CAAK,EAAA,EAAI,CAAA,EAAA,EAAA,CAAM,EAAA,EAAI,CAAA,EAAA,EAAK,EAAA,EAAA,CAAK,EAAA,EAAI,CAAA,EAAA,EAAA,CAAM,EAAA,EAAI,CAAA,EAAA,EAAK,EAAA,EAAI,EAAA,EAAA,CAAK,EAAA,EAAI,CAAA,EAAA,EAAK,EAAA,EAAI,CAAA;AAGhI,SAAS,WAAA,CAAY,EAAA,EAAY,EAAA,EAAY,CAAA,EAAmB;AAC/D,EAAA,IAAI,EAAA,EAAI,CAAA;AACR,EAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,CAAA,EAAG,CAAA,EAAA,EAAK;AAC3B,IAAA,MAAM,IAAA,EAAM,GAAA,CAAI,EAAA,EAAI,EAAA,EAAI,CAAC,EAAA,EAAI,CAAA;AAC7B,IAAA,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,GAAG,EAAA,EAAI,IAAA,EAAM,OAAO,CAAA;AACjC,IAAA,MAAM,EAAA,EAAI,QAAA,CAAS,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AAC5B,IAAA,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,CAAC,EAAA,EAAI,IAAA,EAAM,KAAA;AACxB,IAAA,EAAA,GAAK,IAAA,EAAM,CAAA;AACX,IAAA,GAAA,CAAI,EAAA,EAAI,EAAA,GAAK,EAAA,EAAI,CAAA,EAAG,KAAA;AAAA,EACrB;AACA,EAAA,IAAI,GAAA,EAAK,CAAA;AACT,EAAA,IAAI,GAAA,EAAK,CAAA;AACT,EAAA,EAAA,EAAI,CAAA;AACJ,EAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,EAAA,EAAI,CAAA,EAAA,EAAK;AAC5B,IAAA,MAAM,EAAA,EAAI,GAAA,CAAI,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACvB,IAAA,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,CAAC,EAAA,EAAI,IAAA,EAAM,KAAA;AAC5B,IAAA,GAAA,CAAI,EAAA,EAAI,CAAA,EAAG,GAAA,EAAK,CAAA;AAAA,IAAA,KACX,GAAA,EAAK,CAAA;AACV,IAAA,EAAA,EAAA,CAAK,GAAA,EAAK,EAAA,EAAA,EAAM,CAAA;AAAA,EACjB;AACA,EAAA,OAAO,CAAA;AACR;AAGO,SAAS,iBAAA,CAAkB,IAAA,EAA6B,CAAA,EAAmB;AACjF,EAAA,GAAA,CAAI,EAAA,GAAK,CAAA,EAAG,OAAO,CAAA;AACnB,EAAA,GAAA,CAAI,EAAA,GAAK,CAAA,EAAG,OAAO,KAAA,IAAS,OAAA,EAAS,EAAA,EAAI,CAAA;AACzC,EAAA,GAAA,CAAI,KAAA,IAAS,MAAA,EAAQ,OAAO,CAAA;AAC5B,EAAA,MAAM,EAAA,EAAI,eAAA,CAAgB,IAAI,CAAA;AAC9B,EAAA,GAAA,CAAI,CAAC,CAAA,EAAG,OAAO,CAAA;AACf,EAAA,OAAO,GAAA,CAAI,CAAA,CAAE,CAAC,CAAA,EAAG,CAAA,CAAE,CAAC,CAAA,EAAG,WAAA,CAAY,CAAA,CAAE,CAAC,CAAA,EAAG,CAAA,CAAE,CAAC,CAAA,EAAG,CAAC,CAAC,CAAA;AAClD;AAGA,SAAS,SAAA,CAAU,IAAA,EAA2B,CAAA,EAAmB;AAChE,EAAA,IAAI,GAAA,EAAK,CAAA;AACT,EAAA,IAAI,GAAA,EAAK,IAAA,CAAK,OAAA,EAAS,CAAA;AACvB,EAAA,GAAA,CAAI,EAAA,EAAI,IAAA,CAAK,CAAC,CAAA,CAAE,CAAA,EAAG,OAAO,CAAA,CAAA;AAC1B,EAAA,MAAA,CAAO,GAAA,EAAK,EAAA,EAAI;AACf,IAAA,MAAM,IAAA,EAAO,GAAA,EAAK,GAAA,EAAK,EAAA,GAAM,CAAA;AAC7B,IAAA,GAAA,CAAI,IAAA,CAAK,GAAG,CAAA,CAAE,EAAA,GAAK,CAAA,EAAG,GAAA,EAAK,GAAA;AAAA,IAAA,KACtB,GAAA,EAAK,IAAA,EAAM,CAAA;AAAA,EACjB;AACA,EAAA,OAAO,EAAA;AACR;AAMO,SAAS,YAAA,CAAa,IAAA,EAAmC,QAAA,EAAkB,SAAA,EAAW,CAAA,EAAW;AACvG,EAAA,GAAA,CAAI,KAAA,IAAS,KAAA,CAAA,EAAW,OAAO,QAAA;AAC/B,EAAA,GAAA,CAAI,OAAO,KAAA,IAAS,QAAA,EAAU,OAAO,IAAA;AACrC,EAAA,MAAM,EAAE,KAAK,EAAA,EAAI,IAAA;AACjB,EAAA,GAAA,CAAI,CAAC,IAAA,CAAK,MAAA,EAAQ,OAAO,QAAA;AACzB,EAAA,MAAM,EAAA,EAAI,SAAA,CAAU,IAAA,EAAM,QAAQ,CAAA;AAClC,EAAA,GAAA,CAAI,EAAA,EAAI,CAAA,EAAG,OAAO,IAAA,CAAK,CAAC,CAAA,CAAE,CAAA;AAC1B,EAAA,GAAA,CAAI,EAAA,GAAK,IAAA,CAAK,OAAA,EAAS,CAAA,EAAG,OAAO,IAAA,CAAK,IAAA,CAAK,OAAA,EAAS,CAAC,CAAA,CAAE,CAAA;AACvD,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,CAAC,CAAA;AAChB,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,CAAC,CAAA;AACpB,EAAA,GAAA,CAAI,CAAA,CAAE,KAAA,IAAS,MAAA,EAAQ,OAAO,CAAA,CAAE,CAAA;AAChC,EAAA,OAAO,CAAA,CAAE,EAAA,EAAA,CAAK,CAAA,CAAE,EAAA,EAAI,CAAA,CAAE,CAAA,EAAA,EAAK,iBAAA,CAAkB,CAAA,CAAE,IAAA,EAAA,CAAO,SAAA,EAAW,CAAA,CAAE,CAAA,EAAA,EAAA,CAAM,CAAA,CAAE,EAAA,EAAI,CAAA,CAAE,CAAA,CAAE,CAAA;AACpF;AAGO,IAAM,YAAA,EAAc,CAAC,IAAA,EAAA,GAAmD,gBAAA,CAAiB,IAAI,EAAA,EAAI,IAAA,CAAK,KAAA,EAAO,CAAC,CAAA;AAG9G,SAAS,qBAAA,CAA6D,IAAA,EAAS,EAAA,EAAY,EAAA,EAAI,CAAA,EAAM;AAC3G,EAAA,GAAA,CAAI,CAAC,gBAAA,CAAiB,IAAI,CAAA,EAAG,OAAO,IAAA;AACpC,EAAA,OAAO,EAAE,IAAA,EAAM,IAAA,CAAK,IAAA,CAAK,GAAA,CAAI,CAAC,GAAA,EAAA,GAAA,CAAS,EAAE,GAAG,GAAA,EAAK,CAAA,EAAG,GAAA,CAAI,EAAA,EAAI,EAAA,EAAI,GAAG,CAAA,CAAE,EAAE,CAAA;AACxE;AAGA,SAAS,SAAA,CAAU,IAAA,EAA6B,CAAA,EAA2D;AAC1G,EAAA,GAAA,CAAI,KAAA,IAAS,MAAA,EAAQ,OAAO,CAAC,MAAA,EAAQ,MAAM,CAAA;AAC3C,EAAA,MAAM,EAAA,EAAI,eAAA,CAAgB,IAAI,CAAA;AAC9B,EAAA,GAAA,CAAI,CAAC,CAAA,EAAG,OAAO,CAAC,IAAA,EAAM,IAAI,CAAA;AAC1B,EAAA,MAAM,EAAA,EAAI,WAAA,CAAY,CAAA,CAAE,CAAC,CAAA,EAAG,CAAA,CAAE,CAAC,CAAA,EAAG,CAAC,CAAA;AACnC,EAAA,MAAM,KAAA,EAAO,CAAC,CAAA,EAAqB,CAAA,EAAqB,CAAA,EAAA,GAAgC,CAAC,CAAA,CAAE,CAAC,EAAA,EAAA,CAAK,CAAA,CAAE,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA,EAAA,EAAK,CAAA,EAAG,CAAA,CAAE,CAAC,EAAA,EAAA,CAAK,CAAA,CAAE,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA,EAAA,EAAK,CAAC,CAAA;AAC3I,EAAA,MAAM,GAAA,EAAuB,CAAC,CAAA,EAAG,CAAC,CAAA;AAClC,EAAA,MAAM,GAAA,EAAuB,CAAC,CAAA,CAAE,CAAC,CAAA,EAAG,CAAA,CAAE,CAAC,CAAC,CAAA;AACxC,EAAA,MAAM,GAAA,EAAuB,CAAC,CAAA,CAAE,CAAC,CAAA,EAAG,CAAA,CAAE,CAAC,CAAC,CAAA;AACxC,EAAA,MAAM,GAAA,EAAuB,CAAC,CAAA,EAAG,CAAC,CAAA;AAClC,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACxB,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACxB,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACxB,EAAA,MAAM,GAAA,EAAK,IAAA,CAAK,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AACvB,EAAA,MAAM,GAAA,EAAK,IAAA,CAAK,CAAA,EAAG,CAAA,EAAG,CAAC,CAAA;AACvB,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA;AACxB,EAAA,MAAM,EAAA,EAAI,CAAC,CAAA,EAAA,GAAc,IAAA,CAAK,KAAA,CAAM,EAAA,EAAI,GAAG,EAAA,EAAI,GAAA;AAE/C,EAAA,MAAM,KAAA,EAA8B,IAAA,CAAK,GAAA,CAAI,CAAA,CAAE,CAAC,CAAC,EAAA,EAAI,KAAA,EAAO,SAAA,EAAW,CAAC,CAAA,CAAE,CAAA,CAAE,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAC,CAAA,EAAG,CAAA,CAAE,CAAA,CAAE,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAC,CAAA,EAAG,CAAA,CAAE,EAAA,CAAG,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAC,CAAA,EAAG,CAAA,CAAE,EAAA,CAAG,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAC,CAAC,CAAA;AACxI,EAAA,MAAM,GAAA,EAAK,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA;AAClB,EAAA,MAAM,GAAA,EAAK,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA;AAClB,EAAA,MAAM,MAAA,EAA+B,IAAA,CAAK,GAAA,CAAI,EAAE,EAAA,EAAI,KAAA,EAAO,SAAA,EAAW,CAAC,CAAA,CAAA,CAAG,EAAA,CAAG,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA,EAAA,EAAK,EAAE,CAAA,EAAG,CAAA,CAAA,CAAG,EAAA,CAAG,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA,EAAA,EAAK,EAAE,CAAA,EAAG,CAAA,CAAA,CAAG,CAAA,CAAE,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA,EAAA,EAAK,EAAE,CAAA,EAAG,CAAA,CAAA,CAAG,CAAA,CAAE,CAAC,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA,EAAA,EAAK,EAAE,CAAC,CAAA;AACnK,EAAA,OAAO,CAAC,IAAA,EAAM,KAAK,CAAA;AACpB;AAOO,SAAS,oBAAA,CAA4D,IAAA,EAAS,EAAA,EAAoB;AACxG,EAAA,GAAA,CAAI,CAAC,gBAAA,CAAiB,IAAI,CAAA,EAAG,OAAO,CAAC,IAAA,EAAM,IAAI,CAAA;AAC/C,EAAA,MAAM,KAAA,EAAO,IAAA,CAAK,IAAA;AAClB,EAAA,MAAM,IAAA,EAAM,IAAA;AACZ,EAAA,MAAM,EAAA,EAAI,YAAA,CAAa,IAAA,EAAM,EAAE,CAAA;AAC/B,EAAA,MAAM,EAAA,EAAI,SAAA,CAAU,IAAA,EAAM,EAAE,CAAA;AAC5B,EAAA,MAAM,MAAA,EAAQ,EAAA,GAAK,EAAA,GAAK,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,CAAC,CAAA,CAAE,EAAA,EAAI,EAAE,EAAA,EAAI,GAAA;AACnD,EAAA,IAAI,QAAA;AACJ,EAAA,IAAI,SAAA;AACJ,EAAA,GAAA,CAAI,KAAA,EAAO,UAAA,EAAY,IAAA,CAAK,CAAC,CAAA,CAAE,IAAA;AAAA,EAAA,KAAA,GAAA,CACtB,EAAA,GAAK,EAAA,GAAK,EAAA,EAAI,IAAA,CAAK,OAAA,EAAS,CAAA,EAAG;AACvC,IAAA,MAAM,EAAA,EAAI,IAAA,CAAK,CAAC,CAAA;AAChB,IAAA,CAAC,QAAA,EAAU,SAAS,EAAA,EAAI,SAAA,CAAU,CAAA,CAAE,IAAA,EAAA,CAAO,GAAA,EAAK,CAAA,CAAE,CAAA,EAAA,EAAA,CAAM,IAAA,CAAK,EAAA,EAAI,CAAC,CAAA,CAAE,EAAA,EAAI,CAAA,CAAE,CAAA,CAAE,CAAA;AAAA,EAC7E;AACA,EAAA,MAAM,OAAA,EAAS,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,MAAA,EAAQ,EAAA,EAAI,EAAA,EAAI,CAAC,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,EAAA,GAAA,CAAO,EAAE,GAAG,EAAE,CAAA,CAAE,CAAA;AACrE,EAAA,GAAA,CAAI,MAAA,CAAO,OAAA,GAAU,SAAA,IAAa,KAAA,CAAA,EAAW,MAAA,CAAO,MAAA,CAAO,OAAA,EAAS,CAAC,EAAA,EAAI,QAAA,CAAS,MAAA,CAAO,MAAA,CAAO,OAAA,EAAS,CAAC,CAAA,EAAG,QAAQ,CAAA;AACrH,EAAA,MAAM,KAAA,EAAmB,CAAC,GAAG,MAAA,EAAQ,EAAE,CAAA,EAAG,EAAA,EAAI,EAAE,CAAC,CAAA;AACjD,EAAA,MAAM,MAAA,EAAoB,CAAC,QAAA,CAAS,EAAE,CAAA,EAAG,CAAA,EAAG,EAAE,CAAA,EAAG,SAAS,CAAA,EAAG,GAAG,IAAA,CAAK,KAAA,CAAM,EAAA,EAAI,CAAC,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,EAAA,GAAA,CAAO,EAAE,GAAG,CAAA,EAAG,CAAA,EAAG,CAAA,CAAE,EAAA,EAAI,GAAG,CAAA,CAAE,CAAC,CAAA;AACrH,EAAA,OAAO,CAAC,EAAE,IAAA,EAAM,KAAK,CAAA,EAAQ,EAAE,IAAA,EAAM,MAAM,CAAM,CAAA;AAClD;AAEA,IAAM,SAAA,EAAW,CAAC,GAAA,EAAe,IAAA,EAAA,GAA0C;AAC1E,EAAA,MAAM,EAAE,IAAA,EAAM,IAAA,EAAM,GAAG,KAAK,EAAA,EAAI,GAAA;AAChC,EAAA,OAAO,KAAA,IAAS,KAAA,EAAA,GAAa,KAAA,IAAS,SAAA,EAAW,KAAA,EAAO,EAAE,GAAG,IAAA,EAAM,KAAK,CAAA;AACzE,CAAA;AAGO,SAAS,aAAA,CAAc,IAAA,EAAuC;AACpE,EAAA,MAAM,OAAA,EAAS,CAAC,GAAG,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,EAAA,GAAM,CAAA,CAAE,EAAA,EAAI,CAAA,CAAE,CAAC,CAAA;AACjD,EAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,MAAA,CAAO,MAAA,EAAQ,CAAA,EAAA,EAAK,GAAA,CAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,CAAC,CAAA,CAAE,EAAA,EAAI,MAAA,CAAO,EAAA,EAAI,CAAC,CAAA,CAAE,CAAC,EAAA,EAAI,IAAA,EAAM,MAAM,IAAI,KAAA,CAAM,CAAA,gBAAA,EAAmB,MAAA,CAAO,CAAC,CAAA,CAAE,CAAC,CAAA,gCAAA,CAAkC,CAAA;AAC5K,EAAA,OAAO,MAAA;AACR;AAMO,SAAS,WAAA,CAAY,IAAA,EAAmC,GAAA,EAAe,QAAA,EAAkC;AAC/G,EAAA,MAAM,KAAA,EAAO,gBAAA,CAAiB,IAAI,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,EAAA,GAAM,IAAA,CAAK,GAAA,CAAI,CAAA,CAAE,EAAA,EAAI,GAAA,CAAI,CAAC,EAAA,GAAK,IAAI,EAAA,EAAI,CAAC,CAAA;AAChG,EAAA,GAAA,CAAI,CAAC,gBAAA,CAAiB,IAAI,CAAA,EAAG;AAC5B,IAAA,MAAM,EAAA,mBAAI,IAAA,UAAQ,UAAA;AAClB,IAAA,GAAA,CAAI,GAAA,CAAI,EAAA,IAAM,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,EAAE,CAAA,EAAG,CAAA,EAAG,EAAE,CAAC,CAAA;AAAA,EACvC;AACA,EAAA,OAAO,EAAE,IAAA,EAAM,aAAA,CAAc,CAAC,GAAG,IAAA,EAAM,GAAG,CAAC,EAAE,CAAA;AAC9C;AAGO,SAAS,cAAA,CAAe,IAAA,EAAsB,CAAA,EAA4B;AAChF,EAAA,MAAM,KAAA,EAAO,IAAA,CAAK,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,EAAA,GAAM,IAAA,CAAK,GAAA,CAAI,CAAA,CAAE,EAAA,EAAI,CAAC,EAAA,GAAK,IAAI,CAAA;AAC9D,EAAA,OAAO,IAAA,CAAK,OAAA,EAAS,EAAE,KAAK,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,CAAC,CAAA,CAAE,CAAA;AAC9C;AAMA,IAAM,IAAA,EAAM,CAAC,CAAA,EAAA,GAAc;AAC1B,EAAA,MAAM,EAAA,EAAI,IAAA,CAAK,KAAA,CAAM,EAAA,EAAI,GAAG,EAAA,EAAI,GAAA;AAChC,EAAA,OAAO,MAAA,CAAO,EAAA,CAAG,CAAA,EAAG,CAAA,CAAE,EAAA,EAAI,IAAA,EAAM,MAAA,CAAO,CAAC,CAAA;AACzC,CAAA;AAGA,IAAM,OAAA,EAAS,CAAC,CAAA,EAAA,GAAe,GAAA,CAAI,CAAC,CAAA,CAAE,UAAA,CAAW,GAAG,EAAA,EAAI,GAAA,CAAI,CAAC,EAAA,EAAI,CAAA,CAAA,EAAI,GAAA,CAAI,CAAC,CAAC,CAAA,CAAA;AAMsD;AAC/G,EAAA;AACmB,EAAA;AACN,EAAA;AACQ,EAAA;AACrB,IAAA;AACM,IAAA;AACF,IAAA;AACZ,IAAA;AACe,IAAA;AACK,MAAA;AAC3B,MAAA;AACD,IAAA;AAC8B,IAAA;AACqB,IAAA;AACvB,IAAA;AACSA,MAAAA;AACE,MAAA;AACvC,IAAA;AACD,EAAA;AACuB,EAAA;AACxB;AAOiI;AACxE,EAAA;AACK,EAAA;AACJ,EAAA;AACd,EAAA;AACE,EAAA;AACnB,IAAA;AACI,IAAA;AAC+B,IAAA;AACb,IAAA;AAChD,EAAA;AAC+D,EAAA;AAChE;AFpEiE;AACA;AG5MkC;AAC/C;AACjB,EAAA;AACnC;AACgF;AACvC,EAAA;AACoB,EAAA;AACrD,EAAA;AACR;AAC6K;AACpI,EAAA;AACsB,EAAA;AACD,EAAA;AACI,EAAA;AACD,EAAA;AACzD,EAAA;AACR;AACgE;AACnC,EAAA;AACkC,EAAA;AACvD,EAAA;AACR;AACqH;AAC5E,EAAA;AAC4B,EAAA;AAC7D,EAAA;AACR;AAE2F;AAElB;AAC5C,EAAA;AACN,EAAA;AAC8B,IAAA;AACU,IAAA;AAC/B,IAAA;AAC/B,EAAA;AACsC,EAAA;AACvC;AAMiI;AAC5F,EAAA;AAC0B,EAAA;AACJ,EAAA;AACC,EAAA;AAC9B,IAAA;AACM,IAAA;AACwB,IAAA;AACC,IAAA;AAC3D,EAAA;AAC4D,EAAA;AAC/C,EAAA;AACf;AAG4J;AAC7G,EAAA;AACzB,EAAA;AACuC,EAAA;AAC5B,EAAA;AACjC;AAE0F;AAC7D,EAAA;AACQ,EAAA;AACuB,EAAA;AAC5C,EAAA;AAC8C,IAAA;AACA,IAAA;AACJ,IAAA;AACN,IAAA;AAClD,EAAA;AACF;AAE4F;AAC/D,EAAA;AACQ,EAAA;AACrB,EAAA;AAC8C,IAAA;AACF,IAAA;AACjB,IAAA;AACmB,IAAA;AAC5D,EAAA;AACF;AAEwE;AAChB,EAAA;AACiB,EAAA;AACtC,EAAA;AACP,IAAA;AAC1B,IAAA;AAC6B,IAAA;AACS,IAAA;AACoB,IAAA;AACH,IAAA;AACG,IAAA;AACb,IAAA;AACD,IAAA;AAC5C,EAAA;AACiE,EAAA;AAC3D,EAAA;AACR;AAEqE;AACb,EAAA;AACQ,EAAA;AAClD,EAAA;AACc,IAAA;AACY,IAAA;AACS,IAAA;AACW,IAAA;AACE,IAAA;AACT,IAAA;AACR,IAAA;AACe,IAAA;AACG,IAAA;AACP,IAAA;AACvD,EAAA;AACa,EAAA;AACG,IAAA;AACd,MAAA;AACG,MAAA;AACgC,MAAA;AACmB,MAAA;AACC,MAAA;AACI,MAAA;AACvB,MAAA;AACpC,IAAA;AACW,EAAA;AACG,IAAA;AACd,MAAA;AACG,MAAA;AAC6B,MAAA;AACS,MAAA;AACiB,MAAA;AACF,MAAA;AACF,MAAA;AACE,MAAA;AACxD,IAAA;AACyD,EAAA;AAC5D;AAEwE;AAChB,EAAA;AACW,EAAA;AACnD,EAAA;AACY,IAAA;AAC4C,IAAA;AAChC,IAAA;AACF,IAAA;AACG,IAAA;AACA,IAAA;AACmB,IAAA;AACe,IAAA;AACzE,EAAA;AACF;AAE2E;AACnB,EAAA;AACI,EAAA;AAC5D;AAQgE;AACvD,EAAA;AACmB,EAAA;AACtB,IAAA;AACa,MAAA;AACD,IAAA;AACoD,MAAA;AACpE,IAAA;AACD,EAAA;AAC6C,EAAA;AACmB,EAAA;AACJ,EAAA;AACtB,EAAA;AAC5B,IAAA;AACqB,IAAA;AACiB,IAAA;AACG,IAAA;AACO,IAAA;AACT,IAAA;AACyB,IAAA;AACA,IAAA;AACO,IAAA;AAChF,EAAA;AAC+C,EAAA;AAClB,IAAA;AACP,IAAA;AACwB,MAAA;AAClC,MAAA;AACZ,IAAA;AACD,EAAA;AACgD,EAAA;AACA,EAAA;AACa,EAAA;AACH,EAAA;AACnD,EAAA;AACR;AAc0F;AAC1E,EAAA;AACL,IAAA;AACK,IAAA;AACU,IAAA;AACE,IAAA;AACN,IAAA;AACA,IAAA;AACX,IAAA;AACD,IAAA;AACwD,MAAA;AACA,MAAA;AAChE,IAAA;AACA,EAAA;AACF;AH2KiE;AACA;AIvZ/B;AAEyC;AAGe;AAGA;AACvD,EAAA;AACT,EAAA;AACwB,EAAA;AAClD;AAGkE;AAGE;AACvB,EAAA;AACO,EAAA;AACpD;AAGgG;AAGzB;AAC3C,EAAA;AACqB,EAAA;AAC7B,EAAA;AACiB,EAAA;AACgB,EAAA;AACK,EAAA;AAC1D;AAGuF;AAET;AAC1B,EAAA;AACpD;AAGiE;AACF,EAAA;AAC/D;AAU2F;AAC7B,EAAA;AACrB,IAAA;AACe,IAAA;AACE,IAAA;AACzD,EAAA;AACO,EAAA;AACR;AAEiE;AACH,EAAA;AAC9D;AAGoE;AACzD,EAAA;AACmD,EAAA;AACtD,EAAA;AACR;AAGsF;AACxD,EAAA;AAC2B,EAAA;AACjD,EAAA;AACR;AAGqF;AAC1B,EAAA;AACnD,EAAA;AACR;AAOsH;AACxD,EAAA;AAC1B,EAAA;AACF,EAAA;AACM,EAAA;AACb,EAAA;AACiC,EAAA;AACC,EAAA;AAC7D;AAMsG;AAE5C,EAAA;AAClB,EAAA;AACmB,EAAA;AACtC,EAAA;AACrB;AAUiG;AACnD,EAAA;AACX,EAAA;AACN,EAAA;AACK,IAAA;AACL,MAAA;AACL,MAAA;AACQ,MAAA;AAC9B,IAAA;AAC6D,EAAA;AACC,EAAA;AACxB,EAAA;AACxC;AAGoI;AACzG,EAAA;AACwB,EAAA;AACa,EAAA;AAChE;AASmF;AACxD,EAAA;AACU,EAAA;AACgB,IAAA;AAC1C,IAAA;AACmC,IAAA;AACW,MAAA;AAClB,MAAA;AACrC,IAAA;AACD,EAAA;AACO,EAAA;AACR;AAG0G;AAChE,EAAA;AAClB,IAAA;AACsC,IAAA;AAC7D,EAAA;AACO,EAAA;AACR;AAGmH;AACzG,EAAA;AACgC,EAAA;AAClB,IAAA;AAC0B,IAAA;AACG,IAAA;AAChC,IAAA;AACpB,EAAA;AACO,EAAA;AACR;AAGwG;AACpD,EAAA;AACU,EAAA;AACtD,EAAA;AACR;AJkViE;AACA;AKhhBiD;AACrG,EAAA;AAC4C,IAAA;AACH,IAAA;AACe,IAAA;AACpE,EAAA;AACsC,EAAA;AACvC;AAIiC;AAChC,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;AAe8F;AAQvB;AACxC,EAAA;AACM,EAAA;AACP,EAAA;AACD,EAAA;AAC+B,IAAA;AACP,MAAA;AAChB,EAAA;AACC,IAAA;AACG,IAAA;AAClB,MAAA;AACwB,MAAA;AACjB,MAAA;AACP,QAAA;AACgB,QAAA;AACc,QAAA;AAC5C,QAAA;AACkD,UAAA;AACA,UAAA;AACf,UAAA;AACe,UAAA;AACL,YAAA;AAClD,QAAA;AACsD,MAAA;AAC/B,MAAA;AACsB,MAAA;AACY,QAAA;AACN,MAAA;AACH,QAAA;AACU,MAAA;AAChB,QAAA;AAC9B,QAAA;AACC,UAAA;AACL,YAAA;AACI,YAAA;AACP,YAAA;AAC8C,YAAA;AACjD,UAAA;AACH,MAAA;AACqB,MAAA;AACM,QAAA;AACgB,QAAA;AAC7B,QAAA;AACkC,UAAA;AAC9C,UAAA;AACD,QAAA;AACyD,QAAA;AACH,UAAA;AACrD,UAAA;AACD,QAAA;AAEoC,QAAA;AACc,QAAA;AACF,QAAA;AAC/B,QAAA;AACsC,QAAA;AACV,UAAA;AAChC,UAAA;AACL,YAAA;AACI,YAAA;AACP,YAAA;AAC6C,YAAA;AAChD,UAAA;AACF,QAAA;AACD,MAAA;AACD,IAAA;AACD,EAAA;AAC6D,EAAA;AACrB,EAAA;AACC,EAAA;AACkB,IAAA;AACI,EAAA;AAClB,EAAA;AACkB,EAAA;AAChE;ALyfiE;AACA;AMnlBX;AACrD,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;AACD;AAiB4C;AACA;AAEd;AAAC;AACY;AACf,EAAA;AAC5B;AAEkE;AACvC,EAAA;AAC6B,EAAA;AACxD;AAKW;AAO4H;AAC5H,EAAA;AACE,EAAA;AACsE,IAAA;AACF,IAAA;AAChF,EAAA;AAC4D,EAAA;AACrD,EAAA;AACR;AAGsF;AAC9C,EAAA;AACtB,IAAA;AACoB,IAAA;AACrC,EAAA;AACD;AAE8D;AAGjD;AAEwB,EAAA;AAAjB,IAAA;AAAkB,EAAA;AAAlB,EAAA;AADsB,iBAAA;AAGX,EAAA;AACkB,IAAA;AACa,IAAA;AACrD,IAAA;AACR,EAAA;AAEiB,EAAA;AACmB,IAAA;AAC1B,IAAA;AAC8C,MAAA;AACI,MAAA;AAC3D,IAAA;AACO,IAAA;AACR,EAAA;AAE4B,EAAA;AACgB,IAAA;AAC5C,EAAA;AAE8B,EAAA;AACkB,IAAA;AACa,IAAA;AACrD,IAAA;AACR,EAAA;AAE4F,EAAA;AAClC,IAAA;AAChD,IAAA;AAC+C,MAAA;AACL,MAAA;AACnD,IAAA;AACD,EAAA;AAEoE,EAAA;AACP,IAAA;AACJ,IAAA;AACD,IAAA;AACxD,EAAA;AAAA;AAG+F,EAAA;AAClF,IAAA;AAC4C,IAAA;AACzD,EAAA;AAEkB,EAAA;AACH,IAAA;AACQ,MAAA;AACa,QAAA;AACc,QAAA;AACQ,UAAA;AACZ,UAAA;AAC3C,QAAA;AACsC,QAAA;AACvC,MAAA;AACkB,MAAA;AACwB,QAAA;AACiB,QAAA;AACF,QAAA;AACL,QAAA;AACE,QAAA;AACA,QAAA;AACtD,MAAA;AACqB,MAAA;AACY,QAAA;AACG,QAAA;AACI,QAAA;AACM,QAAA;AACC,QAAA;AACS,QAAA;AACxD,MAAA;AACqB,MAAA;AACY,QAAA;AACmB,QAAA;AACR,QAAA;AACY,QAAA;AACA,QAAA;AACxD,MAAA;AACkB,MAAA;AACwB,QAAA;AACU,QAAA;AACD,QAAA;AACC,QAAA;AACa,QAAA;AAER,QAAA;AACL,QAAA;AAClB,QAAA;AACgB,QAAA;AAClD,MAAA;AACqB,MAAA;AACY,QAAA;AACwB,QAAA;AAC9B,QAAA;AAC6B,QAAA;AACA,QAAA;AACxD,MAAA;AACqB,MAAA;AACY,QAAA;AACb,QAAA;AACoC,QAAA;AACA,QAAA;AACxD,MAAA;AACmB,MAAA;AACc,QAAA;AACqB,QAAA;AACD,QAAA;AACjB,QAAA;AACoB,QAAA;AACxD,MAAA;AACiB,MAAA;AACgB,QAAA;AACb,QAAA;AAC0C,QAAA;AACnC,QAAA;AAC4B,QAAA;AACA,QAAA;AACzB,QAAA;AACK,QAAA;AACL,UAAA;AACS,YAAA;AACS,YAAA;AACW,YAAA;AACH,YAAA;AACF,YAAA;AAC5B,UAAA;AACzB,QAAA;AACqD,QAAA;AACrC,QAAA;AACyB,QAAA;AAC3B,QAAA;AACyC,UAAA;AACF,UAAA;AACT,UAAA;AACO,QAAA;AAC9B,QAAA;AACuB,QAAA;AACS,QAAA;AACtD,MAAA;AACkB,MAAA;AACY,QAAA;AACL,QAAA;AACiC,QAAA;AACzC,QAAA;AACkC,QAAA;AACf,QAAA;AACM,QAAA;AACvB,QAAA;AACK,QAAA;AACe,UAAA;AACd,UAAA;AACxB,QAAA;AACyC,QAAA;AACa,QAAA;AACvD,MAAA;AACkB,MAAA;AACuB,QAAA;AACrB,QAAA;AACc,QAAA;AACJ,QAAA;AACC,QAAA;AACK,QAAA;AACX,QAAA;AACG,UAAA;AACY,UAAA;AACX,UAAA;AACgB,YAAA;AACU,YAAA;AACtB,YAAA;AACC,YAAA;AAChC,UAAA;AAEsD,UAAA;AACxC,UAAA;AACI,YAAA;AACkB,YAAA;AAC7B,UAAA;AAC8C,YAAA;AACN,YAAA;AAC/B,YAAA;AACE,YAAA;AAClB,UAAA;AACM,QAAA;AACuB,UAAA;AACuB,UAAA;AACzB,UAAA;AACS,YAAA;AACK,YAAA;AACS,YAAA;AACN,cAAA;AAC5C,UAAA;AACiD,UAAA;AACM,UAAA;AACvC,UAAA;AACjB,QAAA;AACqD,QAAA;AACtD,MAAA;AACmB,MAAA;AACsB,QAAA;AACrB,QAAA;AACU,QAAA;AACC,QAAA;AAC0B,QAAA;AACF,QAAA;AACD,QAAA;AACR,QAAA;AACpB,QAAA;AACmC,QAAA;AAC/C,QAAA;AACA,QAAA;AAE0B,QAAA;AACE,QAAA;AACe,UAAA;AACX,UAAA;AACV,UAAA;AAClC,QAAA;AAC0B,QAAA;AAC4B,UAAA;AACvD,QAAA;AACgB,QAAA;AACJ,QAAA;AACU,QAAA;AACsB,QAAA;AACa,QAAA;AAC1D,MAAA;AACqB,MAAA;AACiC,QAAA;AACT,QAAA;AACY,QAAA;AACH,UAAA;AACG,UAAA;AACA,UAAA;AACxD,QAAA;AACwD,QAAA;AACzD,MAAA;AACoB,MAAA;AACQ,QAAA;AACL,QAAA;AACS,QAAA;AACsB,QAAA;AAChB,QAAA;AAEmB,QAAA;AAC1C,QAAA;AACwC,QAAA;AACnB,UAAA;AAC6B,UAAA;AAC9C,UAAA;AAC0B,YAAA;AAC1B,YAAA;AACjB,UAAA;AACD,QAAA;AACwD,QAAA;AAET,QAAA;AACF,QAAA;AACN,QAAA;AACsB,QAAA;AACJ,QAAA;AAC9B,QAAA;AACoB,QAAA;AACM,QAAA;AACtD,MAAA;AACuB,MAAA;AACkB,QAAA;AACrB,QAAA;AACoB,QAAA;AACO,QAAA;AACO,QAAA;AACtD,MAAA;AACsB,MAAA;AACmB,QAAA;AACrB,QAAA;AACkC,QAAA;AACC,QAAA;AACP,QAAA;AACgC,QAAA;AACrE,QAAA;AAC2E,QAAA;AACjF,QAAA;AACiB,QAAA;AAE6B,UAAA;AACC,UAAA;AAC5C,QAAA;AAC+C,UAAA;AAC3B,UAAA;AACtB,UAAA;AAC0B,YAAA;AACd,UAAA;AACgB,YAAA;AAChC,UAAA;AACiD,UAAA;AAClD,QAAA;AACe,QAAA;AACsC,UAAA;AACE,UAAA;AACP,UAAA;AACG,QAAA;AAC9B,QAAA;AACgC,QAAA;AACtD,MAAA;AACmB,MAAA;AACc,QAAA;AACb,QAAA;AACV,QAAA;AACgB,QAAA;AACmB,QAAA;AACT,UAAA;AACI,YAAA;AACnB,YAAA;AACnB,UAAA;AACoC,UAAA;AACrC,QAAA;AACuD,QAAA;AACxD,MAAA;AACmB,MAAA;AACmB,QAAA;AACkB,QAAA;AACF,QAAA;AAClC,QAAA;AACmB,QAAA;AACmB,QAAA;AAC1D,MAAA;AACsB,MAAA;AACc,QAAA;AACiB,QAAA;AACZ,QAAA;AACL,QAAA;AACS,QAAA;AACC,QAAA;AACX,QAAA;AACI,QAAA;AACkB,QAAA;AACzD,MAAA;AACsB,MAAA;AACc,QAAA;AACiB,QAAA;AACI,QAAA;AACd,QAAA;AACc,QAAA;AACzD,MAAA;AACwB,MAAA;AACa,QAAA;AACE,QAAA;AACvC,MAAA;AACA,MAAA;AAC6E,QAAA;AAC9E,IAAA;AACD,EAAA;AACD;AAQiH;AACjD,EAAA;AACT,EAAA;AACJ,EAAA;AACZ,EAAA;AAClB,IAAA;AACf,IAAA;AACY,MAAA;AACA,IAAA;AACkB,MAAA;AAC0B,MAAA;AAC5D,IAAA;AACD,EAAA;AACI,EAAA;AACA,EAAA;AAC8B,IAAA;AAClB,EAAA;AACuC,IAAA;AACvD,EAAA;AAC0D,EAAA;AACrB,EAAA;AACuB,EAAA;AACtB,EAAA;AACyB,EAAA;AACJ,EAAA;AAC5D;ANmiBiE;AACA;AOr+B4E;AACpF,EAAA;AACzD;AAG6I;AAC/H,EAAA;AACA,EAAA;AACO,EAAA;AACT,IAAA;AACA,IAAA;AACgB,EAAA;AACkC,IAAA;AACvD,IAAA;AACA,IAAA;AACN,EAAA;AAC8C,EAAA;AACvC,EAAA;AACA,EAAA;AACwB,EAAA;AACC,EAAA;AAC2B,EAAA;AAC5D;AAGqF;AAC1B,EAAA;AAC3D;AASsG;AACjC,EAAA;AACX,EAAA;AAC1D;AAEyD;AAGA;AAChD,EAAA;AACuD,EAAA;AACF,EAAA;AACtD,EAAA;AACR;AAUkE;AAC1C,EAAA;AACqB,EAAA;AACiB,IAAA;AACN,IAAA;AACf,IAAA;AACoB,IAAA;AACE,IAAA;AACpC,IAAA;AAC4B,IAAA;AACO,IAAA;AAClD,IAAA;AACX,EAAA;AACO,EAAA;AACR;AAEuF;AAOT;AACzC,EAAA;AACN,EAAA;AACK,EAAA;AAEqB,EAAA;AACxB,IAAA;AAC0B,IAAA;AAGC,MAAA;AACC,MAAA;AACD,MAAA;AACgD,MAAA;AAEnD,MAAA;AACK,MAAA;AACjB,MAAA;AAEb,MAAA;AAC8B,MAAA;AACH,MAAA;AACA,QAAA;AACH,QAAA;AACrD,MAAA;AAC4C,MAAA;AACW,MAAA;AACC,MAAA;AACpD,MAAA;AACiD,MAAA;AACvC,MAAA;AAC0C,QAAA;AACA,QAAA;AACN,QAAA;AAClD,MAAA;AAC2D,MAAA;AACN,MAAA;AACJ,MAAA;AACQ,MAAA;AACc,MAAA;AAChD,MAAA;AACkC,MAAA;AACC,MAAA;AACZ,MAAA;AACN,MAAA;AACQ,MAAA;AACjD,IAAA;AACD,EAAA;AAC6D,EAAA;AAC9D;APm8BiE;AACA;AQ/nCS;AACjB;AAGwC;AAChE,EAAA;AACT,EAAA;AACC,EAAA;AAC8B,EAAA;AACO,EAAA;AACP,EAAA;AACG,EAAA;AAC1D;AAa+H;AACtG,EAAA;AACA,EAAA;AACmC,EAAA;AACxB,EAAA;AACjB,IAAA;AACQ,IAAA;AACoC,IAAA;AACD,IAAA;AAC5C,IAAA;AACyB,MAAA;AACmB,MAAA;AACvB,MAAA;AACrC,IAAA;AAC0D,IAAA;AAC9C,IAAA;AAC8C,IAAA;AACZ,IAAA;AAC7B,MAAA;AACe,MAAA;AACP,MAAA;AACS,MAAA;AACC,MAAA;AACW,MAAA;AAC3B,MAAA;AACqC,QAAA;AACX,QAAA;AACY,QAAA;AACxD,MAAA;AAC0B,MAAA;AACpB,IAAA;AACkD,MAAA;AACL,MAAA;AAC9C,MAAA;AAC4C,QAAA;AACS,QAAA;AACrC,QAAA;AACrB,MAAA;AACD,IAAA;AACuD,IAAA;AACvD,EAAA;AAC8D,EAAA;AAChE;AAG4G;AAChD,EAAA;AAC5D;AAGoK;AACtG,EAAA;AAClC,EAAA;AACc,EAAA;AAC3B,EAAA;AACF,EAAA;AACiC,EAAA;AACL,EAAA;AACI,EAAA;AACV,EAAA;AACI,IAAA;AACkB,IAAA;AACE,IAAA;AACP,IAAA;AAClD,EAAA;AAC8D,EAAA;AAChE;AAawJ;AAC9I,EAAA;AACgC,EAAA;AACvB,EAAA;AACI,EAAA;AACsB,EAAA;AACV,EAAA;AAChB,IAAA;AACR,IAAA;AACe,IAAA;AACJ,IAAA;AACkC,MAAA;AACV,MAAA;AACe,MAAA;AAC3D,IAAA;AACgB,IAAA;AACyB,MAAA;AAC1B,MAAA;AACsB,MAAA;AAC3B,MAAA;AACV,IAAA;AAC4B,IAAA;AACI,MAAA;AACqB,MAAA;AACL,IAAA;AAC/B,MAAA;AACe,MAAA;AACP,MAAA;AACS,MAAA;AACmB,MAAA;AACM,MAAA;AAC1C,MAAA;AACG,MAAA;AACD,MAAA;AACuC,QAAA;AACb,QAAA;AACzB,QAAA;AACsC,QAAA;AACzD,MAAA;AACkB,MAAA;AACuC,MAAA;AACnD,IAAA;AACwB,MAAA;AACyB,MAAA;AAClD,MAAA;AACY,QAAA;AACoC,QAAA;AAClC,QAAA;AACF,QAAA;AACsC,QAAA;AACvD,MAAA;AACD,IAAA;AACY,IAAA;AACb,EAAA;AACY,EAAA;AACb;ARmmCiE;AACA;ASpvCsB;AAQE;AACvB,EAAA;AACP,EAAA;AACtC,EAAA;AACkC,EAAA;AAClB,EAAA;AACA,EAAA;AACW,EAAA;AACY,EAAA;AAC/C,EAAA;AACqC,EAAA;AACkC,EAAA;AAElF,EAAA;AAC2C,EAAA;AACgB,IAAA;AACJ,IAAA;AAC1B,IAAA;AAC+B,IAAA;AAEE,IAAA;AACP,IAAA;AACK,IAAA;AACjC,IAAA;AACb,IAAA;AACF,IAAA;AACI,MAAA;AAC8C,MAAA;AACH,MAAA;AACzB,MAAA;AACoB,MAAA;AACG,MAAA;AACE,IAAA;AACF,IAAA;AACE,IAAA;AACA,IAAA;AACC,IAAA;AAC/C,IAAA;AACd,EAAA;AACsB,EAAA;AACvB;AAyCwD;AACK;AAI1B;AAEN;AACkC,EAAA;AACd,EAAA;AACc,EAAA;AACD,EAAA;AAC9D;AAC0E;AAGjC;AACjB,EAAA;AACf,EAAA;AACM,EAAA;AACM,IAAA;AACd,IAAA;AACN,EAAA;AACgB,EAAA;AACM,IAAA;AAChB,IAAA;AACN,EAAA;AAC8D,EAAA;AACvD,EAAA;AACR;AAQ0G;AAChF,EAAA;AACW,EAAA;AACE,EAAA;AACV,EAAA;AACA,EAAA;AACC,EAAA;AACT,EAAA;AACQ,EAAA;AAC6C,EAAA;AAChD,EAAA;AACf,EAAA;AACS,EAAA;AACc,EAAA;AACzB,EAAA;AAEY,EAAA;AACkF,EAAA;AACxD,IAAA;AACgB,IAAA;AAC9D,EAAA;AACsG,EAAA;AACpE,IAAA;AAC4B,IAAA;AACzB,IAAA;AACmB,IAAA;AACM,IAAA;AACtC,IAAA;AACxB,EAAA;AAEmD,EAAA;AACN,IAAA;AACS,MAAA;AAC5B,MAAA;AACY,MAAA;AACP,MAAA;AAC8B,MAAA;AACtB,MAAA;AACqB,QAAA;AACzD,QAAA;AACD,MAAA;AACI,MAAA;AACkD,MAAA;AACK,MAAA;AACH,MAAA;AACL,QAAA;AAChB,QAAA;AACa,QAAA;AACE,QAAA;AACO,QAAA;AACD,QAAA;AACD,QAAA;AACD,QAAA;AACG,QAAA;AACL,QAAA;AACG,QAAA;AACH,QAAA;AACR,QAAA;AACY,QAAA;AACxB,QAAA;AAChC,MAAA;AAC4C,MAAA;AACY,MAAA;AACpC,MAAA;AAEgC,MAAA;AACc,MAAA;AAClD,MAAA;AACsC,MAAA;AACN,MAAA;AAEO,QAAA;AAC9B,QAAA;AACQ,MAAA;AACjB,QAAA;AAC0C,QAAA;AACH,QAAA;AACnB,QAAA;AACf,UAAA;AACoC,UAAA;AACA,UAAA;AACxD,QAAA;AACkC,QAAA;AACrB,QAAA;AACwB,UAAA;AACa,UAAA;AACE,UAAA;AACR,UAAA;AAClC,UAAA;AACA,UAAA;AACM,UAAA;AACwC,UAAA;AACP,UAAA;AACjD,QAAA;AAC8C,QAAA;AACU,QAAA;AAClD,QAAA;AACN,QAAA;AACD,MAAA;AACqD,MAAA;AAEQ,MAAA;AAEF,MAAA;AACF,MAAA;AAChC,MAAA;AACrB,MAAA;AACsD,MAAA;AACrD,MAAA;AACc,QAAA;AACgC,QAAA;AACnD,MAAA;AACqC,MAAA;AACiB,QAAA;AACI,QAAA;AAC1D,MAAA;AAC4B,MAAA;AACmB,MAAA;AACU,MAAA;AACE,MAAA;AACF,MAAA;AACA,MAAA;AACX,MAAA;AACvB,QAAA;AACA,QAAA;AACoB,QAAA;AACc,QAAA;AAC7C,QAAA;AACZ,MAAA;AACyB,MAAA;AACgC,MAAA;AACT,MAAA;AACX,MAAA;AACJ,MAAA;AACC,MAAA;AACS,MAAA;AACnB,QAAA;AAC6B,QAAA;AACrD,MAAA;AACyD,MAAA;AACC,MAAA;AACpD,MAAA;AACkD,MAAA;AACT,MAAA;AACnB,QAAA;AACV,QAAA;AACqC,QAAA;AAC3B,QAAA;AACrB,QAAA;AACP,MAAA;AACD,IAAA;AACD,EAAA;AACyC,EAAA;AACoB,EAAA;AACC,EAAA;AAClD,IAAA;AACC,EAAA;AAC+C,IAAA;AAC/B,EAAA;AACO,EAAA;AACvB,EAAA;AACZ,IAAA;AACe,IAAA;AACf,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACsD,IAAA;AACtD,IAAA;AACsB,qBAAA;AACiC,IAAA;AACvD,IAAA;AACA,IAAA;AACA,IAAA;AACU,IAAA;AACV,IAAA;AACK,IAAA;AACa,qBAAA;AACnB,EAAA;AAC4D,EAAA;AAC7D;ATkrCiE;AACA;AU37CjC;AAa7B,EAAA;AAHgB,IAAA;AAEA,IAAA;AAEH,IAAA;AACiD,IAAA;AACR,IAAA;AAC3C,IAAA;AACb,EAAA;AARkB,EAAA;AAEA,EAAA;AAXD,EAAA;AACA,EAAA;AAC+B,kBAAA;AACa,kBAAA;AAC5C,EAAA;AACG,kBAAA;AACA,kBAAA;AAa4C,EAAA;AACb,IAAA;AACZ,IAAA;AACzB,IAAA;AACI,IAAA;AAC4C,IAAA;AACtD,IAAA;AACR,EAAA;AAE8C,EAAA;AAChB,IAAA;AACnB,IAAA;AACiD,MAAA;AACV,MAAA;AACtC,MAAA;AACc,MAAA;AACzB,IAAA;AACO,IAAA;AACR,EAAA;AAEyE,EAAA;AACpC,IAAA;AACS,IAAA;AACV,IAAA;AACA,IAAA;AACD,IAAA;AACM,IAAA;AACI,IAAA;AAChC,IAAA;AAC8B,IAAA;AAChB,IAAA;AACnB,IAAA;AACR,EAAA;AAE8B,EAAA;AACX,IAAA;AACqC,IAAA;AACI,IAAA;AACxB,oBAAA;AAChB,oBAAA;AACS,IAAA;AAC7B,EAAA;AAAA;AAGe,EAAA;AACM,IAAA;AACoC,IAAA;AAChB,IAAA;AACY,IAAA;AACrB,IAAA;AAC8F,IAAA;AAChF,MAAA;AAC7B,MAAA;AACa,MAAA;AAC2B,MAAA;AACtB,MAAA;AAC5B,MAAA;AACkB,QAAA;AACsB,QAAA;AAC7C,MAAA;AACa,MAAA;AAC2C,QAAA;AACF,QAAA;AAAc,QAAA;AAC7D,MAAA;AACyB,QAAA;AACqB,QAAA;AACrD,MAAA;AACD,IAAA;AAC4D,IAAA;AAC1B,IAAA;AACiB,MAAA;AACb,MAAA;AACoB,MAAA;AAC/B,MAAA;AAC3B,IAAA;AAEuD,IAAA;AACG,IAAA;AAC3C,IAAA;AAChB,EAAA;AAEiB,EAAA;AACsB,IAAA;AACrB,IAAA;AACI,IAAA;AACH,MAAA;AACuC,MAAA;AACxD,IAAA;AACF,EAAA;AAEqE,kBAAA;AACtB,IAAA;AACb,MAAA;AAC8B,MAAA;AAC/D,IAAA;AACuC,IAAA;AACuD,IAAA;AAC/F,EAAA;AAEgC,EAAA;AAChB,IAAA;AACgC,IAAA;AACI,IAAA;AACQ,IAAA;AACD,IAAA;AACC,IAAA;AACV,IAAA;AACE,IAAA;AACO,IAAA;AAC9B,oBAAA;AAC7B,EAAA;AAAA;AAGgB,EAAA;AACE,IAAA;AACA,IAAA;AAC0C,IAAA;AACzC,IAAA;AACnB,EAAA;AACD;AVm7CiE;AACA;AWhkD9B;AACzB,EAAA;AACA,EAAA;AACD,EAAA;AACA,EAAA;AACuB,kBAAA;AACvB,EAAA;AACuB,kBAAA;AACZ,kBAAA;AACA,mBAAA;AACF,EAAA;AACuD,mBAAA;AACtB,mBAAA;AAC1C,EAAA;AAEsC,EAAA;AACQ,IAAA;AACM,IAAA;AACf,IAAA;AACR,IAAA;AACZ,IAAA;AACkC,IAAA;AAC3B,IAAA;AAClB,IAAA;AACd,EAAA;AAEkB,EAAA;AACc,IAAA;AACZ,IAAA;AACuB,MAAA;AACZ,MAAA;AACI,QAAA;AAChC,MAAA;AACF,IAAA;AACD,EAAA;AAAA;AAGgB,EAAA;AACG,IAAA;AAC6B,IAAA;AAChD,EAAA;AAEwC,mBAAA;AAEmC,mBAAA;AAC7D,IAAA;AACc,IAAA;AACd,IAAA;AACkB,MAAA;AAC/B,IAAA;AACD,EAAA;AAAA;AAG2B,EAAA;AACd,IAAA;AACb,EAAA;AAEyC,EAAA;AACT,IAAA;AACC,IAAA;AACzB,IAAA;AACQ,MAAA;AAC6B,MAAA;AACnC,MAAA;AACG,MAAA;AACH,MAAA;AACQ,MAAA;AACyC,MAAA;AAC5C,MAAA;AACE,MAAA;AACH,MAAA;AACD,MAAA;AACA,MAAA;AACE,MAAA;AACA,MAAA;AACE,MAAA;AAChB,IAAA;AACD,EAAA;AAEiB,EAAA;AACX,IAAA;AAC0B,IAAA;AACZ,IAAA;AACkB,IAAA;AAChC,MAAA;AACW,QAAA;AACC,MAAA;AACD,QAAA;AACf,MAAA;AACA,IAAA;AACF,EAAA;AAE0C,EAAA;AACzB,IAAA;AACZ,IAAA;AACY,MAAA;AACwB,MAAA;AACmB,MAAA;AACN,MAAA;AACI,MAAA;AACvD,IAAA;AACe,MAAA;AACjB,IAAA;AACY,IAAA;AACW,oBAAA;AACxB,EAAA;AAEkD,EAAA;AAC7B,IAAA;AACP,IAAA;AACQ,IAAA;AACR,MAAA;AACZ,MAAA;AACD,IAAA;AACyB,IAAA;AAClB,MAAA;AACD,MAAA;AAC8B,MAAA;AACD,MAAA;AACF,MAAA;AACjC,IAAA;AAC4B,IAAA;AAC7B,EAAA;AAEiD,EAAA;AACnC,IAAA;AACD,IAAA;AACuB,IAAA;AACpC,EAAA;AAAA;AAGgC,EAAA;AAC4B,IAAA;AAC5D,EAAA;AAAA;AAAA;AAAA;AAAA;AAM0H,EAAA;AACtF,IAAA;AACE,IAAA;AACa,IAAA;AACW,IAAA;AACP,IAAA;AAC/C,IAAA;AACR,EAAA;AAAA;AAGgG,EAAA;AAC3F,IAAA;AACA,IAAA;AAC+B,MAAA;AACnB,IAAA;AAC4B,MAAA;AAC5C,IAAA;AAC0B,IAAA;AACN,MAAA;AACN,MAAA;AACK,MAAA;AACI,MAAA;AACJ,MAAA;AACuC,MAAA;AAC1D,IAAA;AAC2D,IAAA;AAC5D,EAAA;AAEqC,EAAA;AACJ,IAAA;AAChB,IAAA;AACJ,IAAA;AACb,EAAA;AAAA;AAG+E,EAAA;AACvB,IAAA;AACnD,IAAA;AACa,IAAA;AACS,MAAA;AAC+B,MAAA;AACzB,IAAA;AAC6B,IAAA;AAC5C,IAAA;AACL,IAAA;AACb,EAAA;AAEa,EAAA;AACK,IAAA;AAClB,EAAA;AAEc,EAAA;AACK,IAAA;AACnB,EAAA;AAEe,EAAA;AACK,IAAA;AACpB,EAAA;AAAA;AAGyB,EAAA;AACH,IAAA;AACtB,EAAA;AAAA;AAG2B,EAAA;AACR,IAAA;AACO,IAAA;AACiC,IAAA;AAC3D,EAAA;AAEuC,EAAA;AACd,IAAA;AACzB,EAAA;AAEgB,EAAA;AACU,IAAA;AAC1B,EAAA;AAEgB,EAAA;AACU,IAAA;AAC1B,EAAA;AAAA;AAAA;AAAA;AAAA;AAMmJ,EAAA;AACzF,IAAA;AACD,IAAA;AACP,IAAA;AACO,IAAA;AACzD,EAAA;AAAA;AAGmC,EAAA;AACuB,IAAA;AACE,IAAA;AACC,IAAA;AACrB,IAAA;AACW,IAAA;AACnD,EAAA;AAAA;AAGqE,EAAA;AACrB,IAAA;AACS,IAAA;AACzD,EAAA;AAAA;AAGkD,EAAA;AACX,IAAA;AACvC,EAAA;AAAA;AAG6E,EAAA;AAC1B,IAAA;AACnD,EAAA;AAAA;AAG8D,EAAA;AACnB,IAAA;AAC3C,EAAA;AAAA;AAG6C,EAAA;AACL,IAAA;AACxC,EAAA;AACD;AXmiDiE;AACA;AYr1DN;AACE;AACI;AACC;AAE0E;AACrI,EAAA;AAC+B,EAAA;AACrC,EAAA;AACsC,EAAA;AAChB,EAAA;AACvB;AAEyE;AACZ;AACC;AACA;AACG;AAClB;AAE7B;AACjB,EAAA;AACmC,IAAA;AACA,IAAA;AACgB,IAAA;AACT,IAAA;AAC1C,EAAA;AACC,EAAA;AACD,EAAA;AACD;AACmB;AAClB,EAAA;AAC4D,IAAA;AACV,IAAA;AACpC,IAAA;AAC+C,IAAA;AAC7D,EAAA;AACmB,EAAA;AACnB,EAAA;AACD;AACgD;AACc,EAAA;AACV,EAAA;AACxC,EAAA;AACgD,EAAA;AAChD,EAAA;AACsC,EAAA;AAClD;AACiD;AACE,EAAA;AACG,EAAA;AACU,EAAA;AACxB,EAAA;AACxC;AACgD;AACI,EAAA;AACT,EAAA;AACoB,EAAA;AACnB,EAAA;AACgB,EAAA;AACH,EAAA;AACzD;AAC8C;AACI,EAAA;AACvC,EAAA;AACoD,EAAA;AAC/D;AAC6B;AACf,EAAA;AACN,EAAA;AACwC,IAAA;AACD,IAAA;AACC,IAAA;AAC/C,EAAA;AACD;AAC6C;AACS,EAAA;AACC,EAAA;AACC,EAAA;AACG,EAAA;AACxC,EAAA;AACnB;AACiD;AACI,EAAA;AACb,EAAA;AACC,EAAA;AACA,EAAA;AACuB,EAAA;AAChE;AACiD;AACE,EAAA;AACb,EAAA;AACyB,EAAA;AACH,EAAA;AACC,EAAA;AAClB,EAAA;AACH,EAAA;AACxC;AAEqB;AACd,EAAA;AAC6B,EAAA;AACtB,EAAA;AACd;AACyB;AAClB,EAAA;AACI,EAAA;AACA,EAAA;AACG,EAAA;AACN,EAAA;AACN,IAAA;AACmC,MAAA;AACA,MAAA;AAC5B,MAAA;AACQ,QAAA;AACN,QAAA;AACwC,UAAA;AACQ,UAAA;AACvD,QAAA;AACD,MAAA;AACD,IAAA;AACS,IAAA;AACT,IAAA;AACD,EAAA;AACD;AAEyD;AACH,EAAA;AACI,EAAA;AACN,EAAA;AACG,EAAA;AACvD;AAG4D;AACpD,EAAA;AACuC,IAAA;AAC9B,IAAA;AACC,IAAA;AACH,IAAA;AACyB,IAAA;AACwB,IAAA;AAC9C,IAAA;AAC0B,IAAA;AACnC,MAAA;AACE,MAAA;AACR,MAAA;AACiC,MAAA;AACA,MAAA;AACE,MAAA;AACA,MAAA;AACkB,MAAA;AACD,MAAA;AACnD,IAAA;AAC2B,IAAA;AAC9B,EAAA;AACD;AAUkJ;AACrG,EAAA;AACgB,IAAA;AACjB,IAAA;AACT,IAAA;AAClC,EAAA;AAC4C,EAAA;AACsC,EAAA;AACrC,IAAA;AACa,IAAA;AAC1D,EAAA;AACiJ,EAAA;AACpG,IAAA;AACiB,IAAA;AAC9D,EAAA;AACiD,EAAA;AACtB,IAAA;AACgB,MAAA;AACgB,MAAA;AAC1D,IAAA;AACO,IAAA;AACR,EAAA;AAEoF,EAAA;AAC1D,IAAA;AACqF,IAAA;AAClD,IAAA;AAC7D,EAAA;AAE8B,EAAA;AACvB,IAAA;AACC,IAAA;AAEN,IAAA;AACqB,IAAA;AACkC,IAAA;AACzC,IAAA;AACoB,MAAA;AACJ,MAAA;AAC6B,MAAA;AAC5D,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACkB,IAAA;AACyB,IAAA;AACjC,IAAA;AACmC,MAAA;AACF,MAAA;AACxD,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AAC4C,IAAA;AACd,IAAA;AACnB,IAAA;AACoC,MAAA;AACZ,MAAA;AAChD,IAAA;AACD,EAAA;AAEgC,EAAA;AACzB,IAAA;AACC,IAAA;AACM,IAAA;AACS,IAAA;AACmC,IAAA;AAC1C,IAAA;AACuB,MAAA;AACI,MAAA;AAC1C,IAAA;AACD,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AAEN,IAAA;AACmB,IAAA;AACmC,MAAA;AACI,MAAA;AACN,MAAA;AACJ,MAAA;AAChD,IAAA;AAC+C,IAAA;AACa,IAAA;AAC1C,MAAA;AACiC,MAAA;AAC7B,MAAA;AACmB,QAAA;AACe,QAAA;AACH,QAAA;AACE,QAAA;AACvD,MAAA;AACa,MAAA;AACd,IAAA;AACD,EAAA;AAEgC,EAAA;AACzB,IAAA;AACC,IAAA;AACM,IAAA;AACO,IAAA;AACkC,MAAA;AACJ,MAAA;AACjD,IAAA;AACyD,IAAA;AACF,IAAA;AACzD,EAAA;AAE6B,EAAA;AACtB,IAAA;AACC,IAAA;AACM,IAAA;AACO,IAAA;AACgB,MAAA;AACG,MAAA;AACtC,IAAA;AAC8C,IAAA;AACe,IAAA;AAC/D,EAAA;AAEwB,EAAA;AACjB,IAAA;AACC,IAAA;AACM,IAAA;AACO,IAAA;AACW,MAAA;AACG,MAAA;AACoB,MAAA;AACf,MAAA;AACtC,IAAA;AAC0D,IAAA;AAChB,IAAA;AACxB,MAAA;AACM,MAAA;AACE,QAAA;AAC4B,QAAA;AAC9B,QAAA;AACS,MAAA;AACuB,QAAA;AACF,QAAA;AAC/B,QAAA;AAC4B,MAAA;AAChB,MAAA;AACE,MAAA;AACH,MAAA;AACuB,MAAA;AAC1D,IAAA;AACD,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AACM,IAAA;AACe,IAAA;AACwB,IAAA;AACjC,IAAA;AAC6B,MAAA;AACD,MAAA;AAC/C,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AAC6C,IAAA;AACd,IAAA;AACwB,IAAA;AACrE,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AACM,IAAA;AACsC,IAAA;AACF,IAAA;AACA,IAAA;AAClD,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AACM,IAAA;AACqC,IAAA;AACG,IAAA;AACQ,IAAA;AAC9D,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACwC,IAAA;AACP,IAAA;AACQ,IAAA;AACvD,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AACM,IAAA;AACsC,IAAA;AACS,IAAA;AAE/C,IAAA;AAC4C,MAAA;AACT,MAAA;AAC9C,IAAA;AACH,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AACM,IAAA;AACqC,IAAA;AACK,IAAA;AACM,IAAA;AAC9D,EAAA;AAE6B,EAAA;AACtB,IAAA;AACC,IAAA;AACM,IAAA;AACsC,IAAA;AACU,IAAA;AACA,IAAA;AAC9D,EAAA;AAE2B,EAAA;AACpB,IAAA;AACC,IAAA;AAEN,IAAA;AACsD,IAAA;AACV,IAAA;AACK,IAAA;AACnD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AACM,IAAA;AACkC,IAAA;AACU,IAAA;AACpB,IAAA;AAC4B,MAAA;AACV,MAAA;AACvD,IAAA;AACD,EAAA;AAE4B,EAAA;AACrB,IAAA;AACC,IAAA;AAEN,IAAA;AAC4D,IAAA;AACJ,IAAA;AACJ,IAAA;AACtD,EAAA;AAE6B,EAAA;AACtB,IAAA;AACC,IAAA;AACM,IAAA;AAC+B,IAAA;AACG,IAAA;AACU,IAAA;AAC1D,EAAA;AAE+B,EAAA;AACxB,IAAA;AACC,IAAA;AACM,IAAA;AACwC,IAAA;AACA,IAAA;AACO,IAAA;AAC7D,EAAA;AAE8B,EAAA;AACvB,IAAA;AACC,IAAA;AAEN,IAAA;AACY,IAAA;AACZ,MAAA;AACO,QAAA;AACkD,QAAA;AACC,QAAA;AAC1D,MAAA;AACO,MAAA;AACR,IAAA;AAC2D,IAAA;AACvB,IAAA;AACI,MAAA;AACnB,MAAA;AACc,QAAA;AACqB,QAAA;AACA,QAAA;AACvD,MAAA;AACuD,MAAA;AACxD,IAAA;AACD,EAAA;AAEiC,EAAA;AAC1B,IAAA;AACC,IAAA;AAEN,IAAA;AACsD,IAAA;AACV,IAAA;AACE,IAAA;AAChD,EAAA;AAEgC,EAAA;AACzB,IAAA;AACC,IAAA;AAEN,IAAA;AAC4D,IAAA;AACT,IAAA;AACD,IAAA;AACpD,EAAA;AAEmC,EAAA;AAC5B,IAAA;AACC,IAAA;AAEN,IAAA;AAC2D,IAAA;AACD,IAAA;AACd,IAAA;AAC9C,EAAA;AAE6B,EAAA;AACtB,IAAA;AACC,IAAA;AACM,IAAA;AACmC,IAAA;AACQ,IAAA;AACA,IAAA;AACzD,EAAA;AAE6B,EAAA;AACtB,IAAA;AACC,IAAA;AACM,IAAA;AAC6C,IAAA;AACX,IAAA;AACyB,IAAA;AACzE,EAAA;AAEgC,EAAA;AACzB,IAAA;AACC,IAAA;AACM,IAAA;AAC8C,IAAA;AACH,IAAA;AACK,IAAA;AAC9D,EAAA;AAE4B,EAAA;AACrB,IAAA;AACkD,IAAA;AACC,IAAA;AAC1C,IAAA;AAChB,EAAA;AAE6B,EAAA;AACtB,IAAA;AACC,IAAA;AAEN,IAAA;AAC2D,IAAA;AACC,IAAA;AACtB,IAAA;AACqC,MAAA;AACP,MAAA;AACd,MAAA;AACvD,IAAA;AACD,EAAA;AAEwB,EAAA;AACjB,IAAA;AACC,IAAA;AACM,IAAA;AACS,IAAA;AAC8B,IAAA;AACrC,IAAA;AACsB,MAAA;AACL,MAAA;AACe,MAAA;AAC/C,IAAA;AACD,EAAA;AAEwB,EAAA;AACjB,IAAA;AACC,IAAA;AACM,IAAA;AACS,IAAA;AAC8B,IAAA;AACrC,IAAA;AACsB,MAAA;AACL,MAAA;AACe,MAAA;AAC/C,IAAA;AACD,EAAA;AAE6D,EAAA;AACT,EAAA;AAE7C,EAAA;AACY,IAAA;AACC,IAAA;AAC0C,IAAA;AACtC,IAAA;AACI,MAAA;AACQ,MAAA;AACN,QAAA;AACT,QAAA;AACV,UAAA;AACA,UAAA;AACR,QAAA;AACA,MAAA;AACF,IAAA;AACD,EAAA;AACD;AZ0xDiE;AACA;Aax3EQ;AAG6B;AACvE,EAAA;AAC8B,EAAA;AAGjC,EAAA;AACL,EAAA;AACW,EAAA;AACgB,EAAA;AACU,IAAA;AACA,IAAA;AAC3D,EAAA;AACO,EAAA;AACR;AAG2E;AACzC,EAAA;AACM,EAAA;AACpB,EAAA;AACwC,EAAA;AAC5D;AAG4E;AAChD,EAAA;AAC6B,EAAA;AACjB,EAAA;AACsB,EAAA;AAC5B,EAAA;AAC8B,EAAA;AAChE;AAgC8G;AACjE,EAAA;AAC5B,EAAA;AACQ,EAAA;AACmB,EAAA;AAChB,EAAA;AACsB,IAAA;AAEvB,IAAA;AAC4B,IAAA;AACO,IAAA;AACJ,IAAA;AACpB,IAAA;AACiB,IAAA;AACG,IAAA;AACzD,EAAA;AAC+B,EAAA;AACE,EAAA;AACwB,IAAA;AACZ,IAAA;AACe,IAAA;AACF,IAAA;AAC1D,EAAA;AAC4D,EAAA;AACR,EAAA;AACU,EAAA;AAC/D;AAGiF;AACxC,EAAA;AACvB,EAAA;AACuC,EAAA;AACzD;AAGiK;AAChH,EAAA;AAC7B,EAAA;AAC6B,EAAA;AACY,EAAA;AACzC,EAAA;AAC2C,EAAA;AAC/D;AAW4E;AAEnB,EAAA;AACM,EAAA;AAChC,EAAA;AACA,EAAA;AACvB,EAAA;AACR;AAM8G;AACrE,EAAA;AACsB,IAAA;AACN,IAAA;AACd,IAAA;AACtB,MAAA;AACL,MAAA;AACd,IAAA;AACqB,IAAA;AACF,MAAA;AACyC,MAAA;AAC5D,IAAA;AACsB,IAAA;AACC,MAAA;AACmC,MAAA;AAC3C,MAAA;AACJ,MAAA;AACV,MAAA;AACD,IAAA;AACsC,IAAA;AACzB,IAAA;AACe,IAAA;AACgC,MAAA;AACjD,MAAA;AACmD,MAAA;AAC9D,IAAA;AACa,IAAA;AACJ,IAAA;AACT,EAAA;AACF;AAMmF;AAC1C,EAAA;AACF,EAAA;AACb,EAAA;AACA,EAAA;AACW,EAAA;AACG,EAAA;AACxC;AAsBE;AAEiF;AAMwB;AAClE,EAAA;AACvB,EAAA;AACA,EAAA;AAC4C,EAAA;AACC,EAAA;AACJ,EAAA;AAC6C,EAAA;AAC7D,EAAA;AACN,EAAA;AACK,IAAA;AACW,IAAA;AACN,IAAA;AACtC,IAAA;AACN,MAAA;AACsB,MAAA;AACgC,MAAA;AACjC,MAAA;AACV,MAAA;AAC2C,MAAA;AACtD,MAAA;AACsD,MAAA;AACA,MAAA;AACvD,IAAA;AACA,EAAA;AACF;AAGgH;AACvC,EAAA;AACV,IAAA;AACoB,EAAA;AAChC,EAAA;AACU,EAAA;AAC7D;AAM6H;AACpF,EAAA;AACxB,EAAA;AAC2B,EAAA;AACiB,EAAA;AAC5B,EAAA;AAC4B,EAAA;AAC7D;AAMgH;AACvE,EAAA;AACxB,EAAA;AAC2B,EAAA;AACL,EAAA;AACW,EAAA;AACrB,EAAA;AAC8B,IAAA;AAChD,IAAA;AAC+B,MAAA;AACgB,MAAA;AACxD,IAAA;AACD,EAAA;AACwC,EAAA;AACoB,EAAA;AAC7D;AbkxEiE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;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,"/**\n * A video project is plain JSON: the media it uses (assets), tracks of clips placed on a timeline,\n * and markers. Every time is in milliseconds; `fps` sets the frame grid edits snap to and exports use.\n *\n * Tracks are listed top to bottom as a timeline shows them: on screen, a clip on an earlier video\n * track draws over one on a later track. Clips on one track never overlap.\n */\n\nimport type { VideoAnimatable } from './keyframes.js';\n\nexport type VideoAssetKind = 'video' | 'audio' | 'image';\n\n/** A piece of source media. The project refers to it by id; `src` is whatever the host can load. */\nexport interface VideoAsset {\n\tid: string;\n\tkind: VideoAssetKind;\n\t/** URL, blob URL, path or data URL (whatever the playback engine and exporter can open). */\n\tsrc: string;\n\tname?: string;\n\t/** Length of the source (ms). Required for video and audio; images have none. */\n\tduration?: number;\n\t/** Intrinsic size in pixels (video and image). Placement assumes the project size when missing. */\n\twidth?: number;\n\theight?: number;\n\t/** Whether a video asset has sound (default true). */\n\thasAudio?: boolean;\n\t/** A still to show for a video where its frames cannot be drawn (SVG renders, thumbnails). */\n\tposter?: string;\n}\n\nexport type VideoTrackKind = 'video' | 'audio';\n\nexport interface VideoTrack {\n\tid: string;\n\tkind: VideoTrackKind;\n\tname?: string;\n\t/** Silences every clip on the track. */\n\tmuted?: boolean;\n\t/** Leaves the track's pictures out of the frame. */\n\thidden?: boolean;\n\t/** Refuses edits to the track's clips. */\n\tlocked?: boolean;\n\t/** Gain applied to every clip on the track (1 = unchanged, default 1). */\n\tvolume?: number;\n\tclips: VideoClip[];\n}\n\n/** Where a picture sits in the frame, after it has been fitted. Each field may be keyframed. */\nexport interface VideoTransform {\n\t/** Offset of the centre from the frame's centre, in pixels (default 0). */\n\tx?: VideoAnimatable;\n\ty?: VideoAnimatable;\n\t/** 1 = fitted size (default 1). */\n\tscale?: VideoAnimatable;\n\t/** Degrees clockwise (default 0). */\n\trotation?: VideoAnimatable;\n}\n\n/** How a picture is fitted to the frame before its transform: like CSS `object-fit`. */\nexport type VideoFit = 'contain' | 'cover' | 'fill' | 'none';\nexport const VIDEO_FITS = ['contain', 'cover', 'fill', 'none'] as const;\n\nexport type VideoTransitionType = 'crossfade' | 'dip' | 'wipe';\nexport const VIDEO_TRANSITION_TYPES = ['crossfade', 'dip', 'wipe'] as const;\nexport type VideoWipeDirection = 'left' | 'right' | 'up' | 'down';\nexport const VIDEO_WIPE_DIRECTIONS = ['left', 'right', 'up', 'down'] as const;\n\n/**\n * A transition into a clip from the clip that ends where it starts on the same track. It plays over\n * the cut, half before and half after, so both clips run past the cut into their handles (media\n * beyond the out point of the one before, before the in point of this one).\n */\nexport interface VideoTransition {\n\ttype: VideoTransitionType;\n\t/** Length (ms), centred on the cut. */\n\tduration: number;\n\t/** For dip: the colour passed through (default black). */\n\tcolor?: string;\n\t/** For wipe: the way the edge travels (default right, revealing from the left). */\n\tdirection?: VideoWipeDirection;\n}\n\ninterface VideoClipBase {\n\tid: string;\n\tname?: string;\n\t/** Where it starts on the timeline (ms). */\n\tstart: number;\n\t/** How long it lasts on the timeline (ms). */\n\tduration: number;\n\t/** 0–1 (default 1), or keyframes (`{ keys: [{ t, v, ease }] }`, `t` in ms from the clip's start). */\n\topacity?: VideoAnimatable;\n\ttransform?: VideoTransform;\n\t/** Default contain. */\n\tfit?: VideoFit;\n\t/** Fade in from nothing over this long (ms), pictures and sound. */\n\tfadeIn?: number;\n\t/** Fade out over this long (ms). */\n\tfadeOut?: number;\n\t/** Transition from the clip before (see `VideoTransition`). */\n\ttransition?: VideoTransition;\n}\n\n/** A stretch of an asset. */\nexport interface VideoMediaClip extends VideoClipBase {\n\ttype: 'media';\n\tasset: string;\n\t/** Where in the source it starts (ms, default 0). */\n\tin?: number;\n\t/** Playback speed (default 1): it uses `duration × speed` of the source. */\n\tspeed?: number;\n\t/** Gain (1 = unchanged, default 1), or keyframes. */\n\tvolume?: VideoAnimatable;\n\t/** Silences this clip. */\n\tmuted?: boolean;\n}\n\n/** A title. */\nexport interface VideoTextClip extends VideoClipBase {\n\ttype: 'text';\n\ttext: string;\n\t/** CSS colour (default white). */\n\tcolor?: string;\n\t/** Pixels (default 64). */\n\tfontSize?: number;\n\t/** Default sans-serif. */\n\tfontFamily?: string;\n\t/** Default 700. */\n\tfontWeight?: number;\n\t/** Box behind the text (CSS colour); none by default. */\n\tbackground?: string;\n}\n\n/** A solid colour filling the frame. */\nexport interface VideoColorClip extends VideoClipBase {\n\ttype: 'color';\n\tcolor: string;\n}\n\nexport type VideoClip = VideoMediaClip | VideoTextClip | VideoColorClip;\nexport type VideoClipType = VideoClip['type'];\nexport const VIDEO_CLIP_TYPES = ['media', 'text', 'color'] as const;\n\nexport interface VideoMarker {\n\tid: string;\n\ttime: number;\n\tlabel?: string;\n\t/** CSS colour. */\n\tcolor?: string;\n}\n\nexport interface VideoProject {\n\tversion: 1;\n\tname?: string;\n\t/** Frame size in pixels. */\n\twidth: number;\n\theight: number;\n\t/** Frames per second: the grid edits snap to and exports use. */\n\tfps: number;\n\t/** What shows where no clip covers the frame (default black). */\n\tbackground?: string;\n\tassets: VideoAsset[];\n\ttracks: VideoTrack[];\n\tmarkers?: VideoMarker[];\n}\n","/**\n * Keyframes for a clip's opacity, volume and transform. A property is either a plain number or\n * `{ keys }`: values at clip-relative times (ms from the clip's start), each with the easing of the\n * segment that leaves it. Pure functions only, so previews, renders and exports agree.\n *\n * Easing is stored as CSS `cubic-bezier(x1, y1, x2, y2)` control points (named easings are the CSS\n * ones), which is also what After Effects / Lottie speed-and-influence handles convert to exactly.\n */\n\n/** How a segment moves from its key to the next: a named curve, `hold` (jump at the next key) or CSS cubic-bezier points. */\nexport type VideoEase = 'linear' | 'hold' | 'ease-in' | 'ease-out' | 'ease-in-out' | [number, number, number, number];\nexport const VIDEO_EASE_NAMES = ['linear', 'hold', 'ease-in', 'ease-out', 'ease-in-out'] as const;\n\nexport interface VideoKey {\n\t/** Clip-relative time (ms from the clip's start). */\n\tt: number;\n\tv: number;\n\t/** The segment from this key to the next (default linear). Ignored on the last key. */\n\tease?: VideoEase;\n}\n\nexport interface VideoKeyframes {\n\t/** In time order, at least one. */\n\tkeys: VideoKey[];\n}\n\n/** A number, or keyframes for it. */\nexport type VideoAnimatable = number | VideoKeyframes;\n\n/** The clip properties that can be keyframed: `opacity`, `volume` (media) and the transform's fields. */\nexport type VideoKeyframeProperty = 'opacity' | 'volume' | 'x' | 'y' | 'scale' | 'rotation';\nexport const VIDEO_KEYFRAME_PROPERTIES = ['opacity', 'volume', 'x', 'y', 'scale', 'rotation'] as const;\nexport const VIDEO_TRANSFORM_PROPERTIES = ['x', 'y', 'scale', 'rotation'] as const;\n\n/** Each property's default and allowed range. */\nexport const VIDEO_KEYFRAME_LIMITS: Record<VideoKeyframeProperty, { default: number; min?: number; max?: number }> = {\n\topacity: { default: 1, min: 0, max: 1 },\n\tvolume: { default: 1, min: 0, max: 4 },\n\tx: { default: 0 },\n\ty: { default: 0 },\n\tscale: { default: 1, min: 0 },\n\trotation: { default: 0 }\n};\n\nconst NAMED: Record<'ease-in' | 'ease-out' | 'ease-in-out', [number, number, number, number]> = {\n\t'ease-in': [0.42, 0, 1, 1],\n\t'ease-out': [0, 0, 0.58, 1],\n\t'ease-in-out': [0.42, 0, 0.58, 1]\n};\n\nexport const isVideoKeyframes = (v: unknown): v is VideoKeyframes => typeof v === 'object' && v !== null && Array.isArray((v as VideoKeyframes).keys);\n\n/** The cubic-bezier points for an ease (`null` for linear and hold). */\nexport function videoEaseBezier(ease: VideoEase | undefined): [number, number, number, number] | null {\n\tif (ease === undefined || ease === 'linear' || ease === 'hold') return null;\n\treturn Array.isArray(ease) ? ease : NAMED[ease];\n}\n\nconst bez = (a: number, b: number, s: number) => 3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;\nconst bezSlope = (a: number, b: number, s: number) => 3 * a * (1 - s) * (1 - s) + 6 * (b - a) * (1 - s) * s + 3 * (1 - b) * s * s;\n\n/** The curve parameter where a cubic-bezier's x is `x`: Newton's method, then bisection if that fails. */\nfunction bezierParam(x1: number, x2: number, x: number): number {\n\tlet s = x;\n\tfor (let i = 0; i < 8; i++) {\n\t\tconst err = bez(x1, x2, s) - x;\n\t\tif (Math.abs(err) < 1e-7) return s;\n\t\tconst d = bezSlope(x1, x2, s);\n\t\tif (Math.abs(d) < 1e-6) break;\n\t\ts -= err / d;\n\t\tif (s < 0 || s > 1) break;\n\t}\n\tlet lo = 0;\n\tlet hi = 1;\n\ts = x;\n\tfor (let i = 0; i < 60; i++) {\n\t\tconst v = bez(x1, x2, s);\n\t\tif (Math.abs(v - x) < 1e-9) break;\n\t\tif (v < x) lo = s;\n\t\telse hi = s;\n\t\ts = (lo + hi) / 2;\n\t}\n\treturn s;\n}\n\n/** How far (0–1 in, any y out) an ease has got at progress `p` (0–1) through its segment. */\nexport function videoEaseProgress(ease: VideoEase | undefined, p: number): number {\n\tif (p <= 0) return 0;\n\tif (p >= 1) return ease === 'hold' ? 0 : 1;\n\tif (ease === 'hold') return 0;\n\tconst b = videoEaseBezier(ease);\n\tif (!b) return p;\n\treturn bez(b[1], b[3], bezierParam(b[0], b[2], p));\n}\n\n/** The index of the last key at or before `t` (-1 before the first). */\nfunction keyBefore(keys: readonly VideoKey[], t: number): number {\n\tlet lo = 0;\n\tlet hi = keys.length - 1;\n\tif (t < keys[0].t) return -1;\n\twhile (lo < hi) {\n\t\tconst mid = (lo + hi + 1) >> 1;\n\t\tif (keys[mid].t <= t) lo = mid;\n\t\telse hi = mid - 1;\n\t}\n\treturn lo;\n}\n\n/**\n * A property's value at clip-relative time `clipTime` (ms): the number itself, or the keys\n * interpolated (held before the first and after the last). `fallback` is used when it is unset.\n */\nexport function videoValueAt(prop: VideoAnimatable | undefined, clipTime: number, fallback = 0): number {\n\tif (prop === undefined) return fallback;\n\tif (typeof prop === 'number') return prop;\n\tconst { keys } = prop;\n\tif (!keys.length) return fallback;\n\tconst i = keyBefore(keys, clipTime);\n\tif (i < 0) return keys[0].v;\n\tif (i >= keys.length - 1) return keys[keys.length - 1].v;\n\tconst a = keys[i];\n\tconst b = keys[i + 1];\n\tif (a.ease === 'hold') return a.v;\n\treturn a.v + (b.v - a.v) * videoEaseProgress(a.ease, (clipTime - a.t) / (b.t - a.t));\n}\n\n/** A property's keys (a plain number or unset gives none). */\nexport const videoKeysOf = (prop: VideoAnimatable | undefined): VideoKey[] => (isVideoKeyframes(prop) ? prop.keys : []);\n\n/** Shifts every key by `dt` ms (or scales times by `k` first); numbers pass through. */\nexport function retimeVideoAnimatable<T extends VideoAnimatable | undefined>(prop: T, dt: number, k = 1): T {\n\tif (!isVideoKeyframes(prop)) return prop;\n\treturn { keys: prop.keys.map((key) => ({ ...key, t: key.t * k + dt })) } as T;\n}\n\n/** Splits a cubic-bezier ease at progress `p`, giving the eases of the two halves (renormalised). */\nfunction splitEase(ease: VideoEase | undefined, p: number): [VideoEase | undefined, VideoEase | undefined] {\n\tif (ease === 'hold') return ['hold', 'hold'];\n\tconst b = videoEaseBezier(ease);\n\tif (!b) return [ease, ease];\n\tconst s = bezierParam(b[0], b[2], p);\n\tconst lerp = (u: [number, number], v: [number, number], k: number): [number, number] => [u[0] + (v[0] - u[0]) * k, u[1] + (v[1] - u[1]) * k];\n\tconst P0: [number, number] = [0, 0];\n\tconst P1: [number, number] = [b[0], b[1]];\n\tconst P2: [number, number] = [b[2], b[3]];\n\tconst P3: [number, number] = [1, 1];\n\tconst A = lerp(P0, P1, s);\n\tconst B = lerp(P1, P2, s);\n\tconst C = lerp(P2, P3, s);\n\tconst AB = lerp(A, B, s);\n\tconst BC = lerp(B, C, s);\n\tconst M = lerp(AB, BC, s);\n\tconst r = (v: number) => Math.round(v * 1e6) / 1e6;\n\t// Each half is rescaled to run 0–1 in both x and y; one with no rise has nothing to ease.\n\tconst left: VideoEase | undefined = Math.abs(M[1]) < 1e-9 ? 'linear' : [r(A[0] / M[0]), r(A[1] / M[1]), r(AB[0] / M[0]), r(AB[1] / M[1])];\n\tconst dx = 1 - M[0];\n\tconst dy = 1 - M[1];\n\tconst right: VideoEase | undefined = Math.abs(dy) < 1e-9 ? 'linear' : [r((BC[0] - M[0]) / dx), r((BC[1] - M[1]) / dy), r((C[0] - M[0]) / dx), r((C[1] - M[1]) / dy)];\n\treturn [left, right];\n}\n\n/**\n * Splits keyframes at clip time `at`: the keys before it (with a key added at `at`) and the keys\n * after it, moved to start at 0 (with a key added at 0). Eased segments across the cut are split\n * into two curves that trace the same path, so the two halves play exactly as the whole did.\n */\nexport function splitVideoAnimatable<T extends VideoAnimatable | undefined>(prop: T, at: number): [T, T] {\n\tif (!isVideoKeyframes(prop)) return [prop, prop];\n\tconst keys = prop.keys;\n\tconst eps = 1e-6;\n\tconst v = videoValueAt(prop, at);\n\tconst i = keyBefore(keys, at);\n\tconst exact = i >= 0 && Math.abs(keys[i].t - at) < eps;\n\tlet leftEase: VideoEase | undefined;\n\tlet rightEase: VideoEase | undefined;\n\tif (exact) rightEase = keys[i].ease;\n\telse if (i >= 0 && i < keys.length - 1) {\n\t\tconst a = keys[i];\n\t\t[leftEase, rightEase] = splitEase(a.ease, (at - a.t) / (keys[i + 1].t - a.t));\n\t}\n\tconst before = keys.slice(0, exact ? i : i + 1).map((k) => ({ ...k }));\n\tif (before.length && leftEase !== undefined) before[before.length - 1] = withEase(before[before.length - 1], leftEase);\n\tconst left: VideoKey[] = [...before, { t: at, v }];\n\tconst right: VideoKey[] = [withEase({ t: 0, v }, rightEase), ...keys.slice(i + 1).map((k) => ({ ...k, t: k.t - at }))];\n\treturn [{ keys: left } as T, { keys: right } as T];\n}\n\nconst withEase = (key: VideoKey, ease: VideoEase | undefined): VideoKey => {\n\tconst { ease: _old, ...rest } = key;\n\treturn ease === undefined || ease === 'linear' ? rest : { ...rest, ease };\n};\n\n/** The keys sorted by time, refusing two at the same time. */\nexport function sortVideoKeys(keys: readonly VideoKey[]): VideoKey[] {\n\tconst sorted = [...keys].sort((a, b) => a.t - b.t);\n\tfor (let i = 1; i < sorted.length; i++) if (Math.abs(sorted[i].t - sorted[i - 1].t) < 1e-6) throw new Error(`Two keys are at ${sorted[i].t} ms; give each key its own time.`);\n\treturn sorted;\n}\n\n/**\n * Keys with one set (or replaced) at `t`: a plain value becomes keys holding it and the new one.\n * For a keyframe toggle or \"add key at playhead\".\n */\nexport function setVideoKey(prop: VideoAnimatable | undefined, key: VideoKey, fallback: number): VideoKeyframes {\n\tconst keys = isVideoKeyframes(prop) ? prop.keys.filter((k) => Math.abs(k.t - key.t) >= 1e-6) : [];\n\tif (!isVideoKeyframes(prop)) {\n\t\tconst v = prop ?? fallback;\n\t\tif (key.t !== 0) keys.push({ t: 0, v });\n\t}\n\treturn { keys: sortVideoKeys([...keys, key]) };\n}\n\n/** Keys with the one at `t` removed; the value it then holds when none are left. */\nexport function removeVideoKey(prop: VideoKeyframes, t: number): VideoAnimatable {\n\tconst keys = prop.keys.filter((k) => Math.abs(k.t - t) >= 1e-6);\n\treturn keys.length ? { keys } : prop.keys[0].v;\n}\n\n// ---------------------------------------------------------------------------------------------\n// ffmpeg expressions\n// ---------------------------------------------------------------------------------------------\n\nconst fmt = (v: number) => {\n\tconst r = Math.round(v * 1e4) / 1e4;\n\treturn Object.is(r, -0) ? '0' : String(r);\n};\n\n/** `+n` or `-n`, so expressions never read `--n`. */\nconst signed = (v: number) => (fmt(v).startsWith('-') ? fmt(v) : `+${fmt(v)}`);\n\n/**\n * Breakpoints `[clipTime, value]` tracing a property as straight lines: linear segments as they\n * are, holds as steps, eased segments sampled every `step` ms (about two frames).\n */\nexport function videoKeyframeBreakpoints(prop: VideoKeyframes, step: number): { points: [number, number][]; holds: Set<number> } {\n\tconst { keys } = prop;\n\tconst points: [number, number][] = [];\n\tconst holds = new Set<number>();\n\tfor (let i = 0; i < keys.length; i++) {\n\t\tconst a = keys[i];\n\t\tpoints.push([a.t, a.v]);\n\t\tconst b = keys[i + 1];\n\t\tif (!b) break;\n\t\tif (a.ease === 'hold') {\n\t\t\tholds.add(points.length - 1);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!videoEaseBezier(a.ease)) continue;\n\t\tconst n = Math.max(1, Math.ceil((b.t - a.t) / step));\n\t\tfor (let k = 1; k < n; k++) {\n\t\t\tconst t = a.t + ((b.t - a.t) * k) / n;\n\t\t\tpoints.push([t, videoValueAt(prop, t)]);\n\t\t}\n\t}\n\treturn { points, holds };\n}\n\n/**\n * An ffmpeg expression for a property, given `time`: an expression for the clip-relative time in\n * ms (e.g. `(t*1000-1500)`). Piecewise linear `if(lt(…))` chains; eased segments are baked to\n * breakpoints every `step` ms.\n */\nexport function videoKeyframeFfmpegExpr(prop: VideoAnimatable | undefined, time: string, fallback: number, step: number): string {\n\tif (!isVideoKeyframes(prop)) return fmt(prop ?? fallback);\n\tconst { points, holds } = videoKeyframeBreakpoints(prop, step);\n\tif (points.length === 1 || points.every(([, v]) => v === points[0][1])) return fmt(points[0][1]);\n\tlet expr = fmt(points[points.length - 1][1]);\n\tfor (let i = points.length - 2; i >= 0; i--) {\n\t\tconst [t0, v0] = points[i];\n\t\tconst [t1, v1] = points[i + 1];\n\t\tconst seg = holds.has(i) || v0 === v1 ? fmt(v0) : `${fmt(v0)}${signed((v1 - v0) / (t1 - t0))}*(${time}${signed(-t0)})`;\n\t\texpr = `if(lt(${time},${fmt(t1)}),${seg},${expr})`;\n\t}\n\treturn `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;\n}\n","import { VIDEO_EASE_NAMES, type VideoAnimatable, type VideoEase, type VideoKey, type VideoKeyframes } from './keyframes.js';\nimport { VIDEO_CLIP_TYPES, VIDEO_FITS, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoAsset, type VideoClip, type VideoMarker, type VideoProject, type VideoTrack, 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 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});\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});\n\tif (kind === 'image' && asset.duration !== undefined) fail(`${path}.duration`, 'is only for video and audio assets');\n\treturn asset;\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\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});\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\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\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, clips and markers. Overlaps and missing media 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', '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\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.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 type { VideoAsset, VideoClip, VideoProject, VideoTrack } from './types.js';\n\n/** Clip edges closer than this (ms) count as touching: a cut, not a gap. */\nexport const VIDEO_TIME_EPSILON = 0.5;\n\nexport const videoClipEnd = (clip: VideoClip): number => clip.start + clip.duration;\n\n/** Playback speed of a clip (1 for titles and colours). */\nexport const videoClipSpeed = (clip: VideoClip): number => (clip.type === 'media' ? (clip.speed ?? 1) : 1);\n\n/** The stretch of source a media clip plays (ms), or `null` for titles and colours. */\nexport function videoClipSourceRange(clip: VideoClip): { in: number; out: number } | null {\n\tif (clip.type !== 'media') return null;\n\tconst start = clip.in ?? 0;\n\treturn { in: start, out: start + clip.duration * videoClipSpeed(clip) };\n}\n\n/** Length of one frame (ms). */\nexport const videoFrameDuration = (fps: number): number => 1000 / fps;\n\n/** `time` moved to the nearest frame boundary. */\nexport function snapToVideoFrame(time: number, fps: number): number {\n\tconst frame = Math.round((time * fps) / 1000);\n\treturn Math.round(((frame * 1000) / fps) * 1000) / 1000;\n}\n\n/** Whole frames in `time` at `fps` (rounded). */\nexport const videoFrames = (time: number, fps: number): number => Math.round((time * fps) / 1000);\n\n/** Non-drop SMPTE timecode `HH:MM:SS:FF` for a time (ms). */\nexport function formatVideoTimecode(time: number, fps: number): string {\n\tconst base = Math.round(fps);\n\tconst total = Math.max(0, videoFrames(time, fps));\n\tconst ff = total % base;\n\tconst secs = Math.floor(total / base);\n\tconst pad = (n: number) => String(n).padStart(2, '0');\n\treturn `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(ff)}`;\n}\n\n/** Readable time for messages: `2.50 s`. */\nexport const formatVideoSeconds = (time: number): string => `${(time / 1000).toFixed(2)} s`;\n\nexport function videoAssetMap(project: VideoProject): Map<string, VideoAsset> {\n\treturn new Map(project.assets.map((a) => [a.id, a]));\n}\n\n/** A track's clips in timeline order. */\nexport function sortedVideoClips(track: VideoTrack): VideoClip[] {\n\treturn [...track.clips].sort((a, b) => a.start - b.start || a.duration - b.duration);\n}\n\nexport interface VideoClipLocation {\n\ttrack: VideoTrack;\n\ttrackIndex: number;\n\tclip: VideoClip;\n\t/** Index in `track.clips`. */\n\tindex: number;\n}\n\nexport function findVideoClip(project: VideoProject, id: string): VideoClipLocation | null {\n\tfor (let trackIndex = 0; trackIndex < project.tracks.length; trackIndex++) {\n\t\tconst track = project.tracks[trackIndex];\n\t\tconst index = track.clips.findIndex((c) => c.id === id);\n\t\tif (index >= 0) return { track, trackIndex, clip: track.clips[index], index };\n\t}\n\treturn null;\n}\n\nexport function allVideoClipIds(project: VideoProject): string[] {\n\treturn project.tracks.flatMap((t) => t.clips.map((c) => c.id));\n}\n\n/** Total length: the end of the last clip (ms). */\nexport function videoProjectDuration(project: VideoProject): number {\n\tlet end = 0;\n\tfor (const track of project.tracks) for (const clip of track.clips) end = Math.max(end, videoClipEnd(clip));\n\treturn end;\n}\n\n/** The clip that ends where `clip` starts on its track (a cut), if any. */\nexport function videoClipBefore(track: VideoTrack, clip: VideoClip): VideoClip | null {\n\tlet best: VideoClip | null = null;\n\tfor (const c of track.clips) if (c !== clip && Math.abs(videoClipEnd(c) - clip.start) <= VIDEO_TIME_EPSILON) best = c;\n\treturn best;\n}\n\n/** The clip that starts where `clip` ends on its track (a cut), if any. */\nexport function videoClipAfter(track: VideoTrack, clip: VideoClip): VideoClip | null {\n\tfor (const c of track.clips) if (c !== clip && Math.abs(c.start - videoClipEnd(clip)) <= VIDEO_TIME_EPSILON) return c;\n\treturn null;\n}\n\n/**\n * Timeline time (ms) a clip can run past its edges into source media: before its in point\n * (`before`) and after its out point (`after`). Unlimited for stills, titles, colours and media\n * whose length is unknown.\n */\nexport function videoClipHandles(clip: VideoClip, assets: Map<string, VideoAsset>): { before: number; after: number } {\n\tif (clip.type !== 'media') return { before: Infinity, after: Infinity };\n\tconst asset = assets.get(clip.asset);\n\tconst speed = videoClipSpeed(clip);\n\tconst range = videoClipSourceRange(clip)!;\n\tconst before = range.in / speed;\n\tif (!asset || asset.kind === 'image' || asset.duration === undefined) return { before: asset?.kind === 'image' ? Infinity : before, after: Infinity };\n\treturn { before, after: Math.max(0, (asset.duration - range.out) / speed) };\n}\n\n/**\n * How far a clip plays past each edge because of transitions: `lead` before its start (its own\n * transition in) and `tail` after its end (the next clip's transition in). Dips need neither.\n */\nexport function videoClipOverhang(track: VideoTrack, clip: VideoClip): { lead: number; tail: number } {\n\t// A dip cuts under its colour, so neither clip plays past the cut.\n\tconst lead = clip.transition && clip.transition.type !== 'dip' && videoClipBefore(track, clip) ? clip.transition.duration / 2 : 0;\n\tconst next = videoClipAfter(track, clip);\n\tconst tail = next?.transition && next.transition.type !== 'dip' ? next.transition.duration / 2 : 0;\n\treturn { lead, tail };\n}\n\nexport interface VideoSnapOptions {\n\t/** Clip ids whose edges are not snap targets (the ones being dragged). */\n\texclude?: Iterable<string>;\n\t/** Also snap to this time (usually the playhead). */\n\tplayhead?: number;\n}\n\n/** Times worth snapping to: 0, every clip edge, every marker and the playhead, ascending. */\nexport function videoSnapPoints(project: VideoProject, options: VideoSnapOptions = {}): number[] {\n\tconst exclude = new Set(options.exclude ?? []);\n\tconst points = new Set<number>([0]);\n\tfor (const track of project.tracks)\n\t\tfor (const clip of track.clips) {\n\t\t\tif (exclude.has(clip.id)) continue;\n\t\t\tpoints.add(clip.start);\n\t\t\tpoints.add(videoClipEnd(clip));\n\t\t}\n\tfor (const marker of project.markers ?? []) points.add(marker.time);\n\tif (options.playhead !== undefined) points.add(options.playhead);\n\treturn [...points].sort((a, b) => a - b);\n}\n\n/** The snap point nearest `time` if it is within `threshold` (ms), else `time`. */\nexport function snapVideoTime(time: number, points: readonly number[], threshold: number): { time: number; snapped: number | null } {\n\tlet best: number | null = null;\n\tfor (const p of points) if (Math.abs(p - time) <= threshold && (best === null || Math.abs(p - time) < Math.abs(best - time))) best = p;\n\treturn best === null ? { time, snapped: null } : { time: best, snapped: best };\n}\n\nexport interface VideoGap {\n\ttrack: string;\n\tstart: number;\n\tend: number;\n}\n\n/** Empty stretches between clips (and before the first) on each track, or one track. */\nexport function findVideoGaps(project: VideoProject, trackId?: string): VideoGap[] {\n\tconst gaps: VideoGap[] = [];\n\tfor (const track of project.tracks) {\n\t\tif (trackId !== undefined && track.id !== trackId) continue;\n\t\tlet at = 0;\n\t\tfor (const clip of sortedVideoClips(track)) {\n\t\t\tif (clip.start - at > VIDEO_TIME_EPSILON) gaps.push({ track: track.id, start: at, end: clip.start });\n\t\t\tat = Math.max(at, videoClipEnd(clip));\n\t\t}\n\t}\n\treturn gaps;\n}\n\n/** The first clip on `track` (other than `ignore`) that overlaps `[start, end)`, if any. */\nexport function videoOverlap(track: VideoTrack, start: number, end: number, ignore: ReadonlySet<string> = new Set()): VideoClip | null {\n\tfor (const c of sortedVideoClips(track)) {\n\t\tif (ignore.has(c.id)) continue;\n\t\tif (c.start < end - VIDEO_TIME_EPSILON && videoClipEnd(c) > start + VIDEO_TIME_EPSILON) return c;\n\t}\n\treturn null;\n}\n\n/** The first free stretch on `track` at or after `from` that fits `duration`. */\nexport function nextFreeVideoSlot(track: VideoTrack, from: number, duration: number, ignore: ReadonlySet<string> = new Set()): number {\n\tlet at = from;\n\tfor (const c of sortedVideoClips(track)) {\n\t\tif (ignore.has(c.id)) continue;\n\t\tif (videoClipEnd(c) <= at + VIDEO_TIME_EPSILON) continue;\n\t\tif (c.start >= at + duration - VIDEO_TIME_EPSILON) break;\n\t\tat = videoClipEnd(c);\n\t}\n\treturn at;\n}\n\n/** Clips under `time` (start inclusive, end exclusive), top track first. */\nexport function videoClipsAt(project: VideoProject, time: number): { track: string; clip: VideoClip }[] {\n\tconst out: { track: string; clip: VideoClip }[] = [];\n\tfor (const track of project.tracks) for (const clip of track.clips) if (time >= clip.start && time < videoClipEnd(clip)) out.push({ track: track.id, clip });\n\treturn out;\n}\n","import { formatVideoSeconds, sortedVideoClips, VIDEO_TIME_EPSILON, videoAssetMap, videoClipBefore, videoClipEnd, videoClipHandles, videoClipSourceRange, videoProjectDuration } from './timeline.js';\nimport { VIDEO_TRANSFORM_PROPERTIES, videoKeysOf } from './keyframes.js';\nimport type { VideoClip, VideoProject } 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' ? 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'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'keyframes-outside-clip',\n\t'unknown-duration',\n\t'unused-asset',\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\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.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). 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 used = new Set<string>();\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\tfor (const track of project.tracks) {\n\t\tconst clips = sortedVideoClips(track);\n\t\tfor (let i = 0; i < clips.length; i++) {\n\t\t\tconst clip = clips[i];\n\t\t\tconst at = { track: track.id, clip: clip.id };\n\t\t\tif (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}\" 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\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).after;\n\t\t\t\tconst incoming = videoClipHandles(clip, assets).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 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, parseVideoTransition } from './parse.js';\nimport { findVideoClip, formatVideoSeconds, nextFreeVideoSlot, sortedVideoClips, VIDEO_TIME_EPSILON, videoAssetMap, videoClipEnd, videoClipSpeed, videoOverlap } from './timeline.js';\nimport type { VideoAsset, VideoClip, VideoMarker, VideoProject, VideoTrack, VideoTrackKind, 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; volume?: number };\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; ripple?: boolean }\n\t| { op: 'move_clip'; clip: string; start?: number; track?: string }\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/** 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| { 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'set_keyframes',\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}\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/** 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];\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', 'fadeIn', 'fadeOut', 'transition', 'in', 'speed', 'volume', 'muted', 'color', 'fontSize', 'fontFamily', 'fontWeight', 'background'];\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\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 pass ripple to push later clips along.`);\n\t\t}\n\t}\n\n\tkindFits(track: VideoTrack, clip: { type: string; asset?: string }) {\n\t\tconst asset = clip.type === 'media' ? this.asset(clip.asset!) : null;\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}\n\n\trun(e: VideoEdit) {\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 = this.p.tracks.flatMap((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 (!['name', 'muted', 'hidden', 'locked', 'volume'].includes(k)) refuse(`\"${k}\" cannot be set on a track; use name, muted, hidden, locked or volume.`);\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 draft: any = { ...e.clip, type: e.clip.type ?? 'media' };\n\t\t\t\tthis.kindFits(track, draft);\n\t\t\t\tconst ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));\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 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\tif (e.ripple) {\n\t\t\t\t\tconst inside = track.clips.find((c) => c.start < clip.start - VIDEO_TIME_EPSILON && videoClipEnd(c) > clip.start + VIDEO_TIME_EPSILON);\n\t\t\t\t\tif (inside) refuse(`${formatVideoSeconds(clip.start)} is inside clip \"${inside.id}\"; split it there first (split_clip), or insert at its start or end.`);\n\t\t\t\t\tthis.shift(track, clip.start, clip.duration);\n\t\t\t\t} else this.fits(track, clip.start, clip.duration, new Set(), `Clip \"${clip.id}\"`);\n\t\t\t\ttrack.clips.push(clip);\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, track: track.id });\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 start = e.start ?? from.clip.start;\n\t\t\t\tthis.fits(to, start, from.clip.duration, new Set([from.clip.id]), `Clip \"${from.clip.id}\"`);\n\t\t\t\tfrom.clip.start = start;\n\t\t\t\tif (to !== from.track) {\n\t\t\t\t\tfrom.track.clips.splice(from.index, 1);\n\t\t\t\t\tto.clips.push(from.clip);\n\t\t\t\t}\n\t\t\t\tto.clips.sort((a, b) => a.start - b.start);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clip: from.clip.id, track: to.id });\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\tif (clip.type === 'media') {\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\tclip.duration -= delta;\n\t\t\t\t\t\tthis.shift(track, end, -delta, new Set([clip.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') {\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 (e.ripple) this.shift(track, end, e.to - end, new Set([clip.id]));\n\t\t\t\t\telse if (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\tconst ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));\n\t\t\t\tif (e.id !== undefined && ids.includes(e.id)) refuse(`There is already a clip \"${e.id}\".`);\n\t\t\t\tconst id = e.id ?? freshId(`${clip.id}-`, ids);\n\t\t\t\tconst left = e.at - clip.start;\n\t\t\t\tconst right: any = { ...structuredClone(clip), id, start: e.at, duration: end - e.at };\n\t\t\t\tdelete right.transition;\n\t\t\t\tdelete right.fadeIn;\n\t\t\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\t\t\tconst leftValues = clipAnimatables(clip);\n\t\t\t\tclipAnimatables(right).forEach((r, i) => {\n\t\t\t\t\tconst [l, rr] = splitVideoAnimatable(leftValues[i].get(), left);\n\t\t\t\t\tif (isVideoKeyframes(l)) leftValues[i].set(l);\n\t\t\t\t\tif (isVideoKeyframes(rr)) r.set(rr);\n\t\t\t\t});\n\t\t\t\tif (clip.type === 'media') {\n\t\t\t\t\tright.in = (clip.in ?? 0) + left * videoClipSpeed(clip);\n\t\t\t\t}\n\t\t\t\tclip.duration = left;\n\t\t\t\tdelete clip.fadeOut;\n\t\t\t\ttrack.clips.push(right);\n\t\t\t\ttrack.clips.sort((a, b) => a.start - b.start);\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: [clip.id, id], clip: 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))) {\n\t\t\t\t\tconst gone = found.filter((f) => f.track === track).map((f) => f.clip);\n\t\t\t\t\ttrack.clips = track.clips.filter((c) => !gone.includes(c));\n\t\t\t\t\tif (e.ripple) for (const c of [...gone].sort((a, b) => b.start - a.start)) this.shift(track, videoClipEnd(c), -c.duration);\n\t\t\t\t}\n\t\t\t\treturn this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.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\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_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 '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') refuse(`Clip \"${clip.id}\" is a ${clip.type} clip and has no volume; volume keys go on media 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 { videoValueAt } from './keyframes.js';\nimport { sortedVideoClips, videoAssetMap, videoClipAfter, videoClipBefore, videoClipEnd, videoClipSpeed } from './timeline.js';\nimport type { VideoAsset, VideoClip, VideoFit, VideoProject, VideoTextClip, VideoTrack, VideoWipeDirection } from './types.js';\n\n/** A rectangle in frame pixels. */\nexport interface VideoRect {\n\tx: number;\n\ty: number;\n\twidth: number;\n\theight: number;\n}\n\n/** One picture in a frame, in draw order (first is at the back). */\nexport interface VideoLayer {\n\t/** The clip it comes from, or `transition:<clip>` for a dip's colour. */\n\tclip: string;\n\ttrack: string;\n\ttype: VideoClip['type'];\n\t/** For media: the asset, its kind and `src`, and the moment of the source to show (ms). */\n\tasset?: string;\n\tassetKind?: VideoAsset['kind'];\n\tsrc?: string;\n\tsourceTime?: number;\n\t/** 0–1, after fades and transitions. */\n\topacity: number;\n\t/** Where the fitted picture sits before rotation (its centre is the rotation centre). */\n\trect: VideoRect;\n\t/** Degrees clockwise about the rect's centre. */\n\trotation: number;\n\t/** For a wipe: the part shown is the `progress` (0–1) of the rect from the edge opposite `direction`. */\n\twipe?: { direction: VideoWipeDirection; progress: number };\n\t/** For text: what to draw. */\n\ttext?: { text: string; color: string; fontSize: number; fontFamily: string; fontWeight: number; background?: string };\n\t/** For colour clips and dips. */\n\tcolor?: string;\n}\n\n/** One sound playing at a moment. */\nexport interface VideoAudioSource {\n\tclip: string;\n\ttrack: string;\n\tasset: string;\n\tsrc: string;\n\t/** The moment of the source playing (ms). */\n\tsourceTime: number;\n\t/** Gain after clip and track volume, fades and transitions (0 = silent). */\n\tgain: number;\n\tspeed: number;\n}\n\nexport interface VideoFrame {\n\ttime: number;\n\twidth: number;\n\theight: number;\n\tbackground: string;\n\tlayers: VideoLayer[];\n\taudio: VideoAudioSource[];\n}\n\n/** A transform's values at clip-relative time `clipTime` (ms), keyframes resolved and defaults filled in. */\nexport function videoTransformAt(transform: VideoClip['transform'], clipTime = 0): { x: number; y: number; scale: number; rotation: number } {\n\treturn { x: videoValueAt(transform?.x, clipTime, 0), y: videoValueAt(transform?.y, clipTime, 0), scale: videoValueAt(transform?.scale, clipTime, 1), rotation: videoValueAt(transform?.rotation, clipTime, 0) };\n}\n\n/** Places a picture of `size` in the frame by `fit`, then the clip's transform at clip-relative `clipTime` (ms). */\nexport function placeVideoRect(frame: { width: number; height: number }, size: { width: number; height: number }, fit: VideoFit = 'contain', transform: VideoClip['transform'] = {}, clipTime = 0): VideoRect {\n\tlet w = size.width;\n\tlet h = size.height;\n\tif (fit === 'fill') {\n\t\tw = frame.width;\n\t\th = frame.height;\n\t} else if (fit !== 'none') {\n\t\tconst k = fit === 'cover' ? Math.max(frame.width / w, frame.height / h) : Math.min(frame.width / w, frame.height / h);\n\t\tw *= k;\n\t\th *= k;\n\t}\n\tconst t = videoTransformAt(transform, clipTime);\n\tw *= t.scale;\n\th *= t.scale;\n\tconst cx = frame.width / 2 + t.x;\n\tconst cy = frame.height / 2 + t.y;\n\treturn { x: cx - w / 2, y: cy - h / 2, width: w, height: h };\n}\n\n/** A title's text and style with the defaults filled in. */\nexport function videoTextStyle(clip: VideoTextClip): NonNullable<VideoLayer['text']> {\n\treturn { text: clip.text, color: clip.color ?? '#ffffff', fontSize: clip.fontSize ?? 64, fontFamily: clip.fontFamily ?? 'sans-serif', fontWeight: clip.fontWeight ?? 700, ...(clip.background ? { background: clip.background } : {}) };\n}\n\n/**\n * A frame holding only one title, fully opaque on a transparent background, placed as the preview\n * places it: draw it with `drawVideoFrame` and save the canvas as a PNG to give the ffmpeg export\n * titles without `drawtext` (`VideoFfmpegOptions.titleImage`). Fades and opacity are left to the export.\n * A keyframed transform is placed as it is at `clipTime` (the export passes titles with keyframed\n * transforms without them, and moves the image itself).\n */\nexport function videoTitleFrame(project: VideoProject, clip: VideoTextClip, clipTime = 0): VideoFrame {\n\tconst layer: VideoLayer = { clip: clip.id, track: '', type: 'text', opacity: 1, rect: placeVideoRect(project, project, 'fill', clip.transform, clipTime), rotation: videoTransformAt(clip.transform, clipTime).rotation, text: videoTextStyle(clip) };\n\treturn { time: clip.start, width: project.width, height: project.height, background: 'rgba(0,0,0,0)', layers: [layer], audio: [] };\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\n\n/** Fade-in / fade-out multiplier for a clip at timeline `time`. */\nfunction fadeGain(clip: VideoClip, time: number): number {\n\tlet g = 1;\n\tif (clip.fadeIn) g *= clamp01((time - clip.start) / clip.fadeIn);\n\tif (clip.fadeOut) g *= clamp01((videoClipEnd(clip) - time) / clip.fadeOut);\n\treturn g;\n}\n\ninterface Active {\n\tclip: VideoClip;\n\t/** 0–1 mix through a transition (1 = fully this clip); undefined outside one. */\n\tincoming?: { progress: number; type: NonNullable<VideoClip['transition']>['type']; transition: NonNullable<VideoClip['transition']> };\n\toutgoing?: { progress: number; type: NonNullable<VideoClip['transition']>['type']; next: VideoClip };\n}\n\n/** The clips of a track playing at `time`, with any transition they are in (outgoing first). */\nfunction activeOnTrack(track: VideoTrack, time: number): Active[] {\n\tconst out: Active[] = [];\n\tfor (const clip of sortedVideoClips(track)) {\n\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\tconst lead = before ? clip.transition!.duration / 2 : 0;\n\t\tconst next = videoClipAfter(track, clip);\n\t\tconst tail = next?.transition ? next.transition.duration / 2 : 0;\n\t\tif (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;\n\t\tconst a: Active = { clip };\n\t\tif (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition!.type, transition: clip.transition! };\n\t\tif (tail && time >= videoClipEnd(clip) - tail) a.outgoing = { progress: clamp01((time - (videoClipEnd(clip) - tail)) / (2 * tail)), type: next!.transition!.type, next: next! };\n\t\tout.push(a);\n\t}\n\treturn out;\n}\n\nconst sourceTimeAt = (clip: VideoClip, time: number) => (clip.type === 'media' ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : 0);\n\n/**\n * What plays at `time` (ms): the pictures to draw, back to front, with their placement, opacity\n * and the moment of source each shows, and the sounds with their gain. A pure function of the\n * project and the time, so previews, exporters and agents all agree on every frame.\n */\nexport function videoFrameAt(project: VideoProject, time: number): VideoFrame {\n\tconst assets = videoAssetMap(project);\n\tconst layers: VideoLayer[] = [];\n\tconst audio: VideoAudioSource[] = [];\n\t// Later tracks are further back, so draw from the last track to the first.\n\tfor (let ti = project.tracks.length - 1; ti >= 0; ti--) {\n\t\tconst track = project.tracks[ti];\n\t\tfor (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {\n\t\t\t// A dip plays the outgoing clip down into its colour and this one up out of it, so neither\n\t\t\t// runs past the cut (and needs no handles).\n\t\t\tif (incoming?.type === 'dip' && incoming.progress < 0.5) continue;\n\t\t\tif (outgoing?.type === 'dip' && outgoing.progress >= 0.5) continue;\n\t\t\tconst fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));\n\t\t\tconst level = (t: { type: string; progress: number }, into: boolean) => (t.type === 'dip' ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);\n\t\t\t// Sound crossfades through a crossfade or wipe, and dips with the picture.\n\t\t\tconst mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);\n\t\t\tconst asset = clip.type === 'media' ? assets.get(clip.asset) : undefined;\n\t\t\tconst sourceTime = sourceTimeAt(clip, time);\n\t\t\t// Keyframes are in clip time, held before the first key and after the last.\n\t\t\tconst clipTime = time - clip.start;\n\t\t\tconst audible = asset && (asset.kind === 'audio' || (asset.kind === 'video' && asset.hasAudio !== false));\n\t\t\tif (audible && clip.type === 'media' && !clip.muted && !track.muted) {\n\t\t\t\tconst gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fade * mix;\n\t\t\t\taudio.push({ clip: clip.id, track: track.id, asset: asset!.id, src: asset!.src, sourceTime, gain, speed: videoClipSpeed(clip) });\n\t\t\t}\n\t\t\tif (track.kind !== 'video' || track.hidden) continue;\n\t\t\tif (clip.type === 'media' && (!asset || asset.kind === 'audio')) continue;\n\t\t\tlet opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;\n\t\t\tlet wipe: VideoLayer['wipe'];\n\t\t\tlet dip: { color: string; opacity: number } | null = null;\n\t\t\tif (incoming) {\n\t\t\t\tif (incoming.type === 'crossfade') opacity *= incoming.progress;\n\t\t\t\telse if (incoming.type === 'wipe') wipe = { direction: incoming.transition.direction ?? 'right', progress: incoming.progress };\n\t\t\t\telse dip = { color: incoming.transition.color ?? '#000000', opacity: (1 - incoming.progress) * 2 };\n\t\t\t}\n\t\t\tif (outgoing?.type === 'dip') dip = { color: outgoing.next.transition!.color ?? '#000000', opacity: outgoing.progress * 2 };\n\t\t\tconst size = clip.type === 'media' ? { width: asset!.width ?? project.width, height: asset!.height ?? project.height } : { width: project.width, height: project.height };\n\t\t\tconst fit = clip.fit ?? (clip.type === 'media' ? 'contain' : 'fill');\n\t\t\tconst rect = placeVideoRect(project, size, clip.type === 'text' ? 'fill' : fit, clip.transform, clipTime);\n\t\t\tconst layer: VideoLayer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(clip.transform?.rotation, clipTime, 0) };\n\t\t\tif (wipe) layer.wipe = wipe;\n\t\t\tif (clip.type === 'media') Object.assign(layer, { asset: asset!.id, assetKind: asset!.kind, src: asset!.src, sourceTime });\n\t\t\tif (clip.type === 'text') layer.text = videoTextStyle(clip);\n\t\t\tif (clip.type === 'color') layer.color = clip.color;\n\t\t\tif (layer.opacity > 0) layers.push(layer);\n\t\t\tif (dip && dip.opacity > 0) layers.push({ clip: `transition:${incoming ? clip.id : outgoing!.next.id}`, track: track.id, type: 'color', color: dip.color, opacity: clamp01(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });\n\t\t}\n\t}\n\treturn { time, width: project.width, height: project.height, background: project.background ?? '#000000', layers, audio };\n}\n","import { videoFrameAt, type VideoFrame, type VideoLayer, type VideoRect } from './evaluate.js';\nimport { formatVideoTimecode } from './timeline.js';\nimport type { VideoProject } from './types.js';\n\nconst esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\nconst n = (v: number) => String(Math.round(v * 100) / 100);\n\n/** The part of a rect a wipe shows. */\nexport function videoWipeRect(rect: VideoRect, wipe: NonNullable<VideoLayer['wipe']>): VideoRect {\n\tconst { direction, progress } = wipe;\n\tconst w = rect.width * progress;\n\tconst h = rect.height * progress;\n\tif (direction === 'right') return { ...rect, width: w };\n\tif (direction === 'left') return { ...rect, x: rect.x + rect.width - w, width: w };\n\tif (direction === 'down') return { ...rect, height: h };\n\treturn { ...rect, y: rect.y + rect.height - h, height: h };\n}\n\nexport interface VideoSvgOptions {\n\t/**\n\t * The picture for a media layer: return an href (URL or data URL) to draw, or `null` for a\n\t * labelled placeholder. Default: images use `src`, videos their `poster`, else a placeholder.\n\t */\n\timageFor?: (layer: VideoLayer, frame: VideoFrame) => string | null;\n\t/** Frame rate for the timecode on placeholders (default 30). */\n\tfps?: number;\n}\n\n/** A frame as standalone SVG: images and posters as `<image>`, video without one as a labelled box. */\nexport function videoFrameToSvg(frame: VideoFrame, options: VideoSvgOptions = {}, assets: VideoProject['assets'] = []): string {\n\tconst defs: string[] = [];\n\tconst body: string[] = [];\n\tconst posters = new Map(assets.map((a) => [a.id, a.poster]));\n\tframe.layers.forEach((layer, i) => {\n\t\tconst { rect } = layer;\n\t\tconst attrs: string[] = [];\n\t\tif (layer.opacity < 1) attrs.push(`opacity=\"${n(layer.opacity)}\"`);\n\t\tif (layer.rotation) attrs.push(`transform=\"rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})\"`);\n\t\tif (layer.wipe) {\n\t\t\tconst r = videoWipeRect(rect, layer.wipe);\n\t\t\tdefs.push(`<clipPath id=\"w${i}\"><rect x=\"${n(r.x)}\" y=\"${n(r.y)}\" width=\"${n(r.width)}\" height=\"${n(r.height)}\"/></clipPath>`);\n\t\t\tattrs.push(`clip-path=\"url(#w${i})\"`);\n\t\t}\n\t\tconst box = `x=\"${n(rect.x)}\" y=\"${n(rect.y)}\" width=\"${n(rect.width)}\" height=\"${n(rect.height)}\"`;\n\t\tlet inner = '';\n\t\tif (layer.type === 'color') inner = `<rect ${box} fill=\"${esc(layer.color ?? '#000000')}\"/>`;\n\t\telse if (layer.type === 'text' && layer.text) {\n\t\t\tconst t = layer.text;\n\t\t\tconst lines = t.text.split('\\n');\n\t\t\tconst lh = t.fontSize * 1.2;\n\t\t\tconst cx = rect.x + rect.width / 2;\n\t\t\tconst cy = rect.y + rect.height / 2;\n\t\t\tconst top = cy - (lh * (lines.length - 1)) / 2;\n\t\t\tif (t.background) {\n\t\t\t\tconst w = Math.max(...lines.map((l) => l.length)) * t.fontSize * 0.6 + t.fontSize;\n\t\t\t\tconst h = lh * lines.length + t.fontSize * 0.4;\n\t\t\t\tinner += `<rect x=\"${n(cx - w / 2)}\" y=\"${n(cy - h / 2)}\" width=\"${n(w)}\" height=\"${n(h)}\" fill=\"${esc(t.background)}\"/>`;\n\t\t\t}\n\t\t\tinner += `<text x=\"${n(cx)}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"${esc(t.fontFamily)}\" font-size=\"${n(t.fontSize)}\" font-weight=\"${t.fontWeight}\" fill=\"${esc(t.color)}\">${lines.map((l, k) => `<tspan x=\"${n(cx)}\" y=\"${n(top + k * lh)}\">${esc(l)}</tspan>`).join('')}</text>`;\n\t\t} else {\n\t\t\tconst href = options.imageFor ? options.imageFor(layer, frame) : layer.assetKind === 'image' ? (layer.src ?? null) : (posters.get(layer.asset ?? '') ?? null);\n\t\t\tif (href) inner = `<image href=\"${esc(href)}\" ${box} preserveAspectRatio=\"none\"/>`;\n\t\t\telse {\n\t\t\t\tconst label = `${layer.asset ?? layer.clip} · ${formatVideoTimecode(layer.sourceTime ?? 0, options.fps ?? 30)}`;\n\t\t\t\tconst size = Math.max(10, Math.min(rect.height / 8, rect.width / Math.max(8, label.length * 0.6)));\n\t\t\t\tinner = `<rect ${box} fill=\"#334155\" stroke=\"#94a3b8\" stroke-width=\"2\"/><text x=\"${n(rect.x + rect.width / 2)}\" y=\"${n(rect.y + rect.height / 2)}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"${n(size)}\" fill=\"#e2e8f0\">${esc(label)}</text>`;\n\t\t\t}\n\t\t}\n\t\tbody.push(attrs.length ? `<g ${attrs.join(' ')}>${inner}</g>` : inner);\n\t});\n\treturn `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${n(frame.width)} ${n(frame.height)}\" width=\"${n(frame.width)}\" height=\"${n(frame.height)}\">${defs.length ? `<defs>${defs.join('')}</defs>` : ''}<rect width=\"100%\" height=\"100%\" fill=\"${esc(frame.background)}\"/>${body.join('')}</svg>`;\n}\n\n/** The project at `time` as SVG. */\nexport function videoProjectSvg(project: VideoProject, time: number, options: VideoSvgOptions = {}): string {\n\treturn videoFrameToSvg(videoFrameAt(project, time), { fps: project.fps, ...options }, project.assets);\n}\n\n/** Several moments in one labelled picture, `columns` a row, each frame scaled to `width` pixels. */\nexport function videoContactSheetSvg(project: VideoProject, times: readonly number[], options: VideoSvgOptions & { columns?: number; width?: number } = {}): string {\n\tconst columns = options.columns ?? Math.ceil(Math.sqrt(times.length));\n\tconst w = options.width ?? 320;\n\tconst h = (w * project.height) / project.width;\n\tconst label = 18;\n\tconst gap = 8;\n\tconst rows = Math.ceil(times.length / columns);\n\tconst W = columns * w + (columns + 1) * gap;\n\tconst H = rows * (h + label) + (rows + 1) * gap;\n\tconst cells = times.map((t, i) => {\n\t\tconst x = gap + (i % columns) * (w + gap);\n\t\tconst y = gap + Math.floor(i / columns) * (h + label + gap);\n\t\tconst svg = videoProjectSvg(project, t, options).replace(/^<svg xmlns=\"http:\\/\\/www.w3.org\\/2000\\/svg\"/, `<svg x=\"${n(x)}\" y=\"${n(y)}\" width=\"${n(w)}\" height=\"${n(h)}\"`).replace(/ width=\"[\\d.]+\" height=\"[\\d.]+\">/, '>');\n\t\treturn `${svg}<text x=\"${n(x)}\" y=\"${n(y + h + 14)}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#0f172a\">${(t / 1000).toFixed(2)} s · ${formatVideoTimecode(t, project.fps)}</text>`;\n\t});\n\treturn `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${n(W)} ${n(H)}\" width=\"${n(W)}\" height=\"${n(H)}\"><rect width=\"100%\" height=\"100%\" fill=\"#f8fafc\"/>${cells.join('')}</svg>`;\n}\n\n/** The subset of `CanvasRenderingContext2D` `drawVideoFrame` uses (so tests and workers can pass their own). */\nexport type VideoCanvasContext = Pick<\n\tCanvasRenderingContext2D,\n\t'save' | 'restore' | 'fillRect' | 'drawImage' | 'translate' | 'rotate' | 'beginPath' | 'rect' | 'clip' | 'fillText' | 'measureText' | 'setTransform'\n> & { globalAlpha: number; fillStyle: string | CanvasGradient | CanvasPattern; font: string; textAlign: CanvasTextAlign; textBaseline: CanvasTextBaseline; strokeStyle: string | CanvasGradient | CanvasPattern; lineWidth: number; strokeRect(x: number, y: number, w: number, h: number): void };\n\n/**\n * Draws a frame on a 2D canvas scaled by `scale` (canvas pixels per frame pixel). `sourceFor`\n * returns what to draw for a media layer (a `<video>` seeked to `layer.sourceTime`, an `<img>`,\n * an `ImageBitmap`…) or `null` for a placeholder box.\n */\nexport function drawVideoFrame(ctx: VideoCanvasContext, frame: VideoFrame, sourceFor: (layer: VideoLayer) => CanvasImageSource | null, scale = 1): void {\n\tctx.save();\n\tctx.setTransform(scale, 0, 0, scale, 0, 0);\n\tctx.globalAlpha = 1;\n\tctx.fillStyle = frame.background;\n\tctx.fillRect(0, 0, frame.width, frame.height);\n\tfor (const layer of frame.layers) {\n\t\tconst { rect } = layer;\n\t\tctx.save();\n\t\tctx.globalAlpha = layer.opacity;\n\t\tif (layer.rotation) {\n\t\t\tctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);\n\t\t\tctx.rotate((layer.rotation * Math.PI) / 180);\n\t\t\tctx.translate(-(rect.x + rect.width / 2), -(rect.y + rect.height / 2));\n\t\t}\n\t\tif (layer.wipe) {\n\t\t\tconst r = videoWipeRect(rect, layer.wipe);\n\t\t\tctx.beginPath();\n\t\t\tctx.rect(r.x, r.y, r.width, r.height);\n\t\t\tctx.clip();\n\t\t}\n\t\tif (layer.type === 'color') {\n\t\t\tctx.fillStyle = layer.color ?? '#000000';\n\t\t\tctx.fillRect(rect.x, rect.y, rect.width, rect.height);\n\t\t} else if (layer.type === 'text' && layer.text) {\n\t\t\tconst t = layer.text;\n\t\t\tconst lines = t.text.split('\\n');\n\t\t\tconst lh = t.fontSize * 1.2;\n\t\t\tconst cx = rect.x + rect.width / 2;\n\t\t\tconst top = rect.y + rect.height / 2 - (lh * (lines.length - 1)) / 2;\n\t\t\tctx.font = `${t.fontWeight} ${t.fontSize}px ${t.fontFamily}`;\n\t\t\tctx.textAlign = 'center';\n\t\t\tctx.textBaseline = 'middle';\n\t\t\tif (t.background) {\n\t\t\t\tconst w = Math.max(...lines.map((l) => ctx.measureText(l).width)) + t.fontSize;\n\t\t\t\tconst h = lh * lines.length + t.fontSize * 0.4;\n\t\t\t\tctx.fillStyle = t.background;\n\t\t\t\tctx.fillRect(cx - w / 2, rect.y + rect.height / 2 - h / 2, w, h);\n\t\t\t}\n\t\t\tctx.fillStyle = t.color;\n\t\t\tlines.forEach((l, k) => ctx.fillText(l, cx, top + k * lh));\n\t\t} else {\n\t\t\tconst source = sourceFor(layer);\n\t\t\tif (source) ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);\n\t\t\telse {\n\t\t\t\tctx.fillStyle = '#334155';\n\t\t\t\tctx.fillRect(rect.x, rect.y, rect.width, rect.height);\n\t\t\t\tctx.strokeStyle = '#94a3b8';\n\t\t\t\tctx.lineWidth = 2;\n\t\t\t\tctx.strokeRect(rect.x, rect.y, rect.width, rect.height);\n\t\t\t}\n\t\t}\n\t\tctx.restore();\n\t}\n\tctx.restore();\n}\n","import { placeVideoRect } from './evaluate.js';\nimport { isVideoKeyframes, VIDEO_TRANSFORM_PROPERTIES, videoKeyframeFfmpegExpr, type VideoAnimatable } from './keyframes.js';\nimport { formatVideoTimecode, sortedVideoClips, videoAssetMap, videoClipBefore, videoClipEnd, videoClipOverhang, videoClipSpeed, videoProjectDuration } from './timeline.js';\nimport type { VideoAsset, VideoClip, VideoProject, VideoTextClip, VideoTrack } from './types.js';\n\n// ---------------------------------------------------------------------------------------------\n// CMX 3600 EDL\n// ---------------------------------------------------------------------------------------------\n\nexport interface VideoEdlOptions {\n\t/** Track to export (default: the bottom video track, V1 in most NLEs). */\n\ttrack?: string;\n\t/** Record timecode of the timeline's start (ms, default 1 hour, the usual 01:00:00:00). */\n\trecordStart?: number;\n\t/** Title line (default: the project's name). */\n\ttitle?: string;\n}\n\n/** Reel names are at most 8 characters, letters and digits. */\nconst reelName = (asset: VideoAsset | undefined) => (asset?.name ?? asset?.id ?? 'AX').replace(/\\.[a-z0-9]+$/i, '').replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, 8) || 'AX';\n\n/**\n * One track as a CMX 3600 edit decision list, for conforming in another NLE: an event per clip with\n * source and record timecode at the project's frame rate, dissolves (`D`) for crossfades, dips and\n * wipes (`W001`), `M2` lines for speed changes, and `* FROM CLIP NAME` comments. Titles and colour\n * clips become black (`BL`) events.\n */\nexport function toVideoEdl(project: VideoProject, options: VideoEdlOptions = {}): string {\n\tconst track = options.track !== undefined ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === 'video');\n\tif (!track) throw new Error(options.track !== undefined ? `There is no track \"${options.track}\".` : 'The project has no video track.');\n\tconst fps = project.fps;\n\tconst tc = (ms: number) => formatVideoTimecode(ms, fps);\n\tconst rec0 = options.recordStart ?? 3600000;\n\tconst assets = videoAssetMap(project);\n\tconst channel = track.kind === 'video' ? 'V' : 'A';\n\tconst lines = [`TITLE: ${(options.title ?? project.name ?? 'Untitled').toUpperCase()}`, 'FCM: NON-DROP FRAME', ''];\n\tlet event = 0;\n\tconst num = () => String(++event).padStart(3, '0');\n\tconst sourceAt = (clip: VideoClip, time: number) => (clip.type === 'media' ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : time - clip.start);\n\tconst row = (n: string, reel: string, kind: string, srcIn: number, srcOut: number, recIn: number, recOut: number) =>\n\t\t`${n} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${tc(rec0 + recIn)} ${tc(rec0 + recOut)}`;\n\tfor (const clip of sortedVideoClips(track)) {\n\t\tconst asset = clip.type === 'media' ? assets.get(clip.asset) : undefined;\n\t\tconst reel = clip.type === 'media' ? reelName(asset) : 'BL';\n\t\tconst end = videoClipEnd(clip);\n\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\t// The cut moves to where the transition starts; this clip's event begins there.\n\t\tconst half = before && clip.transition!.type !== 'dip' ? clip.transition!.duration / 2 : 0;\n\t\tconst next = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition && c.transition.type !== 'dip');\n\t\tconst recOut = next ? end - next.transition!.duration / 2 : end;\n\t\tconst recIn = clip.start - half;\n\t\tconst n = num();\n\t\tif (before) {\n\t\t\tconst t = clip.transition!;\n\t\t\tconst frames = String(Math.round((t.duration * fps) / 1000)).padStart(3, '0');\n\t\t\tconst outReel = before.type === 'media' ? reelName(assets.get(before.asset)) : 'BL';\n\t\t\tconst at = sourceAt(before, recIn);\n\t\t\tlines.push(row(n, outReel, 'C', at, at, recIn, recIn));\n\t\t\tlines.push(row(n, reel, `${t.type === 'wipe' ? 'W001' : 'D'} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));\n\t\t} else lines.push(row(n, reel, 'C', sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));\n\t\tif (clip.type === 'media' && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);\n\t\tif (clip.type === 'media') lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);\n\t\tif (clip.type === 'text') lines.push(`* TITLE: ${clip.text.replace(/\\n/g, ' ')}`);\n\t\tif (clip.type === 'color') lines.push(`* COLOR: ${clip.color}`);\n\t\tlines.push('');\n\t}\n\treturn lines.join('\\n');\n}\n\n// ---------------------------------------------------------------------------------------------\n// ffmpeg\n// ---------------------------------------------------------------------------------------------\n\nexport interface VideoFfmpegOptions {\n\t/** Output file (default `output.mp4`). */\n\toutput?: string;\n\t/** The path or URL ffmpeg should open for an asset (default its `src`). */\n\tresolve?: (asset: VideoAsset) => string;\n\t/** Default `libx264`. */\n\tvideoCodec?: string;\n\t/** Default `aac`. */\n\taudioCodec?: string;\n\t/** x264 quality (default 18). */\n\tcrf?: number;\n\t/** A font file for titles (drawtext's `fontfile`); default the system's font for the family. */\n\tfontFile?: string;\n\t/**\n\t * Titles as images instead of `drawtext` (which needs an ffmpeg built with libfreetype; Homebrew's\n\t * is not): return the path or URL of a transparent PNG the size of the frame showing the title as\n\t * the preview draws it (`videoTitleFrame` + `drawVideoFrame`). It is overlaid like a still, with\n\t * the title's opacity and fades.\n\t */\n\ttitleImage?: (clip: VideoTextClip, size: { width: number; height: number }) => string;\n}\n\nexport interface VideoFfmpegExport {\n\t/** Arguments after `ffmpeg` (pass to `spawn('ffmpeg', args)`; no shell quoting needed). */\n\targs: string[];\n\t/** The same as one shell command line, quoted for a POSIX shell. */\n\tcommand: string;\n\t/** The `-filter_complex` graph on its own. */\n\tfilterComplex: string;\n\t/** Length rendered (ms). */\n\tduration: number;\n\t/** Anything the render only approximates. */\n\tnotes: string[];\n}\n\nconst sec = (ms: number) => String(Math.round(ms) / 1000);\nconst even = (v: number) => Math.max(2, 2 * Math.round(v / 2));\n/** Escapes a value for a filter option inside a filtergraph (option level, then graph level). */\nexport const ffmpegFilterValue = (s: string): string =>\n\ts\n\t\t.replace(/[\\\\':%]/g, (c) => `\\\\${c}`)\n\t\t.replace(/[\\\\'[\\],;]/g, (c) => `\\\\${c}`);\nconst color = (c: string) => {\n\tconst hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(c);\n\tif (!hex) return c.replace(/[^A-Za-z0-9@.]/g, '');\n\tconst h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join('') : hex[1];\n\treturn `0x${h.slice(0, 6)}${h.length === 8 ? `@${(parseInt(h.slice(6), 16) / 255).toFixed(3)}` : ''}`;\n};\nconst shellQuote = (s: string) => (/^[\\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\\\''`)}'`);\n\n/** `atempo` only takes 0.5–2 (100 in newer builds), so larger changes chain several. */\nfunction atempo(speed: number): string[] {\n\tconst out: string[] = [];\n\tlet s = speed;\n\twhile (s > 2) {\n\t\tout.push('atempo=2');\n\t\ts /= 2;\n\t}\n\twhile (s < 0.5) {\n\t\tout.push('atempo=0.5');\n\t\ts /= 0.5;\n\t}\n\tif (Math.abs(s - 1) > 1e-6) out.push(`atempo=${Math.round(s * 1e6) / 1e6}`);\n\treturn out;\n}\n\n/**\n * The project as an ffmpeg command: each clip an input trimmed to what it plays (plus transition\n * overlap), scaled and placed as `videoFrameAt` places it, faded, and overlaid on a background in\n * track order between its start and end; titles with `drawtext`; sound delayed into place, faded,\n * mixed and trimmed to the project's length. Render on a server or with ffmpeg.wasm.\n */\nexport function toVideoFfmpeg(project: VideoProject, options: VideoFfmpegOptions = {}): VideoFfmpegExport {\n\tconst notes: string[] = [];\n\tconst assets = videoAssetMap(project);\n\tconst D = videoProjectDuration(project);\n\tif (D <= 0) throw new Error('The project has no clips, so there is nothing to render.');\n\tconst W = even(project.width);\n\tconst H = even(project.height);\n\tconst fps = project.fps;\n\tconst inputs: string[][] = [];\n\tconst graph: string[] = [`color=c=${color(project.background ?? '#000000')}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];\n\tconst audio: string[] = [];\n\tlet cur = 'base';\n\tlet usesDrawtext = false;\n\tconst animated = new Set<string>();\n\tlet k = 0;\n\t// Keyframes become piecewise-linear expressions; eased segments get a breakpoint every two frames.\n\tconst step = 2000 / fps;\n\tconst expr = (prop: VideoAnimatable | undefined, clipTime: string, fallback: number, what: string) => {\n\t\tif (isVideoKeyframes(prop)) animated.add(what);\n\t\treturn videoKeyframeFfmpegExpr(prop, clipTime, fallback, step);\n\t};\n\tconst input = (clip: VideoClip & { type: 'media' }, asset: VideoAsset, lead: number, tail: number) => {\n\t\tconst speed = videoClipSpeed(clip);\n\t\tconst src = options.resolve ? options.resolve(asset) : asset.src;\n\t\tconst span = clip.duration + lead + tail;\n\t\tif (asset.kind === 'image') inputs.push(['-loop', '1', '-framerate', String(fps), '-t', sec(span), '-i', src]);\n\t\telse inputs.push(['-ss', sec((clip.in ?? 0) - lead * speed), '-t', sec(span * speed), '-i', src]);\n\t\treturn inputs.length - 1;\n\t};\n\t// Back to front: later tracks are further back.\n\tfor (const track of [...project.tracks].reverse()) {\n\t\tfor (const clip of sortedVideoClips(track)) {\n\t\t\tconst { lead, tail } = videoClipOverhang(track, clip);\n\t\t\tconst t0 = clip.start - lead;\n\t\t\tconst span = clip.duration + lead + tail;\n\t\t\tconst end = videoClipEnd(clip);\n\t\t\tconst asset = clip.type === 'media' ? assets.get(clip.asset) : undefined;\n\t\t\tif (clip.type === 'media' && !asset) {\n\t\t\t\tnotes.push(`Clip \"${clip.id}\" was left out: its asset \"${clip.asset}\" is missing.`);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tlet index: number | undefined;\n\t\t\tif (asset && asset.kind !== 'audio' && track.kind === 'video' && !track.hidden) index = input(clip as VideoClip & { type: 'media' }, asset, lead, tail);\n\t\t\tconst audible = asset && (asset.kind === 'audio' || (asset.kind === 'video' && asset.hasAudio !== false));\n\t\t\tif (audible && clip.type === 'media' && !clip.muted && !track.muted) {\n\t\t\t\tconst ai = index ?? input(clip, asset!, lead, tail);\n\t\t\t\tconst trackGain = track.volume ?? 1;\n\t\t\t\tconst volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ''})`, 1, 'volume')})':eval=frame` : `volume=${(clip.volume ?? 1) * trackGain}`;\n\t\t\t\tconst chain = ['asetpts=PTS-STARTPTS', ...atempo(videoClipSpeed(clip)), volume];\n\t\t\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\t\t\tif (lead) chain.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);\n\t\t\t\tif (tail) chain.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);\n\t\t\t\tif (before && clip.transition!.type === 'dip') chain.push(`afade=t=in:st=0:d=${sec(clip.transition!.duration / 2)}`);\n\t\t\t\tconst after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition?.type === 'dip');\n\t\t\t\tif (after) chain.push(`afade=t=out:st=${sec(span - after.transition!.duration / 2)}:d=${sec(after.transition!.duration / 2)}`);\n\t\t\t\tif (clip.fadeIn) chain.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}`);\n\t\t\t\tif (clip.fadeOut) chain.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}`);\n\t\t\t\tchain.push(`adelay=${Math.round(t0)}:all=1`);\n\t\t\t\tgraph.push(`[${ai}:a]${chain.join(',')}[a${audio.length}]`);\n\t\t\t\taudio.push(`[a${audio.length}]`);\n\t\t\t}\n\t\t\tif (track.kind !== 'video' || track.hidden) continue;\n\t\t\tconst enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;\n\t\t\tconst next = `v${k++}`;\n\t\t\t// Clip time (ms) in a filter's `t`: timeline time on the composite, time from t0 on the clip's own chain.\n\t\t\tconst onTimeline = `(t*1000-${Math.round(clip.start)})`;\n\t\t\tconst onChain = (v: string) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ''})`;\n\t\t\tconst tf = clip.transform;\n\t\t\tconst moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(tf?.[p]));\n\t\t\tif (clip.type === 'text' && options.titleImage) {\n\t\t\t\t// A title image has its transform baked in, unless the transform moves: then the image is drawn unplaced and moved here.\n\t\t\t\tinputs.push(['-loop', '1', '-framerate', String(fps), '-t', sec(span), '-i', options.titleImage(moving ? { ...clip, transform: undefined } : clip, { width: project.width, height: project.height })]);\n\t\t\t\tindex = inputs.length - 1;\n\t\t\t} else if (clip.type === 'text') {\n\t\t\t\tusesDrawtext = true;\n\t\t\t\tconst op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, 'opacity')},0,1)` : String(clip.opacity ?? 1);\n\t\t\t\tconst alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : '', clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ''].filter(Boolean).join('*');\n\t\t\t\tconst coord = (axis: 'x' | 'y') => {\n\t\t\t\t\tconst v = tf?.[axis];\n\t\t\t\t\tconst base = `(${axis === 'x' ? 'w-text_w' : 'h-text_h'})/2`;\n\t\t\t\t\treturn isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, 'position')})'` : `${axis}=${base}+${v ?? 0}`;\n\t\t\t\t};\n\t\t\t\tconst fontSize = clip.fontSize ?? 64;\n\t\t\t\tconst opts = [\n\t\t\t\t\t`text=${ffmpegFilterValue(clip.text)}`,\n\t\t\t\t\toptions.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(clip.fontFamily ?? 'sans-serif')}`,\n\t\t\t\t\tisVideoKeyframes(tf?.scale) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, 'scale')})'` : `fontsize=${Math.round(fontSize * ((tf?.scale as number | undefined) ?? 1))}`,\n\t\t\t\t\t`fontcolor=${color(clip.color ?? '#ffffff')}`,\n\t\t\t\t\tcoord('x'),\n\t\t\t\t\tcoord('y'),\n\t\t\t\t\t`alpha='${alpha}'`,\n\t\t\t\t\t...(clip.background ? ['box=1', `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((clip.fontSize ?? 64) / 2)}`] : []),\n\t\t\t\t\t`enable='between(t,${sec(clip.start)},${sec(end)})'`\n\t\t\t\t];\n\t\t\t\tif (tf?.rotation) notes.push(`Title \"${clip.id}\" is drawn without its rotation (drawtext cannot rotate).`);\n\t\t\t\tgraph.push(`[${cur}]drawtext=${opts.join(':')}[${next}]`);\n\t\t\t\tcur = next;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst size = asset ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };\n\t\t\t// A title image already has the title placed (and turned) in a full frame, unless its transform moves.\n\t\t\tconst placed = clip.type === 'text' && !moving ? undefined : tf;\n\t\t\t// Keyframed fields are left out of the fitted rect and animated by the filters below.\n\t\t\tconst still = Object.fromEntries(Object.entries(placed ?? {}).filter(([, v]) => !isVideoKeyframes(v)));\n\t\t\tconst rect = placeVideoRect(project, size, clip.type === 'text' ? 'fill' : (clip.fit ?? (clip.type === 'media' ? 'contain' : 'fill')), still);\n\t\t\tconst chain: string[] = [];\n\t\t\tlet source: string;\n\t\t\tif (clip.type === 'color') source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;\n\t\t\telse {\n\t\t\t\tsource = `[${index}:v]`;\n\t\t\t\tchain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : 'PTS-STARTPTS'}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);\n\t\t\t}\n\t\t\tif (isVideoKeyframes(placed?.scale)) {\n\t\t\t\tconst s = expr(placed.scale, onChain('t'), 1, 'scale');\n\t\t\t\tchain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);\n\t\t\t}\n\t\t\tchain.push('format=yuva420p');\n\t\t\tif (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain('T'), 1, 'opacity')},0,1)'`);\n\t\t\telse if (clip.opacity !== undefined && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);\n\t\t\tif (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);\n\t\t\tif (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);\n\t\t\tif (lead && clip.transition!.type === 'crossfade') chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);\n\t\t\tif (lead && clip.transition!.type === 'wipe') {\n\t\t\t\tconst d = sec(2 * lead);\n\t\t\t\tconst p = `min(1,T/${d})`;\n\t\t\t\tconst dir = clip.transition!.direction ?? 'right';\n\t\t\t\tconst test = dir === 'right' ? `lt(X,W*${p})` : dir === 'left' ? `gt(X,W*(1-${p}))` : dir === 'down' ? `lt(Y,H*${p})` : `gt(Y,H*(1-${p}))`;\n\t\t\t\tchain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);\n\t\t\t}\n\t\t\tconst rotation = placed?.rotation;\n\t\t\tif (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain('t'), 0, 'rotation')})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);\n\t\t\telse if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);\n\t\t\tchain.push(`setpts=PTS+${sec(t0)}/TB`);\n\t\t\tconst cx = rect.x + rect.width / 2;\n\t\t\tconst cy = rect.y + rect.height / 2;\n\t\t\tconst at = (axis: 'x' | 'y', c: number) => {\n\t\t\t\tconst v = placed?.[axis];\n\t\t\t\treturn isVideoKeyframes(v) ? `${axis}='${Math.round(axis === 'x' ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, 'position')})-${axis === 'x' ? 'w' : 'h'}/2'` : `${axis}='${Math.round(c)}-${axis === 'x' ? 'w' : 'h'}/2'`;\n\t\t\t};\n\t\t\tgraph.push(`${source.startsWith('[') ? source : `${source},`}${chain.join(',')}[l${next}]`);\n\t\t\tgraph.push(`[${cur}][l${next}]overlay=${at('x', cx)}:${at('y', cy)}:eof_action=pass:${enable}[${next}]`);\n\t\t\tcur = next;\n\t\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\t\tif (before && clip.transition!.type === 'dip') {\n\t\t\t\tconst d = clip.transition!.duration;\n\t\t\t\tconst dip = `d${k}`;\n\t\t\t\tgraph.push(`color=c=${color(clip.transition!.color ?? '#000000')}:s=${W}x${H}:r=${fps}:d=${sec(d)},format=yuva420p,fade=t=in:st=0:d=${sec(d / 2)}:alpha=1,fade=t=out:st=${sec(d / 2)}:d=${sec(d / 2)}:alpha=1,setpts=PTS+${sec(clip.start - d / 2)}/TB[l${dip}]`);\n\t\t\t\tgraph.push(`[${cur}][l${dip}]overlay=eof_action=pass:enable='between(t,${sec(clip.start - d / 2)},${sec(clip.start + d / 2)})'[${dip}]`);\n\t\t\t\tcur = dip;\n\t\t\t}\n\t\t}\n\t}\n\tgraph.push(`[${cur}]format=yuv420p[vout]`);\n\tif (audio.length) graph.push(`${audio.join('')}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);\n\tif (project.tracks.some((t) => t.clips.some((c) => c.type === 'media' && assets.get(c.asset)?.kind === 'video' && assets.get(c.asset)?.hasAudio === undefined)))\n\t\tnotes.push('Video assets are assumed to have sound; set \"hasAudio\": false on any that do not, or ffmpeg will stop at a missing audio stream.');\n\tif (animated.size)\n\t\tnotes.push(`Keyframed ${[...animated].sort().join(', ')} ${animated.size > 1 ? 'are' : 'is'} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has('scale') ? '; animated scale needs ffmpeg 5 or later (scale with eval=frame)' : ''}.`);\n\tif (usesDrawtext) notes.push('Titles use drawtext, which needs an ffmpeg built with libfreetype (Homebrew\\'s is not: check `ffmpeg -filters | grep drawtext`); pass titleImage to render them as images instead.');\n\tconst filterComplex = graph.join(';');\n\tconst args = [\n\t\t'-y',\n\t\t...inputs.flat(),\n\t\t'-filter_complex',\n\t\tfilterComplex,\n\t\t'-map',\n\t\t'[vout]',\n\t\t...(audio.length ? ['-map', '[aout]', '-c:a', options.audioCodec ?? 'aac'] : ['-an']),\n\t\t'-c:v',\n\t\toptions.videoCodec ?? 'libx264',\n\t\t...((options.videoCodec ?? 'libx264') === 'libx264' ? ['-crf', String(options.crf ?? 18), '-preset', 'medium'] : []),\n\t\t'-pix_fmt',\n\t\t'yuv420p',\n\t\t'-r',\n\t\tString(fps),\n\t\t'-t',\n\t\tsec(D),\n\t\toptions.output ?? 'output.mp4'\n\t];\n\treturn { args, command: ['ffmpeg', ...args].map(shellQuote).join(' '), filterComplex, duration: D, notes };\n}\n","import { videoFrameAt, type VideoFrame, type VideoLayer } from './evaluate.js';\nimport { drawVideoFrame, type VideoCanvasContext } from './render.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 };\n\tsubscribe(listener: () => void): () => void;\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\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 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}\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\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.update();\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 { project, time, playing } = this.source.getState();\n\t\tconst frame = videoFrameAt(project, time);\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\tif (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) {\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 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, layer.src, '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\treturn entry && entry.media.readyState >= 2 ? (entry.media as unknown as CanvasImageSource) : null;\n\t};\n\n\tprivate draw(frame: VideoFrame) {\n\t\tif (!this.ctx) return;\n\t\tconst shown = this.canvas.clientWidth || frame.width;\n\t\tconst dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;\n\t\tconst resolution = this.options.resolution ?? Math.min(1, (shown * dpr) / 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\tdrawVideoFrame(this.ctx, frame, this.sourceFor, resolution);\n\t\tthis.options.onFrame?.(frame);\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.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 { 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, type VideoEdlOptions, type VideoFfmpegExport, type VideoFfmpegOptions } from './export.js';\nimport { createVideoProject, parseVideoProject } from './parse.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}\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/** 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\tprivate project: VideoProject;\n\tprivate issues: VideoIssue[];\n\tprivate selection: string[] = [];\n\tprivate readOnly: boolean;\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.project = options.project ? parseVideoProject(options.project) : createVideoProject();\n\t\tthis.issues = checkVideoProject(this.project);\n\t\tthis.readOnly = options.readOnly ?? false;\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\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/** 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, 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/** One track as a CMX 3600 EDL. */\n\ttoEdl(options: VideoEdlOptions = {}): string {\n\t\treturn toVideoEdl(this.project, options);\n\t}\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 VideoEditResult } from './edit.js';\nimport { videoFrameAt } from './evaluate.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 { findVideoGaps, formatVideoTimecode, sortedVideoClips, videoClipEnd, videoClipSourceRange, videoProjectDuration } from './timeline.js';\nimport { VIDEO_FITS, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoProject } from './types.js';\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}\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 = { type: 'boolean', description: 'Push (or pull) the later clips on the same track along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap).' } 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) or wipe (an edge travels across revealing this clip).' },\n\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms, centred on the cut. A crossfade or wipe 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: the way the edge travels (default right).' }\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 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\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};\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\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/** A project as agents see it: settings, assets, and each track's clips in order with their times. */\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,\n\t\ttracks: project.tracks.map((t, index) => ({\n\t\t\tid: t.id,\n\t\t\tkind: t.kind,\n\t\t\tindex,\n\t\t\t...(t.name ? { name: t.name } : {}),\n\t\t\t...(t.muted ? { muted: true } : {}),\n\t\t\t...(t.hidden ? { hidden: true } : {}),\n\t\t\t...(t.locked ? { locked: true } : {}),\n\t\t\t...(t.volume !== undefined ? { volume: t.volume } : {}),\n\t\t\tclips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...(c.type === 'media' ? { source: videoClipSourceRange(c) } : {}) }))\n\t\t})),\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, ripple,\n * 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' ? { source: videoClipSourceRange(c) } : {}) };\n\t\t}\n\t\treturn null;\n\t};\n\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, fades, transition) and markers, plus the playhead, selection, issues and whether it can be edited.',\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, 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) and the sounds (clip, source time, gain after volumes, fades and transitions).',\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 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 longer than the clip, 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\tconst svg = times ? videoContactSheetSvg(p, times, columns ? { columns } : {}) : videoProjectSvg(p, time ?? controller.getState().time);\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 }] };\n\t\t\t}\n\t\t\treturn { svg };\n\t\t}\n\t};\n\n\tconst exportFfmpeg: AgentTool = {\n\t\tname: 'export_video_ffmpeg',\n\t\ttitle: 'Export an ffmpeg command',\n\t\tdescription: 'Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. 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}),\n\t\tannotations: { title: 'Export an ffmpeg command', ...READ },\n\t\thandler: ({ output, crf }) => controller.toFfmpeg({ ...(output ? { output } : {}), ...(crf !== undefined ? { crf } : {}) })\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, 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}),\n\t\tannotations: { title: 'Export an EDL', ...READ },\n\t\thandler: ({ track, title }) => ({ edl: controller.toEdl({ ...(track ? { track } : {}), ...(title ? { title } : {}) }) })\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'] }, description: 'Fields to clear.' } }, ['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: 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 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', '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; with ripple it is inserted at \"start\", pushing later clips along; otherwise an overlap is refused with the next free place. Returns the clip with its end and source range.',\n\t\tinputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, ripple: RIPPLE }, ['track', 'clip']),\n\t\tannotations: { title: 'Add a clip', ...WRITE },\n\t\thandler: ({ track, clip, ripple }) => apply({ op: 'add_clip', track, clip, ripple }, 'Add clip', (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip!) }))\n\t};\n\n\tconst moveClip: AgentTool = {\n\t\tname: 'move_video_clip',\n\t\ttitle: 'Move a clip',\n\t\tdescription: 'Moves a clip to a new start and/or another track of the same kind (one undo step). Refused if it would overlap another clip, naming it and the next free place. Returns the clip.',\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).' } }, ['clip']),\n\t\tannotations: { title: 'Move a clip', ...WRITE_IDEMPOTENT },\n\t\thandler: ({ clip, start, track }) => {\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 }, 'Move clip', (r) => ({ clip: clipOut(r.project, clip) }));\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 }, 'Trim clip', (r) => ({ clip: clipOut(r.project, clip) }))\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: 'Removes clips (one undo step). With ripple the later clips on each track move up to close the gaps. Returns the ids removed.',\n\t\tinputSchema: object({ clips: { type: 'array', items: CLIP_ID, minItems: 1, description: 'Clip ids.' }, ripple: { type: 'boolean', description: 'Close the gaps they leave (default false).' } }, ['clips']),\n\t\tannotations: { title: 'Delete clips', ...DESTRUCTIVE },\n\t\thandler: ({ clips, ripple }) => apply({ op: 'delete_clips', clips, ripple }, ripple ? 'Ripple delete' : 'Delete clips', () => ({ removed: clips }))\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, fades, name; for media volume, mute, speed (keeps the stretch of source, so the length changes) or asset; 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', 'fadeIn', 'fadeOut', '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', 'fadeIn', 'fadeOut', ...(current.type === 'media' ? ['in', 'speed', 'volume', 'muted', 'asset'] : 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\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). A crossfade or wipe 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 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, ripple }, move_clip { clip, start, track }, 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) }, 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\tconst reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];\n\tconst writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, 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","import type { VideoEdit } from './edit.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 } from './timeline.js';\nimport type { VideoAsset, VideoAssetKind, VideoClip, VideoProject } 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. */\nexport function videoClipTint(clip: VideoClip, asset?: VideoAsset): string {\n\tif (clip.type === 'text') return '#a855f7';\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. */\nexport function videoClipLabel(clip: VideoClip, asset?: VideoAsset): 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\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/** 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\treturn { start, duration: clip.duration, track: target, edit: moved ? { op: 'move_clip', clip: clip.id, start, ...(target !== track.id ? { track: target } : {}) } : 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 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 } = {}): 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 } : {}) };\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.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' && (asset?.kind === 'audio' || (asset?.kind === 'video' && asset.hasAudio !== false));\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"]}
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/editor/editor/packages/core/dist/video-editor/index.cjs","../../src/video-editor/types.ts","../../src/video-editor/keyframes.ts","../../src/video-editor/parse.ts","../../src/video-editor/timeline.ts","../../src/video-editor/check.ts","../../src/video-editor/edit.ts","../../src/video-editor/evaluate.ts","../../src/video-editor/render.ts","../../src/video-editor/export.ts","../../src/video-editor/preview.ts","../../src/video-editor/controller.ts","../../src/video-editor/tools.ts","../../src/video-editor/ui.ts"],"names":["n"],"mappings":"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AACvB,EAAA;AACR;AAM8G;AACrE,EAAA;AACsB,IAAA;AACN,IAAA;AACd,IAAA;AACtB,MAAA;AACL,MAAA;AACd,IAAA;AACqB,IAAA;AACF,MAAA;AACyC,MAAA;AAC5D,IAAA;AACsB,IAAA;AACC,MAAA;AACmC,MAAA;AAC3C,MAAA;AACJ,MAAA;AACV,MAAA;AACD,IAAA;AACsC,IAAA;AACzB,IAAA;AACe,IAAA;AACgC,MAAA;AACjD,MAAA;AACmD,MAAA;AAC9D,IAAA;AACa,IAAA;AACJ,IAAA;AACT,EAAA;AACF;AAMmF;AAC1C,EAAA;AACF,EAAA;AACb,EAAA;AACA,EAAA;AACW,EAAA;AACG,EAAA;AACxC;AAsBE;AAEiF;AAMwB;AAClE,EAAA;AACvB,EAAA;AACA,EAAA;AAC4C,EAAA;AACC,EAAA;AACJ,EAAA;AAC6C,EAAA;AAC7D,EAAA;AACN,EAAA;AACK,IAAA;AACW,IAAA;AACN,IAAA;AACtC,IAAA;AACN,MAAA;AACsB,MAAA;AACgC,MAAA;AACjC,MAAA;AACV,MAAA;AAC2C,MAAA;AACtD,MAAA;AACsD,MAAA;AACA,MAAA;AACvD,IAAA;AACA,EAAA;AACF;AAGgH;AACvC,EAAA;AACV,IAAA;AACoB,EAAA;AAChC,EAAA;AACU,EAAA;AAC7D;AAM6H;AACpF,EAAA;AACxB,EAAA;AAC2B,EAAA;AACiB,EAAA;AAC5B,EAAA;AAC4B,EAAA;AAC7D;AAMgH;AACvE,EAAA;AACxB,EAAA;AAC2B,EAAA;AACL,EAAA;AACW,EAAA;AACrB,EAAA;AAC8B,IAAA;AAChD,IAAA;AAC+B,MAAA;AACgB,MAAA;AACxD,IAAA;AACD,EAAA;AACwC,EAAA;AACoB,EAAA;AAC7D;Ab2mFiE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;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,"/**\n * A video project is plain JSON: the media it uses (assets), tracks of clips placed on a timeline,\n * and markers. Every time is in milliseconds; `fps` sets the frame grid edits snap to and exports use.\n *\n * Tracks are listed top to bottom as a timeline shows them: on screen, a clip on an earlier video\n * track draws over one on a later track. Clips on one track never overlap.\n */\n\nimport type { VideoAnimatable } from './keyframes.js';\n\nexport type VideoAssetKind = 'video' | 'audio' | 'image';\n\n/** A piece of source media. The project refers to it by id; `src` is whatever the host can load. */\nexport interface VideoAsset {\n\tid: string;\n\tkind: VideoAssetKind;\n\t/** URL, blob URL, path or data URL (whatever the playback engine and exporter can open). */\n\tsrc: string;\n\tname?: string;\n\t/** Length of the source (ms). Required for video and audio; images have none. */\n\tduration?: number;\n\t/** Intrinsic size in pixels (video and image). Placement assumes the project size when missing. */\n\twidth?: number;\n\theight?: number;\n\t/** Whether a video asset has sound (default true). */\n\thasAudio?: boolean;\n\t/** A still to show for a video where its frames cannot be drawn (SVG renders, thumbnails). */\n\tposter?: string;\n}\n\nexport type VideoTrackKind = 'video' | 'audio';\n\nexport interface VideoTrack {\n\tid: string;\n\tkind: VideoTrackKind;\n\tname?: string;\n\t/** Silences every clip on the track. */\n\tmuted?: boolean;\n\t/** Leaves the track's pictures out of the frame. */\n\thidden?: boolean;\n\t/** Refuses edits to the track's clips. */\n\tlocked?: boolean;\n\t/**\n\t * Moves with ripple edits made on other sync-locked tracks, so it stays in sync (default true).\n\t * Turn it off for a track that should stay put while the others ripple (e.g. a music bed).\n\t */\n\tsyncLocked?: boolean;\n\t/** Gain applied to every clip on the track (1 = unchanged, default 1). */\n\tvolume?: number;\n\tclips: VideoClip[];\n}\n\n/** Where a picture sits in the frame, after it has been fitted. Each field may be keyframed. */\nexport interface VideoTransform {\n\t/** Offset of the centre from the frame's centre, in pixels (default 0). */\n\tx?: VideoAnimatable;\n\ty?: VideoAnimatable;\n\t/** 1 = fitted size (default 1). */\n\tscale?: VideoAnimatable;\n\t/** Degrees clockwise (default 0). */\n\trotation?: VideoAnimatable;\n}\n\n/** How a picture is fitted to the frame before its transform: like CSS `object-fit`. */\nexport type VideoFit = 'contain' | 'cover' | 'fill' | 'none';\nexport const VIDEO_FITS = ['contain', 'cover', 'fill', 'none'] as const;\n\nexport type VideoTransitionType = 'crossfade' | 'dip' | 'wipe';\nexport const VIDEO_TRANSITION_TYPES = ['crossfade', 'dip', 'wipe'] as const;\nexport type VideoWipeDirection = 'left' | 'right' | 'up' | 'down';\nexport const VIDEO_WIPE_DIRECTIONS = ['left', 'right', 'up', 'down'] as const;\n\n/**\n * A transition into a clip from the clip that ends where it starts on the same track. It plays over\n * the cut, half before and half after, so both clips run past the cut into their handles (media\n * beyond the out point of the one before, before the in point of this one).\n */\nexport interface VideoTransition {\n\ttype: VideoTransitionType;\n\t/** Length (ms), centred on the cut. */\n\tduration: number;\n\t/** For dip: the colour passed through (default black). */\n\tcolor?: string;\n\t/** For wipe: the way the edge travels (default right, revealing from the left). */\n\tdirection?: VideoWipeDirection;\n}\n\ninterface VideoClipBase {\n\tid: string;\n\tname?: string;\n\t/** Where it starts on the timeline (ms). */\n\tstart: number;\n\t/** How long it lasts on the timeline (ms). */\n\tduration: number;\n\t/** 0–1 (default 1), or keyframes (`{ keys: [{ t, v, ease }] }`, `t` in ms from the clip's start). */\n\topacity?: VideoAnimatable;\n\ttransform?: VideoTransform;\n\t/** Default contain. */\n\tfit?: VideoFit;\n\t/** Fade in from nothing over this long (ms), pictures and sound. */\n\tfadeIn?: number;\n\t/** Fade out over this long (ms). */\n\tfadeOut?: number;\n\t/** Transition from the clip before (see `VideoTransition`). */\n\ttransition?: VideoTransition;\n}\n\n/** A stretch of an asset. */\nexport interface VideoMediaClip extends VideoClipBase {\n\ttype: 'media';\n\tasset: string;\n\t/** Where in the source it starts (ms, default 0). */\n\tin?: number;\n\t/** Playback speed (default 1): it uses `duration × speed` of the source. */\n\tspeed?: number;\n\t/** Gain (1 = unchanged, default 1), or keyframes. */\n\tvolume?: VideoAnimatable;\n\t/** Silences this clip. */\n\tmuted?: boolean;\n}\n\n/** A title. */\nexport interface VideoTextClip extends VideoClipBase {\n\ttype: 'text';\n\ttext: string;\n\t/** CSS colour (default white). */\n\tcolor?: string;\n\t/** Pixels (default 64). */\n\tfontSize?: number;\n\t/** Default sans-serif. */\n\tfontFamily?: string;\n\t/** Default 700. */\n\tfontWeight?: number;\n\t/** Box behind the text (CSS colour); none by default. */\n\tbackground?: string;\n}\n\n/** A solid colour filling the frame. */\nexport interface VideoColorClip extends VideoClipBase {\n\ttype: 'color';\n\tcolor: string;\n}\n\nexport type VideoClip = VideoMediaClip | VideoTextClip | VideoColorClip;\nexport type VideoClipType = VideoClip['type'];\n\n/**\n * How a clip is placed where others already are: `refuse` an overlap (the default), `overwrite`\n * what is underneath (cutting, trimming or removing it), or `insert` (cut everything across the\n * point and push later material along on every sync-locked track).\n */\nexport type VideoPlacementMode = 'refuse' | 'overwrite' | 'insert';\nexport const VIDEO_PLACEMENT_MODES = ['refuse', 'overwrite', 'insert'] as const;\nexport const VIDEO_CLIP_TYPES = ['media', 'text', 'color'] as const;\n\nexport interface VideoMarker {\n\tid: string;\n\ttime: number;\n\tlabel?: string;\n\t/** CSS colour. */\n\tcolor?: string;\n}\n\nexport interface VideoProject {\n\tversion: 1;\n\tname?: string;\n\t/** Frame size in pixels. */\n\twidth: number;\n\theight: number;\n\t/** Frames per second: the grid edits snap to and exports use. */\n\tfps: number;\n\t/** What shows where no clip covers the frame (default black). */\n\tbackground?: string;\n\tassets: VideoAsset[];\n\ttracks: VideoTrack[];\n\tmarkers?: VideoMarker[];\n}\n","/**\n * Keyframes for a clip's opacity, volume and transform. A property is either a plain number or\n * `{ keys }`: values at clip-relative times (ms from the clip's start), each with the easing of the\n * segment that leaves it. Pure functions only, so previews, renders and exports agree.\n *\n * Easing is stored as CSS `cubic-bezier(x1, y1, x2, y2)` control points (named easings are the CSS\n * ones), which is also what After Effects / Lottie speed-and-influence handles convert to exactly.\n */\n\n/** How a segment moves from its key to the next: a named curve, `hold` (jump at the next key) or CSS cubic-bezier points. */\nexport type VideoEase = 'linear' | 'hold' | 'ease-in' | 'ease-out' | 'ease-in-out' | [number, number, number, number];\nexport const VIDEO_EASE_NAMES = ['linear', 'hold', 'ease-in', 'ease-out', 'ease-in-out'] as const;\n\nexport interface VideoKey {\n\t/** Clip-relative time (ms from the clip's start). */\n\tt: number;\n\tv: number;\n\t/** The segment from this key to the next (default linear). Ignored on the last key. */\n\tease?: VideoEase;\n}\n\nexport interface VideoKeyframes {\n\t/** In time order, at least one. */\n\tkeys: VideoKey[];\n}\n\n/** A number, or keyframes for it. */\nexport type VideoAnimatable = number | VideoKeyframes;\n\n/** The clip properties that can be keyframed: `opacity`, `volume` (media) and the transform's fields. */\nexport type VideoKeyframeProperty = 'opacity' | 'volume' | 'x' | 'y' | 'scale' | 'rotation';\nexport const VIDEO_KEYFRAME_PROPERTIES = ['opacity', 'volume', 'x', 'y', 'scale', 'rotation'] as const;\nexport const VIDEO_TRANSFORM_PROPERTIES = ['x', 'y', 'scale', 'rotation'] as const;\n\n/** Each property's default and allowed range. */\nexport const VIDEO_KEYFRAME_LIMITS: Record<VideoKeyframeProperty, { default: number; min?: number; max?: number }> = {\n\topacity: { default: 1, min: 0, max: 1 },\n\tvolume: { default: 1, min: 0, max: 4 },\n\tx: { default: 0 },\n\ty: { default: 0 },\n\tscale: { default: 1, min: 0 },\n\trotation: { default: 0 }\n};\n\nconst NAMED: Record<'ease-in' | 'ease-out' | 'ease-in-out', [number, number, number, number]> = {\n\t'ease-in': [0.42, 0, 1, 1],\n\t'ease-out': [0, 0, 0.58, 1],\n\t'ease-in-out': [0.42, 0, 0.58, 1]\n};\n\nexport const isVideoKeyframes = (v: unknown): v is VideoKeyframes => typeof v === 'object' && v !== null && Array.isArray((v as VideoKeyframes).keys);\n\n/** The cubic-bezier points for an ease (`null` for linear and hold). */\nexport function videoEaseBezier(ease: VideoEase | undefined): [number, number, number, number] | null {\n\tif (ease === undefined || ease === 'linear' || ease === 'hold') return null;\n\treturn Array.isArray(ease) ? ease : NAMED[ease];\n}\n\nconst bez = (a: number, b: number, s: number) => 3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;\nconst bezSlope = (a: number, b: number, s: number) => 3 * a * (1 - s) * (1 - s) + 6 * (b - a) * (1 - s) * s + 3 * (1 - b) * s * s;\n\n/** The curve parameter where a cubic-bezier's x is `x`: Newton's method, then bisection if that fails. */\nfunction bezierParam(x1: number, x2: number, x: number): number {\n\tlet s = x;\n\tfor (let i = 0; i < 8; i++) {\n\t\tconst err = bez(x1, x2, s) - x;\n\t\tif (Math.abs(err) < 1e-7) return s;\n\t\tconst d = bezSlope(x1, x2, s);\n\t\tif (Math.abs(d) < 1e-6) break;\n\t\ts -= err / d;\n\t\tif (s < 0 || s > 1) break;\n\t}\n\tlet lo = 0;\n\tlet hi = 1;\n\ts = x;\n\tfor (let i = 0; i < 60; i++) {\n\t\tconst v = bez(x1, x2, s);\n\t\tif (Math.abs(v - x) < 1e-9) break;\n\t\tif (v < x) lo = s;\n\t\telse hi = s;\n\t\ts = (lo + hi) / 2;\n\t}\n\treturn s;\n}\n\n/** How far (0–1 in, any y out) an ease has got at progress `p` (0–1) through its segment. */\nexport function videoEaseProgress(ease: VideoEase | undefined, p: number): number {\n\tif (p <= 0) return 0;\n\tif (p >= 1) return ease === 'hold' ? 0 : 1;\n\tif (ease === 'hold') return 0;\n\tconst b = videoEaseBezier(ease);\n\tif (!b) return p;\n\treturn bez(b[1], b[3], bezierParam(b[0], b[2], p));\n}\n\n/** The index of the last key at or before `t` (-1 before the first). */\nfunction keyBefore(keys: readonly VideoKey[], t: number): number {\n\tlet lo = 0;\n\tlet hi = keys.length - 1;\n\tif (t < keys[0].t) return -1;\n\twhile (lo < hi) {\n\t\tconst mid = (lo + hi + 1) >> 1;\n\t\tif (keys[mid].t <= t) lo = mid;\n\t\telse hi = mid - 1;\n\t}\n\treturn lo;\n}\n\n/**\n * A property's value at clip-relative time `clipTime` (ms): the number itself, or the keys\n * interpolated (held before the first and after the last). `fallback` is used when it is unset.\n */\nexport function videoValueAt(prop: VideoAnimatable | undefined, clipTime: number, fallback = 0): number {\n\tif (prop === undefined) return fallback;\n\tif (typeof prop === 'number') return prop;\n\tconst { keys } = prop;\n\tif (!keys.length) return fallback;\n\tconst i = keyBefore(keys, clipTime);\n\tif (i < 0) return keys[0].v;\n\tif (i >= keys.length - 1) return keys[keys.length - 1].v;\n\tconst a = keys[i];\n\tconst b = keys[i + 1];\n\tif (a.ease === 'hold') return a.v;\n\treturn a.v + (b.v - a.v) * videoEaseProgress(a.ease, (clipTime - a.t) / (b.t - a.t));\n}\n\n/** A property's keys (a plain number or unset gives none). */\nexport const videoKeysOf = (prop: VideoAnimatable | undefined): VideoKey[] => (isVideoKeyframes(prop) ? prop.keys : []);\n\n/** Shifts every key by `dt` ms (or scales times by `k` first); numbers pass through. */\nexport function retimeVideoAnimatable<T extends VideoAnimatable | undefined>(prop: T, dt: number, k = 1): T {\n\tif (!isVideoKeyframes(prop)) return prop;\n\treturn { keys: prop.keys.map((key) => ({ ...key, t: key.t * k + dt })) } as T;\n}\n\n/** Splits a cubic-bezier ease at progress `p`, giving the eases of the two halves (renormalised). */\nfunction splitEase(ease: VideoEase | undefined, p: number): [VideoEase | undefined, VideoEase | undefined] {\n\tif (ease === 'hold') return ['hold', 'hold'];\n\tconst b = videoEaseBezier(ease);\n\tif (!b) return [ease, ease];\n\tconst s = bezierParam(b[0], b[2], p);\n\tconst lerp = (u: [number, number], v: [number, number], k: number): [number, number] => [u[0] + (v[0] - u[0]) * k, u[1] + (v[1] - u[1]) * k];\n\tconst P0: [number, number] = [0, 0];\n\tconst P1: [number, number] = [b[0], b[1]];\n\tconst P2: [number, number] = [b[2], b[3]];\n\tconst P3: [number, number] = [1, 1];\n\tconst A = lerp(P0, P1, s);\n\tconst B = lerp(P1, P2, s);\n\tconst C = lerp(P2, P3, s);\n\tconst AB = lerp(A, B, s);\n\tconst BC = lerp(B, C, s);\n\tconst M = lerp(AB, BC, s);\n\tconst r = (v: number) => Math.round(v * 1e6) / 1e6;\n\t// Each half is rescaled to run 0–1 in both x and y; one with no rise has nothing to ease.\n\tconst left: VideoEase | undefined = Math.abs(M[1]) < 1e-9 ? 'linear' : [r(A[0] / M[0]), r(A[1] / M[1]), r(AB[0] / M[0]), r(AB[1] / M[1])];\n\tconst dx = 1 - M[0];\n\tconst dy = 1 - M[1];\n\tconst right: VideoEase | undefined = Math.abs(dy) < 1e-9 ? 'linear' : [r((BC[0] - M[0]) / dx), r((BC[1] - M[1]) / dy), r((C[0] - M[0]) / dx), r((C[1] - M[1]) / dy)];\n\treturn [left, right];\n}\n\n/**\n * Splits keyframes at clip time `at`: the keys before it (with a key added at `at`) and the keys\n * after it, moved to start at 0 (with a key added at 0). Eased segments across the cut are split\n * into two curves that trace the same path, so the two halves play exactly as the whole did.\n */\nexport function splitVideoAnimatable<T extends VideoAnimatable | undefined>(prop: T, at: number): [T, T] {\n\tif (!isVideoKeyframes(prop)) return [prop, prop];\n\tconst keys = prop.keys;\n\tconst eps = 1e-6;\n\tconst v = videoValueAt(prop, at);\n\tconst i = keyBefore(keys, at);\n\tconst exact = i >= 0 && Math.abs(keys[i].t - at) < eps;\n\tlet leftEase: VideoEase | undefined;\n\tlet rightEase: VideoEase | undefined;\n\tif (exact) rightEase = keys[i].ease;\n\telse if (i >= 0 && i < keys.length - 1) {\n\t\tconst a = keys[i];\n\t\t[leftEase, rightEase] = splitEase(a.ease, (at - a.t) / (keys[i + 1].t - a.t));\n\t}\n\tconst before = keys.slice(0, exact ? i : i + 1).map((k) => ({ ...k }));\n\tif (before.length && leftEase !== undefined) before[before.length - 1] = withEase(before[before.length - 1], leftEase);\n\tconst left: VideoKey[] = [...before, { t: at, v }];\n\tconst right: VideoKey[] = [withEase({ t: 0, v }, rightEase), ...keys.slice(i + 1).map((k) => ({ ...k, t: k.t - at }))];\n\treturn [{ keys: left } as T, { keys: right } as T];\n}\n\nconst withEase = (key: VideoKey, ease: VideoEase | undefined): VideoKey => {\n\tconst { ease: _old, ...rest } = key;\n\treturn ease === undefined || ease === 'linear' ? rest : { ...rest, ease };\n};\n\n/** The keys sorted by time, refusing two at the same time. */\nexport function sortVideoKeys(keys: readonly VideoKey[]): VideoKey[] {\n\tconst sorted = [...keys].sort((a, b) => a.t - b.t);\n\tfor (let i = 1; i < sorted.length; i++) if (Math.abs(sorted[i].t - sorted[i - 1].t) < 1e-6) throw new Error(`Two keys are at ${sorted[i].t} ms; give each key its own time.`);\n\treturn sorted;\n}\n\n/**\n * Keys with one set (or replaced) at `t`: a plain value becomes keys holding it and the new one.\n * For a keyframe toggle or \"add key at playhead\".\n */\nexport function setVideoKey(prop: VideoAnimatable | undefined, key: VideoKey, fallback: number): VideoKeyframes {\n\tconst keys = isVideoKeyframes(prop) ? prop.keys.filter((k) => Math.abs(k.t - key.t) >= 1e-6) : [];\n\tif (!isVideoKeyframes(prop)) {\n\t\tconst v = prop ?? fallback;\n\t\tif (key.t !== 0) keys.push({ t: 0, v });\n\t}\n\treturn { keys: sortVideoKeys([...keys, key]) };\n}\n\n/** Keys with the one at `t` removed; the value it then holds when none are left. */\nexport function removeVideoKey(prop: VideoKeyframes, t: number): VideoAnimatable {\n\tconst keys = prop.keys.filter((k) => Math.abs(k.t - t) >= 1e-6);\n\treturn keys.length ? { keys } : prop.keys[0].v;\n}\n\n// ---------------------------------------------------------------------------------------------\n// ffmpeg expressions\n// ---------------------------------------------------------------------------------------------\n\nconst fmt = (v: number) => {\n\tconst r = Math.round(v * 1e4) / 1e4;\n\treturn Object.is(r, -0) ? '0' : String(r);\n};\n\n/** `+n` or `-n`, so expressions never read `--n`. */\nconst signed = (v: number) => (fmt(v).startsWith('-') ? fmt(v) : `+${fmt(v)}`);\n\n/**\n * Breakpoints `[clipTime, value]` tracing a property as straight lines: linear segments as they\n * are, holds as steps, eased segments sampled every `step` ms (about two frames).\n */\nexport function videoKeyframeBreakpoints(prop: VideoKeyframes, step: number): { points: [number, number][]; holds: Set<number> } {\n\tconst { keys } = prop;\n\tconst points: [number, number][] = [];\n\tconst holds = new Set<number>();\n\tfor (let i = 0; i < keys.length; i++) {\n\t\tconst a = keys[i];\n\t\tpoints.push([a.t, a.v]);\n\t\tconst b = keys[i + 1];\n\t\tif (!b) break;\n\t\tif (a.ease === 'hold') {\n\t\t\tholds.add(points.length - 1);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!videoEaseBezier(a.ease)) continue;\n\t\tconst n = Math.max(1, Math.ceil((b.t - a.t) / step));\n\t\tfor (let k = 1; k < n; k++) {\n\t\t\tconst t = a.t + ((b.t - a.t) * k) / n;\n\t\t\tpoints.push([t, videoValueAt(prop, t)]);\n\t\t}\n\t}\n\treturn { points, holds };\n}\n\n/**\n * An ffmpeg expression for a property, given `time`: an expression for the clip-relative time in\n * ms (e.g. `(t*1000-1500)`). Piecewise linear `if(lt(…))` chains; eased segments are baked to\n * breakpoints every `step` ms.\n */\nexport function videoKeyframeFfmpegExpr(prop: VideoAnimatable | undefined, time: string, fallback: number, step: number): string {\n\tif (!isVideoKeyframes(prop)) return fmt(prop ?? fallback);\n\tconst { points, holds } = videoKeyframeBreakpoints(prop, step);\n\tif (points.length === 1 || points.every(([, v]) => v === points[0][1])) return fmt(points[0][1]);\n\tlet expr = fmt(points[points.length - 1][1]);\n\tfor (let i = points.length - 2; i >= 0; i--) {\n\t\tconst [t0, v0] = points[i];\n\t\tconst [t1, v1] = points[i + 1];\n\t\tconst seg = holds.has(i) || v0 === v1 ? fmt(v0) : `${fmt(v0)}${signed((v1 - v0) / (t1 - t0))}*(${time}${signed(-t0)})`;\n\t\texpr = `if(lt(${time},${fmt(t1)}),${seg},${expr})`;\n\t}\n\treturn `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;\n}\n","import { VIDEO_EASE_NAMES, type VideoAnimatable, type VideoEase, type VideoKey, type VideoKeyframes } from './keyframes.js';\nimport { VIDEO_CLIP_TYPES, VIDEO_FITS, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoAsset, type VideoClip, type VideoMarker, type VideoProject, type VideoTrack, 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 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});\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});\n\tif (kind === 'image' && asset.duration !== undefined) fail(`${path}.duration`, 'is only for video and audio assets');\n\treturn asset;\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\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});\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\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, clips and markers. Overlaps and missing media 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', '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\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.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 type { VideoAsset, VideoClip, VideoProject, VideoTrack } from './types.js';\n\n/** Clip edges closer than this (ms) count as touching: a cut, not a gap. */\nexport const VIDEO_TIME_EPSILON = 0.5;\n\nexport const videoClipEnd = (clip: VideoClip): number => clip.start + clip.duration;\n\n/** Whether a track moves with ripple edits on other tracks (`syncLocked`, default true). */\nexport const videoTrackSyncLocked = (track: VideoTrack): boolean => track.syncLocked !== false;\n\n/** Playback speed of a clip (1 for titles and colours). */\nexport const videoClipSpeed = (clip: VideoClip): number => (clip.type === 'media' ? (clip.speed ?? 1) : 1);\n\n/** The stretch of source a media clip plays (ms), or `null` for titles and colours. */\nexport function videoClipSourceRange(clip: VideoClip): { in: number; out: number } | null {\n\tif (clip.type !== 'media') return null;\n\tconst start = clip.in ?? 0;\n\treturn { in: start, out: start + clip.duration * videoClipSpeed(clip) };\n}\n\n/** Length of one frame (ms). */\nexport const videoFrameDuration = (fps: number): number => 1000 / fps;\n\n/** `time` moved to the nearest frame boundary. */\nexport function snapToVideoFrame(time: number, fps: number): number {\n\tconst frame = Math.round((time * fps) / 1000);\n\treturn Math.round(((frame * 1000) / fps) * 1000) / 1000;\n}\n\n/** Whole frames in `time` at `fps` (rounded). */\nexport const videoFrames = (time: number, fps: number): number => Math.round((time * fps) / 1000);\n\n/** Non-drop SMPTE timecode `HH:MM:SS:FF` for a time (ms). */\nexport function formatVideoTimecode(time: number, fps: number): string {\n\tconst base = Math.round(fps);\n\tconst total = Math.max(0, videoFrames(time, fps));\n\tconst ff = total % base;\n\tconst secs = Math.floor(total / base);\n\tconst pad = (n: number) => String(n).padStart(2, '0');\n\treturn `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(ff)}`;\n}\n\n/** Readable time for messages: `2.50 s`. */\nexport const formatVideoSeconds = (time: number): string => `${(time / 1000).toFixed(2)} s`;\n\nexport function videoAssetMap(project: VideoProject): Map<string, VideoAsset> {\n\treturn new Map(project.assets.map((a) => [a.id, a]));\n}\n\n/** A track's clips in timeline order. */\nexport function sortedVideoClips(track: VideoTrack): VideoClip[] {\n\treturn [...track.clips].sort((a, b) => a.start - b.start || a.duration - b.duration);\n}\n\nexport interface VideoClipLocation {\n\ttrack: VideoTrack;\n\ttrackIndex: number;\n\tclip: VideoClip;\n\t/** Index in `track.clips`. */\n\tindex: number;\n}\n\nexport function findVideoClip(project: VideoProject, id: string): VideoClipLocation | null {\n\tfor (let trackIndex = 0; trackIndex < project.tracks.length; trackIndex++) {\n\t\tconst track = project.tracks[trackIndex];\n\t\tconst index = track.clips.findIndex((c) => c.id === id);\n\t\tif (index >= 0) return { track, trackIndex, clip: track.clips[index], index };\n\t}\n\treturn null;\n}\n\nexport function allVideoClipIds(project: VideoProject): string[] {\n\treturn project.tracks.flatMap((t) => t.clips.map((c) => c.id));\n}\n\n/** Total length: the end of the last clip (ms). */\nexport function videoProjectDuration(project: VideoProject): number {\n\tlet end = 0;\n\tfor (const track of project.tracks) for (const clip of track.clips) end = Math.max(end, videoClipEnd(clip));\n\treturn end;\n}\n\n/** The clip that ends where `clip` starts on its track (a cut), if any. */\nexport function videoClipBefore(track: VideoTrack, clip: VideoClip): VideoClip | null {\n\tlet best: VideoClip | null = null;\n\tfor (const c of track.clips) if (c !== clip && Math.abs(videoClipEnd(c) - clip.start) <= VIDEO_TIME_EPSILON) best = c;\n\treturn best;\n}\n\n/** The clip that starts where `clip` ends on its track (a cut), if any. */\nexport function videoClipAfter(track: VideoTrack, clip: VideoClip): VideoClip | null {\n\tfor (const c of track.clips) if (c !== clip && Math.abs(c.start - videoClipEnd(clip)) <= VIDEO_TIME_EPSILON) return c;\n\treturn null;\n}\n\n/**\n * Timeline time (ms) a clip can run past its edges into source media: before its in point\n * (`before`) and after its out point (`after`). Unlimited for stills, titles, colours and media\n * whose length is unknown.\n */\nexport function videoClipHandles(clip: VideoClip, assets: Map<string, VideoAsset>): { before: number; after: number } {\n\tif (clip.type !== 'media') return { before: Infinity, after: Infinity };\n\tconst asset = assets.get(clip.asset);\n\tconst speed = videoClipSpeed(clip);\n\tconst range = videoClipSourceRange(clip)!;\n\tconst before = range.in / speed;\n\tif (!asset || asset.kind === 'image' || asset.duration === undefined) return { before: asset?.kind === 'image' ? Infinity : before, after: Infinity };\n\treturn { before, after: Math.max(0, (asset.duration - range.out) / speed) };\n}\n\n/**\n * How far a clip plays past each edge because of transitions: `lead` before its start (its own\n * transition in) and `tail` after its end (the next clip's transition in). Dips need neither.\n */\nexport function videoClipOverhang(track: VideoTrack, clip: VideoClip): { lead: number; tail: number } {\n\t// A dip cuts under its colour, so neither clip plays past the cut.\n\tconst lead = clip.transition && clip.transition.type !== 'dip' && videoClipBefore(track, clip) ? clip.transition.duration / 2 : 0;\n\tconst next = videoClipAfter(track, clip);\n\tconst tail = next?.transition && next.transition.type !== 'dip' ? next.transition.duration / 2 : 0;\n\treturn { lead, tail };\n}\n\nexport interface VideoSnapOptions {\n\t/** Clip ids whose edges are not snap targets (the ones being dragged). */\n\texclude?: Iterable<string>;\n\t/** Also snap to this time (usually the playhead). */\n\tplayhead?: number;\n}\n\n/** Times worth snapping to: 0, every clip edge, every marker and the playhead, ascending. */\nexport function videoSnapPoints(project: VideoProject, options: VideoSnapOptions = {}): number[] {\n\tconst exclude = new Set(options.exclude ?? []);\n\tconst points = new Set<number>([0]);\n\tfor (const track of project.tracks)\n\t\tfor (const clip of track.clips) {\n\t\t\tif (exclude.has(clip.id)) continue;\n\t\t\tpoints.add(clip.start);\n\t\t\tpoints.add(videoClipEnd(clip));\n\t\t}\n\tfor (const marker of project.markers ?? []) points.add(marker.time);\n\tif (options.playhead !== undefined) points.add(options.playhead);\n\treturn [...points].sort((a, b) => a - b);\n}\n\n/** The snap point nearest `time` if it is within `threshold` (ms), else `time`. */\nexport function snapVideoTime(time: number, points: readonly number[], threshold: number): { time: number; snapped: number | null } {\n\tlet best: number | null = null;\n\tfor (const p of points) if (Math.abs(p - time) <= threshold && (best === null || Math.abs(p - time) < Math.abs(best - time))) best = p;\n\treturn best === null ? { time, snapped: null } : { time: best, snapped: best };\n}\n\nexport interface VideoGap {\n\ttrack: string;\n\tstart: number;\n\tend: number;\n}\n\n/** Empty stretches between clips (and before the first) on each track, or one track. */\nexport function findVideoGaps(project: VideoProject, trackId?: string): VideoGap[] {\n\tconst gaps: VideoGap[] = [];\n\tfor (const track of project.tracks) {\n\t\tif (trackId !== undefined && track.id !== trackId) continue;\n\t\tlet at = 0;\n\t\tfor (const clip of sortedVideoClips(track)) {\n\t\t\tif (clip.start - at > VIDEO_TIME_EPSILON) gaps.push({ track: track.id, start: at, end: clip.start });\n\t\t\tat = Math.max(at, videoClipEnd(clip));\n\t\t}\n\t}\n\treturn gaps;\n}\n\n/** The first clip on `track` (other than `ignore`) that overlaps `[start, end)`, if any. */\nexport function videoOverlap(track: VideoTrack, start: number, end: number, ignore: ReadonlySet<string> = new Set()): VideoClip | null {\n\tfor (const c of sortedVideoClips(track)) {\n\t\tif (ignore.has(c.id)) continue;\n\t\tif (c.start < end - VIDEO_TIME_EPSILON && videoClipEnd(c) > start + VIDEO_TIME_EPSILON) return c;\n\t}\n\treturn null;\n}\n\n/** The first free stretch on `track` at or after `from` that fits `duration`. */\nexport function nextFreeVideoSlot(track: VideoTrack, from: number, duration: number, ignore: ReadonlySet<string> = new Set()): number {\n\tlet at = from;\n\tfor (const c of sortedVideoClips(track)) {\n\t\tif (ignore.has(c.id)) continue;\n\t\tif (videoClipEnd(c) <= at + VIDEO_TIME_EPSILON) continue;\n\t\tif (c.start >= at + duration - VIDEO_TIME_EPSILON) break;\n\t\tat = videoClipEnd(c);\n\t}\n\treturn at;\n}\n\n/** Clips under `time` (start inclusive, end exclusive), top track first. */\nexport function videoClipsAt(project: VideoProject, time: number): { track: string; clip: VideoClip }[] {\n\tconst out: { track: string; clip: VideoClip }[] = [];\n\tfor (const track of project.tracks) for (const clip of track.clips) if (time >= clip.start && time < videoClipEnd(clip)) out.push({ track: track.id, clip });\n\treturn out;\n}\n","import { formatVideoSeconds, sortedVideoClips, VIDEO_TIME_EPSILON, videoAssetMap, videoClipBefore, videoClipEnd, videoClipHandles, videoClipSourceRange, videoProjectDuration } from './timeline.js';\nimport { VIDEO_TRANSFORM_PROPERTIES, videoKeysOf } from './keyframes.js';\nimport type { VideoClip, VideoProject } 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' ? 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'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'keyframes-outside-clip',\n\t'unknown-duration',\n\t'unused-asset',\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\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.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). 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 used = new Set<string>();\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\tfor (const track of project.tracks) {\n\t\tconst clips = sortedVideoClips(track);\n\t\tfor (let i = 0; i < clips.length; i++) {\n\t\t\tconst clip = clips[i];\n\t\t\tconst at = { track: track.id, clip: clip.id };\n\t\t\tif (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}\" 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\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).after;\n\t\t\t\tconst incoming = videoClipHandles(clip, assets).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 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, parseVideoTransition } from './parse.js';\nimport {\n\tfindVideoClip,\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\tvideoTrackSyncLocked\n} from './timeline.js';\nimport { VIDEO_PLACEMENT_MODES, type VideoAsset, type VideoClip, type VideoMarker, 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| { 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'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/** 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];\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', 'fadeIn', 'fadeOut', 'transition', 'in', 'speed', '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\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 }) {\n\t\tconst asset = clip.type === 'media' ? this.asset(clip.asset!) : null;\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 this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));\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 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 !== '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\tthis.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\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\tthis.moveIn(right, at - clip.start);\n\t\tclip.duration = at - clip.start;\n\t\tdelete clip.fadeOut;\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 = this.p.tracks.flatMap((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.p.tracks.flatMap((t) => t.clips.map((c) => c.id));\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 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\tif (clip.type === 'media') {\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') {\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 (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\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_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') refuse(`Clip \"${clip.id}\" is a ${clip.type === 'text' ? 'title' : 'colour'}, so it has no source to slip; only video and audio clips slip.`);\n\t\t\t\tconst asset = this.asset((clip as { asset: string }).asset);\n\t\t\t\tif (asset.kind === 'image') refuse(`Clip \"${clip.id}\" is a still (\"${asset.id}\"), 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));\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 \"${asset.id}\"${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 \"${asset.id}\"${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).before - videoClipOverhang(track, R).lead));\n\t\t\t\tconst hi = Math.min(R.duration - this.frame, videoClipHandles(L, assets).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).before - videoClipOverhang(track, after).lead : Infinity);\n\t\t\t\tconst hi = Math.min(room.later, before ? videoClipHandles(before, assets).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') refuse(`Clip \"${clip.id}\" is a ${clip.type} clip and has no volume; volume keys go on media 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 { videoValueAt } from './keyframes.js';\nimport { sortedVideoClips, videoAssetMap, videoClipAfter, videoClipBefore, videoClipEnd, videoClipSpeed } from './timeline.js';\nimport type { VideoAsset, VideoClip, VideoFit, VideoProject, VideoTextClip, VideoTrack, VideoWipeDirection } from './types.js';\n\n/** A rectangle in frame pixels. */\nexport interface VideoRect {\n\tx: number;\n\ty: number;\n\twidth: number;\n\theight: number;\n}\n\n/** One picture in a frame, in draw order (first is at the back). */\nexport interface VideoLayer {\n\t/** The clip it comes from, or `transition:<clip>` for a dip's colour. */\n\tclip: string;\n\ttrack: string;\n\ttype: VideoClip['type'];\n\t/** For media: the asset, its kind and `src`, and the moment of the source to show (ms). */\n\tasset?: string;\n\tassetKind?: VideoAsset['kind'];\n\tsrc?: string;\n\tsourceTime?: number;\n\t/** 0–1, after fades and transitions. */\n\topacity: number;\n\t/** Where the fitted picture sits before rotation (its centre is the rotation centre). */\n\trect: VideoRect;\n\t/** Degrees clockwise about the rect's centre. */\n\trotation: number;\n\t/** For a wipe: the part shown is the `progress` (0–1) of the rect from the edge opposite `direction`. */\n\twipe?: { direction: VideoWipeDirection; progress: number };\n\t/** For text: what to draw. */\n\ttext?: { text: string; color: string; fontSize: number; fontFamily: string; fontWeight: number; background?: string };\n\t/** For colour clips and dips. */\n\tcolor?: string;\n}\n\n/** One sound playing at a moment. */\nexport interface VideoAudioSource {\n\tclip: string;\n\ttrack: string;\n\tasset: string;\n\tsrc: string;\n\t/** The moment of the source playing (ms). */\n\tsourceTime: number;\n\t/** Gain after clip and track volume, fades and transitions (0 = silent). */\n\tgain: number;\n\tspeed: number;\n}\n\nexport interface VideoFrame {\n\ttime: number;\n\twidth: number;\n\theight: number;\n\tbackground: string;\n\tlayers: VideoLayer[];\n\taudio: VideoAudioSource[];\n}\n\n/** A transform's values at clip-relative time `clipTime` (ms), keyframes resolved and defaults filled in. */\nexport function videoTransformAt(transform: VideoClip['transform'], clipTime = 0): { x: number; y: number; scale: number; rotation: number } {\n\treturn { x: videoValueAt(transform?.x, clipTime, 0), y: videoValueAt(transform?.y, clipTime, 0), scale: videoValueAt(transform?.scale, clipTime, 1), rotation: videoValueAt(transform?.rotation, clipTime, 0) };\n}\n\n/** Places a picture of `size` in the frame by `fit`, then the clip's transform at clip-relative `clipTime` (ms). */\nexport function placeVideoRect(frame: { width: number; height: number }, size: { width: number; height: number }, fit: VideoFit = 'contain', transform: VideoClip['transform'] = {}, clipTime = 0): VideoRect {\n\tlet w = size.width;\n\tlet h = size.height;\n\tif (fit === 'fill') {\n\t\tw = frame.width;\n\t\th = frame.height;\n\t} else if (fit !== 'none') {\n\t\tconst k = fit === 'cover' ? Math.max(frame.width / w, frame.height / h) : Math.min(frame.width / w, frame.height / h);\n\t\tw *= k;\n\t\th *= k;\n\t}\n\tconst t = videoTransformAt(transform, clipTime);\n\tw *= t.scale;\n\th *= t.scale;\n\tconst cx = frame.width / 2 + t.x;\n\tconst cy = frame.height / 2 + t.y;\n\treturn { x: cx - w / 2, y: cy - h / 2, width: w, height: h };\n}\n\n/** A title's text and style with the defaults filled in. */\nexport function videoTextStyle(clip: VideoTextClip): NonNullable<VideoLayer['text']> {\n\treturn { text: clip.text, color: clip.color ?? '#ffffff', fontSize: clip.fontSize ?? 64, fontFamily: clip.fontFamily ?? 'sans-serif', fontWeight: clip.fontWeight ?? 700, ...(clip.background ? { background: clip.background } : {}) };\n}\n\n/**\n * A frame holding only one title, fully opaque on a transparent background, placed as the preview\n * places it: draw it with `drawVideoFrame` and save the canvas as a PNG to give the ffmpeg export\n * titles without `drawtext` (`VideoFfmpegOptions.titleImage`). Fades and opacity are left to the export.\n * A keyframed transform is placed as it is at `clipTime` (the export passes titles with keyframed\n * transforms without them, and moves the image itself).\n */\nexport function videoTitleFrame(project: VideoProject, clip: VideoTextClip, clipTime = 0): VideoFrame {\n\tconst layer: VideoLayer = { clip: clip.id, track: '', type: 'text', opacity: 1, rect: placeVideoRect(project, project, 'fill', clip.transform, clipTime), rotation: videoTransformAt(clip.transform, clipTime).rotation, text: videoTextStyle(clip) };\n\treturn { time: clip.start, width: project.width, height: project.height, background: 'rgba(0,0,0,0)', layers: [layer], audio: [] };\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\n\n/** Fade-in / fade-out multiplier for a clip at timeline `time`. */\nfunction fadeGain(clip: VideoClip, time: number): number {\n\tlet g = 1;\n\tif (clip.fadeIn) g *= clamp01((time - clip.start) / clip.fadeIn);\n\tif (clip.fadeOut) g *= clamp01((videoClipEnd(clip) - time) / clip.fadeOut);\n\treturn g;\n}\n\ninterface Active {\n\tclip: VideoClip;\n\t/** 0–1 mix through a transition (1 = fully this clip); undefined outside one. */\n\tincoming?: { progress: number; type: NonNullable<VideoClip['transition']>['type']; transition: NonNullable<VideoClip['transition']> };\n\toutgoing?: { progress: number; type: NonNullable<VideoClip['transition']>['type']; next: VideoClip };\n}\n\n/** The clips of a track playing at `time`, with any transition they are in (outgoing first). */\nfunction activeOnTrack(track: VideoTrack, time: number): Active[] {\n\tconst out: Active[] = [];\n\tfor (const clip of sortedVideoClips(track)) {\n\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\tconst lead = before ? clip.transition!.duration / 2 : 0;\n\t\tconst next = videoClipAfter(track, clip);\n\t\tconst tail = next?.transition ? next.transition.duration / 2 : 0;\n\t\tif (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;\n\t\tconst a: Active = { clip };\n\t\tif (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition!.type, transition: clip.transition! };\n\t\tif (tail && time >= videoClipEnd(clip) - tail) a.outgoing = { progress: clamp01((time - (videoClipEnd(clip) - tail)) / (2 * tail)), type: next!.transition!.type, next: next! };\n\t\tout.push(a);\n\t}\n\treturn out;\n}\n\nconst sourceTimeAt = (clip: VideoClip, time: number) => (clip.type === 'media' ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : 0);\n\n/**\n * What plays at `time` (ms): the pictures to draw, back to front, with their placement, opacity\n * and the moment of source each shows, and the sounds with their gain. A pure function of the\n * project and the time, so previews, exporters and agents all agree on every frame.\n */\nexport function videoFrameAt(project: VideoProject, time: number): VideoFrame {\n\tconst assets = videoAssetMap(project);\n\tconst layers: VideoLayer[] = [];\n\tconst audio: VideoAudioSource[] = [];\n\t// Later tracks are further back, so draw from the last track to the first.\n\tfor (let ti = project.tracks.length - 1; ti >= 0; ti--) {\n\t\tconst track = project.tracks[ti];\n\t\tfor (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {\n\t\t\t// A dip plays the outgoing clip down into its colour and this one up out of it, so neither\n\t\t\t// runs past the cut (and needs no handles).\n\t\t\tif (incoming?.type === 'dip' && incoming.progress < 0.5) continue;\n\t\t\tif (outgoing?.type === 'dip' && outgoing.progress >= 0.5) continue;\n\t\t\tconst fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));\n\t\t\tconst level = (t: { type: string; progress: number }, into: boolean) => (t.type === 'dip' ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);\n\t\t\t// Sound crossfades through a crossfade or wipe, and dips with the picture.\n\t\t\tconst mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);\n\t\t\tconst asset = clip.type === 'media' ? assets.get(clip.asset) : undefined;\n\t\t\tconst sourceTime = sourceTimeAt(clip, time);\n\t\t\t// Keyframes are in clip time, held before the first key and after the last.\n\t\t\tconst clipTime = time - clip.start;\n\t\t\tconst audible = asset && (asset.kind === 'audio' || (asset.kind === 'video' && asset.hasAudio !== false));\n\t\t\tif (audible && clip.type === 'media' && !clip.muted && !track.muted) {\n\t\t\t\tconst gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fade * mix;\n\t\t\t\taudio.push({ clip: clip.id, track: track.id, asset: asset!.id, src: asset!.src, sourceTime, gain, speed: videoClipSpeed(clip) });\n\t\t\t}\n\t\t\tif (track.kind !== 'video' || track.hidden) continue;\n\t\t\tif (clip.type === 'media' && (!asset || asset.kind === 'audio')) continue;\n\t\t\tlet opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;\n\t\t\tlet wipe: VideoLayer['wipe'];\n\t\t\tlet dip: { color: string; opacity: number } | null = null;\n\t\t\tif (incoming) {\n\t\t\t\tif (incoming.type === 'crossfade') opacity *= incoming.progress;\n\t\t\t\telse if (incoming.type === 'wipe') wipe = { direction: incoming.transition.direction ?? 'right', progress: incoming.progress };\n\t\t\t\telse dip = { color: incoming.transition.color ?? '#000000', opacity: (1 - incoming.progress) * 2 };\n\t\t\t}\n\t\t\tif (outgoing?.type === 'dip') dip = { color: outgoing.next.transition!.color ?? '#000000', opacity: outgoing.progress * 2 };\n\t\t\tconst size = clip.type === 'media' ? { width: asset!.width ?? project.width, height: asset!.height ?? project.height } : { width: project.width, height: project.height };\n\t\t\tconst fit = clip.fit ?? (clip.type === 'media' ? 'contain' : 'fill');\n\t\t\tconst rect = placeVideoRect(project, size, clip.type === 'text' ? 'fill' : fit, clip.transform, clipTime);\n\t\t\tconst layer: VideoLayer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(clip.transform?.rotation, clipTime, 0) };\n\t\t\tif (wipe) layer.wipe = wipe;\n\t\t\tif (clip.type === 'media') Object.assign(layer, { asset: asset!.id, assetKind: asset!.kind, src: asset!.src, sourceTime });\n\t\t\tif (clip.type === 'text') layer.text = videoTextStyle(clip);\n\t\t\tif (clip.type === 'color') layer.color = clip.color;\n\t\t\tif (layer.opacity > 0) layers.push(layer);\n\t\t\tif (dip && dip.opacity > 0) layers.push({ clip: `transition:${incoming ? clip.id : outgoing!.next.id}`, track: track.id, type: 'color', color: dip.color, opacity: clamp01(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });\n\t\t}\n\t}\n\treturn { time, width: project.width, height: project.height, background: project.background ?? '#000000', layers, audio };\n}\n","import { videoFrameAt, type VideoFrame, type VideoLayer, type VideoRect } from './evaluate.js';\nimport { formatVideoTimecode } from './timeline.js';\nimport type { VideoProject } from './types.js';\n\nconst esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\nconst n = (v: number) => String(Math.round(v * 100) / 100);\n\n/** The part of a rect a wipe shows. */\nexport function videoWipeRect(rect: VideoRect, wipe: NonNullable<VideoLayer['wipe']>): VideoRect {\n\tconst { direction, progress } = wipe;\n\tconst w = rect.width * progress;\n\tconst h = rect.height * progress;\n\tif (direction === 'right') return { ...rect, width: w };\n\tif (direction === 'left') return { ...rect, x: rect.x + rect.width - w, width: w };\n\tif (direction === 'down') return { ...rect, height: h };\n\treturn { ...rect, y: rect.y + rect.height - h, height: h };\n}\n\nexport interface VideoSvgOptions {\n\t/**\n\t * The picture for a media layer: return an href (URL or data URL) to draw, or `null` for a\n\t * labelled placeholder. Default: images use `src`, videos their `poster`, else a placeholder.\n\t */\n\timageFor?: (layer: VideoLayer, frame: VideoFrame) => string | null;\n\t/** Frame rate for the timecode on placeholders (default 30). */\n\tfps?: number;\n}\n\n/** A frame as standalone SVG: images and posters as `<image>`, video without one as a labelled box. */\nexport function videoFrameToSvg(frame: VideoFrame, options: VideoSvgOptions = {}, assets: VideoProject['assets'] = []): string {\n\tconst defs: string[] = [];\n\tconst body: string[] = [];\n\tconst posters = new Map(assets.map((a) => [a.id, a.poster]));\n\tframe.layers.forEach((layer, i) => {\n\t\tconst { rect } = layer;\n\t\tconst attrs: string[] = [];\n\t\tif (layer.opacity < 1) attrs.push(`opacity=\"${n(layer.opacity)}\"`);\n\t\tif (layer.rotation) attrs.push(`transform=\"rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})\"`);\n\t\tif (layer.wipe) {\n\t\t\tconst r = videoWipeRect(rect, layer.wipe);\n\t\t\tdefs.push(`<clipPath id=\"w${i}\"><rect x=\"${n(r.x)}\" y=\"${n(r.y)}\" width=\"${n(r.width)}\" height=\"${n(r.height)}\"/></clipPath>`);\n\t\t\tattrs.push(`clip-path=\"url(#w${i})\"`);\n\t\t}\n\t\tconst box = `x=\"${n(rect.x)}\" y=\"${n(rect.y)}\" width=\"${n(rect.width)}\" height=\"${n(rect.height)}\"`;\n\t\tlet inner = '';\n\t\tif (layer.type === 'color') inner = `<rect ${box} fill=\"${esc(layer.color ?? '#000000')}\"/>`;\n\t\telse if (layer.type === 'text' && layer.text) {\n\t\t\tconst t = layer.text;\n\t\t\tconst lines = t.text.split('\\n');\n\t\t\tconst lh = t.fontSize * 1.2;\n\t\t\tconst cx = rect.x + rect.width / 2;\n\t\t\tconst cy = rect.y + rect.height / 2;\n\t\t\tconst top = cy - (lh * (lines.length - 1)) / 2;\n\t\t\tif (t.background) {\n\t\t\t\tconst w = Math.max(...lines.map((l) => l.length)) * t.fontSize * 0.6 + t.fontSize;\n\t\t\t\tconst h = lh * lines.length + t.fontSize * 0.4;\n\t\t\t\tinner += `<rect x=\"${n(cx - w / 2)}\" y=\"${n(cy - h / 2)}\" width=\"${n(w)}\" height=\"${n(h)}\" fill=\"${esc(t.background)}\"/>`;\n\t\t\t}\n\t\t\tinner += `<text x=\"${n(cx)}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"${esc(t.fontFamily)}\" font-size=\"${n(t.fontSize)}\" font-weight=\"${t.fontWeight}\" fill=\"${esc(t.color)}\">${lines.map((l, k) => `<tspan x=\"${n(cx)}\" y=\"${n(top + k * lh)}\">${esc(l)}</tspan>`).join('')}</text>`;\n\t\t} else {\n\t\t\tconst href = options.imageFor ? options.imageFor(layer, frame) : layer.assetKind === 'image' ? (layer.src ?? null) : (posters.get(layer.asset ?? '') ?? null);\n\t\t\tif (href) inner = `<image href=\"${esc(href)}\" ${box} preserveAspectRatio=\"none\"/>`;\n\t\t\telse {\n\t\t\t\tconst label = `${layer.asset ?? layer.clip} · ${formatVideoTimecode(layer.sourceTime ?? 0, options.fps ?? 30)}`;\n\t\t\t\tconst size = Math.max(10, Math.min(rect.height / 8, rect.width / Math.max(8, label.length * 0.6)));\n\t\t\t\tinner = `<rect ${box} fill=\"#334155\" stroke=\"#94a3b8\" stroke-width=\"2\"/><text x=\"${n(rect.x + rect.width / 2)}\" y=\"${n(rect.y + rect.height / 2)}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"${n(size)}\" fill=\"#e2e8f0\">${esc(label)}</text>`;\n\t\t\t}\n\t\t}\n\t\tbody.push(attrs.length ? `<g ${attrs.join(' ')}>${inner}</g>` : inner);\n\t});\n\treturn `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${n(frame.width)} ${n(frame.height)}\" width=\"${n(frame.width)}\" height=\"${n(frame.height)}\">${defs.length ? `<defs>${defs.join('')}</defs>` : ''}<rect width=\"100%\" height=\"100%\" fill=\"${esc(frame.background)}\"/>${body.join('')}</svg>`;\n}\n\n/** The project at `time` as SVG. */\nexport function videoProjectSvg(project: VideoProject, time: number, options: VideoSvgOptions = {}): string {\n\treturn videoFrameToSvg(videoFrameAt(project, time), { fps: project.fps, ...options }, project.assets);\n}\n\n/** Several moments in one labelled picture, `columns` a row, each frame scaled to `width` pixels. */\nexport function videoContactSheetSvg(project: VideoProject, times: readonly number[], options: VideoSvgOptions & { columns?: number; width?: number } = {}): string {\n\tconst columns = options.columns ?? Math.ceil(Math.sqrt(times.length));\n\tconst w = options.width ?? 320;\n\tconst h = (w * project.height) / project.width;\n\tconst label = 18;\n\tconst gap = 8;\n\tconst rows = Math.ceil(times.length / columns);\n\tconst W = columns * w + (columns + 1) * gap;\n\tconst H = rows * (h + label) + (rows + 1) * gap;\n\tconst cells = times.map((t, i) => {\n\t\tconst x = gap + (i % columns) * (w + gap);\n\t\tconst y = gap + Math.floor(i / columns) * (h + label + gap);\n\t\tconst svg = videoProjectSvg(project, t, options).replace(/^<svg xmlns=\"http:\\/\\/www.w3.org\\/2000\\/svg\"/, `<svg x=\"${n(x)}\" y=\"${n(y)}\" width=\"${n(w)}\" height=\"${n(h)}\"`).replace(/ width=\"[\\d.]+\" height=\"[\\d.]+\">/, '>');\n\t\treturn `${svg}<text x=\"${n(x)}\" y=\"${n(y + h + 14)}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#0f172a\">${(t / 1000).toFixed(2)} s · ${formatVideoTimecode(t, project.fps)}</text>`;\n\t});\n\treturn `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${n(W)} ${n(H)}\" width=\"${n(W)}\" height=\"${n(H)}\"><rect width=\"100%\" height=\"100%\" fill=\"#f8fafc\"/>${cells.join('')}</svg>`;\n}\n\n/** The subset of `CanvasRenderingContext2D` `drawVideoFrame` uses (so tests and workers can pass their own). */\nexport type VideoCanvasContext = Pick<\n\tCanvasRenderingContext2D,\n\t'save' | 'restore' | 'fillRect' | 'drawImage' | 'translate' | 'rotate' | 'beginPath' | 'rect' | 'clip' | 'fillText' | 'measureText' | 'setTransform'\n> & { globalAlpha: number; fillStyle: string | CanvasGradient | CanvasPattern; font: string; textAlign: CanvasTextAlign; textBaseline: CanvasTextBaseline; strokeStyle: string | CanvasGradient | CanvasPattern; lineWidth: number; strokeRect(x: number, y: number, w: number, h: number): void };\n\n/**\n * Draws a frame on a 2D canvas scaled by `scale` (canvas pixels per frame pixel). `sourceFor`\n * returns what to draw for a media layer (a `<video>` seeked to `layer.sourceTime`, an `<img>`,\n * an `ImageBitmap`…) or `null` for a placeholder box.\n */\nexport function drawVideoFrame(ctx: VideoCanvasContext, frame: VideoFrame, sourceFor: (layer: VideoLayer) => CanvasImageSource | null, scale = 1): void {\n\tctx.save();\n\tctx.setTransform(scale, 0, 0, scale, 0, 0);\n\tctx.globalAlpha = 1;\n\tctx.fillStyle = frame.background;\n\tctx.fillRect(0, 0, frame.width, frame.height);\n\tfor (const layer of frame.layers) {\n\t\tconst { rect } = layer;\n\t\tctx.save();\n\t\tctx.globalAlpha = layer.opacity;\n\t\tif (layer.rotation) {\n\t\t\tctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);\n\t\t\tctx.rotate((layer.rotation * Math.PI) / 180);\n\t\t\tctx.translate(-(rect.x + rect.width / 2), -(rect.y + rect.height / 2));\n\t\t}\n\t\tif (layer.wipe) {\n\t\t\tconst r = videoWipeRect(rect, layer.wipe);\n\t\t\tctx.beginPath();\n\t\t\tctx.rect(r.x, r.y, r.width, r.height);\n\t\t\tctx.clip();\n\t\t}\n\t\tif (layer.type === 'color') {\n\t\t\tctx.fillStyle = layer.color ?? '#000000';\n\t\t\tctx.fillRect(rect.x, rect.y, rect.width, rect.height);\n\t\t} else if (layer.type === 'text' && layer.text) {\n\t\t\tconst t = layer.text;\n\t\t\tconst lines = t.text.split('\\n');\n\t\t\tconst lh = t.fontSize * 1.2;\n\t\t\tconst cx = rect.x + rect.width / 2;\n\t\t\tconst top = rect.y + rect.height / 2 - (lh * (lines.length - 1)) / 2;\n\t\t\tctx.font = `${t.fontWeight} ${t.fontSize}px ${t.fontFamily}`;\n\t\t\tctx.textAlign = 'center';\n\t\t\tctx.textBaseline = 'middle';\n\t\t\tif (t.background) {\n\t\t\t\tconst w = Math.max(...lines.map((l) => ctx.measureText(l).width)) + t.fontSize;\n\t\t\t\tconst h = lh * lines.length + t.fontSize * 0.4;\n\t\t\t\tctx.fillStyle = t.background;\n\t\t\t\tctx.fillRect(cx - w / 2, rect.y + rect.height / 2 - h / 2, w, h);\n\t\t\t}\n\t\t\tctx.fillStyle = t.color;\n\t\t\tlines.forEach((l, k) => ctx.fillText(l, cx, top + k * lh));\n\t\t} else {\n\t\t\tconst source = sourceFor(layer);\n\t\t\tif (source) ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);\n\t\t\telse {\n\t\t\t\tctx.fillStyle = '#334155';\n\t\t\t\tctx.fillRect(rect.x, rect.y, rect.width, rect.height);\n\t\t\t\tctx.strokeStyle = '#94a3b8';\n\t\t\t\tctx.lineWidth = 2;\n\t\t\t\tctx.strokeRect(rect.x, rect.y, rect.width, rect.height);\n\t\t\t}\n\t\t}\n\t\tctx.restore();\n\t}\n\tctx.restore();\n}\n","import { placeVideoRect } from './evaluate.js';\nimport { isVideoKeyframes, VIDEO_TRANSFORM_PROPERTIES, videoKeyframeFfmpegExpr, type VideoAnimatable } from './keyframes.js';\nimport { formatVideoTimecode, sortedVideoClips, videoAssetMap, videoClipBefore, videoClipEnd, videoClipOverhang, videoClipSpeed, videoProjectDuration } from './timeline.js';\nimport type { VideoAsset, VideoClip, VideoProject, VideoTextClip, VideoTrack } from './types.js';\n\n// ---------------------------------------------------------------------------------------------\n// CMX 3600 EDL\n// ---------------------------------------------------------------------------------------------\n\nexport interface VideoEdlOptions {\n\t/** Track to export (default: the bottom video track, V1 in most NLEs). */\n\ttrack?: string;\n\t/** Record timecode of the timeline's start (ms, default 1 hour, the usual 01:00:00:00). */\n\trecordStart?: number;\n\t/** Title line (default: the project's name). */\n\ttitle?: string;\n}\n\n/** Reel names are at most 8 characters, letters and digits. */\nconst reelName = (asset: VideoAsset | undefined) => (asset?.name ?? asset?.id ?? 'AX').replace(/\\.[a-z0-9]+$/i, '').replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, 8) || 'AX';\n\n/**\n * One track as a CMX 3600 edit decision list, for conforming in another NLE: an event per clip with\n * source and record timecode at the project's frame rate, dissolves (`D`) for crossfades, dips and\n * wipes (`W001`), `M2` lines for speed changes, and `* FROM CLIP NAME` comments. Titles and colour\n * clips become black (`BL`) events.\n */\nexport function toVideoEdl(project: VideoProject, options: VideoEdlOptions = {}): string {\n\tconst track = options.track !== undefined ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === 'video');\n\tif (!track) throw new Error(options.track !== undefined ? `There is no track \"${options.track}\".` : 'The project has no video track.');\n\tconst fps = project.fps;\n\tconst tc = (ms: number) => formatVideoTimecode(ms, fps);\n\tconst rec0 = options.recordStart ?? 3600000;\n\tconst assets = videoAssetMap(project);\n\tconst channel = track.kind === 'video' ? 'V' : 'A';\n\tconst lines = [`TITLE: ${(options.title ?? project.name ?? 'Untitled').toUpperCase()}`, 'FCM: NON-DROP FRAME', ''];\n\tlet event = 0;\n\tconst num = () => String(++event).padStart(3, '0');\n\tconst sourceAt = (clip: VideoClip, time: number) => (clip.type === 'media' ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : time - clip.start);\n\tconst row = (n: string, reel: string, kind: string, srcIn: number, srcOut: number, recIn: number, recOut: number) =>\n\t\t`${n} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${tc(rec0 + recIn)} ${tc(rec0 + recOut)}`;\n\tfor (const clip of sortedVideoClips(track)) {\n\t\tconst asset = clip.type === 'media' ? assets.get(clip.asset) : undefined;\n\t\tconst reel = clip.type === 'media' ? reelName(asset) : 'BL';\n\t\tconst end = videoClipEnd(clip);\n\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\t// The cut moves to where the transition starts; this clip's event begins there.\n\t\tconst half = before && clip.transition!.type !== 'dip' ? clip.transition!.duration / 2 : 0;\n\t\tconst next = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition && c.transition.type !== 'dip');\n\t\tconst recOut = next ? end - next.transition!.duration / 2 : end;\n\t\tconst recIn = clip.start - half;\n\t\tconst n = num();\n\t\tif (before) {\n\t\t\tconst t = clip.transition!;\n\t\t\tconst frames = String(Math.round((t.duration * fps) / 1000)).padStart(3, '0');\n\t\t\tconst outReel = before.type === 'media' ? reelName(assets.get(before.asset)) : 'BL';\n\t\t\tconst at = sourceAt(before, recIn);\n\t\t\tlines.push(row(n, outReel, 'C', at, at, recIn, recIn));\n\t\t\tlines.push(row(n, reel, `${t.type === 'wipe' ? 'W001' : 'D'} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));\n\t\t} else lines.push(row(n, reel, 'C', sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));\n\t\tif (clip.type === 'media' && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);\n\t\tif (clip.type === 'media') lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);\n\t\tif (clip.type === 'text') lines.push(`* TITLE: ${clip.text.replace(/\\n/g, ' ')}`);\n\t\tif (clip.type === 'color') lines.push(`* COLOR: ${clip.color}`);\n\t\tlines.push('');\n\t}\n\treturn lines.join('\\n');\n}\n\n// ---------------------------------------------------------------------------------------------\n// ffmpeg\n// ---------------------------------------------------------------------------------------------\n\nexport interface VideoFfmpegOptions {\n\t/** Output file (default `output.mp4`). */\n\toutput?: string;\n\t/** The path or URL ffmpeg should open for an asset (default its `src`). */\n\tresolve?: (asset: VideoAsset) => string;\n\t/** Default `libx264`. */\n\tvideoCodec?: string;\n\t/** Default `aac`. */\n\taudioCodec?: string;\n\t/** x264 quality (default 18). */\n\tcrf?: number;\n\t/** A font file for titles (drawtext's `fontfile`); default the system's font for the family. */\n\tfontFile?: string;\n\t/**\n\t * Titles as images instead of `drawtext` (which needs an ffmpeg built with libfreetype; Homebrew's\n\t * is not): return the path or URL of a transparent PNG the size of the frame showing the title as\n\t * the preview draws it (`videoTitleFrame` + `drawVideoFrame`). It is overlaid like a still, with\n\t * the title's opacity and fades.\n\t */\n\ttitleImage?: (clip: VideoTextClip, size: { width: number; height: number }) => string;\n}\n\nexport interface VideoFfmpegExport {\n\t/** Arguments after `ffmpeg` (pass to `spawn('ffmpeg', args)`; no shell quoting needed). */\n\targs: string[];\n\t/** The same as one shell command line, quoted for a POSIX shell. */\n\tcommand: string;\n\t/** The `-filter_complex` graph on its own. */\n\tfilterComplex: string;\n\t/** Length rendered (ms). */\n\tduration: number;\n\t/** Anything the render only approximates. */\n\tnotes: string[];\n}\n\nconst sec = (ms: number) => String(Math.round(ms) / 1000);\nconst even = (v: number) => Math.max(2, 2 * Math.round(v / 2));\n/** Escapes a value for a filter option inside a filtergraph (option level, then graph level). */\nexport const ffmpegFilterValue = (s: string): string =>\n\ts\n\t\t.replace(/[\\\\':%]/g, (c) => `\\\\${c}`)\n\t\t.replace(/[\\\\'[\\],;]/g, (c) => `\\\\${c}`);\nconst color = (c: string) => {\n\tconst hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(c);\n\tif (!hex) return c.replace(/[^A-Za-z0-9@.]/g, '');\n\tconst h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join('') : hex[1];\n\treturn `0x${h.slice(0, 6)}${h.length === 8 ? `@${(parseInt(h.slice(6), 16) / 255).toFixed(3)}` : ''}`;\n};\nconst shellQuote = (s: string) => (/^[\\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\\\''`)}'`);\n\n/** `atempo` only takes 0.5–2 (100 in newer builds), so larger changes chain several. */\nfunction atempo(speed: number): string[] {\n\tconst out: string[] = [];\n\tlet s = speed;\n\twhile (s > 2) {\n\t\tout.push('atempo=2');\n\t\ts /= 2;\n\t}\n\twhile (s < 0.5) {\n\t\tout.push('atempo=0.5');\n\t\ts /= 0.5;\n\t}\n\tif (Math.abs(s - 1) > 1e-6) out.push(`atempo=${Math.round(s * 1e6) / 1e6}`);\n\treturn out;\n}\n\n/**\n * The project as an ffmpeg command: each clip an input trimmed to what it plays (plus transition\n * overlap), scaled and placed as `videoFrameAt` places it, faded, and overlaid on a background in\n * track order between its start and end; titles with `drawtext`; sound delayed into place, faded,\n * mixed and trimmed to the project's length. Render on a server or with ffmpeg.wasm.\n */\nexport function toVideoFfmpeg(project: VideoProject, options: VideoFfmpegOptions = {}): VideoFfmpegExport {\n\tconst notes: string[] = [];\n\tconst assets = videoAssetMap(project);\n\tconst D = videoProjectDuration(project);\n\tif (D <= 0) throw new Error('The project has no clips, so there is nothing to render.');\n\tconst W = even(project.width);\n\tconst H = even(project.height);\n\tconst fps = project.fps;\n\tconst inputs: string[][] = [];\n\tconst graph: string[] = [`color=c=${color(project.background ?? '#000000')}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];\n\tconst audio: string[] = [];\n\tlet cur = 'base';\n\tlet usesDrawtext = false;\n\tconst animated = new Set<string>();\n\tlet k = 0;\n\t// Keyframes become piecewise-linear expressions; eased segments get a breakpoint every two frames.\n\tconst step = 2000 / fps;\n\tconst expr = (prop: VideoAnimatable | undefined, clipTime: string, fallback: number, what: string) => {\n\t\tif (isVideoKeyframes(prop)) animated.add(what);\n\t\treturn videoKeyframeFfmpegExpr(prop, clipTime, fallback, step);\n\t};\n\tconst input = (clip: VideoClip & { type: 'media' }, asset: VideoAsset, lead: number, tail: number) => {\n\t\tconst speed = videoClipSpeed(clip);\n\t\tconst src = options.resolve ? options.resolve(asset) : asset.src;\n\t\tconst span = clip.duration + lead + tail;\n\t\tif (asset.kind === 'image') inputs.push(['-loop', '1', '-framerate', String(fps), '-t', sec(span), '-i', src]);\n\t\telse inputs.push(['-ss', sec((clip.in ?? 0) - lead * speed), '-t', sec(span * speed), '-i', src]);\n\t\treturn inputs.length - 1;\n\t};\n\t// Back to front: later tracks are further back.\n\tfor (const track of [...project.tracks].reverse()) {\n\t\tfor (const clip of sortedVideoClips(track)) {\n\t\t\tconst { lead, tail } = videoClipOverhang(track, clip);\n\t\t\tconst t0 = clip.start - lead;\n\t\t\tconst span = clip.duration + lead + tail;\n\t\t\tconst end = videoClipEnd(clip);\n\t\t\tconst asset = clip.type === 'media' ? assets.get(clip.asset) : undefined;\n\t\t\tif (clip.type === 'media' && !asset) {\n\t\t\t\tnotes.push(`Clip \"${clip.id}\" was left out: its asset \"${clip.asset}\" is missing.`);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tlet index: number | undefined;\n\t\t\tif (asset && asset.kind !== 'audio' && track.kind === 'video' && !track.hidden) index = input(clip as VideoClip & { type: 'media' }, asset, lead, tail);\n\t\t\tconst audible = asset && (asset.kind === 'audio' || (asset.kind === 'video' && asset.hasAudio !== false));\n\t\t\tif (audible && clip.type === 'media' && !clip.muted && !track.muted) {\n\t\t\t\tconst ai = index ?? input(clip, asset!, lead, tail);\n\t\t\t\tconst trackGain = track.volume ?? 1;\n\t\t\t\tconst volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ''})`, 1, 'volume')})':eval=frame` : `volume=${(clip.volume ?? 1) * trackGain}`;\n\t\t\t\tconst chain = ['asetpts=PTS-STARTPTS', ...atempo(videoClipSpeed(clip)), volume];\n\t\t\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\t\t\tif (lead) chain.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);\n\t\t\t\tif (tail) chain.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);\n\t\t\t\tif (before && clip.transition!.type === 'dip') chain.push(`afade=t=in:st=0:d=${sec(clip.transition!.duration / 2)}`);\n\t\t\t\tconst after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition?.type === 'dip');\n\t\t\t\tif (after) chain.push(`afade=t=out:st=${sec(span - after.transition!.duration / 2)}:d=${sec(after.transition!.duration / 2)}`);\n\t\t\t\tif (clip.fadeIn) chain.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}`);\n\t\t\t\tif (clip.fadeOut) chain.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}`);\n\t\t\t\tchain.push(`adelay=${Math.round(t0)}:all=1`);\n\t\t\t\tgraph.push(`[${ai}:a]${chain.join(',')}[a${audio.length}]`);\n\t\t\t\taudio.push(`[a${audio.length}]`);\n\t\t\t}\n\t\t\tif (track.kind !== 'video' || track.hidden) continue;\n\t\t\tconst enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;\n\t\t\tconst next = `v${k++}`;\n\t\t\t// Clip time (ms) in a filter's `t`: timeline time on the composite, time from t0 on the clip's own chain.\n\t\t\tconst onTimeline = `(t*1000-${Math.round(clip.start)})`;\n\t\t\tconst onChain = (v: string) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ''})`;\n\t\t\tconst tf = clip.transform;\n\t\t\tconst moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(tf?.[p]));\n\t\t\tif (clip.type === 'text' && options.titleImage) {\n\t\t\t\t// A title image has its transform baked in, unless the transform moves: then the image is drawn unplaced and moved here.\n\t\t\t\tinputs.push(['-loop', '1', '-framerate', String(fps), '-t', sec(span), '-i', options.titleImage(moving ? { ...clip, transform: undefined } : clip, { width: project.width, height: project.height })]);\n\t\t\t\tindex = inputs.length - 1;\n\t\t\t} else if (clip.type === 'text') {\n\t\t\t\tusesDrawtext = true;\n\t\t\t\tconst op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, 'opacity')},0,1)` : String(clip.opacity ?? 1);\n\t\t\t\tconst alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : '', clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ''].filter(Boolean).join('*');\n\t\t\t\tconst coord = (axis: 'x' | 'y') => {\n\t\t\t\t\tconst v = tf?.[axis];\n\t\t\t\t\tconst base = `(${axis === 'x' ? 'w-text_w' : 'h-text_h'})/2`;\n\t\t\t\t\treturn isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, 'position')})'` : `${axis}=${base}+${v ?? 0}`;\n\t\t\t\t};\n\t\t\t\tconst fontSize = clip.fontSize ?? 64;\n\t\t\t\tconst opts = [\n\t\t\t\t\t`text=${ffmpegFilterValue(clip.text)}`,\n\t\t\t\t\toptions.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(clip.fontFamily ?? 'sans-serif')}`,\n\t\t\t\t\tisVideoKeyframes(tf?.scale) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, 'scale')})'` : `fontsize=${Math.round(fontSize * ((tf?.scale as number | undefined) ?? 1))}`,\n\t\t\t\t\t`fontcolor=${color(clip.color ?? '#ffffff')}`,\n\t\t\t\t\tcoord('x'),\n\t\t\t\t\tcoord('y'),\n\t\t\t\t\t`alpha='${alpha}'`,\n\t\t\t\t\t...(clip.background ? ['box=1', `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((clip.fontSize ?? 64) / 2)}`] : []),\n\t\t\t\t\t`enable='between(t,${sec(clip.start)},${sec(end)})'`\n\t\t\t\t];\n\t\t\t\tif (tf?.rotation) notes.push(`Title \"${clip.id}\" is drawn without its rotation (drawtext cannot rotate).`);\n\t\t\t\tgraph.push(`[${cur}]drawtext=${opts.join(':')}[${next}]`);\n\t\t\t\tcur = next;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst size = asset ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };\n\t\t\t// A title image already has the title placed (and turned) in a full frame, unless its transform moves.\n\t\t\tconst placed = clip.type === 'text' && !moving ? undefined : tf;\n\t\t\t// Keyframed fields are left out of the fitted rect and animated by the filters below.\n\t\t\tconst still = Object.fromEntries(Object.entries(placed ?? {}).filter(([, v]) => !isVideoKeyframes(v)));\n\t\t\tconst rect = placeVideoRect(project, size, clip.type === 'text' ? 'fill' : (clip.fit ?? (clip.type === 'media' ? 'contain' : 'fill')), still);\n\t\t\tconst chain: string[] = [];\n\t\t\tlet source: string;\n\t\t\tif (clip.type === 'color') source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;\n\t\t\telse {\n\t\t\t\tsource = `[${index}:v]`;\n\t\t\t\tchain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : 'PTS-STARTPTS'}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);\n\t\t\t}\n\t\t\tif (isVideoKeyframes(placed?.scale)) {\n\t\t\t\tconst s = expr(placed.scale, onChain('t'), 1, 'scale');\n\t\t\t\tchain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);\n\t\t\t}\n\t\t\tchain.push('format=yuva420p');\n\t\t\tif (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain('T'), 1, 'opacity')},0,1)'`);\n\t\t\telse if (clip.opacity !== undefined && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);\n\t\t\tif (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);\n\t\t\tif (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);\n\t\t\tif (lead && clip.transition!.type === 'crossfade') chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);\n\t\t\tif (lead && clip.transition!.type === 'wipe') {\n\t\t\t\tconst d = sec(2 * lead);\n\t\t\t\tconst p = `min(1,T/${d})`;\n\t\t\t\tconst dir = clip.transition!.direction ?? 'right';\n\t\t\t\tconst test = dir === 'right' ? `lt(X,W*${p})` : dir === 'left' ? `gt(X,W*(1-${p}))` : dir === 'down' ? `lt(Y,H*${p})` : `gt(Y,H*(1-${p}))`;\n\t\t\t\tchain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);\n\t\t\t}\n\t\t\tconst rotation = placed?.rotation;\n\t\t\tif (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain('t'), 0, 'rotation')})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);\n\t\t\telse if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);\n\t\t\tchain.push(`setpts=PTS+${sec(t0)}/TB`);\n\t\t\tconst cx = rect.x + rect.width / 2;\n\t\t\tconst cy = rect.y + rect.height / 2;\n\t\t\tconst at = (axis: 'x' | 'y', c: number) => {\n\t\t\t\tconst v = placed?.[axis];\n\t\t\t\treturn isVideoKeyframes(v) ? `${axis}='${Math.round(axis === 'x' ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, 'position')})-${axis === 'x' ? 'w' : 'h'}/2'` : `${axis}='${Math.round(c)}-${axis === 'x' ? 'w' : 'h'}/2'`;\n\t\t\t};\n\t\t\tgraph.push(`${source.startsWith('[') ? source : `${source},`}${chain.join(',')}[l${next}]`);\n\t\t\tgraph.push(`[${cur}][l${next}]overlay=${at('x', cx)}:${at('y', cy)}:eof_action=pass:${enable}[${next}]`);\n\t\t\tcur = next;\n\t\t\tconst before = clip.transition ? videoClipBefore(track, clip) : null;\n\t\t\tif (before && clip.transition!.type === 'dip') {\n\t\t\t\tconst d = clip.transition!.duration;\n\t\t\t\tconst dip = `d${k}`;\n\t\t\t\tgraph.push(`color=c=${color(clip.transition!.color ?? '#000000')}:s=${W}x${H}:r=${fps}:d=${sec(d)},format=yuva420p,fade=t=in:st=0:d=${sec(d / 2)}:alpha=1,fade=t=out:st=${sec(d / 2)}:d=${sec(d / 2)}:alpha=1,setpts=PTS+${sec(clip.start - d / 2)}/TB[l${dip}]`);\n\t\t\t\tgraph.push(`[${cur}][l${dip}]overlay=eof_action=pass:enable='between(t,${sec(clip.start - d / 2)},${sec(clip.start + d / 2)})'[${dip}]`);\n\t\t\t\tcur = dip;\n\t\t\t}\n\t\t}\n\t}\n\tgraph.push(`[${cur}]format=yuv420p[vout]`);\n\tif (audio.length) graph.push(`${audio.join('')}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);\n\tif (project.tracks.some((t) => t.clips.some((c) => c.type === 'media' && assets.get(c.asset)?.kind === 'video' && assets.get(c.asset)?.hasAudio === undefined)))\n\t\tnotes.push('Video assets are assumed to have sound; set \"hasAudio\": false on any that do not, or ffmpeg will stop at a missing audio stream.');\n\tif (animated.size)\n\t\tnotes.push(`Keyframed ${[...animated].sort().join(', ')} ${animated.size > 1 ? 'are' : 'is'} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has('scale') ? '; animated scale needs ffmpeg 5 or later (scale with eval=frame)' : ''}.`);\n\tif (usesDrawtext) notes.push('Titles use drawtext, which needs an ffmpeg built with libfreetype (Homebrew\\'s is not: check `ffmpeg -filters | grep drawtext`); pass titleImage to render them as images instead.');\n\tconst filterComplex = graph.join(';');\n\tconst args = [\n\t\t'-y',\n\t\t...inputs.flat(),\n\t\t'-filter_complex',\n\t\tfilterComplex,\n\t\t'-map',\n\t\t'[vout]',\n\t\t...(audio.length ? ['-map', '[aout]', '-c:a', options.audioCodec ?? 'aac'] : ['-an']),\n\t\t'-c:v',\n\t\toptions.videoCodec ?? 'libx264',\n\t\t...((options.videoCodec ?? 'libx264') === 'libx264' ? ['-crf', String(options.crf ?? 18), '-preset', 'medium'] : []),\n\t\t'-pix_fmt',\n\t\t'yuv420p',\n\t\t'-r',\n\t\tString(fps),\n\t\t'-t',\n\t\tsec(D),\n\t\toptions.output ?? 'output.mp4'\n\t];\n\treturn { args, command: ['ffmpeg', ...args].map(shellQuote).join(' '), filterComplex, duration: D, notes };\n}\n","import { videoFrameAt, type VideoFrame, type VideoLayer } from './evaluate.js';\nimport { drawVideoFrame, type VideoCanvasContext } from './render.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 };\n\tsubscribe(listener: () => void): () => void;\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\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 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}\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\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.update();\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 { project, time, playing } = this.source.getState();\n\t\tconst frame = videoFrameAt(project, time);\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\tif (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) {\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 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, layer.src, '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\treturn entry && entry.media.readyState >= 2 ? (entry.media as unknown as CanvasImageSource) : null;\n\t};\n\n\tprivate draw(frame: VideoFrame) {\n\t\tif (!this.ctx) return;\n\t\tconst shown = this.canvas.clientWidth || frame.width;\n\t\tconst dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;\n\t\tconst resolution = this.options.resolution ?? Math.min(1, (shown * dpr) / 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\tdrawVideoFrame(this.ctx, frame, this.sourceFor, resolution);\n\t\tthis.options.onFrame?.(frame);\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.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 { 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, type VideoEdlOptions, type VideoFfmpegExport, type VideoFfmpegOptions } from './export.js';\nimport { createVideoProject, parseVideoProject } from './parse.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}\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/** 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\tprivate project: VideoProject;\n\tprivate issues: VideoIssue[];\n\tprivate selection: string[] = [];\n\tprivate readOnly: boolean;\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.project = options.project ? parseVideoProject(options.project) : createVideoProject();\n\t\tthis.issues = checkVideoProject(this.project);\n\t\tthis.readOnly = options.readOnly ?? false;\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\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/** 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, 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/** One track as a CMX 3600 EDL. */\n\ttoEdl(options: VideoEdlOptions = {}): string {\n\t\treturn toVideoEdl(this.project, options);\n\t}\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 { videoFrameAt } from './evaluate.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 { findVideoGaps, formatVideoTimecode, sortedVideoClips, videoClipEnd, videoClipSourceRange, videoProjectDuration } from './timeline.js';\nimport { VIDEO_FITS, VIDEO_PLACEMENT_MODES, VIDEO_TRANSITION_TYPES, VIDEO_WIPE_DIRECTIONS, type VideoProject } from './types.js';\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}\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) or wipe (an edge travels across revealing this clip).' },\n\t\tduration: { type: 'number', exclusiveMinimum: 0, description: 'Length in ms, centred on the cut. A crossfade or wipe 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: the way the edge travels (default right).' }\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 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\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};\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/** A project as agents see it: settings, assets, and each track's clips in order with their times. */\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,\n\t\ttracks: project.tracks.map((t, index) => ({\n\t\t\tid: t.id,\n\t\t\tkind: t.kind,\n\t\t\tindex,\n\t\t\t...(t.name ? { name: t.name } : {}),\n\t\t\t...(t.muted ? { muted: true } : {}),\n\t\t\t...(t.hidden ? { hidden: true } : {}),\n\t\t\t...(t.locked ? { locked: true } : {}),\n\t\t\t...(t.syncLocked === false ? { syncLocked: false } : {}),\n\t\t\t...(t.volume !== undefined ? { volume: t.volume } : {}),\n\t\t\tclips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...(c.type === 'media' ? { source: videoClipSourceRange(c) } : {}) }))\n\t\t})),\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' ? { 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, fades, transition) and markers, plus the playhead, selection, issues and whether it can be edited.',\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, 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) and the sounds (clip, source time, gain after volumes, fades and transitions).',\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 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 longer than the clip, 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\tconst svg = times ? videoContactSheetSvg(p, times, columns ? { columns } : {}) : videoProjectSvg(p, time ?? controller.getState().time);\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 }] };\n\t\t\t}\n\t\t\treturn { svg };\n\t\t}\n\t};\n\n\tconst exportFfmpeg: AgentTool = {\n\t\tname: 'export_video_ffmpeg',\n\t\ttitle: 'Export an ffmpeg command',\n\t\tdescription: 'Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. 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}),\n\t\tannotations: { title: 'Export an ffmpeg command', ...READ },\n\t\thandler: ({ output, crf }) => controller.toFfmpeg({ ...(output ? { output } : {}), ...(crf !== undefined ? { crf } : {}) })\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, 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}),\n\t\tannotations: { title: 'Export an EDL', ...READ },\n\t\thandler: ({ track, title }) => ({ edl: controller.toEdl({ ...(track ? { track } : {}), ...(title ? { title } : {}) }) })\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'] }, description: 'Fields to clear.' } }, ['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: 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, fades, name; for media volume, mute, speed (keeps the stretch of source, so the length changes) or asset; 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', 'fadeIn', 'fadeOut', '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', 'fadeIn', 'fadeOut', ...(current.type === 'media' ? ['in', 'speed', 'volume', 'muted', 'asset'] : 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\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). A crossfade or wipe 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) }, 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\tconst reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];\n\tconst writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, 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","import type { VideoEdit } from './edit.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 } from './timeline.js';\nimport type { VideoAsset, VideoAssetKind, VideoClip, VideoPlacementMode, VideoProject } 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. */\nexport function videoClipTint(clip: VideoClip, asset?: VideoAsset): string {\n\tif (clip.type === 'text') return '#a855f7';\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. */\nexport function videoClipLabel(clip: VideoClip, asset?: VideoAsset): 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\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.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' && (asset?.kind === 'audio' || (asset?.kind === 'video' && asset.hasAudio !== false));\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"]}
|