@cut-workbench/core 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/chunks/{canvas-size-IzoEg2cq.cjs → canvas-size-DIABzEW2.cjs} +209 -302
  2. package/dist/chunks/canvas-size-DIABzEW2.cjs.map +1 -0
  3. package/dist/chunks/{canvas-size-iWL8zZPl.js → canvas-size-Dlnng74w.js} +168 -171
  4. package/dist/chunks/canvas-size-Dlnng74w.js.map +1 -0
  5. package/dist/chunks/id-CJsdlGei.js +35 -0
  6. package/dist/chunks/id-CJsdlGei.js.map +1 -0
  7. package/dist/chunks/id-DaCxD4-5.cjs +70 -0
  8. package/dist/chunks/id-DaCxD4-5.cjs.map +1 -0
  9. package/dist/chunks/rolldown-runtime-B2SfzNBj.cjs +17 -0
  10. package/dist/chunks/rolldown-runtime-D8jQpDG7.js +12 -0
  11. package/dist/chunks/{snap-CeYuTN6d.cjs → snap-Cfk15JVg.cjs} +3 -23
  12. package/dist/chunks/{snap-CeYuTN6d.cjs.map → snap-Cfk15JVg.cjs.map} +1 -1
  13. package/dist/chunks/{snap-Dc022smD.js → snap-DpkVPVJN.js} +3 -17
  14. package/dist/chunks/{snap-Dc022smD.js.map → snap-DpkVPVJN.js.map} +1 -1
  15. package/dist/chunks/srt-D539yJ-n.js +51 -0
  16. package/dist/chunks/srt-D539yJ-n.js.map +1 -0
  17. package/dist/chunks/srt-DaL36mXN.cjs +73 -0
  18. package/dist/chunks/srt-DaL36mXN.cjs.map +1 -0
  19. package/dist/chunks/thumbnails-DEjnao5r.cjs +81 -0
  20. package/dist/chunks/thumbnails-DEjnao5r.cjs.map +1 -0
  21. package/dist/chunks/thumbnails-qH0dX_Zs.js +64 -0
  22. package/dist/chunks/thumbnails-qH0dX_Zs.js.map +1 -0
  23. package/dist/chunks/time-CYEd91qN.cjs +196 -0
  24. package/dist/chunks/time-CYEd91qN.cjs.map +1 -0
  25. package/dist/chunks/time-ChF1GwCy.js +113 -0
  26. package/dist/chunks/time-ChF1GwCy.js.map +1 -0
  27. package/dist/chunks/{waveform-Bhvv2t7-.cjs → waveform-BYwDdKpC.cjs} +150 -31
  28. package/dist/chunks/waveform-BYwDdKpC.cjs.map +1 -0
  29. package/dist/chunks/{waveform-CWU8Fa-n.js → waveform-uyGNE01H.js} +111 -22
  30. package/dist/chunks/waveform-uyGNE01H.js.map +1 -0
  31. package/dist/editor/create-editor.d.ts +8 -106
  32. package/dist/editor/editor-store.d.ts +4 -0
  33. package/dist/editor/handle-types.d.ts +256 -0
  34. package/dist/engine/index.cjs +1185 -455
  35. package/dist/engine/index.cjs.map +1 -1
  36. package/dist/engine/index.d.ts +3 -0
  37. package/dist/engine/index.js +1169 -446
  38. package/dist/engine/index.js.map +1 -1
  39. package/dist/engine/webav/export-runner.d.ts +61 -0
  40. package/dist/engine/webav/exporter.d.ts +3 -1
  41. package/dist/engine/webav/import-files.d.ts +58 -0
  42. package/dist/engine/webav/import-pipeline.d.ts +23 -1
  43. package/dist/engine/webav/types.d.ts +15 -0
  44. package/dist/errors.d.ts +12 -0
  45. package/dist/export/types.d.ts +49 -0
  46. package/dist/index.cjs +83 -68
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.ts +6 -0
  49. package/dist/index.js +47 -43
  50. package/dist/index.js.map +1 -1
  51. package/dist/lib/error.d.ts +6 -0
  52. package/dist/media/thumbnails.d.ts +8 -0
  53. package/dist/ports/host-bridge.d.ts +7 -0
  54. package/dist/project/file-storage.d.ts +23 -0
  55. package/dist/project/schema.d.ts +16 -4
  56. package/dist/timeline/index.cjs +24 -19
  57. package/dist/timeline/index.d.ts +1 -0
  58. package/dist/timeline/index.js +4 -3
  59. package/dist/timeline/normalize.d.ts +28 -0
  60. package/dist/timeline/ops.d.ts +0 -4
  61. package/dist/timeline/snap.d.ts +0 -2
  62. package/dist/types.d.ts +14 -2
  63. package/package.json +3 -2
  64. package/dist/chunks/canvas-size-IzoEg2cq.cjs.map +0 -1
  65. package/dist/chunks/canvas-size-iWL8zZPl.js.map +0 -1
  66. package/dist/chunks/waveform-Bhvv2t7-.cjs.map +0 -1
  67. package/dist/chunks/waveform-CWU8Fa-n.js.map +0 -1
@@ -1,7 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_canvas_size = require("../chunks/canvas-size-IzoEg2cq.cjs");
3
- const require_waveform = require("../chunks/waveform-Bhvv2t7-.cjs");
2
+ const require_rolldown_runtime = require("../chunks/rolldown-runtime-B2SfzNBj.cjs");
3
+ const require_id = require("../chunks/id-DaCxD4-5.cjs");
4
+ const require_time = require("../chunks/time-CYEd91qN.cjs");
5
+ const require_canvas_size = require("../chunks/canvas-size-DIABzEW2.cjs");
6
+ const require_waveform = require("../chunks/waveform-BYwDdKpC.cjs");
4
7
  const require_file_reader = require("../chunks/file-reader-DfMEluGr.cjs");
8
+ const require_thumbnails = require("../chunks/thumbnails-DEjnao5r.cjs");
5
9
  let _webav_av_cliper = require("@webav/av-cliper");
6
10
  let _webav_av_canvas = require("@webav/av-canvas");
7
11
  // src/engine/webav/styles.ts
@@ -223,7 +227,7 @@ async function createClipFromFile(file, kind) {
223
227
  return {
224
228
  clip,
225
229
  meta: {
226
- durationUs: Number.isFinite(meta.duration) ? meta.duration : require_canvas_size.DEFAULT_STILL_DURATION_US,
230
+ durationUs: Number.isFinite(meta.duration) ? meta.duration : require_id.DEFAULT_STILL_DURATION_US,
227
231
  width: meta.width,
228
232
  height: meta.height,
229
233
  hasAudio: false
@@ -240,7 +244,7 @@ async function createClipFromFile(file, kind) {
240
244
  return {
241
245
  clip,
242
246
  meta: {
243
- durationUs: Number.isFinite(meta.duration) ? meta.duration : require_canvas_size.DEFAULT_STILL_DURATION_US,
247
+ durationUs: Number.isFinite(meta.duration) ? meta.duration : require_id.DEFAULT_STILL_DURATION_US,
244
248
  width: meta.width,
245
249
  height: meta.height,
246
250
  hasAudio: false
@@ -310,7 +314,10 @@ var OffsetClip = class OffsetClip {
310
314
  height: 0,
311
315
  duration: opts.durationUs
312
316
  };
313
- this.ready = inner.ready.then((meta) => {
317
+ this.ready = inner.ready.then(async (meta) => {
318
+ try {
319
+ await inner.tick(opts.inPointUs);
320
+ } catch {}
314
321
  this.metaValue = {
315
322
  width: meta.width,
316
323
  height: meta.height,
@@ -356,49 +363,6 @@ var OffsetClip = class OffsetClip {
356
363
  this.inner.destroy();
357
364
  }
358
365
  };
359
- // src/media/thumbnails.ts
360
- var cache = /* @__PURE__ */ new Map();
361
- /**
362
- * 生成等间隔缩略图条。
363
- *
364
- * @param clip MP4Clip 实例(调用方负责生命周期)
365
- * @param assetId 缓存 key
366
- * @param count 期望张数
367
- * @param durationUs 素材总时长,用来算 step
368
- * @param width 单张宽度
369
- */
370
- async function generateThumbnails(clip, assetId, count, durationUs, width = 100) {
371
- const cached = cache.get(assetId);
372
- if (cached) return cached;
373
- const stepUs = Math.max(Math.floor(durationUs / count), require_canvas_size.usToMs(200));
374
- const raw = await clip.thumbnails(width, {
375
- start: 0,
376
- end: Math.max(0, durationUs - 1),
377
- step: stepUs
378
- });
379
- const picked = [];
380
- if (raw.length <= count) picked.push(...raw);
381
- else {
382
- const stride = raw.length / count;
383
- for (let i = 0; i < count; i += 1) {
384
- const item = raw[Math.min(raw.length - 1, Math.floor(i * stride))];
385
- if (item) picked.push(item);
386
- }
387
- }
388
- const frames = picked.map((item) => ({
389
- ts: item.ts,
390
- url: URL.createObjectURL(item.img)
391
- }));
392
- cache.set(assetId, frames);
393
- return frames;
394
- }
395
- /** 释放某素材的缩略图 URL */
396
- function releaseThumbnails(assetId) {
397
- const frames = cache.get(assetId);
398
- if (!frames) return;
399
- for (const f of frames) URL.revokeObjectURL(f.url);
400
- cache.delete(assetId);
401
- }
402
366
  // src/engine/webav/clip-registry.ts
403
367
  var ClipRegistry = class {
404
368
  assets = /* @__PURE__ */ new Map();
@@ -508,7 +472,7 @@ var ClipRegistry = class {
508
472
  runtime?.master?.destroy();
509
473
  } catch {}
510
474
  this.assets.delete(assetId);
511
- releaseThumbnails(assetId);
475
+ require_thumbnails.releaseThumbnails(assetId);
512
476
  }
513
477
  destroyAllInstances() {
514
478
  for (const [id] of this.derived) this.destroyInstance(id);
@@ -520,7 +484,7 @@ var ClipRegistry = class {
520
484
  try {
521
485
  runtime.master?.destroy();
522
486
  } catch {}
523
- releaseThumbnails(assetId);
487
+ require_thumbnails.releaseThumbnails(assetId);
524
488
  }
525
489
  this.assets.clear();
526
490
  }
@@ -834,22 +798,39 @@ function createEditorRuntime() {
834
798
  }
835
799
  };
836
800
  }
801
+ // src/lib/error.ts
802
+ /**
803
+ * 异常 → 可展示文本。
804
+ *
805
+ * 各模块都会在 catch 里把 `unknown` 转成字符串,集中一处避免多份实现漂移。
806
+ */
807
+ function errorMessage(err) {
808
+ return err instanceof Error ? err.message : String(err);
809
+ }
837
810
  // src/editor/create-editor.ts
838
811
  function resolveBridge(bridge) {
839
812
  if (bridge != null) return bridge;
840
813
  return require_waveform.createWebBridge();
841
814
  }
815
+ var loadImportFiles = () => Promise.resolve().then(() => import_files_exports);
816
+ var loadImportPipeline = () => Promise.resolve().then(() => import_pipeline_exports);
817
+ var loadExportRunner = () => Promise.resolve().then(() => export_runner_exports);
842
818
  /**
843
819
  * 创建编辑器实例。
844
820
  *
845
- * 不加载 WebAV:`engine` 子入口需要由调用方按需 `await import()`,因为 opfs-tools
821
+ * 不加载 WebAV:引擎模块在首次导入素材 / 导出时才动态加载 —— opfs-tools
846
822
  * 在模块加载期就会访问 `navigator.storage`,非 Chromium 环境会直接抛错。
847
823
  */
848
824
  function createEditor(options = {}) {
849
825
  const id = `editor_${Math.random().toString(36).slice(2, 10)}`;
850
826
  const bridge = resolveBridge(options.bridge);
851
- const storage = options.storage ?? require_waveform.createMemoryStorage();
852
- const namespace = options.namespace ?? "default";
827
+ let storage = options.storage ?? require_waveform.createMemoryStorage();
828
+ let namespace = options.namespace ?? "default";
829
+ let autoSave = options.autoSave ?? null;
830
+ const initialProject = options.project ?? (options.projectId != null && options.projectId !== "" ? {
831
+ ...require_canvas_size.createEmptyProject(),
832
+ id: options.projectId
833
+ } : require_canvas_size.createEmptyProject());
853
834
  const bus = require_waveform.createEventBus((err) => {
854
835
  bus.emit("error", {
855
836
  scope: "event-listener",
@@ -858,19 +839,91 @@ function createEditor(options = {}) {
858
839
  });
859
840
  const history = require_waveform.createHistoryStore();
860
841
  const store = require_waveform.createEditorStore({
861
- history,
862
- project: options.project,
863
- assets: options.assets
842
+ project: initialProject,
843
+ assets: options.assets,
844
+ history
864
845
  });
865
846
  const runtime = createEditorRuntime();
866
847
  let disposed = false;
867
848
  let lastPlaying = false;
849
+ let capabilityPromise = null;
850
+ /** 防抖计时器:最后一次变更后等待 intervalMs 再写盘 */
851
+ let autoSaveTimer = null;
852
+ /** 串行化写盘:自动保存与手动保存永不并发(并发会交错写同一 namespace) */
853
+ let saveChain = Promise.resolve({ ok: true });
868
854
  let prevProject = store.getState().project;
869
855
  let prevAssets = store.getState().assets;
870
856
  let prevSelection = store.getState().selectedClipIds;
871
857
  let prevAssetId = store.getState().selectedAssetId;
872
858
  let prevPlayhead = store.getState().playheadUs;
873
859
  let prevCanUndo = history.getState().canUndo();
860
+ let prevDirty = store.getState().dirty;
861
+ /** 当前写盘的存储与命名空间(autoSave 指定了自己的目标时以它为准) */
862
+ function saveTarget() {
863
+ if (autoSave != null) return {
864
+ port: autoSave.storage,
865
+ ns: autoSave.namespace
866
+ };
867
+ return {
868
+ port: storage,
869
+ ns: namespace
870
+ };
871
+ }
872
+ /**
873
+ * 自动保存调度(防抖)。
874
+ *
875
+ * 语义是「最后一次结构性变更后静置 intervalMs 才写盘」,而不是
876
+ * 「第一次变更后固定 intervalMs 写盘」:
877
+ * 后者在持续编辑(拖滑块、连续裁剪)时会中途写一次,既浪费 I/O,
878
+ * 又会在宿主展示「保存中」时产生无意义的闪烁。每次新变更都重置计时器。
879
+ */
880
+ function scheduleAutoSave() {
881
+ if (autoSave == null || disposed) return;
882
+ if (autoSaveTimer != null) clearTimeout(autoSaveTimer);
883
+ autoSaveTimer = setTimeout(() => {
884
+ autoSaveTimer = null;
885
+ enqueueSave().catch((err) => {
886
+ bus.emit("error", {
887
+ scope: "auto-save",
888
+ error: err
889
+ });
890
+ });
891
+ }, autoSave.intervalMs ?? 2e3);
892
+ }
893
+ /** 把一次保存排进队列,返回该次保存的结果 */
894
+ function enqueueSave() {
895
+ const run = saveChain.then(() => writeProject());
896
+ saveChain = run.catch(() => ({ ok: false }));
897
+ return run;
898
+ }
899
+ /** 真正写盘 + 广播 project:saved */
900
+ async function writeProject() {
901
+ const { port, ns } = saveTarget();
902
+ const projectAtStart = store.getState().project;
903
+ const result = await port.save(ns, editor.serialize());
904
+ const changedDuringSave = store.getState().project !== projectAtStart;
905
+ if (result.ok) {
906
+ if (!changedDuringSave) store.getState().markSaved();
907
+ else scheduleAutoSave();
908
+ bus.emit("project:saved", result.path != null ? {
909
+ ok: true,
910
+ path: result.path
911
+ } : { ok: true });
912
+ } else bus.emit("project:saved", {
913
+ ok: false,
914
+ ...result.error != null ? { error: result.error } : {},
915
+ ...result.path != null ? { path: result.path } : {}
916
+ });
917
+ return result;
918
+ }
919
+ /** 卸载 / 关闭前把待写的防抖任务立刻执行 */
920
+ function flushAutoSave() {
921
+ if (autoSaveTimer != null) {
922
+ clearTimeout(autoSaveTimer);
923
+ autoSaveTimer = null;
924
+ }
925
+ return enqueueSave();
926
+ }
874
927
  const unsubscribeStore = store.subscribe((state) => {
875
928
  if (disposed) return;
876
929
  if (state.project !== prevProject) {
@@ -879,6 +932,11 @@ function createEditor(options = {}) {
879
932
  project: state.project,
880
933
  dirty: state.dirty
881
934
  });
935
+ if (autoSave != null) scheduleAutoSave();
936
+ }
937
+ if (state.dirty !== prevDirty) {
938
+ prevDirty = state.dirty;
939
+ bus.emit("project:dirty", { dirty: state.dirty });
882
940
  }
883
941
  if (state.assets !== prevAssets) {
884
942
  prevAssets = state.assets;
@@ -915,9 +973,28 @@ function createEditor(options = {}) {
915
973
  history,
916
974
  runtime,
917
975
  bridge,
918
- storage,
976
+ get storage() {
977
+ return saveTarget().port;
978
+ },
919
979
  events: bus,
980
+ commands: {
981
+ updateClip: (clipId, patch, recordHistory, mergeKey) => store.getState().updateClip(clipId, patch, recordHistory, mergeKey),
982
+ moveClip: (clipId, trackId, startUs, recordHistory) => store.getState().moveClip(clipId, trackId, startUs, recordHistory),
983
+ trimClip: (clipId, edge, deltaUs, recordHistory) => store.getState().trimClip(clipId, edge, deltaUs, recordHistory),
984
+ splitClipsAt: (atUs, clipIds) => store.getState().splitClipsAt(atUs, clipIds),
985
+ duplicateSelection: (offsetUs) => store.getState().duplicateSelection(offsetUs),
986
+ addTrack: (kind) => store.getState().addTrack(kind),
987
+ removeTrack: (trackId) => store.getState().removeTrack(trackId),
988
+ updateTrack: (trackId, patch) => store.getState().updateTrack(trackId, patch)
989
+ },
920
990
  getProject: () => store.getState().project,
991
+ getProjectId: () => store.getState().project.id,
992
+ setProjectId(nextId) {
993
+ if (nextId === store.getState().project.id) return;
994
+ store.getState().setProjectId(nextId);
995
+ },
996
+ renameProject: (name) => store.getState().renameProject(name),
997
+ setProjectSize: (width, height, mode) => store.getState().setProjectSize(width, height, mode),
921
998
  getAssets: () => store.getState().assets,
922
999
  getState: () => store.getState(),
923
1000
  setProject(project, assets, missingAssetIds = []) {
@@ -925,17 +1002,135 @@ function createEditor(options = {}) {
925
1002
  history.getState().clear();
926
1003
  },
927
1004
  serialize: () => require_waveform.serializeProject(store.getState().project, store.getState().assets),
1005
+ exportJson: () => require_waveform.serializeProject(store.getState().project, store.getState().assets),
928
1006
  async load(input) {
929
1007
  if ("schemaVersion" in input) {
930
1008
  const { project, assets, missingAssetIds } = require_waveform.deserializeProject(input);
931
1009
  editor.setProject(project, assets, missingAssetIds);
1010
+ bus.emit("project:loaded", {
1011
+ project,
1012
+ assetCount: assets.length,
1013
+ missing: missingAssetIds.length
1014
+ });
932
1015
  return;
933
1016
  }
934
1017
  editor.setProject(input, []);
1018
+ bus.emit("project:loaded", {
1019
+ project: input,
1020
+ assetCount: 0,
1021
+ missing: 0
1022
+ });
1023
+ },
1024
+ async importJson(data, importOptions = {}) {
1025
+ const { project, assets, missingAssetIds } = require_waveform.deserializeProject(data);
1026
+ editor.setProject(project, assets, missingAssetIds);
1027
+ const stillMissing = new Set(missingAssetIds);
1028
+ if (importOptions.resolveFile != null) {
1029
+ const { importFile } = await loadImportPipeline();
1030
+ for (const asset of assets) {
1031
+ if (asset.kind === "text" || asset.kind === "subtitle") continue;
1032
+ let file;
1033
+ try {
1034
+ file = await importOptions.resolveFile(asset);
1035
+ } catch (err) {
1036
+ bus.emit("error", {
1037
+ scope: "import-json:resolve-file",
1038
+ error: err
1039
+ });
1040
+ continue;
1041
+ }
1042
+ if (file == null) continue;
1043
+ try {
1044
+ const result = await importFile(runtime.clips, file, void 0, { id: asset.id });
1045
+ const { regenerateDerived } = await loadImportPipeline();
1046
+ const withDerived = await regenerateDerived(runtime.clips, result.asset, file);
1047
+ store.getState().replaceAsset(asset.id, {
1048
+ ...withDerived,
1049
+ id: asset.id
1050
+ });
1051
+ stillMissing.delete(asset.id);
1052
+ } catch (err) {
1053
+ bus.emit("error", {
1054
+ scope: "import-json:reconnect",
1055
+ error: err
1056
+ });
1057
+ }
1058
+ }
1059
+ }
1060
+ const remaining = [...stillMissing];
1061
+ store.getState().setMissingAssetIds(remaining);
1062
+ bus.emit("project:loaded", {
1063
+ project: store.getState().project,
1064
+ assetCount: assets.length,
1065
+ missing: remaining.length
1066
+ });
1067
+ return { missingAssetIds: remaining };
935
1068
  },
936
1069
  async save() {
937
- const data = editor.serialize();
938
- return storage.save(namespace, data);
1070
+ return enqueueSave();
1071
+ },
1072
+ async saveAs(nextStorage, nextNamespace) {
1073
+ storage = nextStorage;
1074
+ namespace = nextNamespace;
1075
+ if (autoSave != null) autoSave = {
1076
+ ...autoSave,
1077
+ storage: nextStorage,
1078
+ namespace: nextNamespace
1079
+ };
1080
+ return enqueueSave();
1081
+ },
1082
+ isDirty: () => store.getState().dirty,
1083
+ isEmpty: () => store.getState().project.clips.length === 0,
1084
+ async addFiles(files, addOptions = {}) {
1085
+ const { importFiles } = await loadImportFiles();
1086
+ return importFiles(runtime.clips, store, files, addOptions);
1087
+ },
1088
+ async addTextAsset(params) {
1089
+ const { createTextAsset } = await loadImportPipeline();
1090
+ const { project } = store.getState();
1091
+ const { asset } = createTextAsset({
1092
+ registry: runtime.clips,
1093
+ text: params.text,
1094
+ width: project.width,
1095
+ height: project.height,
1096
+ ...params.style != null ? { style: params.style } : {},
1097
+ ...params.durationUs != null ? { durationUs: params.durationUs } : {}
1098
+ });
1099
+ store.getState().addAsset(asset);
1100
+ return asset;
1101
+ },
1102
+ async addSubtitleAsset(params) {
1103
+ const { createSubtitleAsset } = await loadImportPipeline();
1104
+ const { parseSrtDurationUs } = await Promise.resolve().then(() => require("../chunks/srt-DaL36mXN.cjs")).then((n) => n.srt_exports);
1105
+ const { project } = store.getState();
1106
+ const durationUs = params.durationUs != null && params.durationUs > 0 ? params.durationUs : parseSrtDurationUs(params.srt);
1107
+ const { asset } = createSubtitleAsset({
1108
+ registry: runtime.clips,
1109
+ srt: params.srt,
1110
+ width: project.width,
1111
+ height: project.height,
1112
+ ...durationUs > 0 ? { durationUs } : {},
1113
+ ...params.style != null ? { style: params.style } : {}
1114
+ });
1115
+ store.getState().addAsset(asset);
1116
+ return asset;
1117
+ },
1118
+ async reconnectAsset(assetId, file) {
1119
+ const { importFile } = await loadImportPipeline();
1120
+ if (!store.getState().assets.some((a) => a.id === assetId)) throw new Error(`素材不存在:${assetId}`);
1121
+ const result = await importFile(runtime.clips, file, void 0, { id: assetId });
1122
+ const { regenerateDerived } = await loadImportPipeline();
1123
+ const withDerived = await regenerateDerived(runtime.clips, result.asset, file);
1124
+ store.getState().replaceAsset(assetId, {
1125
+ ...withDerived,
1126
+ id: assetId
1127
+ });
1128
+ store.getState().clearMissing(assetId);
1129
+ },
1130
+ getMissingAssetIds: () => [...store.getState().missingAssetIds],
1131
+ removeAsset(assetId) {
1132
+ runtime.clips.removeAsset(assetId);
1133
+ store.getState().removeAsset(assetId);
939
1134
  },
940
1135
  play() {
941
1136
  const { project, playing } = store.getState();
@@ -967,15 +1162,116 @@ function createEditor(options = {}) {
967
1162
  isPlaying: () => store.getState().playing,
968
1163
  undo: () => store.getState().undo(),
969
1164
  redo: () => store.getState().redo(),
1165
+ canUndo: () => history.getState().canUndo(),
1166
+ canRedo: () => history.getState().canRedo(),
970
1167
  addClip: (clip) => store.getState().addClip(clip),
971
1168
  removeClips: (ids) => store.getState().removeClips(ids),
972
1169
  selectClips: (ids) => store.getState().selectClips(ids),
1170
+ getSelectedClipIds: () => [...store.getState().selectedClipIds],
1171
+ setSelection(clipIds, assetId) {
1172
+ store.getState().selectClips(clipIds);
1173
+ if (assetId !== void 0) store.getState().selectAsset(assetId);
1174
+ },
1175
+ updateClip: (clipId, patch, recordHistory, mergeKey) => store.getState().updateClip(clipId, patch, recordHistory, mergeKey),
1176
+ moveClip: (clipId, trackId, startUs, recordHistory) => store.getState().moveClip(clipId, trackId, startUs, recordHistory),
1177
+ trimClip: (clipId, edge, deltaUs, recordHistory) => store.getState().trimClip(clipId, edge, deltaUs, recordHistory),
1178
+ splitClipsAt: (atUs, clipIds) => store.getState().splitClipsAt(atUs, clipIds),
1179
+ duplicateSelection: (offsetUs) => store.getState().duplicateSelection(offsetUs),
1180
+ addTrack: (kind) => store.getState().addTrack(kind),
1181
+ removeTrack: (trackId) => store.getState().removeTrack(trackId),
1182
+ updateTrack: (trackId, patch) => store.getState().updateTrack(trackId, patch),
1183
+ export(request = {}) {
1184
+ const snapshot = store.getState();
1185
+ const settings = require_waveform.resolveExportSettings(snapshot.project, request.settings);
1186
+ const mimeType = request.mimeType ?? "video/mp4";
1187
+ const filename = request.filename ?? require_waveform.createExportFilename();
1188
+ bus.emit("export:start", {
1189
+ totalUs: require_canvas_size.getProjectDuration(snapshot.project),
1190
+ settings
1191
+ });
1192
+ let cancelled = false;
1193
+ let inner = null;
1194
+ const cancel = () => {
1195
+ cancelled = true;
1196
+ inner?.cancel();
1197
+ };
1198
+ return {
1199
+ promise: (async () => {
1200
+ try {
1201
+ const { startExport, computeExportDuration } = await loadExportRunner();
1202
+ if (cancelled) throw new require_waveform.ExportCancelledError();
1203
+ const handle = startExport({
1204
+ registry: runtime.clips,
1205
+ project: snapshot.project,
1206
+ assets: snapshot.assets,
1207
+ settings,
1208
+ maxTimeUs: computeExportDuration(snapshot.project),
1209
+ mimeType,
1210
+ filename,
1211
+ ...bridge.createExportWorker != null ? { createWorker: () => {
1212
+ try {
1213
+ return bridge.createExportWorker?.() ?? null;
1214
+ } catch (err) {
1215
+ bus.emit("error", {
1216
+ scope: "export:worker",
1217
+ error: err
1218
+ });
1219
+ return null;
1220
+ }
1221
+ } } : {}
1222
+ }, (progress) => {
1223
+ if (!cancelled) bus.emit("export:progress", { progress });
1224
+ });
1225
+ inner = handle;
1226
+ const result = await handle.promise;
1227
+ if (cancelled) throw new require_waveform.ExportCancelledError();
1228
+ bus.emit("export:done", {
1229
+ result,
1230
+ blob: result.blob,
1231
+ filename: result.filename
1232
+ });
1233
+ return result;
1234
+ } catch (err) {
1235
+ if (cancelled || err instanceof require_waveform.ExportCancelledError) throw err instanceof require_waveform.ExportCancelledError ? err : new require_waveform.ExportCancelledError();
1236
+ bus.emit("export:error", {
1237
+ message: errorMessage(err),
1238
+ error: err
1239
+ });
1240
+ throw err;
1241
+ }
1242
+ })(),
1243
+ cancel
1244
+ };
1245
+ },
1246
+ async saveExport(result, suggestedName) {
1247
+ const saveBlob = bridge.files?.saveBlob;
1248
+ if (saveBlob == null) return {
1249
+ ok: false,
1250
+ error: "宿主未提供 saveBlob,无法保存成片"
1251
+ };
1252
+ try {
1253
+ return await saveBlob.call(bridge.files, result.blob, suggestedName ?? result.filename);
1254
+ } catch (err) {
1255
+ return {
1256
+ ok: false,
1257
+ error: errorMessage(err)
1258
+ };
1259
+ }
1260
+ },
1261
+ getCapabilities() {
1262
+ capabilityPromise ??= require_waveform.detectEnvironment();
1263
+ return capabilityPromise;
1264
+ },
973
1265
  async dispose() {
974
1266
  if (disposed) return;
975
1267
  disposed = true;
1268
+ const pending = flushAutoSave();
976
1269
  unsubscribeStore();
977
1270
  bus.emit("dispose", {});
978
1271
  await runtime.dispose();
1272
+ try {
1273
+ await pending;
1274
+ } catch {}
979
1275
  bus.clear();
980
1276
  }
981
1277
  };
@@ -989,6 +1285,15 @@ function createEditor(options = {}) {
989
1285
  *
990
1286
  * ⚠️ 所有 WebAV 调用都落在 core 内;本文件位于 core/webav 下,允许 import '@webav/*'。
991
1287
  */
1288
+ var import_pipeline_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
1289
+ UnsupportedFileError: () => require_file_reader.UnsupportedFileError,
1290
+ createSubtitleAsset: () => createSubtitleAsset,
1291
+ createTextAsset: () => createTextAsset,
1292
+ importFile: () => importFile,
1293
+ loadAudioClipForAsset: () => loadAudioClipForAsset,
1294
+ loadPcmForAsset: () => loadPcmForAsset,
1295
+ regenerateDerived: () => regenerateDerived
1296
+ });
992
1297
  var THUMBNAIL_COUNT = 4;
993
1298
  var THUMBNAIL_WIDTH = 120;
994
1299
  var WAVEFORM_BUCKETS = 400;
@@ -998,19 +1303,19 @@ var WAVEFORM_BUCKETS = 400;
998
1303
  * @param onProgress 0~1 进度(用于卡片骨架/进度条)
999
1304
  * @throws UnsupportedFileError 类型不支持时
1000
1305
  */
1001
- async function importFile(registry, file, onProgress) {
1306
+ async function importFile(registry, file, onProgress, options = {}) {
1002
1307
  const detected = require_file_reader.detectFileType(file);
1003
1308
  onProgress?.(.1);
1004
1309
  const { clip, meta } = await createClipFromFile(file, detected.kind);
1005
1310
  onProgress?.(.4);
1006
- const assetId = require_canvas_size.createId("asset");
1311
+ const assetId = options.id ?? require_id.createId("asset");
1007
1312
  const asset = {
1008
1313
  id: assetId,
1009
1314
  name: file.name,
1010
1315
  kind: detected.kind,
1011
1316
  mimeType: detected.mimeType,
1012
1317
  size: file.size,
1013
- durationUs: require_canvas_size.asUs(meta.durationUs),
1318
+ durationUs: require_time.asUs(meta.durationUs),
1014
1319
  width: meta.width,
1015
1320
  height: meta.height,
1016
1321
  hasAudio: meta.hasAudio,
@@ -1018,7 +1323,7 @@ async function importFile(registry, file, onProgress) {
1018
1323
  };
1019
1324
  if (detected.kind === "video") {
1020
1325
  try {
1021
- asset.thumbnailUrls = (await generateThumbnails(clip, assetId, THUMBNAIL_COUNT, meta.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
1326
+ asset.thumbnailUrls = (await require_thumbnails.generateThumbnails(clip, assetId, THUMBNAIL_COUNT, meta.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
1022
1327
  } catch (err) {
1023
1328
  console.warn("[importFile] 缩略图生成失败", err);
1024
1329
  }
@@ -1043,9 +1348,40 @@ async function importFile(registry, file, onProgress) {
1043
1348
  master: clip
1044
1349
  };
1045
1350
  }
1351
+ /**
1352
+ * 为一个「已有元数据」的素材重新生成派生数据(缩略图 / 波形)。
1353
+ *
1354
+ * 用于重新打开工程后 `resolveFile` / `reconnectAsset` 找回文件时:
1355
+ * 元数据里不含 thumbnailUrls(那是会话内 object URL,刷新即失效),
1356
+ * 如果不补这一步,素材卡片会一直显示图标占位 —— 用户看到的就是「资源坏了」。
1357
+ *
1358
+ * 失败只记警告:派生数据缺失不影响素材可用性。
1359
+ */
1360
+ async function regenerateDerived(registry, asset, file) {
1361
+ const master = registry.getMaster(asset.id);
1362
+ if (master == null) return asset;
1363
+ const next = { ...asset };
1364
+ if (asset.kind === "video") {
1365
+ const { regenerateThumbnails } = await Promise.resolve().then(() => require("../chunks/thumbnails-DEjnao5r.cjs")).then((n) => n.thumbnails_exports);
1366
+ try {
1367
+ next.thumbnailUrls = (await regenerateThumbnails(master, asset.id, THUMBNAIL_COUNT, asset.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
1368
+ } catch (err) {
1369
+ console.warn("[regenerateDerived] 缩略图生成失败", asset.id, err);
1370
+ }
1371
+ }
1372
+ if (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio) try {
1373
+ if (asset.kind === "audio") {
1374
+ const pcm = master.getPCMData();
1375
+ next.waveform = require_waveform.computeWaveform(pcm, WAVEFORM_BUCKETS);
1376
+ }
1377
+ } catch (err) {
1378
+ console.warn("[regenerateDerived] 波形生成失败", asset.id, err);
1379
+ }
1380
+ return next;
1381
+ }
1046
1382
  /** 新建文字素材(非文件来源,无需二进制) */
1047
1383
  function createTextAsset(params) {
1048
- const assetId = require_canvas_size.createId("asset");
1384
+ const assetId = params.id ?? require_id.createId("asset");
1049
1385
  const style = {
1050
1386
  ...DEFAULT_TEXT_STYLE,
1051
1387
  ...params.style
@@ -1064,7 +1400,7 @@ function createTextAsset(params) {
1064
1400
  kind: "text",
1065
1401
  mimeType: "text/x-title",
1066
1402
  size: 0,
1067
- durationUs: require_canvas_size.asUs(durationUs),
1403
+ durationUs: require_time.asUs(durationUs),
1068
1404
  width: params.width,
1069
1405
  height: params.height,
1070
1406
  hasAudio: false,
@@ -1120,7 +1456,7 @@ function subtitleName(srt) {
1120
1456
  }
1121
1457
  /** 新建字幕素材(内容来自粘贴的 SRT,无源文件) */
1122
1458
  function createSubtitleAsset(params) {
1123
- const assetId = require_canvas_size.createId("asset");
1459
+ const assetId = params.id ?? require_id.createId("asset");
1124
1460
  const style = {
1125
1461
  ...DEFAULT_SUBTITLE_STYLE,
1126
1462
  ...params.style
@@ -1138,7 +1474,7 @@ function createSubtitleAsset(params) {
1138
1474
  kind: "subtitle",
1139
1475
  mimeType: "text/plain",
1140
1476
  size: params.srt.length,
1141
- durationUs: require_canvas_size.asUs(durationUs),
1477
+ durationUs: require_time.asUs(durationUs),
1142
1478
  width: params.width,
1143
1479
  height: params.height,
1144
1480
  hasAudio: false,
@@ -1152,6 +1488,62 @@ function createSubtitleAsset(params) {
1152
1488
  clipId: assetId
1153
1489
  };
1154
1490
  }
1491
+ // src/engine/webav/import-files.ts
1492
+ var import_files_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({ importFiles: () => importFiles });
1493
+ /** 找出可以「重连」的素材 id(同名 + 同类型 + 当前确实缺少二进制) */
1494
+ function findReconnectableId(state, registry, file, kind) {
1495
+ if (state.missingAssetIds.length === 0) return null;
1496
+ return state.assets.find((asset) => state.missingAssetIds.includes(asset.id) && asset.kind === kind && asset.name === file.name && registry.getFile(asset.id) == null)?.id ?? null;
1497
+ }
1498
+ /**
1499
+ * 批量导入素材。
1500
+ *
1501
+ * - 串行处理,避免同时开辟多个解码器把内存打爆;
1502
+ * - 单个文件失败只影响自身,最后汇总返回;
1503
+ * - 进度由调用方通过 `onProgress` 展示(不写 store,避免高频全局重渲染);
1504
+ * - 与「缺失源文件」的素材同名同类型时重连,复用原 assetId。
1505
+ */
1506
+ async function importFiles(registry, store, files, options = {}) {
1507
+ const { select = true, onProgress, idFor, reconnect = true } = options;
1508
+ const imported = [];
1509
+ const failures = [];
1510
+ let reconnected = 0;
1511
+ for (let index = 0; index < files.length; index += 1) {
1512
+ const file = files[index];
1513
+ if (file == null) continue;
1514
+ try {
1515
+ const detected = require_file_reader.detectFileType(file);
1516
+ const reconnectId = reconnect ? findReconnectableId(store.getState(), registry, file, detected.kind) : null;
1517
+ const requestedId = reconnectId ?? idFor?.(file, index) ?? null;
1518
+ const assetId = requestedId != null && requestedId !== "" ? requestedId : void 0;
1519
+ if (reconnectId != null) registry.removeAsset(reconnectId);
1520
+ const result = await importFile(registry, file, (progress) => onProgress?.(index, progress), assetId != null ? { id: assetId } : void 0);
1521
+ if (reconnectId != null) {
1522
+ store.getState().replaceAsset(reconnectId, result.asset);
1523
+ store.getState().clearMissing(reconnectId);
1524
+ imported.push(result.asset);
1525
+ reconnected += 1;
1526
+ } else {
1527
+ store.getState().addAsset(result.asset);
1528
+ imported.push(result.asset);
1529
+ }
1530
+ } catch (err) {
1531
+ const message = errorMessage(err);
1532
+ failures.push({
1533
+ name: file.name,
1534
+ error: message
1535
+ });
1536
+ onProgress?.(index, 1);
1537
+ if (!(err instanceof require_file_reader.UnsupportedFileError)) console.warn("[importFiles] 导入失败", file.name, err);
1538
+ }
1539
+ }
1540
+ if (select && imported.length > 0) store.getState().selectAsset(imported[0]?.id ?? null);
1541
+ return {
1542
+ imported,
1543
+ failures,
1544
+ reconnected
1545
+ };
1546
+ }
1155
1547
  // src/engine/webav/sprite-factory.ts
1156
1548
  /**
1157
1549
  * 时间轴数据 → Sprite 的转换。
@@ -1177,8 +1569,8 @@ function applyClipToSprite(sprite, clip) {
1177
1569
  sprite.opacity = clip.opacity;
1178
1570
  sprite.flip = clip.flip;
1179
1571
  sprite.time = {
1180
- offset: require_canvas_size.asUs(clip.startUs),
1181
- duration: require_canvas_size.asUs(clip.durationUs),
1572
+ offset: require_time.asUs(clip.startUs),
1573
+ duration: require_time.asUs(clip.durationUs),
1182
1574
  playbackRate: clip.playbackRate
1183
1575
  };
1184
1576
  }
@@ -1220,144 +1612,685 @@ function applyOpacityFade(sprite, clip) {
1220
1612
  iterCount: 1
1221
1613
  });
1222
1614
  }
1223
- // src/engine/webav/sprite-sync.ts
1224
- function zIndexOf(tracks, trackId, clip) {
1225
- const trackIndex = tracks.findIndex((t) => t.id === trackId);
1226
- return Math.max(0, trackIndex) * 1e3 + Math.round(clip.startUs / 1e6);
1227
- }
1228
- /**
1229
- * 计算需要重建实例的 clip(入点 / 时长变了,旧 IClip 的数据窗口不再匹配)。
1230
- */
1231
- function needsRebuild(previous, next) {
1232
- if (!previous) return true;
1233
- return previous.inPointUs !== next.inPointUs;
1234
- }
1235
- var SpriteSynchronizer = class {
1236
- /** 由创建方注入,保证一个预览画布只操作自己的 clip 实例 */
1237
- registry;
1238
- sprites = /* @__PURE__ */ new Map();
1239
- /** 记录上一次同步的数据,用于检测属性变化 */
1240
- lastClips = /* @__PURE__ */ new Map();
1241
- constructor(registry) {
1242
- this.registry = registry;
1243
- }
1244
- getSprite(clipId) {
1245
- return this.sprites.get(clipId);
1615
+ // src/engine/webav/gain-clip.ts
1616
+ var GainClip = class GainClip {
1617
+ ready;
1618
+ inner;
1619
+ opts;
1620
+ destroyed = false;
1621
+ constructor(inner, opts) {
1622
+ this.inner = inner;
1623
+ this.opts = opts;
1624
+ this.ready = inner.ready;
1246
1625
  }
1247
- listSprites() {
1248
- return Array.from(this.sprites.values());
1626
+ get meta() {
1627
+ return this.inner.meta;
1249
1628
  }
1250
- /**
1251
- * 全量对齐:以 store 的 clips 为准增删 sprite。
1252
- *
1253
- * @param assets 素材表(用于判断类型)
1254
- * @returns 变更详情,调用方据此把 sprite 挂到 AVCanvas
1255
- */
1256
- async sync(project, assets, handlers) {
1257
- const assetMap = new Map(assets.map((a) => [a.id, a]));
1258
- const nextIds = new Set(project.clips.map((c) => c.id));
1259
- const removed = [];
1260
- for (const [id, sprite] of Array.from(this.sprites)) {
1261
- if (nextIds.has(id)) continue;
1262
- handlers.onRemove(sprite);
1263
- this.sprites.delete(id);
1264
- this.lastClips.delete(id);
1265
- this.registry.destroyInstance(id);
1266
- removed.push(id);
1267
- }
1268
- const created = [];
1269
- const errors = [];
1270
- for (const clip of project.clips) {
1271
- const track = project.tracks.find((t) => t.id === clip.trackId);
1272
- if (!track) continue;
1273
- const asset = assetMap.get(clip.assetId);
1274
- if (!asset) {
1275
- errors.push({
1276
- clipId: clip.id,
1277
- message: "引用的素材已不存在,请重新导入"
1278
- });
1279
- continue;
1280
- }
1281
- if ((asset.kind === "video" || asset.kind === "audio" || asset.kind === "image") && this.registry.getFile(asset.id) == null) {
1282
- errors.push({
1283
- clipId: clip.id,
1284
- message: `素材文件未载入:${asset.name}`
1285
- });
1286
- continue;
1287
- }
1288
- const previous = this.lastClips.get(clip.id);
1289
- const existing = this.sprites.get(clip.id);
1290
- if (existing && !needsRebuild(previous, clip)) {
1291
- applyClipToSprite(existing, clip);
1292
- existing.zIndex = zIndexOf(project.tracks, clip.trackId, clip);
1293
- existing.visible = !track.hidden;
1294
- this.lastClips.set(clip.id, clip);
1295
- continue;
1296
- }
1297
- if (existing) {
1298
- handlers.onRemove(existing);
1299
- this.sprites.delete(clip.id);
1300
- this.registry.destroyInstance(clip.id);
1301
- }
1302
- try {
1303
- const instance = await this.registry.createInstance({
1304
- timelineClipId: clip.id,
1305
- assetId: clip.assetId,
1306
- inPointUs: clip.inPointUs,
1307
- durationUs: clip.durationUs * clip.playbackRate,
1308
- text: clip.text,
1309
- textStyle: clip.textStyle,
1310
- srt: clip.srt,
1311
- subtitleStyle: clip.subtitleStyle,
1312
- projectWidth: project.width,
1313
- projectHeight: project.height
1314
- });
1315
- const sprite = createVisibleSprite(track.kind === "audio" ? instance : instance, clip, { zIndex: zIndexOf(project.tracks, clip.trackId, clip) });
1316
- sprite.visible = !track.hidden;
1317
- if (track.kind === "audio" && asset.kind === "audio") {
1318
- sprite.rect.w = 0;
1319
- sprite.rect.h = 0;
1320
- }
1321
- await handlers.onAdd(sprite);
1322
- this.sprites.set(clip.id, sprite);
1323
- this.lastClips.set(clip.id, clip);
1324
- created.push(clip.id);
1325
- } catch (err) {
1326
- errors.push({
1327
- clipId: clip.id,
1328
- message: err instanceof Error ? err.message : String(err)
1329
- });
1330
- }
1331
- }
1629
+ async tick(time) {
1630
+ if (this.destroyed) return { state: "done" };
1631
+ const result = await this.inner.tick(time);
1632
+ if (result.audio == null || result.audio.length === 0) return result;
1633
+ const gain = this.gainAt(time);
1634
+ if (gain === 1) return result;
1332
1635
  return {
1333
- sprites: new Map(this.sprites),
1334
- removed,
1335
- created,
1336
- errors
1636
+ ...result,
1637
+ audio: result.audio.map((channel) => {
1638
+ const out = new Float32Array(channel.length);
1639
+ for (let i = 0; i < channel.length; i += 1) out[i] = (channel[i] ?? 0) * gain;
1640
+ return out;
1641
+ })
1337
1642
  };
1338
1643
  }
1339
- /** 清空所有 sprite(销毁实例) */
1340
- clear(handlers) {
1341
- for (const [id, sprite] of this.sprites) {
1342
- handlers.onRemove(sprite);
1343
- this.registry.destroyInstance(id);
1344
- }
1345
- this.sprites.clear();
1346
- this.lastClips.clear();
1644
+ /** 计算某个时刻(源时间,微秒)的线性增益 */
1645
+ gainAt(time) {
1646
+ const { volume, fadeInUs, fadeOutUs, durationUs, playbackRate } = this.opts;
1647
+ const base = Math.max(0, Math.min(1, volume));
1648
+ const totalUs = Math.max(1, durationUs * (playbackRate > 0 ? playbackRate : 1));
1649
+ const fadeIn = Math.min(fadeInUs * (playbackRate > 0 ? playbackRate : 1), totalUs / 2);
1650
+ const fadeOut = Math.min(fadeOutUs * (playbackRate > 0 ? playbackRate : 1), totalUs / 2);
1651
+ let gain = base;
1652
+ if (fadeIn > 0 && time < fadeIn) gain *= Math.max(0, time / fadeIn);
1653
+ if (fadeOut > 0 && time > totalUs - fadeOut) gain *= Math.max(0, (totalUs - time) / fadeOut);
1654
+ return Math.max(0, Math.min(1, gain));
1347
1655
  }
1348
- stats() {
1349
- return this.sprites.size;
1656
+ async clone() {
1657
+ const copy = new GainClip(await this.inner.clone(), { ...this.opts });
1658
+ await copy.ready;
1659
+ return copy;
1350
1660
  }
1351
- };
1352
- // src/engine/webav/preview-canvas.ts
1353
- /**
1354
- * AVCanvas 的封装层(feature 与 WebAV 之间的唯一桥梁)。
1355
- *
1356
- * 目的:把 WebAV 的实例管理、sprite 同步、事件订阅全部收拢到 core,
1357
- * 使 features / hooks 不直接依赖 `@webav/*`(分层规则要求)。
1358
- *
1359
- * 关键设计:
1360
- * - 本类 **不** 调用 `av.play()`。AVCanvas 自带 33ms 步进与自己的 AudioContext,
1661
+ async split(time) {
1662
+ if (time <= 0 || time >= this.opts.durationUs) throw new Error("\"time\" out of bounds");
1663
+ const leftInner = await this.inner.clone();
1664
+ const rightInner = await this.inner.clone();
1665
+ const left = new GainClip(leftInner, {
1666
+ ...this.opts,
1667
+ durationUs: time
1668
+ });
1669
+ const right = new GainClip(rightInner, {
1670
+ ...this.opts,
1671
+ durationUs: this.opts.durationUs - time
1672
+ });
1673
+ await Promise.all([left.ready, right.ready]);
1674
+ return [left, right];
1675
+ }
1676
+ destroy() {
1677
+ if (this.destroyed) return;
1678
+ this.destroyed = true;
1679
+ this.inner.destroy();
1680
+ }
1681
+ };
1682
+ // src/engine/webav/coordinate.ts
1683
+ /**
1684
+ * 把工程坐标下的 transform 等比映射到输出尺寸。
1685
+ *
1686
+ * 使用同一个缩放系数(分别算宽高再取较小值会改变构图,横竖比不同时更明显),
1687
+ * 并按输出画布居中,保证「预览里居中」在导出后依然居中。
1688
+ *
1689
+ * @param transform 工程坐标系下的变换
1690
+ * @param source 工程分辨率
1691
+ * @param target 输出分辨率
1692
+ */
1693
+ function mapTransformToOutput(transform, source, target) {
1694
+ const safeSourceW = Math.max(1, source.width);
1695
+ const safeSourceH = Math.max(1, source.height);
1696
+ const scaleX = target.width / safeSourceW;
1697
+ const scaleY = target.height / safeSourceH;
1698
+ const scale = Math.min(scaleX, scaleY);
1699
+ const scaledW = transform.w * scale;
1700
+ const scaledH = transform.h * scale;
1701
+ const centerX = (transform.x + transform.w / 2) * scaleX;
1702
+ const centerY = (transform.y + transform.h / 2) * scaleY;
1703
+ const offsetX = (target.width - safeSourceW * scale) / 2;
1704
+ const offsetY = (target.height - safeSourceH * scale) / 2;
1705
+ return {
1706
+ x: centerX + offsetX - scaledW / 2,
1707
+ y: centerY + offsetY - scaledH / 2,
1708
+ w: scaledW,
1709
+ h: scaledH,
1710
+ angle: transform.angle
1711
+ };
1712
+ }
1713
+ /** 便捷版:直接返回换算后的 clip(浅拷贝,只替换 transform) */
1714
+ function mapClipToOutput(clip, source, target) {
1715
+ return {
1716
+ ...clip,
1717
+ transform: mapTransformToOutput(clip.transform, source, target)
1718
+ };
1719
+ }
1720
+ // src/engine/webav/exporter.ts
1721
+ /**
1722
+ * 导出流水线:时间轴数据 → Combinator → MP4 二进制。
1723
+ *
1724
+ * 该模块不依赖 React;`buildCombinator` 同时被主线程与 Worker 复用。
1725
+ *
1726
+ * ⚠️ `Combinator.output()` 在当前线程同步步进渲染,会阻塞 UI。
1727
+ * 主线程路径(`exportProject`)只作为 Worker 不可用时的降级方案,必须配全屏遮罩。
1728
+ */
1729
+ /**
1730
+ * 给 clip 套上音量包络。
1731
+ *
1732
+ * WebAV 的音频混音是直接相加,没有每轨音量;`MP4Clip` / `AudioClip` 的 volume
1733
+ * 只能在构造时指定,且 clone 会沿用(同一素材被多个 clip 复用时无法区分)。
1734
+ * 因此统一用 `GainClip` 包装:每个时间轴 clip 走自己的实例与包络。
1735
+ *
1736
+ * @param instance 该 clip 独占的 IClip
1737
+ * @param clip 时间轴数据(提供 volume / fadeIn / fadeOut)
1738
+ */
1739
+ function withClipGain(instance, clip, options = {}) {
1740
+ const volume = options.trackMuted === true ? 0 : clip.volume;
1741
+ if (!(clip.muted || options.trackMuted === true || volume !== 1 || clip.fadeInUs > 0 || clip.fadeOutUs > 0)) return instance;
1742
+ if (clip.muted) return instance;
1743
+ return new GainClip(instance, {
1744
+ volume,
1745
+ fadeInUs: clip.fadeInUs,
1746
+ fadeOutUs: clip.fadeOutUs,
1747
+ durationUs: clip.durationUs,
1748
+ playbackRate: clip.playbackRate
1749
+ });
1750
+ }
1751
+ /**
1752
+ * 组装 Combinator。
1753
+ *
1754
+ * - 只加入未隐藏轨道的 clip;`includeAudio === false` 时跳过音频轨;
1755
+ * - 显式给 `output({ maxTime })`,因此不依赖 main sprite(静态素材不会导致
1756
+ * "Unable to determine the end time");
1757
+ * - 音频 sprite 不参与画面,但仍需加入才能混合音轨。
1758
+ */
1759
+ async function buildCombinator(inputs, opts) {
1760
+ const combinator = new _webav_av_cliper.Combinator({
1761
+ width: opts.width,
1762
+ height: opts.height,
1763
+ fps: opts.fps,
1764
+ bitrate: opts.bitrate,
1765
+ bgColor: opts.bgColor ?? "#000000",
1766
+ audio: opts.includeAudio ? void 0 : false
1767
+ });
1768
+ const skipped = [];
1769
+ const usable = inputs.filter((item) => !item.track.hidden);
1770
+ if (usable.length === 0) throw new Error("时间轴为空,无法导出");
1771
+ const ordered = [...usable].sort((a, b) => {
1772
+ const aAudio = a.track.kind === "audio" ? 1 : 0;
1773
+ const bAudio = b.track.kind === "audio" ? 1 : 0;
1774
+ if (aAudio !== bAudio) return aAudio - bAudio;
1775
+ if (a.clip.startUs !== b.clip.startUs) return a.clip.startUs - b.clip.startUs;
1776
+ return a.clip.id.localeCompare(b.clip.id);
1777
+ });
1778
+ const sourceSize = {
1779
+ width: opts.projectWidth ?? opts.width,
1780
+ height: opts.projectHeight ?? opts.height
1781
+ };
1782
+ const targetSize = {
1783
+ width: opts.width,
1784
+ height: opts.height
1785
+ };
1786
+ let maxTimeUs = 0;
1787
+ let zIndex = 0;
1788
+ for (const item of ordered) {
1789
+ const { instance, track } = item;
1790
+ const clip = mapClipToOutput(item.clip, sourceSize, targetSize);
1791
+ if (track.kind === "audio" && !opts.includeAudio) continue;
1792
+ const trackMuted = track.muted;
1793
+ if (clip.muted) continue;
1794
+ const duration = require_canvas_size.clampStillDuration(clip.durationUs);
1795
+ if (clip.durationUs <= 0) {
1796
+ skipped.push({
1797
+ clipId: clip.id,
1798
+ reason: "时长为 0,已跳过"
1799
+ });
1800
+ continue;
1801
+ }
1802
+ const normalized = {
1803
+ ...clip,
1804
+ durationUs: duration
1805
+ };
1806
+ const sprite = createOffscreenSprite(withClipGain(instance, normalized, { trackMuted }), normalized, { zIndex });
1807
+ applyOpacityFade(sprite, normalized);
1808
+ await combinator.addSprite(sprite, { main: false });
1809
+ maxTimeUs = Math.max(maxTimeUs, clip.startUs + duration);
1810
+ zIndex += 1;
1811
+ }
1812
+ if (!Number.isFinite(maxTimeUs) || maxTimeUs <= 0) throw new Error("无法确定导出时长:请确认时间轴上有有效片段");
1813
+ return {
1814
+ combinator,
1815
+ maxTimeUs,
1816
+ skipped
1817
+ };
1818
+ }
1819
+ /**
1820
+ * 主线程导出(Worker 不可用时的降级路径)。
1821
+ *
1822
+ * @param inputs 已构建好的 clip 实例(调用方负责 destroy)
1823
+ * @param opts 输出配置
1824
+ * @param onProgress 0~1 进度回调
1825
+ */
1826
+ async function exportProject(inputs, opts, onProgress) {
1827
+ const startedAt = performance.now();
1828
+ const { combinator, maxTimeUs, skipped } = await buildCombinator(inputs, opts);
1829
+ const unsubscribe = onProgress ? combinator.on("OutputProgress", (p) => onProgress(p)) : () => void 0;
1830
+ try {
1831
+ const stream = combinator.output({ maxTime: maxTimeUs });
1832
+ const raw = await new Response(stream).arrayBuffer();
1833
+ if (opts.fixDuration) {
1834
+ const fixed = await (0, _webav_av_cliper.fixFMP4Duration)(new Response(raw).body);
1835
+ return {
1836
+ buffer: await new Response(fixed).arrayBuffer(),
1837
+ elapsedMs: performance.now() - startedAt,
1838
+ skipped
1839
+ };
1840
+ }
1841
+ return {
1842
+ buffer: raw,
1843
+ elapsedMs: performance.now() - startedAt,
1844
+ skipped
1845
+ };
1846
+ } finally {
1847
+ unsubscribe();
1848
+ combinator.destroy();
1849
+ }
1850
+ }
1851
+ /** 检查分辨率 / 帧率 / 码率组合是否可编码 */
1852
+ async function checkExportSupport(opts) {
1853
+ return _webav_av_cliper.Combinator.isSupported({
1854
+ width: opts.width,
1855
+ height: opts.height,
1856
+ bitrate: opts.bitrate
1857
+ });
1858
+ }
1859
+ /** 时间轴结束时间(微秒) */
1860
+ function computeTimelineEnd(clips) {
1861
+ return clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
1862
+ }
1863
+ // src/engine/webav/export-runner.ts
1864
+ var export_runner_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
1865
+ buildWorkerPayload: () => buildWorkerPayload,
1866
+ canUseWorker: () => canUseWorker,
1867
+ computeExportDuration: () => computeExportDuration,
1868
+ setDefaultExportWorkerFactory: () => setDefaultExportWorkerFactory,
1869
+ startExport: () => startExport
1870
+ });
1871
+ /** 标记取消的哨兵:只在本模块内部流转,不暴露给宿主 */
1872
+ var CANCELLED = "__export_cancelled__";
1873
+ /**
1874
+ * 默认 Worker 工厂(构建形态相关)。
1875
+ *
1876
+ * core 不能知道 UI 包把导出 Worker 打成了哪种资源(独立 chunk / UMD 内联),
1877
+ * 因此由 `@cut-workbench/workbench` 在模块加载时注册一份;宿主若提供了
1878
+ * `HostBridge.createExportWorker`,那个优先级更高。两者都没有时走主线程。
1879
+ */
1880
+ var defaultWorkerFactory = null;
1881
+ function setDefaultExportWorkerFactory(factory) {
1882
+ defaultWorkerFactory = factory;
1883
+ }
1884
+ /** 判断是否可以用 Worker 导出(文字素材依赖 DOM) */
1885
+ function canUseWorker(project, assets) {
1886
+ const kinds = new Map(assets.map((a) => [a.id, a.kind]));
1887
+ return !project.clips.some((clip) => kinds.get(clip.assetId) === "text");
1888
+ }
1889
+ /** 参与导出的片段最大结束时间(跳过隐藏轨道) */
1890
+ function computeExportDuration(project, maxTimeUs) {
1891
+ if (maxTimeUs != null) return maxTimeUs;
1892
+ const hidden = new Set(project.tracks.filter((t) => t.hidden).map((t) => t.id));
1893
+ return project.clips.reduce((max, clip) => hidden.has(clip.trackId) ? max : Math.max(max, clip.startUs + clip.durationUs), 0);
1894
+ }
1895
+ /**
1896
+ * 构建 Worker 载荷。
1897
+ *
1898
+ * 音频素材需要主线程预解码 PCM(Worker 无 AudioContext)。为避免无谓解码,
1899
+ * 只有「音频轨素材」或「含音轨且会被播放的视频」才解码。
1900
+ */
1901
+ async function buildWorkerPayload(registry, project, assets, settings, maxTimeUs) {
1902
+ const usedAssetIds = new Set(project.clips.map((c) => c.assetId));
1903
+ const missing = [];
1904
+ const payloadAssets = [];
1905
+ for (const asset of assets) {
1906
+ if (!usedAssetIds.has(asset.id)) continue;
1907
+ const file = registry.getFile(asset.id);
1908
+ if (file == null && asset.kind !== "text" && asset.kind !== "subtitle") {
1909
+ missing.push(asset.name);
1910
+ continue;
1911
+ }
1912
+ let pcm;
1913
+ if (settings.includeAudio && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio)) pcm = await loadPcmForAsset(registry, asset.id);
1914
+ payloadAssets.push({
1915
+ id: asset.id,
1916
+ kind: asset.kind,
1917
+ file,
1918
+ width: asset.width,
1919
+ height: asset.height,
1920
+ durationUs: asset.durationUs,
1921
+ hasAudio: asset.hasAudio,
1922
+ pcm,
1923
+ text: asset.text,
1924
+ textStyle: asset.textStyle,
1925
+ srt: asset.srt,
1926
+ subtitleStyle: asset.subtitleStyle
1927
+ });
1928
+ }
1929
+ const kindOf = new Map(assets.map((a) => [a.id, a.kind]));
1930
+ const clips = project.clips.map((clip) => ({
1931
+ clip,
1932
+ kind: kindOf.get(clip.assetId) ?? "video"
1933
+ }));
1934
+ return {
1935
+ assets: payloadAssets,
1936
+ timeline: {
1937
+ width: settings.width,
1938
+ height: settings.height,
1939
+ projectWidth: project.width,
1940
+ projectHeight: project.height,
1941
+ fps: settings.fps,
1942
+ bitrate: settings.bitrate,
1943
+ includeAudio: settings.includeAudio,
1944
+ bgColor: "#000000",
1945
+ maxTimeUs,
1946
+ tracks: project.tracks.map((t) => ({
1947
+ id: t.id,
1948
+ kind: t.kind,
1949
+ hidden: t.hidden,
1950
+ muted: t.muted
1951
+ })),
1952
+ clips
1953
+ },
1954
+ missing
1955
+ };
1956
+ }
1957
+ /**
1958
+ * 主线程回退导出:为每个 clip 从 registry 建实例后交给 core exporter。
1959
+ *
1960
+ * 注意:实例用 `export:<clipId>` 作为临时 key 注册进 registry,导出结束后统一销毁,
1961
+ * 避免污染预览用的实例(两者不可共用,因为共享 tick 状态)。
1962
+ */
1963
+ async function exportOnMainThread(options, onProgress, shouldCancel) {
1964
+ const { registry, project, settings, mimeType = "video/mp4", filename } = options;
1965
+ const trackMap = new Map(project.tracks.map((t) => [t.id, t]));
1966
+ const inputs = [];
1967
+ const skipped = [];
1968
+ const tempIds = [];
1969
+ try {
1970
+ for (const clip of project.clips) {
1971
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
1972
+ const track = trackMap.get(clip.trackId);
1973
+ if (!track || track.hidden) continue;
1974
+ const asset = registry.getAsset(clip.assetId);
1975
+ if (!asset) {
1976
+ skipped.push({
1977
+ clipId: clip.id,
1978
+ reason: "素材缺失"
1979
+ });
1980
+ continue;
1981
+ }
1982
+ const tempId = `export:${clip.id}`;
1983
+ try {
1984
+ const instance = await registry.createInstance({
1985
+ timelineClipId: tempId,
1986
+ assetId: clip.assetId,
1987
+ inPointUs: clip.inPointUs,
1988
+ durationUs: clip.durationUs * clip.playbackRate,
1989
+ text: clip.text,
1990
+ textStyle: clip.textStyle,
1991
+ srt: clip.srt,
1992
+ subtitleStyle: clip.subtitleStyle,
1993
+ projectWidth: project.width,
1994
+ projectHeight: project.height
1995
+ });
1996
+ tempIds.push(tempId);
1997
+ inputs.push({
1998
+ clip,
1999
+ instance,
2000
+ kind: asset.kind,
2001
+ track
2002
+ });
2003
+ } catch (err) {
2004
+ skipped.push({
2005
+ clipId: clip.id,
2006
+ reason: err instanceof Error ? err.message : String(err)
2007
+ });
2008
+ }
2009
+ }
2010
+ if (inputs.length === 0) throw new Error("时间轴为空,无法导出");
2011
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
2012
+ const startedAt = performance.now();
2013
+ const { buffer, skipped: buildSkipped } = await exportProject(inputs, {
2014
+ width: settings.width,
2015
+ height: settings.height,
2016
+ fps: settings.fps,
2017
+ bitrate: settings.bitrate,
2018
+ includeAudio: settings.includeAudio,
2019
+ projectWidth: project.width,
2020
+ projectHeight: project.height
2021
+ }, onProgress);
2022
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
2023
+ return {
2024
+ blob: new Blob([buffer], { type: mimeType }),
2025
+ filename: filename ?? require_waveform.createExportFilename(),
2026
+ mimeType,
2027
+ durationUs: computeExportDuration(project, options.maxTimeUs),
2028
+ width: settings.width,
2029
+ height: settings.height,
2030
+ fps: settings.fps,
2031
+ bitrate: settings.bitrate,
2032
+ elapsedMs: performance.now() - startedAt,
2033
+ skipped: [...skipped, ...buildSkipped],
2034
+ mode: "main"
2035
+ };
2036
+ } finally {
2037
+ for (const id of tempIds) registry.destroyInstance(id);
2038
+ }
2039
+ }
2040
+ async function exportViaWorker(options, worker, onProgress, shouldCancel) {
2041
+ const { registry, project, assets, settings, maxTimeUs, mimeType = "video/mp4", filename } = options;
2042
+ const { assets: payloadAssets, timeline, missing } = await buildWorkerPayload(registry, project, assets, settings, maxTimeUs);
2043
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
2044
+ if (missing.length > 0) console.warn("[export] 以下素材缺少源文件,相关片段将被跳过:", missing.join("、"));
2045
+ return new Promise((resolve, reject) => {
2046
+ const startedAt = performance.now();
2047
+ const cleanup = () => {
2048
+ worker.onmessage = null;
2049
+ worker.onerror = null;
2050
+ worker.terminate();
2051
+ };
2052
+ worker.onmessage = (event) => {
2053
+ const message = event.data;
2054
+ if (message.type === "progress") {
2055
+ if (shouldCancel()) return;
2056
+ onProgress(message.progress);
2057
+ return;
2058
+ }
2059
+ if (message.type === "done") {
2060
+ cleanup();
2061
+ if (shouldCancel()) {
2062
+ reject(new require_waveform.ExportCancelledError());
2063
+ return;
2064
+ }
2065
+ resolve({
2066
+ blob: new Blob([message.buffer], { type: mimeType }),
2067
+ filename: filename ?? require_waveform.createExportFilename(),
2068
+ mimeType,
2069
+ durationUs: computeExportDuration(project, maxTimeUs),
2070
+ width: settings.width,
2071
+ height: settings.height,
2072
+ fps: settings.fps,
2073
+ bitrate: settings.bitrate,
2074
+ elapsedMs: performance.now() - startedAt,
2075
+ skipped: message.skipped ?? [],
2076
+ mode: "worker"
2077
+ });
2078
+ return;
2079
+ }
2080
+ if (message.type === "error") {
2081
+ cleanup();
2082
+ reject(message.message === CANCELLED || shouldCancel() ? new require_waveform.ExportCancelledError() : new Error(message.message));
2083
+ }
2084
+ };
2085
+ worker.onerror = (event) => {
2086
+ cleanup();
2087
+ reject(new Error(event.message || "导出 Worker 异常"));
2088
+ };
2089
+ if (shouldCancel()) {
2090
+ cleanup();
2091
+ reject(new require_waveform.ExportCancelledError());
2092
+ return;
2093
+ }
2094
+ worker.postMessage({
2095
+ type: "export",
2096
+ assets: payloadAssets,
2097
+ timeline,
2098
+ fixDuration: true
2099
+ });
2100
+ });
2101
+ }
2102
+ /**
2103
+ * 启动导出。
2104
+ *
2105
+ * 优先走 Worker(不阻塞 UI);含文字片段或 Worker 不可用 / 失败时回退主线程。
2106
+ * `cancel()` 后 promise 以 `ExportCancelledError` reject。
2107
+ */
2108
+ function startExport(options, onProgress = () => void 0) {
2109
+ const { project, assets, settings } = options;
2110
+ let cancelled = false;
2111
+ let worker = null;
2112
+ const cancel = () => {
2113
+ cancelled = true;
2114
+ if (worker != null) {
2115
+ try {
2116
+ worker.postMessage({ type: "cancel" });
2117
+ } catch {}
2118
+ worker.terminate();
2119
+ worker = null;
2120
+ }
2121
+ };
2122
+ return {
2123
+ promise: (async () => {
2124
+ if (!await checkExportSupport({
2125
+ width: settings.width,
2126
+ height: settings.height,
2127
+ bitrate: settings.bitrate
2128
+ })) throw new Error(`当前设备不支持编码 ${settings.width}×${settings.height} @ ${(settings.bitrate / 1e6).toFixed(1)}Mbps,请降低分辨率或码率`);
2129
+ if (cancelled) throw new require_waveform.ExportCancelledError();
2130
+ const makeWorker = () => {
2131
+ const factory = options.createWorker ?? defaultWorkerFactory;
2132
+ if (factory == null) return null;
2133
+ try {
2134
+ return factory();
2135
+ } catch (err) {
2136
+ console.warn("[export] 创建 Worker 失败,回退主线程", err);
2137
+ return null;
2138
+ }
2139
+ };
2140
+ if (!canUseWorker(project, assets)) return exportOnMainThread(options, onProgress, () => cancelled);
2141
+ const created = makeWorker();
2142
+ if (created == null) return exportOnMainThread(options, onProgress, () => cancelled);
2143
+ worker = created;
2144
+ try {
2145
+ return await exportViaWorker(options, created, onProgress, () => cancelled);
2146
+ } catch (err) {
2147
+ if (cancelled || err instanceof require_waveform.ExportCancelledError) throw new require_waveform.ExportCancelledError();
2148
+ console.warn("[export] Worker 导出失败,回退主线程", err);
2149
+ return exportOnMainThread(options, onProgress, () => cancelled);
2150
+ }
2151
+ })(),
2152
+ cancel
2153
+ };
2154
+ }
2155
+ // src/engine/webav/sprite-sync.ts
2156
+ function zIndexOf(tracks, trackId, clip) {
2157
+ const trackIndex = tracks.findIndex((t) => t.id === trackId);
2158
+ return Math.max(0, trackIndex) * 1e3 + Math.round(clip.startUs / 1e6);
2159
+ }
2160
+ /**
2161
+ * 计算需要重建实例的 clip(入点 / 时长变了,旧 IClip 的数据窗口不再匹配)。
2162
+ */
2163
+ function needsRebuild(previous, next) {
2164
+ if (!previous) return true;
2165
+ return previous.inPointUs !== next.inPointUs;
2166
+ }
2167
+ var SpriteSynchronizer = class {
2168
+ /** 由创建方注入,保证一个预览画布只操作自己的 clip 实例 */
2169
+ registry;
2170
+ sprites = /* @__PURE__ */ new Map();
2171
+ /** 记录上一次同步的数据,用于检测属性变化 */
2172
+ lastClips = /* @__PURE__ */ new Map();
2173
+ constructor(registry) {
2174
+ this.registry = registry;
2175
+ }
2176
+ getSprite(clipId) {
2177
+ return this.sprites.get(clipId);
2178
+ }
2179
+ listSprites() {
2180
+ return Array.from(this.sprites.values());
2181
+ }
2182
+ /**
2183
+ * 全量对齐:以 store 的 clips 为准增删 sprite。
2184
+ *
2185
+ * @param assets 素材表(用于判断类型)
2186
+ * @returns 变更详情,调用方据此把 sprite 挂到 AVCanvas
2187
+ */
2188
+ async sync(project, assets, handlers) {
2189
+ const assetMap = new Map(assets.map((a) => [a.id, a]));
2190
+ const nextIds = new Set(project.clips.map((c) => c.id));
2191
+ const removed = [];
2192
+ for (const [id, sprite] of Array.from(this.sprites)) {
2193
+ if (nextIds.has(id)) continue;
2194
+ handlers.onRemove(sprite);
2195
+ this.sprites.delete(id);
2196
+ this.lastClips.delete(id);
2197
+ this.registry.destroyInstance(id);
2198
+ removed.push(id);
2199
+ }
2200
+ const created = [];
2201
+ const errors = [];
2202
+ for (const clip of project.clips) {
2203
+ const track = project.tracks.find((t) => t.id === clip.trackId);
2204
+ if (!track) continue;
2205
+ const asset = assetMap.get(clip.assetId);
2206
+ if (!asset) {
2207
+ errors.push({
2208
+ clipId: clip.id,
2209
+ message: "引用的素材已不存在,请重新导入"
2210
+ });
2211
+ continue;
2212
+ }
2213
+ if ((asset.kind === "video" || asset.kind === "audio" || asset.kind === "image") && this.registry.getFile(asset.id) == null) {
2214
+ errors.push({
2215
+ clipId: clip.id,
2216
+ message: `素材文件未载入:${asset.name}`
2217
+ });
2218
+ continue;
2219
+ }
2220
+ const previous = this.lastClips.get(clip.id);
2221
+ const existing = this.sprites.get(clip.id);
2222
+ if (existing && !needsRebuild(previous, clip)) {
2223
+ applyClipToSprite(existing, clip);
2224
+ existing.zIndex = zIndexOf(project.tracks, clip.trackId, clip);
2225
+ existing.visible = !track.hidden;
2226
+ if (previous == null || previous.durationUs !== clip.durationUs || previous.fadeInUs !== clip.fadeInUs || previous.fadeOutUs !== clip.fadeOutUs || previous.opacity !== clip.opacity) applyOpacityFade(existing, clip);
2227
+ this.lastClips.set(clip.id, clip);
2228
+ continue;
2229
+ }
2230
+ if (existing) {
2231
+ handlers.onRemove(existing);
2232
+ this.sprites.delete(clip.id);
2233
+ this.registry.destroyInstance(clip.id);
2234
+ }
2235
+ try {
2236
+ const sprite = createVisibleSprite(await this.registry.createInstance({
2237
+ timelineClipId: clip.id,
2238
+ assetId: clip.assetId,
2239
+ inPointUs: clip.inPointUs,
2240
+ durationUs: clip.durationUs * clip.playbackRate,
2241
+ text: clip.text,
2242
+ textStyle: clip.textStyle,
2243
+ srt: clip.srt,
2244
+ subtitleStyle: clip.subtitleStyle,
2245
+ projectWidth: project.width,
2246
+ projectHeight: project.height
2247
+ }), clip, { zIndex: zIndexOf(project.tracks, clip.trackId, clip) });
2248
+ sprite.visible = !track.hidden;
2249
+ applyOpacityFade(sprite, clip);
2250
+ if (track.kind === "audio" && asset.kind === "audio") {
2251
+ sprite.rect.w = 0;
2252
+ sprite.rect.h = 0;
2253
+ }
2254
+ await handlers.onAdd(sprite);
2255
+ this.sprites.set(clip.id, sprite);
2256
+ this.lastClips.set(clip.id, clip);
2257
+ created.push(clip.id);
2258
+ } catch (err) {
2259
+ errors.push({
2260
+ clipId: clip.id,
2261
+ message: err instanceof Error ? err.message : String(err)
2262
+ });
2263
+ }
2264
+ }
2265
+ return {
2266
+ sprites: new Map(this.sprites),
2267
+ removed,
2268
+ created,
2269
+ errors
2270
+ };
2271
+ }
2272
+ /** 清空所有 sprite(销毁实例) */
2273
+ clear(handlers) {
2274
+ for (const [id, sprite] of this.sprites) {
2275
+ handlers.onRemove(sprite);
2276
+ this.registry.destroyInstance(id);
2277
+ }
2278
+ this.sprites.clear();
2279
+ this.lastClips.clear();
2280
+ }
2281
+ stats() {
2282
+ return this.sprites.size;
2283
+ }
2284
+ };
2285
+ // src/engine/webav/preview-canvas.ts
2286
+ /**
2287
+ * AVCanvas 的封装层(feature 与 WebAV 之间的唯一桥梁)。
2288
+ *
2289
+ * 目的:把 WebAV 的实例管理、sprite 同步、事件订阅全部收拢到 core,
2290
+ * 使 features / hooks 不直接依赖 `@webav/*`(分层规则要求)。
2291
+ *
2292
+ * 关键设计:
2293
+ * - 本类 **不** 调用 `av.play()`。AVCanvas 自带 33ms 步进与自己的 AudioContext,
1361
2294
  * 与我们的 PlaybackClock 会打架;播放统一由外层 `previewFrame` 驱动。
1362
2295
  * - 播放时画面靠「每帧 preFrame + 重绘」推进;AVCanvas 的内部渲染循环在暂停态
1363
2296
  * 仍会按当前时间持续重绘,这正是我们要的(属性变化立刻反映)。
@@ -1572,253 +2505,6 @@ var PreviewCanvas = class {
1572
2505
  } catch {}
1573
2506
  }
1574
2507
  };
1575
- // src/engine/webav/gain-clip.ts
1576
- var GainClip = class GainClip {
1577
- ready;
1578
- inner;
1579
- opts;
1580
- destroyed = false;
1581
- constructor(inner, opts) {
1582
- this.inner = inner;
1583
- this.opts = opts;
1584
- this.ready = inner.ready;
1585
- }
1586
- get meta() {
1587
- return this.inner.meta;
1588
- }
1589
- async tick(time) {
1590
- if (this.destroyed) return { state: "done" };
1591
- const result = await this.inner.tick(time);
1592
- if (result.audio == null || result.audio.length === 0) return result;
1593
- const gain = this.gainAt(time);
1594
- if (gain === 1) return result;
1595
- return {
1596
- ...result,
1597
- audio: result.audio.map((channel) => {
1598
- const out = new Float32Array(channel.length);
1599
- for (let i = 0; i < channel.length; i += 1) out[i] = (channel[i] ?? 0) * gain;
1600
- return out;
1601
- })
1602
- };
1603
- }
1604
- /** 计算某个时刻(源时间,微秒)的线性增益 */
1605
- gainAt(time) {
1606
- const { volume, fadeInUs, fadeOutUs, durationUs, playbackRate } = this.opts;
1607
- const base = Math.max(0, Math.min(1, volume));
1608
- const totalUs = Math.max(1, durationUs * (playbackRate > 0 ? playbackRate : 1));
1609
- const fadeIn = Math.min(fadeInUs * (playbackRate > 0 ? playbackRate : 1), totalUs / 2);
1610
- const fadeOut = Math.min(fadeOutUs * (playbackRate > 0 ? playbackRate : 1), totalUs / 2);
1611
- let gain = base;
1612
- if (fadeIn > 0 && time < fadeIn) gain *= Math.max(0, time / fadeIn);
1613
- if (fadeOut > 0 && time > totalUs - fadeOut) gain *= Math.max(0, (totalUs - time) / fadeOut);
1614
- return Math.max(0, Math.min(1, gain));
1615
- }
1616
- async clone() {
1617
- const copy = new GainClip(await this.inner.clone(), { ...this.opts });
1618
- await copy.ready;
1619
- return copy;
1620
- }
1621
- async split(time) {
1622
- if (time <= 0 || time >= this.opts.durationUs) throw new Error("\"time\" out of bounds");
1623
- const leftInner = await this.inner.clone();
1624
- const rightInner = await this.inner.clone();
1625
- const left = new GainClip(leftInner, {
1626
- ...this.opts,
1627
- durationUs: time
1628
- });
1629
- const right = new GainClip(rightInner, {
1630
- ...this.opts,
1631
- durationUs: this.opts.durationUs - time
1632
- });
1633
- await Promise.all([left.ready, right.ready]);
1634
- return [left, right];
1635
- }
1636
- destroy() {
1637
- if (this.destroyed) return;
1638
- this.destroyed = true;
1639
- this.inner.destroy();
1640
- }
1641
- };
1642
- // src/engine/webav/coordinate.ts
1643
- /**
1644
- * 把工程坐标下的 transform 等比映射到输出尺寸。
1645
- *
1646
- * 使用同一个缩放系数(分别算宽高再取较小值会改变构图,横竖比不同时更明显),
1647
- * 并按输出画布居中,保证「预览里居中」在导出后依然居中。
1648
- *
1649
- * @param transform 工程坐标系下的变换
1650
- * @param source 工程分辨率
1651
- * @param target 输出分辨率
1652
- */
1653
- function mapTransformToOutput(transform, source, target) {
1654
- const safeSourceW = Math.max(1, source.width);
1655
- const safeSourceH = Math.max(1, source.height);
1656
- const scaleX = target.width / safeSourceW;
1657
- const scaleY = target.height / safeSourceH;
1658
- const scale = Math.min(scaleX, scaleY);
1659
- const scaledW = transform.w * scale;
1660
- const scaledH = transform.h * scale;
1661
- const centerX = (transform.x + transform.w / 2) * scaleX;
1662
- const centerY = (transform.y + transform.h / 2) * scaleY;
1663
- const offsetX = (target.width - safeSourceW * scale) / 2;
1664
- const offsetY = (target.height - safeSourceH * scale) / 2;
1665
- return {
1666
- x: centerX + offsetX - scaledW / 2,
1667
- y: centerY + offsetY - scaledH / 2,
1668
- w: scaledW,
1669
- h: scaledH,
1670
- angle: transform.angle
1671
- };
1672
- }
1673
- /** 便捷版:直接返回换算后的 clip(浅拷贝,只替换 transform) */
1674
- function mapClipToOutput(clip, source, target) {
1675
- return {
1676
- ...clip,
1677
- transform: mapTransformToOutput(clip.transform, source, target)
1678
- };
1679
- }
1680
- // src/engine/webav/exporter.ts
1681
- /**
1682
- * 导出流水线:时间轴数据 → Combinator → MP4 二进制。
1683
- *
1684
- * 该模块不依赖 React;`buildCombinator` 同时被主线程与 Worker 复用。
1685
- *
1686
- * ⚠️ `Combinator.output()` 在当前线程同步步进渲染,会阻塞 UI。
1687
- * 主线程路径(`exportProject`)只作为 Worker 不可用时的降级方案,必须配全屏遮罩。
1688
- */
1689
- /**
1690
- * 给 clip 套上音量包络。
1691
- *
1692
- * WebAV 的音频混音是直接相加,没有每轨音量;`MP4Clip` / `AudioClip` 的 volume
1693
- * 只能在构造时指定,且 clone 会沿用(同一素材被多个 clip 复用时无法区分)。
1694
- * 因此统一用 `GainClip` 包装:每个时间轴 clip 走自己的实例与包络。
1695
- *
1696
- * @param instance 该 clip 独占的 IClip
1697
- * @param clip 时间轴数据(提供 volume / fadeIn / fadeOut)
1698
- */
1699
- function withClipGain(instance, clip) {
1700
- if (!(clip.muted || clip.volume !== 1 || clip.fadeInUs > 0 || clip.fadeOutUs > 0)) return instance;
1701
- if (clip.muted) return instance;
1702
- return new GainClip(instance, {
1703
- volume: clip.volume,
1704
- fadeInUs: clip.fadeInUs,
1705
- fadeOutUs: clip.fadeOutUs,
1706
- durationUs: clip.durationUs,
1707
- playbackRate: clip.playbackRate
1708
- });
1709
- }
1710
- /**
1711
- * 组装 Combinator。
1712
- *
1713
- * - 只加入未隐藏轨道的 clip;`includeAudio === false` 时跳过音频轨;
1714
- * - 显式给 `output({ maxTime })`,因此不依赖 main sprite(静态素材不会导致
1715
- * "Unable to determine the end time");
1716
- * - 音频 sprite 不参与画面,但仍需加入才能混合音轨。
1717
- */
1718
- async function buildCombinator(inputs, opts) {
1719
- const combinator = new _webav_av_cliper.Combinator({
1720
- width: opts.width,
1721
- height: opts.height,
1722
- fps: opts.fps,
1723
- bitrate: opts.bitrate,
1724
- bgColor: opts.bgColor ?? "#000000",
1725
- audio: opts.includeAudio ? void 0 : false
1726
- });
1727
- const skipped = [];
1728
- const usable = inputs.filter((item) => !item.track.hidden);
1729
- if (usable.length === 0) throw new Error("时间轴为空,无法导出");
1730
- const ordered = [...usable].sort((a, b) => {
1731
- const aAudio = a.track.kind === "audio" ? 1 : 0;
1732
- const bAudio = b.track.kind === "audio" ? 1 : 0;
1733
- if (aAudio !== bAudio) return aAudio - bAudio;
1734
- if (a.clip.startUs !== b.clip.startUs) return a.clip.startUs - b.clip.startUs;
1735
- return a.clip.id.localeCompare(b.clip.id);
1736
- });
1737
- const sourceSize = {
1738
- width: opts.projectWidth ?? opts.width,
1739
- height: opts.projectHeight ?? opts.height
1740
- };
1741
- const targetSize = {
1742
- width: opts.width,
1743
- height: opts.height
1744
- };
1745
- let maxTimeUs = 0;
1746
- let zIndex = 0;
1747
- for (const item of ordered) {
1748
- const { instance, track } = item;
1749
- const clip = mapClipToOutput(item.clip, sourceSize, targetSize);
1750
- if (track.kind === "audio" && !opts.includeAudio) continue;
1751
- if (track.muted) continue;
1752
- if (clip.muted) continue;
1753
- const duration = require_canvas_size.clampStillDuration(clip.durationUs);
1754
- if (clip.durationUs <= 0) {
1755
- skipped.push({
1756
- clipId: clip.id,
1757
- reason: "时长为 0,已跳过"
1758
- });
1759
- continue;
1760
- }
1761
- const normalized = {
1762
- ...clip,
1763
- durationUs: duration
1764
- };
1765
- const sprite = createOffscreenSprite(withClipGain(instance, normalized), normalized, { zIndex });
1766
- applyOpacityFade(sprite, normalized);
1767
- await combinator.addSprite(sprite, { main: false });
1768
- maxTimeUs = Math.max(maxTimeUs, clip.startUs + duration);
1769
- zIndex += 1;
1770
- }
1771
- if (!Number.isFinite(maxTimeUs) || maxTimeUs <= 0) throw new Error("无法确定导出时长:请确认时间轴上有有效片段");
1772
- return {
1773
- combinator,
1774
- maxTimeUs,
1775
- skipped
1776
- };
1777
- }
1778
- /**
1779
- * 主线程导出(Worker 不可用时的降级路径)。
1780
- *
1781
- * @param inputs 已构建好的 clip 实例(调用方负责 destroy)
1782
- * @param opts 输出配置
1783
- * @param onProgress 0~1 进度回调
1784
- */
1785
- async function exportProject(inputs, opts, onProgress) {
1786
- const startedAt = performance.now();
1787
- const { combinator, maxTimeUs, skipped } = await buildCombinator(inputs, opts);
1788
- const unsubscribe = onProgress ? combinator.on("OutputProgress", (p) => onProgress(p)) : () => void 0;
1789
- try {
1790
- const stream = combinator.output({ maxTime: maxTimeUs });
1791
- const raw = await new Response(stream).arrayBuffer();
1792
- if (opts.fixDuration) {
1793
- const fixed = await (0, _webav_av_cliper.fixFMP4Duration)(new Response(raw).body);
1794
- return {
1795
- buffer: await new Response(fixed).arrayBuffer(),
1796
- elapsedMs: performance.now() - startedAt,
1797
- skipped
1798
- };
1799
- }
1800
- return {
1801
- buffer: raw,
1802
- elapsedMs: performance.now() - startedAt,
1803
- skipped
1804
- };
1805
- } finally {
1806
- unsubscribe();
1807
- combinator.destroy();
1808
- }
1809
- }
1810
- /** 检查分辨率 / 帧率 / 码率组合是否可编码 */
1811
- async function checkExportSupport(opts) {
1812
- return _webav_av_cliper.Combinator.isSupported({
1813
- width: opts.width,
1814
- height: opts.height,
1815
- bitrate: opts.bitrate
1816
- });
1817
- }
1818
- /** 时间轴结束时间(微秒) */
1819
- function computeTimelineEnd(clips) {
1820
- return clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
1821
- }
1822
2508
  // src/engine/webav/fix-duration.ts
1823
2509
  /**
1824
2510
  * 修正 WebAV 输出的 fMP4 时长。
@@ -1931,6 +2617,16 @@ var cancelled = false;
1931
2617
  function toStream(blob) {
1932
2618
  return blob.stream();
1933
2619
  }
2620
+ /** 轨道静音:音量置 0(画面不受影响) */
2621
+ function withSilence(clip) {
2622
+ return new GainClip(clip, {
2623
+ volume: 0,
2624
+ fadeInUs: 0,
2625
+ fadeOutUs: 0,
2626
+ durationUs: Number.MAX_SAFE_INTEGER,
2627
+ playbackRate: 1
2628
+ });
2629
+ }
1934
2630
  /** 音量 / 淡入淡出包装(与主线程 exporter.withClipGain 保持一致的语义) */
1935
2631
  function withGain(clip, timeline) {
1936
2632
  if (timeline.volume === 1 && timeline.fadeInUs === 0 && timeline.fadeOutUs === 0) return clip;
@@ -1993,6 +2689,7 @@ async function buildClipInWorker(asset) {
1993
2689
  */
1994
2690
  async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
1995
2691
  const assetMap = new Map(assets.map((a) => [a.id, a]));
2692
+ const trackMap = new Map((timeline.tracks ?? []).map((t) => [t.id, t]));
1996
2693
  const combinator = new _webav_av_cliper.Combinator({
1997
2694
  width: timeline.width,
1998
2695
  height: timeline.height,
@@ -2001,7 +2698,8 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
2001
2698
  bgColor: timeline.bgColor,
2002
2699
  audio: timeline.includeAudio ? void 0 : false
2003
2700
  });
2004
- const clipCache = /* @__PURE__ */ new Map();
2701
+ const masterCache = /* @__PURE__ */ new Map();
2702
+ const derived = [];
2005
2703
  const skipped = [];
2006
2704
  const sourceSize = {
2007
2705
  width: timeline.projectWidth ?? timeline.width,
@@ -2033,11 +2731,14 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
2033
2731
  });
2034
2732
  continue;
2035
2733
  }
2734
+ const track = trackMap.get(item.clip.trackId);
2735
+ if (track?.hidden === true) continue;
2036
2736
  if (item.clip.muted) continue;
2037
- let clip = clipCache.get(asset.id);
2038
- if (clip == null) try {
2039
- clip = await buildClipInWorker(asset);
2040
- clipCache.set(asset.id, clip);
2737
+ if (item.kind === "audio" && !timeline.includeAudio) continue;
2738
+ let master = masterCache.get(asset.id);
2739
+ if (master == null) try {
2740
+ master = await buildClipInWorker(asset);
2741
+ masterCache.set(asset.id, master);
2041
2742
  } catch (err) {
2042
2743
  const reason = err instanceof Error ? err.message : String(err);
2043
2744
  console.warn("[export.worker] clip skipped:", item.clip.id, reason);
@@ -2048,7 +2749,24 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
2048
2749
  continue;
2049
2750
  }
2050
2751
  const tc = item.clip;
2051
- const sprite = new _webav_av_cliper.OffscreenSprite(withGain(clip, tc));
2752
+ let instance;
2753
+ try {
2754
+ instance = await master.clone();
2755
+ } catch (err) {
2756
+ const reason = err instanceof Error ? err.message : String(err);
2757
+ skipped.push({
2758
+ clipId: tc.id,
2759
+ reason
2760
+ });
2761
+ continue;
2762
+ }
2763
+ if (tc.inPointUs > 0) instance = new OffsetClip(instance, {
2764
+ inPointUs: tc.inPointUs,
2765
+ durationUs: tc.durationUs * tc.playbackRate
2766
+ });
2767
+ derived.push(instance);
2768
+ const gainInput = track?.muted === true ? withSilence(instance) : instance;
2769
+ const sprite = new _webav_av_cliper.OffscreenSprite(withGain(gainInput, tc));
2052
2770
  const mapped = mapTransformToOutput(tc.transform, sourceSize, targetSize);
2053
2771
  sprite.rect.x = mapped.x;
2054
2772
  sprite.rect.y = mapped.y;
@@ -2063,6 +2781,7 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
2063
2781
  duration: tc.durationUs,
2064
2782
  playbackRate: tc.playbackRate
2065
2783
  };
2784
+ applyOpacityFade(sprite, tc);
2066
2785
  zIndex += 1;
2067
2786
  maxTimeUs = Math.max(maxTimeUs, tc.startUs + tc.durationUs);
2068
2787
  await combinator.addSprite(sprite, { main: false });
@@ -2084,10 +2803,14 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
2084
2803
  skipped
2085
2804
  };
2086
2805
  } finally {
2087
- for (const clip of clipCache.values()) try {
2806
+ for (const clip of derived) try {
2807
+ clip.destroy();
2808
+ } catch {}
2809
+ derived.length = 0;
2810
+ for (const clip of masterCache.values()) try {
2088
2811
  clip.destroy();
2089
2812
  } catch {}
2090
- clipCache.clear();
2813
+ masterCache.clear();
2091
2814
  try {
2092
2815
  combinator.destroy();
2093
2816
  } catch {}
@@ -2143,7 +2866,10 @@ exports.applyClipToSprite = applyClipToSprite;
2143
2866
  exports.applyOpacityFade = applyOpacityFade;
2144
2867
  exports.buildClipInWorker = buildClipInWorker;
2145
2868
  exports.buildCombinator = buildCombinator;
2869
+ exports.buildWorkerPayload = buildWorkerPayload;
2870
+ exports.canUseWorker = canUseWorker;
2146
2871
  exports.checkExportSupport = checkExportSupport;
2872
+ exports.computeExportDuration = computeExportDuration;
2147
2873
  exports.computeTimelineEnd = computeTimelineEnd;
2148
2874
  exports.createClipFromBuffer = createClipFromBuffer;
2149
2875
  exports.createClipFromFile = createClipFromFile;
@@ -2166,11 +2892,13 @@ exports.exportProject = exportProject;
2166
2892
  exports.fixFMP4DurationStream = fixFMP4DurationStream;
2167
2893
  exports.handleWorkerMessage = handleWorkerMessage;
2168
2894
  exports.importFile = importFile;
2895
+ exports.importFiles = importFiles;
2169
2896
  exports.isEnvironmentUsable = require_waveform.isEnvironmentUsable;
2170
2897
  exports.loadAudioClipForAsset = loadAudioClipForAsset;
2171
2898
  exports.loadPcmForAsset = loadPcmForAsset;
2172
2899
  exports.mapClipToOutput = mapClipToOutput;
2173
2900
  exports.mapTransformToOutput = mapTransformToOutput;
2901
+ exports.regenerateDerived = regenerateDerived;
2174
2902
  exports.runExportInWorker = runExportInWorker;
2175
2903
  exports.selectAssetById = require_waveform.selectAssetById;
2176
2904
  exports.selectAssets = require_waveform.selectAssets;
@@ -2181,6 +2909,8 @@ exports.selectProject = require_waveform.selectProject;
2181
2909
  exports.selectProjectDuration = require_waveform.selectProjectDuration;
2182
2910
  exports.selectSelectedClipIds = require_waveform.selectSelectedClipIds;
2183
2911
  exports.selectTracks = require_waveform.selectTracks;
2912
+ exports.setDefaultExportWorkerFactory = setDefaultExportWorkerFactory;
2913
+ exports.startExport = startExport;
2184
2914
  exports.summarizeCapabilities = require_waveform.summarizeCapabilities;
2185
2915
  exports.withClipGain = withClipGain;
2186
2916