@cut-workbench/core 0.3.0 → 0.4.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.
Files changed (52) hide show
  1. package/dist/chunks/{canvas-size-iWL8zZPl.js → canvas-size-CM2oKApi.js} +3 -144
  2. package/dist/chunks/{canvas-size-iWL8zZPl.js.map → canvas-size-CM2oKApi.js.map} +1 -1
  3. package/dist/chunks/{canvas-size-IzoEg2cq.cjs → canvas-size-Ct61OYjW.cjs} +26 -287
  4. package/dist/chunks/canvas-size-Ct61OYjW.cjs.map +1 -0
  5. package/dist/chunks/import-pipeline-A8KkqtZT.cjs +610 -0
  6. package/dist/chunks/import-pipeline-A8KkqtZT.cjs.map +1 -0
  7. package/dist/chunks/import-pipeline-DLIdgrIb.js +521 -0
  8. package/dist/chunks/import-pipeline-DLIdgrIb.js.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-Dc022smD.js → snap-CBV9G4xq.js} +2 -2
  12. package/dist/chunks/{snap-Dc022smD.js.map → snap-CBV9G4xq.js.map} +1 -1
  13. package/dist/chunks/{snap-CeYuTN6d.cjs → snap-DyIuO2hU.cjs} +3 -3
  14. package/dist/chunks/{snap-CeYuTN6d.cjs.map → snap-DyIuO2hU.cjs.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/time-C_gvkVl1.js +145 -0
  20. package/dist/chunks/time-C_gvkVl1.js.map +1 -0
  21. package/dist/chunks/time-CnMfSVYX.cjs +264 -0
  22. package/dist/chunks/time-CnMfSVYX.cjs.map +1 -0
  23. package/dist/chunks/{waveform-CWU8Fa-n.js → waveform-DJkRtMct.js} +82 -12
  24. package/dist/chunks/waveform-DJkRtMct.js.map +1 -0
  25. package/dist/chunks/{waveform-Bhvv2t7-.cjs → waveform-Ie-G5Lle.cjs} +121 -21
  26. package/dist/chunks/waveform-Ie-G5Lle.cjs.map +1 -0
  27. package/dist/editor/create-editor.d.ts +8 -106
  28. package/dist/editor/editor-store.d.ts +4 -0
  29. package/dist/editor/handle-types.d.ts +256 -0
  30. package/dist/engine/index.cjs +961 -823
  31. package/dist/engine/index.cjs.map +1 -1
  32. package/dist/engine/index.d.ts +3 -0
  33. package/dist/engine/index.js +935 -803
  34. package/dist/engine/index.js.map +1 -1
  35. package/dist/engine/webav/export-runner.d.ts +61 -0
  36. package/dist/engine/webav/import-files.d.ts +43 -0
  37. package/dist/engine/webav/import-pipeline.d.ts +13 -1
  38. package/dist/errors.d.ts +12 -0
  39. package/dist/export/types.d.ts +49 -0
  40. package/dist/index.cjs +75 -65
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.ts +5 -0
  43. package/dist/index.js +46 -43
  44. package/dist/index.js.map +1 -1
  45. package/dist/ports/host-bridge.d.ts +7 -0
  46. package/dist/project/file-storage.d.ts +23 -0
  47. package/dist/timeline/index.cjs +17 -16
  48. package/dist/timeline/index.js +3 -2
  49. package/package.json +78 -77
  50. package/dist/chunks/canvas-size-IzoEg2cq.cjs.map +0 -1
  51. package/dist/chunks/waveform-Bhvv2t7-.cjs.map +0 -1
  52. package/dist/chunks/waveform-CWU8Fa-n.js.map +0 -1
@@ -1,300 +1,12 @@
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_time = require("../chunks/time-CnMfSVYX.cjs");
4
+ const require_canvas_size = require("../chunks/canvas-size-Ct61OYjW.cjs");
5
+ const require_waveform = require("../chunks/waveform-Ie-G5Lle.cjs");
4
6
  const require_file_reader = require("../chunks/file-reader-DfMEluGr.cjs");
7
+ const require_import_pipeline = require("../chunks/import-pipeline-A8KkqtZT.cjs");
5
8
  let _webav_av_cliper = require("@webav/av-cliper");
6
9
  let _webav_av_canvas = require("@webav/av-canvas");
7
- // src/engine/webav/styles.ts
8
- var DEFAULT_TEXT_STYLE = {
9
- fontSize: 64,
10
- color: "#ffffff",
11
- fontFamily: "sans-serif",
12
- fontWeight: 600,
13
- fontStyle: "normal",
14
- strokeColor: "#000000",
15
- strokeWidth: 0,
16
- shadowColor: "rgba(0,0,0,0.6)",
17
- shadowBlur: 8,
18
- align: "center",
19
- background: "transparent"
20
- };
21
- /**
22
- * 字幕默认字体。
23
- *
24
- * ⚠️ 不要写 'Noto Sans SC' —— 大多数系统并未安装该字体,`EmbedSubtitlesClip` 会把
25
- * `ctx.font` 设为 `30px Noto Sans SC`,此时浏览器回退到默认字体,中文可能显示为方框。
26
- * 使用系统字体栈(sans-serif)由浏览器挑选可用的中文字体,最稳。
27
- */
28
- var DEFAULT_SUBTITLE_STYLE = {
29
- color: "#ffffff",
30
- textBgColor: null,
31
- fontFamily: "sans-serif",
32
- fontSize: 30,
33
- strokeStyle: "#000000",
34
- lineWidth: null,
35
- bottomOffset: 30
36
- };
37
- // src/engine/webav/text-clip.ts
38
- function cssFromStyle(style) {
39
- const shadow = style.shadowBlur > 0 ? `text-shadow: 0 2px ${style.shadowBlur}px ${style.shadowColor};` : "";
40
- const stroke = style.strokeWidth > 0 ? `-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};` : "";
41
- return [
42
- `font-size:${style.fontSize}px`,
43
- `color:${style.color}`,
44
- `font-family:${style.fontFamily}`,
45
- `font-weight:${style.fontWeight}`,
46
- `font-style:${style.fontStyle}`,
47
- `text-align:${style.align}`,
48
- `background:${style.background}`,
49
- "line-height:1.25",
50
- "padding:8px 16px",
51
- "white-space:pre-wrap",
52
- "max-width:80%",
53
- stroke,
54
- shadow
55
- ].filter(Boolean).join(";");
56
- }
57
- /**
58
- * 文字素材。内部缓存最近一次渲染的 ImageBitmap,按「文本 + 样式」做键避免重复渲染。
59
- */
60
- var TextClip = class TextClip {
61
- ready;
62
- opts;
63
- metaValue;
64
- cacheKey = "";
65
- cachedBitmap = null;
66
- destroyed = false;
67
- constructor(options) {
68
- this.opts = {
69
- ...options,
70
- style: {
71
- ...DEFAULT_TEXT_STYLE,
72
- ...options.style
73
- },
74
- animation: options.animation ?? "none",
75
- charsPerSecond: options.charsPerSecond ?? 12,
76
- animOffsetUs: options.animOffsetUs ?? 0
77
- };
78
- this.metaValue = {
79
- width: options.width,
80
- height: options.height,
81
- duration: Math.max(1, options.durationUs)
82
- };
83
- this.ready = Promise.resolve(this.metaValue);
84
- }
85
- get meta() {
86
- return this.metaValue;
87
- }
88
- visibleTextAt(timeUs) {
89
- const { text, animation, charsPerSecond } = this.opts;
90
- if (animation === "none") return text;
91
- const phaseUs = timeUs + this.opts.animOffsetUs;
92
- if (animation === "typewriter") {
93
- const visible = Math.floor(phaseUs / 1e6 * charsPerSecond);
94
- if (visible <= 0) return null;
95
- return text.slice(0, visible);
96
- }
97
- return Math.floor(phaseUs / 1e6 * 2) % 2 === 0 ? text : null;
98
- }
99
- async tick(time) {
100
- if (this.destroyed) return { state: "done" };
101
- if (time >= this.opts.durationUs) return { state: "done" };
102
- const visible = this.visibleTextAt(time);
103
- if (visible == null || visible.length === 0) return {
104
- video: await this.renderEmpty(),
105
- state: "success"
106
- };
107
- const key = `${visible}|${JSON.stringify(this.opts.style)}`;
108
- if (key !== this.cacheKey || this.cachedBitmap == null) {
109
- this.cachedBitmap?.close();
110
- this.cachedBitmap = await this.renderTextToFrame(visible);
111
- this.cacheKey = key;
112
- }
113
- return {
114
- video: await createImageBitmap(this.cachedBitmap),
115
- state: "success"
116
- };
117
- }
118
- /**
119
- * 把文字渲染成「整帧」位图(工程分辨率)。
120
- *
121
- * 为什么不能直接把 renderTxt2ImgBitmap 的结果当帧用:
122
- * 它返回的是文字自身尺寸的小图(几十到几百像素宽),Sprite 会把它拉伸到 rect 的
123
- * 1280×720 —— 文字被放大后严重模糊,且居中位置由 Sprite 决定而非文字块决定。
124
- * 因此这里先渲染文字小图,再按比例居中合成到工程尺寸的画布上。
125
- */
126
- async renderTextToFrame(text) {
127
- const { width, height } = this.opts;
128
- const canvas = new OffscreenCanvas(width, height);
129
- const ctx = canvas.getContext("2d");
130
- if (ctx == null) throw new Error("无法创建文字渲染上下文");
131
- const layer = await (0, _webav_av_cliper.renderTxt2ImgBitmap)(text, cssFromStyle(this.opts.style));
132
- const align = this.opts.style.align;
133
- const margin = Math.round(width * .05);
134
- const maxWidth = width - margin * 2;
135
- const scale = layer.width > maxWidth ? maxWidth / layer.width : 1;
136
- const drawW = Math.round(layer.width * scale);
137
- const drawH = Math.round(layer.height * scale);
138
- const x = align === "left" ? margin : align === "right" ? width - margin - drawW : (width - drawW) / 2;
139
- const y = Math.round((height - drawH) / 2);
140
- ctx.drawImage(layer, x, y, drawW, drawH);
141
- layer.close();
142
- return canvas.transferToImageBitmap();
143
- }
144
- emptyBitmap = null;
145
- async renderEmpty() {
146
- if (this.emptyBitmap != null) return this.emptyBitmap;
147
- const canvas = new OffscreenCanvas(this.opts.width, this.opts.height);
148
- this.emptyBitmap = canvas.transferToImageBitmap();
149
- return this.emptyBitmap;
150
- }
151
- async split(time) {
152
- if (time <= 0 || time >= this.opts.durationUs) throw new Error("\"time\" out of bounds");
153
- const left = new TextClip({
154
- ...this.opts,
155
- durationUs: time
156
- });
157
- const right = new TextClip({
158
- ...this.opts,
159
- durationUs: this.opts.durationUs - time,
160
- animOffsetUs: this.opts.animOffsetUs + time
161
- });
162
- await Promise.all([left.ready, right.ready]);
163
- return [left, right];
164
- }
165
- async clone() {
166
- const copy = new TextClip({ ...this.opts });
167
- await copy.ready;
168
- return copy;
169
- }
170
- destroy() {
171
- this.destroyed = true;
172
- this.cachedBitmap?.close();
173
- this.cachedBitmap = null;
174
- this.emptyBitmap?.close();
175
- this.emptyBitmap = null;
176
- }
177
- };
178
- // src/engine/webav/clip-factory.ts
179
- /**
180
- * File → IClip 的分派工厂。
181
- *
182
- * ⚠️ 本文件是 `@webav/*` 的合法引用点之一(另一个是 core/webav 下的其他模块)。
183
- * 不要在 features / store / components 中直接 import WebAV。
184
- *
185
- * 所有 clip 实例的生命周期由 `ClipRegistry` 统一管理,这里只负责创建。
186
- */
187
- /**
188
- * 创建素材的「母版」clip 并读取 meta。
189
- *
190
- * 对静态图/文字/字幕,`meta.duration` 可能是 Infinity,这里会归一成有限值:
191
- * 真正的时长由时间轴 clip 决定,母版只用于 clone 与抽帧。
192
- */
193
- async function createClipFromFile(file, kind) {
194
- switch (kind) {
195
- case "video": {
196
- const clip = new _webav_av_cliper.MP4Clip(require_file_reader.fileToStream(file));
197
- const meta = await clip.ready;
198
- return {
199
- clip,
200
- meta: {
201
- durationUs: meta.duration,
202
- width: meta.width,
203
- height: meta.height,
204
- hasAudio: meta.audioChanCount > 0
205
- }
206
- };
207
- }
208
- case "audio": {
209
- const clip = new _webav_av_cliper.AudioClip(require_file_reader.fileToStream(file));
210
- return {
211
- clip,
212
- meta: {
213
- durationUs: (await clip.ready).duration,
214
- width: 0,
215
- height: 0,
216
- hasAudio: true
217
- }
218
- };
219
- }
220
- case "image": {
221
- const clip = new _webav_av_cliper.ImgClip(require_file_reader.fileToStream(file));
222
- const meta = await clip.ready;
223
- return {
224
- clip,
225
- meta: {
226
- durationUs: Number.isFinite(meta.duration) ? meta.duration : require_canvas_size.DEFAULT_STILL_DURATION_US,
227
- width: meta.width,
228
- height: meta.height,
229
- hasAudio: false
230
- }
231
- };
232
- }
233
- case "subtitle": {
234
- const text = await file.text();
235
- const clip = new _webav_av_cliper.EmbedSubtitlesClip(text, {
236
- videoWidth: 1280,
237
- videoHeight: 720
238
- });
239
- const meta = await clip.ready;
240
- return {
241
- clip,
242
- meta: {
243
- durationUs: Number.isFinite(meta.duration) ? meta.duration : require_canvas_size.DEFAULT_STILL_DURATION_US,
244
- width: meta.width,
245
- height: meta.height,
246
- hasAudio: false
247
- }
248
- };
249
- }
250
- default: throw new Error(`Unsupported asset kind: ${kind}`);
251
- }
252
- }
253
- /** 文字素材:不走文件,直接由 TextClip 生成 */
254
- function createTextClip(init) {
255
- return new TextClip({
256
- text: init.text,
257
- style: init.style,
258
- width: init.width,
259
- height: init.height,
260
- durationUs: init.durationUs
261
- });
262
- }
263
- function createSubtitleClip(init) {
264
- return new _webav_av_cliper.EmbedSubtitlesClip(init.srt, {
265
- videoWidth: init.width,
266
- videoHeight: init.height,
267
- color: init.style.color,
268
- textBgColor: init.style.textBgColor,
269
- fontFamily: init.style.fontFamily,
270
- fontSize: init.style.fontSize,
271
- strokeStyle: init.style.strokeStyle,
272
- lineWidth: init.style.lineWidth,
273
- bottomOffset: init.style.bottomOffset
274
- });
275
- }
276
- /** Worker 内重建 clip:只支持可序列化的素材类型 */
277
- function createClipFromBuffer(buffer, kind, opts) {
278
- switch (kind) {
279
- case "video": return new _webav_av_cliper.MP4Clip(require_file_reader.bufferToStream(buffer));
280
- case "audio": return new _webav_av_cliper.AudioClip(require_file_reader.bufferToStream(buffer));
281
- case "image": return new _webav_av_cliper.ImgClip(require_file_reader.bufferToStream(buffer));
282
- case "subtitle": return createSubtitleClip({
283
- srt: opts?.srt ?? "",
284
- style: opts?.subtitleStyle ?? DEFAULT_SUBTITLE_STYLE,
285
- width: opts?.width ?? 1280,
286
- height: opts?.height ?? 720
287
- });
288
- case "text": return createTextClip({
289
- text: opts?.text ?? "",
290
- style: opts?.textStyle ?? DEFAULT_TEXT_STYLE,
291
- width: opts?.width ?? 1280,
292
- height: opts?.height ?? 720,
293
- durationUs: opts?.durationUs ?? 5e6
294
- });
295
- default: throw new Error(`Worker 内不支持重建素材类型:${kind}`);
296
- }
297
- }
298
10
  // src/engine/webav/offset-clip.ts
299
11
  var OffsetClip = class OffsetClip {
300
12
  ready;
@@ -356,49 +68,6 @@ var OffsetClip = class OffsetClip {
356
68
  this.inner.destroy();
357
69
  }
358
70
  };
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
71
  // src/engine/webav/clip-registry.ts
403
72
  var ClipRegistry = class {
404
73
  assets = /* @__PURE__ */ new Map();
@@ -427,10 +96,10 @@ var ClipRegistry = class {
427
96
  * 这两个类型无法 clone(clone 会带走旧的 duration / 内容),必须每次新建。
428
97
  */
429
98
  buildProceduralClip(params, asset) {
430
- if (asset.kind === "text") return createTextClip({
99
+ if (asset.kind === "text") return require_import_pipeline.createTextClip({
431
100
  text: params.text ?? asset.text ?? "文本",
432
101
  style: {
433
- ...DEFAULT_TEXT_STYLE,
102
+ ...require_import_pipeline.DEFAULT_TEXT_STYLE,
434
103
  ...asset.textStyle ?? {},
435
104
  ...params.textStyle ?? {}
436
105
  },
@@ -438,10 +107,10 @@ var ClipRegistry = class {
438
107
  height: params.projectHeight,
439
108
  durationUs: params.durationUs
440
109
  });
441
- return createSubtitleClip({
110
+ return require_import_pipeline.createSubtitleClip({
442
111
  srt: params.srt ?? asset.srt ?? "",
443
112
  style: {
444
- ...DEFAULT_SUBTITLE_STYLE,
113
+ ...require_import_pipeline.DEFAULT_SUBTITLE_STYLE,
445
114
  ...asset.subtitleStyle ?? {},
446
115
  ...params.subtitleStyle ?? {}
447
116
  },
@@ -462,7 +131,7 @@ var ClipRegistry = class {
462
131
  let clip;
463
132
  if (asset.kind === "text" || asset.kind === "subtitle") clip = this.buildProceduralClip(params, asset);
464
133
  else if (runtime.master) clip = await runtime.master.clone();
465
- else if (runtime.file) clip = (await createClipFromFile(runtime.file, asset.kind)).clip;
134
+ else if (runtime.file) clip = (await require_import_pipeline.createClipFromFile(runtime.file, asset.kind)).clip;
466
135
  else throw new Error(`素材缺少可用的数据源:${asset.name}`);
467
136
  const wrapped = params.inPointUs > 0 ? new OffsetClip(clip, {
468
137
  inPointUs: params.inPointUs,
@@ -508,7 +177,7 @@ var ClipRegistry = class {
508
177
  runtime?.master?.destroy();
509
178
  } catch {}
510
179
  this.assets.delete(assetId);
511
- releaseThumbnails(assetId);
180
+ require_import_pipeline.releaseThumbnails(assetId);
512
181
  }
513
182
  destroyAllInstances() {
514
183
  for (const [id] of this.derived) this.destroyInstance(id);
@@ -520,7 +189,7 @@ var ClipRegistry = class {
520
189
  try {
521
190
  runtime.master?.destroy();
522
191
  } catch {}
523
- releaseThumbnails(assetId);
192
+ require_import_pipeline.releaseThumbnails(assetId);
524
193
  }
525
194
  this.assets.clear();
526
195
  }
@@ -839,17 +508,28 @@ function resolveBridge(bridge) {
839
508
  if (bridge != null) return bridge;
840
509
  return require_waveform.createWebBridge();
841
510
  }
511
+ var loadImportFiles = () => Promise.resolve().then(() => import_files_exports);
512
+ var loadImportPipeline = () => Promise.resolve().then(() => require("../chunks/import-pipeline-A8KkqtZT.cjs")).then((n) => n.import_pipeline_exports);
513
+ var loadExportRunner = () => Promise.resolve().then(() => export_runner_exports);
514
+ function errorMessage$1(err) {
515
+ return err instanceof Error ? err.message : String(err);
516
+ }
842
517
  /**
843
518
  * 创建编辑器实例。
844
519
  *
845
- * 不加载 WebAV:`engine` 子入口需要由调用方按需 `await import()`,因为 opfs-tools
520
+ * 不加载 WebAV:引擎模块在首次导入素材 / 导出时才动态加载 —— opfs-tools
846
521
  * 在模块加载期就会访问 `navigator.storage`,非 Chromium 环境会直接抛错。
847
522
  */
848
523
  function createEditor(options = {}) {
849
524
  const id = `editor_${Math.random().toString(36).slice(2, 10)}`;
850
525
  const bridge = resolveBridge(options.bridge);
851
- const storage = options.storage ?? require_waveform.createMemoryStorage();
852
- const namespace = options.namespace ?? "default";
526
+ let storage = options.storage ?? require_waveform.createMemoryStorage();
527
+ let namespace = options.namespace ?? "default";
528
+ let autoSave = options.autoSave ?? null;
529
+ const initialProject = options.project ?? (options.projectId != null && options.projectId !== "" ? {
530
+ ...require_canvas_size.createEmptyProject(),
531
+ id: options.projectId
532
+ } : require_canvas_size.createEmptyProject());
853
533
  const bus = require_waveform.createEventBus((err) => {
854
534
  bus.emit("error", {
855
535
  scope: "event-listener",
@@ -858,19 +538,45 @@ function createEditor(options = {}) {
858
538
  });
859
539
  const history = require_waveform.createHistoryStore();
860
540
  const store = require_waveform.createEditorStore({
861
- history,
862
- project: options.project,
863
- assets: options.assets
541
+ project: initialProject,
542
+ assets: options.assets,
543
+ history
864
544
  });
865
545
  const runtime = createEditorRuntime();
866
546
  let disposed = false;
867
547
  let lastPlaying = false;
548
+ let capabilityPromise = null;
549
+ let autoSaveTimer = null;
868
550
  let prevProject = store.getState().project;
869
551
  let prevAssets = store.getState().assets;
870
552
  let prevSelection = store.getState().selectedClipIds;
871
553
  let prevAssetId = store.getState().selectedAssetId;
872
554
  let prevPlayhead = store.getState().playheadUs;
873
555
  let prevCanUndo = history.getState().canUndo();
556
+ let prevDirty = store.getState().dirty;
557
+ /** 当前写盘的存储与命名空间(autoSave 指定了自己的目标时以它为准) */
558
+ function saveTarget() {
559
+ if (autoSave != null) return {
560
+ port: autoSave.storage,
561
+ ns: autoSave.namespace
562
+ };
563
+ return {
564
+ port: storage,
565
+ ns: namespace
566
+ };
567
+ }
568
+ function scheduleAutoSave() {
569
+ if (autoSave == null || disposed || autoSaveTimer != null) return;
570
+ autoSaveTimer = setTimeout(() => {
571
+ autoSaveTimer = null;
572
+ editor.save().catch((err) => {
573
+ bus.emit("error", {
574
+ scope: "auto-save",
575
+ error: err
576
+ });
577
+ });
578
+ }, autoSave.intervalMs ?? 2e3);
579
+ }
874
580
  const unsubscribeStore = store.subscribe((state) => {
875
581
  if (disposed) return;
876
582
  if (state.project !== prevProject) {
@@ -880,6 +586,11 @@ function createEditor(options = {}) {
880
586
  dirty: state.dirty
881
587
  });
882
588
  }
589
+ if (state.dirty !== prevDirty) {
590
+ prevDirty = state.dirty;
591
+ bus.emit("project:dirty", { dirty: state.dirty });
592
+ if (state.dirty && autoSave != null) scheduleAutoSave();
593
+ }
883
594
  if (state.assets !== prevAssets) {
884
595
  prevAssets = state.assets;
885
596
  bus.emit("assets:changed", { assets: state.assets });
@@ -915,9 +626,28 @@ function createEditor(options = {}) {
915
626
  history,
916
627
  runtime,
917
628
  bridge,
918
- storage,
629
+ get storage() {
630
+ return saveTarget().port;
631
+ },
919
632
  events: bus,
633
+ commands: {
634
+ updateClip: (clipId, patch, recordHistory, mergeKey) => store.getState().updateClip(clipId, patch, recordHistory, mergeKey),
635
+ moveClip: (clipId, trackId, startUs, recordHistory) => store.getState().moveClip(clipId, trackId, startUs, recordHistory),
636
+ trimClip: (clipId, edge, deltaUs, recordHistory) => store.getState().trimClip(clipId, edge, deltaUs, recordHistory),
637
+ splitClipsAt: (atUs, clipIds) => store.getState().splitClipsAt(atUs, clipIds),
638
+ duplicateSelection: (offsetUs) => store.getState().duplicateSelection(offsetUs),
639
+ addTrack: (kind) => store.getState().addTrack(kind),
640
+ removeTrack: (trackId) => store.getState().removeTrack(trackId),
641
+ updateTrack: (trackId, patch) => store.getState().updateTrack(trackId, patch)
642
+ },
920
643
  getProject: () => store.getState().project,
644
+ getProjectId: () => store.getState().project.id,
645
+ setProjectId(nextId) {
646
+ if (nextId === store.getState().project.id) return;
647
+ store.getState().setProjectId(nextId);
648
+ },
649
+ renameProject: (name) => store.getState().renameProject(name),
650
+ setProjectSize: (width, height, mode) => store.getState().setProjectSize(width, height, mode),
921
651
  getAssets: () => store.getState().assets,
922
652
  getState: () => store.getState(),
923
653
  setProject(project, assets, missingAssetIds = []) {
@@ -925,17 +655,144 @@ function createEditor(options = {}) {
925
655
  history.getState().clear();
926
656
  },
927
657
  serialize: () => require_waveform.serializeProject(store.getState().project, store.getState().assets),
658
+ exportJson: () => require_waveform.serializeProject(store.getState().project, store.getState().assets),
928
659
  async load(input) {
929
660
  if ("schemaVersion" in input) {
930
661
  const { project, assets, missingAssetIds } = require_waveform.deserializeProject(input);
931
662
  editor.setProject(project, assets, missingAssetIds);
663
+ bus.emit("project:loaded", {
664
+ project,
665
+ assetCount: assets.length,
666
+ missing: missingAssetIds.length
667
+ });
932
668
  return;
933
669
  }
934
670
  editor.setProject(input, []);
671
+ bus.emit("project:loaded", {
672
+ project: input,
673
+ assetCount: 0,
674
+ missing: 0
675
+ });
676
+ },
677
+ async importJson(data, importOptions = {}) {
678
+ const { project, assets, missingAssetIds } = require_waveform.deserializeProject(data);
679
+ editor.setProject(project, assets, missingAssetIds);
680
+ const stillMissing = new Set(missingAssetIds);
681
+ if (importOptions.resolveFile != null) {
682
+ const { importFile } = await loadImportPipeline();
683
+ for (const asset of assets) {
684
+ if (asset.kind === "text" || asset.kind === "subtitle") continue;
685
+ let file;
686
+ try {
687
+ file = await importOptions.resolveFile(asset);
688
+ } catch (err) {
689
+ bus.emit("error", {
690
+ scope: "import-json:resolve-file",
691
+ error: err
692
+ });
693
+ continue;
694
+ }
695
+ if (file == null) continue;
696
+ try {
697
+ const result = await importFile(runtime.clips, file, void 0, { id: asset.id });
698
+ store.getState().replaceAsset(asset.id, {
699
+ ...result.asset,
700
+ id: asset.id
701
+ });
702
+ stillMissing.delete(asset.id);
703
+ } catch (err) {
704
+ bus.emit("error", {
705
+ scope: "import-json:reconnect",
706
+ error: err
707
+ });
708
+ }
709
+ }
710
+ }
711
+ const remaining = [...stillMissing];
712
+ store.getState().setMissingAssetIds(remaining);
713
+ bus.emit("project:loaded", {
714
+ project: store.getState().project,
715
+ assetCount: assets.length,
716
+ missing: remaining.length
717
+ });
718
+ return { missingAssetIds: remaining };
935
719
  },
936
720
  async save() {
937
- const data = editor.serialize();
938
- return storage.save(namespace, data);
721
+ const { port, ns } = saveTarget();
722
+ const result = await port.save(ns, editor.serialize());
723
+ if (result.ok) {
724
+ store.getState().markSaved();
725
+ bus.emit("project:saved", result.path != null ? {
726
+ ok: true,
727
+ path: result.path
728
+ } : { ok: true });
729
+ } else bus.emit("project:saved", {
730
+ ok: false,
731
+ ...result.error != null ? { error: result.error } : {},
732
+ ...result.path != null ? { path: result.path } : {}
733
+ });
734
+ return result;
735
+ },
736
+ async saveAs(nextStorage, nextNamespace) {
737
+ storage = nextStorage;
738
+ namespace = nextNamespace;
739
+ if (autoSave != null) autoSave = {
740
+ ...autoSave,
741
+ storage: nextStorage,
742
+ namespace: nextNamespace
743
+ };
744
+ return editor.save();
745
+ },
746
+ isDirty: () => store.getState().dirty,
747
+ isEmpty: () => store.getState().project.clips.length === 0,
748
+ async addFiles(files, addOptions = {}) {
749
+ const { importFiles } = await loadImportFiles();
750
+ return importFiles(runtime.clips, store, files, addOptions);
751
+ },
752
+ async addTextAsset(params) {
753
+ const { createTextAsset } = await loadImportPipeline();
754
+ const { project } = store.getState();
755
+ const { asset } = createTextAsset({
756
+ registry: runtime.clips,
757
+ text: params.text,
758
+ width: project.width,
759
+ height: project.height,
760
+ ...params.style != null ? { style: params.style } : {},
761
+ ...params.durationUs != null ? { durationUs: params.durationUs } : {}
762
+ });
763
+ store.getState().addAsset(asset);
764
+ return asset;
765
+ },
766
+ async addSubtitleAsset(params) {
767
+ const { createSubtitleAsset } = await loadImportPipeline();
768
+ const { parseSrtDurationUs } = await Promise.resolve().then(() => require("../chunks/srt-DaL36mXN.cjs")).then((n) => n.srt_exports);
769
+ const { project } = store.getState();
770
+ const durationUs = params.durationUs != null && params.durationUs > 0 ? params.durationUs : parseSrtDurationUs(params.srt);
771
+ const { asset } = createSubtitleAsset({
772
+ registry: runtime.clips,
773
+ srt: params.srt,
774
+ width: project.width,
775
+ height: project.height,
776
+ ...durationUs > 0 ? { durationUs } : {},
777
+ ...params.style != null ? { style: params.style } : {}
778
+ });
779
+ store.getState().addAsset(asset);
780
+ return asset;
781
+ },
782
+ async reconnectAsset(assetId, file) {
783
+ const { importFile } = await loadImportPipeline();
784
+ if (!store.getState().assets.some((a) => a.id === assetId)) throw new Error(`素材不存在:${assetId}`);
785
+ const result = await importFile(runtime.clips, file, void 0, { id: assetId });
786
+ store.getState().replaceAsset(assetId, {
787
+ ...result.asset,
788
+ id: assetId
789
+ });
790
+ store.getState().clearMissing(assetId);
791
+ },
792
+ getMissingAssetIds: () => [...store.getState().missingAssetIds],
793
+ removeAsset(assetId) {
794
+ runtime.clips.removeAsset(assetId);
795
+ store.getState().removeAsset(assetId);
939
796
  },
940
797
  play() {
941
798
  const { project, playing } = store.getState();
@@ -967,12 +824,113 @@ function createEditor(options = {}) {
967
824
  isPlaying: () => store.getState().playing,
968
825
  undo: () => store.getState().undo(),
969
826
  redo: () => store.getState().redo(),
827
+ canUndo: () => history.getState().canUndo(),
828
+ canRedo: () => history.getState().canRedo(),
970
829
  addClip: (clip) => store.getState().addClip(clip),
971
830
  removeClips: (ids) => store.getState().removeClips(ids),
972
831
  selectClips: (ids) => store.getState().selectClips(ids),
832
+ getSelectedClipIds: () => [...store.getState().selectedClipIds],
833
+ setSelection(clipIds, assetId) {
834
+ store.getState().selectClips(clipIds);
835
+ if (assetId !== void 0) store.getState().selectAsset(assetId);
836
+ },
837
+ updateClip: (clipId, patch, recordHistory, mergeKey) => store.getState().updateClip(clipId, patch, recordHistory, mergeKey),
838
+ moveClip: (clipId, trackId, startUs, recordHistory) => store.getState().moveClip(clipId, trackId, startUs, recordHistory),
839
+ trimClip: (clipId, edge, deltaUs, recordHistory) => store.getState().trimClip(clipId, edge, deltaUs, recordHistory),
840
+ splitClipsAt: (atUs, clipIds) => store.getState().splitClipsAt(atUs, clipIds),
841
+ duplicateSelection: (offsetUs) => store.getState().duplicateSelection(offsetUs),
842
+ addTrack: (kind) => store.getState().addTrack(kind),
843
+ removeTrack: (trackId) => store.getState().removeTrack(trackId),
844
+ updateTrack: (trackId, patch) => store.getState().updateTrack(trackId, patch),
845
+ export(request = {}) {
846
+ const snapshot = store.getState();
847
+ const settings = require_waveform.resolveExportSettings(snapshot.project, request.settings);
848
+ const mimeType = request.mimeType ?? "video/mp4";
849
+ const filename = request.filename ?? require_waveform.createExportFilename();
850
+ bus.emit("export:start", {
851
+ totalUs: require_canvas_size.getProjectDuration(snapshot.project),
852
+ settings
853
+ });
854
+ let cancelled = false;
855
+ let inner = null;
856
+ const cancel = () => {
857
+ cancelled = true;
858
+ inner?.cancel();
859
+ };
860
+ return {
861
+ promise: (async () => {
862
+ try {
863
+ const { startExport, computeExportDuration } = await loadExportRunner();
864
+ if (cancelled) throw new require_waveform.ExportCancelledError();
865
+ const handle = startExport({
866
+ registry: runtime.clips,
867
+ project: snapshot.project,
868
+ assets: snapshot.assets,
869
+ settings,
870
+ maxTimeUs: computeExportDuration(snapshot.project),
871
+ mimeType,
872
+ filename,
873
+ ...bridge.createExportWorker != null ? { createWorker: () => {
874
+ try {
875
+ return bridge.createExportWorker?.() ?? null;
876
+ } catch (err) {
877
+ bus.emit("error", {
878
+ scope: "export:worker",
879
+ error: err
880
+ });
881
+ return null;
882
+ }
883
+ } } : {}
884
+ }, (progress) => {
885
+ if (!cancelled) bus.emit("export:progress", { progress });
886
+ });
887
+ inner = handle;
888
+ const result = await handle.promise;
889
+ if (cancelled) throw new require_waveform.ExportCancelledError();
890
+ bus.emit("export:done", {
891
+ result,
892
+ blob: result.blob,
893
+ filename: result.filename
894
+ });
895
+ return result;
896
+ } catch (err) {
897
+ if (cancelled || err instanceof require_waveform.ExportCancelledError) throw err instanceof require_waveform.ExportCancelledError ? err : new require_waveform.ExportCancelledError();
898
+ bus.emit("export:error", {
899
+ message: errorMessage$1(err),
900
+ error: err
901
+ });
902
+ throw err;
903
+ }
904
+ })(),
905
+ cancel
906
+ };
907
+ },
908
+ async saveExport(result, suggestedName) {
909
+ const saveBlob = bridge.files?.saveBlob;
910
+ if (saveBlob == null) return {
911
+ ok: false,
912
+ error: "宿主未提供 saveBlob,无法保存成片"
913
+ };
914
+ try {
915
+ return await saveBlob.call(bridge.files, result.blob, suggestedName ?? result.filename);
916
+ } catch (err) {
917
+ return {
918
+ ok: false,
919
+ error: errorMessage$1(err)
920
+ };
921
+ }
922
+ },
923
+ getCapabilities() {
924
+ capabilityPromise ??= require_waveform.detectEnvironment();
925
+ return capabilityPromise;
926
+ },
973
927
  async dispose() {
974
928
  if (disposed) return;
975
929
  disposed = true;
930
+ if (autoSaveTimer != null) {
931
+ clearTimeout(autoSaveTimer);
932
+ autoSaveTimer = null;
933
+ }
976
934
  unsubscribeStore();
977
935
  bus.emit("dispose", {});
978
936
  await runtime.dispose();
@@ -981,175 +939,63 @@ function createEditor(options = {}) {
981
939
  };
982
940
  return editor;
983
941
  }
984
- // src/engine/webav/import-pipeline.ts
985
- /**
986
- * 素材导入流水线(供 feature 与 Lab 共用)。
987
- *
988
- * 步骤:校验类型 → 创建母版 IClip → 读 meta → 抽缩略图 / 波形 → 写入 registry 与 store。
989
- *
990
- * ⚠️ 所有 WebAV 调用都落在 core 内;本文件位于 core/webav 下,允许 import '@webav/*'。
991
- */
992
- var THUMBNAIL_COUNT = 4;
993
- var THUMBNAIL_WIDTH = 120;
994
- var WAVEFORM_BUCKETS = 400;
942
+ // src/engine/webav/import-files.ts
943
+ var import_files_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({ importFiles: () => importFiles });
944
+ /** 找出可以「重连」的素材 id(同名 + 同类型 + 当前确实缺少二进制) */
945
+ function findReconnectableId(state, registry, file, kind) {
946
+ if (state.missingAssetIds.length === 0) return null;
947
+ return state.assets.find((asset) => state.missingAssetIds.includes(asset.id) && asset.kind === kind && asset.name === file.name && registry.getFile(asset.id) == null)?.id ?? null;
948
+ }
949
+ function errorMessage(err) {
950
+ return err instanceof Error ? err.message : String(err);
951
+ }
995
952
  /**
996
- * 导入单个文件,生成素材记录。
953
+ * 批量导入素材。
997
954
  *
998
- * @param onProgress 0~1 进度(用于卡片骨架/进度条)
999
- * @throws UnsupportedFileError 类型不支持时
955
+ * - 串行处理,避免同时开辟多个解码器把内存打爆;
956
+ * - 单个文件失败只影响自身,最后汇总返回;
957
+ * - 进度由调用方通过 `onProgress` 展示(不写 store,避免高频全局重渲染);
958
+ * - 与「缺失源文件」的素材同名同类型时重连,复用原 assetId。
1000
959
  */
1001
- async function importFile(registry, file, onProgress) {
1002
- const detected = require_file_reader.detectFileType(file);
1003
- onProgress?.(.1);
1004
- const { clip, meta } = await createClipFromFile(file, detected.kind);
1005
- onProgress?.(.4);
1006
- const assetId = require_canvas_size.createId("asset");
1007
- const asset = {
1008
- id: assetId,
1009
- name: file.name,
1010
- kind: detected.kind,
1011
- mimeType: detected.mimeType,
1012
- size: file.size,
1013
- durationUs: require_canvas_size.asUs(meta.durationUs),
1014
- width: meta.width,
1015
- height: meta.height,
1016
- hasAudio: meta.hasAudio,
1017
- createdAt: Date.now()
1018
- };
1019
- if (detected.kind === "video") {
1020
- try {
1021
- asset.thumbnailUrls = (await generateThumbnails(clip, assetId, THUMBNAIL_COUNT, meta.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
1022
- } catch (err) {
1023
- console.warn("[importFile] 缩略图生成失败", err);
1024
- }
1025
- onProgress?.(.7);
1026
- }
1027
- if (detected.kind === "audio" || detected.kind === "video" && meta.hasAudio) {
960
+ async function importFiles(registry, store, files, options = {}) {
961
+ const { select = true, onProgress, idFor, reconnect = true } = options;
962
+ const imported = [];
963
+ const failures = [];
964
+ let reconnected = 0;
965
+ for (let index = 0; index < files.length; index += 1) {
966
+ const file = files[index];
967
+ if (file == null) continue;
1028
968
  try {
1029
- if (detected.kind === "audio") {
1030
- const pcm = clip.getPCMData();
1031
- asset.waveform = require_waveform.computeWaveform(pcm, WAVEFORM_BUCKETS);
969
+ const detected = require_file_reader.detectFileType(file);
970
+ const reconnectId = reconnect ? findReconnectableId(store.getState(), registry, file, detected.kind) : null;
971
+ const requestedId = reconnectId ?? idFor?.(file, index) ?? null;
972
+ const assetId = requestedId != null && requestedId !== "" ? requestedId : void 0;
973
+ if (reconnectId != null) registry.removeAsset(reconnectId);
974
+ const result = await require_import_pipeline.importFile(registry, file, (progress) => onProgress?.(index, progress), assetId != null ? { id: assetId } : void 0);
975
+ if (reconnectId != null) {
976
+ store.getState().replaceAsset(reconnectId, result.asset);
977
+ store.getState().clearMissing(reconnectId);
978
+ imported.push(result.asset);
979
+ reconnected += 1;
980
+ } else {
981
+ store.getState().addAsset(result.asset);
982
+ imported.push(result.asset);
1032
983
  }
1033
984
  } catch (err) {
1034
- console.warn("[importFile] 波形生成失败", err);
985
+ const message = errorMessage(err);
986
+ failures.push({
987
+ name: file.name,
988
+ error: message
989
+ });
990
+ onProgress?.(index, 1);
991
+ if (!(err instanceof require_file_reader.UnsupportedFileError)) console.warn("[importFiles] 导入失败", file.name, err);
1035
992
  }
1036
- onProgress?.(.85);
1037
- }
1038
- registry.registerAsset(asset, file, clip);
1039
- onProgress?.(1);
1040
- return {
1041
- asset,
1042
- clipId: assetId,
1043
- master: clip
1044
- };
1045
- }
1046
- /** 新建文字素材(非文件来源,无需二进制) */
1047
- function createTextAsset(params) {
1048
- const assetId = require_canvas_size.createId("asset");
1049
- const style = {
1050
- ...DEFAULT_TEXT_STYLE,
1051
- ...params.style
1052
- };
1053
- const durationUs = params.durationUs ?? 5e6;
1054
- const master = createTextClip({
1055
- text: params.text,
1056
- style,
1057
- width: params.width,
1058
- height: params.height,
1059
- durationUs
1060
- });
1061
- const asset = {
1062
- id: assetId,
1063
- name: params.text.slice(0, 24) || "文字",
1064
- kind: "text",
1065
- mimeType: "text/x-title",
1066
- size: 0,
1067
- durationUs: require_canvas_size.asUs(durationUs),
1068
- width: params.width,
1069
- height: params.height,
1070
- hasAudio: false,
1071
- createdAt: Date.now(),
1072
- text: params.text,
1073
- textStyle: style
1074
- };
1075
- params.registry.registerAsset(asset, null, master);
1076
- return {
1077
- asset,
1078
- clipId: assetId
1079
- };
1080
- }
1081
- /**
1082
- * 解码素材的完整 PCM(48kHz 双声道),供 Worker 导出使用。
1083
- *
1084
- * ⚠️ 实测:Worker 内没有 AudioContext / OfflineAudioContext,
1085
- * `new AudioClip(stream)` 会在 `decodeAudioData` 处抛错,因此必须在主线程解码后
1086
- * 把 Float32Array 传过去(`new AudioClip(Float32Array[])` 走纯归一化分支)。
1087
- *
1088
- * @returns 声道数组;失败或素材无音轨时返回 undefined
1089
- */
1090
- async function loadPcmForAsset(registry, assetId) {
1091
- if (registry.getAsset(assetId) == null) return void 0;
1092
- const file = registry.getFile(assetId);
1093
- if (file == null) return void 0;
1094
- try {
1095
- const clip = new _webav_av_cliper.AudioClip(require_file_reader.fileToStream(file));
1096
- await clip.ready;
1097
- const pcm = clip.getPCMData().map((channel) => new Float32Array(channel));
1098
- clip.destroy();
1099
- return pcm;
1100
- } catch (err) {
1101
- console.warn("[loadPcmForAsset] 音频解码失败", err);
1102
- return;
1103
993
  }
1104
- }
1105
- /** 解析素材的音频 PCM(供预览音频引擎注册) */
1106
- async function loadAudioClipForAsset(registry, assetId) {
1107
- const file = registry.getFile(assetId);
1108
- if (file == null) return null;
1109
- const asset = registry.getAsset(assetId);
1110
- if (asset == null) return null;
1111
- if (asset.kind !== "audio" && asset.kind !== "video") return null;
1112
- const clip = new _webav_av_cliper.AudioClip(require_file_reader.fileToStream(file));
1113
- await clip.ready;
1114
- return clip;
1115
- }
1116
- /** 用第一条字幕文本给素材命名(无内容时回退为「字幕」) */
1117
- function subtitleName(srt) {
1118
- const base = (srt.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0 && !line.includes("-->") && !/^\d+$/.test(line)) ?? "") || "字幕";
1119
- return base.length > 20 ? `${base.slice(0, 20)}…` : base;
1120
- }
1121
- /** 新建字幕素材(内容来自粘贴的 SRT,无源文件) */
1122
- function createSubtitleAsset(params) {
1123
- const assetId = require_canvas_size.createId("asset");
1124
- const style = {
1125
- ...DEFAULT_SUBTITLE_STYLE,
1126
- ...params.style
1127
- };
1128
- const durationUs = params.durationUs ?? 5e6;
1129
- const master = createSubtitleClip({
1130
- srt: params.srt,
1131
- style,
1132
- width: params.width,
1133
- height: params.height
1134
- });
1135
- const asset = {
1136
- id: assetId,
1137
- name: subtitleName(params.srt),
1138
- kind: "subtitle",
1139
- mimeType: "text/plain",
1140
- size: params.srt.length,
1141
- durationUs: require_canvas_size.asUs(durationUs),
1142
- width: params.width,
1143
- height: params.height,
1144
- hasAudio: false,
1145
- createdAt: Date.now(),
1146
- srt: params.srt,
1147
- subtitleStyle: style
1148
- };
1149
- params.registry.registerAsset(asset, null, master);
994
+ if (select && imported.length > 0) store.getState().selectAsset(imported[0]?.id ?? null);
1150
995
  return {
1151
- asset,
1152
- clipId: assetId
996
+ imported,
997
+ failures,
998
+ reconnected
1153
999
  };
1154
1000
  }
1155
1001
  // src/engine/webav/sprite-factory.ts
@@ -1177,8 +1023,8 @@ function applyClipToSprite(sprite, clip) {
1177
1023
  sprite.opacity = clip.opacity;
1178
1024
  sprite.flip = clip.flip;
1179
1025
  sprite.time = {
1180
- offset: require_canvas_size.asUs(clip.startUs),
1181
- duration: require_canvas_size.asUs(clip.durationUs),
1026
+ offset: require_time.asUs(clip.startUs),
1027
+ duration: require_time.asUs(clip.durationUs),
1182
1028
  playbackRate: clip.playbackRate
1183
1029
  };
1184
1030
  }
@@ -1220,58 +1066,591 @@ function applyOpacityFade(sprite, clip) {
1220
1066
  iterCount: 1
1221
1067
  });
1222
1068
  }
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;
1069
+ // src/engine/webav/gain-clip.ts
1070
+ var GainClip = class GainClip {
1071
+ ready;
1072
+ inner;
1073
+ opts;
1074
+ destroyed = false;
1075
+ constructor(inner, opts) {
1076
+ this.inner = inner;
1077
+ this.opts = opts;
1078
+ this.ready = inner.ready;
1243
1079
  }
1244
- getSprite(clipId) {
1245
- return this.sprites.get(clipId);
1080
+ get meta() {
1081
+ return this.inner.meta;
1246
1082
  }
1247
- listSprites() {
1248
- return Array.from(this.sprites.values());
1083
+ async tick(time) {
1084
+ if (this.destroyed) return { state: "done" };
1085
+ const result = await this.inner.tick(time);
1086
+ if (result.audio == null || result.audio.length === 0) return result;
1087
+ const gain = this.gainAt(time);
1088
+ if (gain === 1) return result;
1089
+ return {
1090
+ ...result,
1091
+ audio: result.audio.map((channel) => {
1092
+ const out = new Float32Array(channel.length);
1093
+ for (let i = 0; i < channel.length; i += 1) out[i] = (channel[i] ?? 0) * gain;
1094
+ return out;
1095
+ })
1096
+ };
1249
1097
  }
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) {
1098
+ /** 计算某个时刻(源时间,微秒)的线性增益 */
1099
+ gainAt(time) {
1100
+ const { volume, fadeInUs, fadeOutUs, durationUs, playbackRate } = this.opts;
1101
+ const base = Math.max(0, Math.min(1, volume));
1102
+ const totalUs = Math.max(1, durationUs * (playbackRate > 0 ? playbackRate : 1));
1103
+ const fadeIn = Math.min(fadeInUs * (playbackRate > 0 ? playbackRate : 1), totalUs / 2);
1104
+ const fadeOut = Math.min(fadeOutUs * (playbackRate > 0 ? playbackRate : 1), totalUs / 2);
1105
+ let gain = base;
1106
+ if (fadeIn > 0 && time < fadeIn) gain *= Math.max(0, time / fadeIn);
1107
+ if (fadeOut > 0 && time > totalUs - fadeOut) gain *= Math.max(0, (totalUs - time) / fadeOut);
1108
+ return Math.max(0, Math.min(1, gain));
1109
+ }
1110
+ async clone() {
1111
+ const copy = new GainClip(await this.inner.clone(), { ...this.opts });
1112
+ await copy.ready;
1113
+ return copy;
1114
+ }
1115
+ async split(time) {
1116
+ if (time <= 0 || time >= this.opts.durationUs) throw new Error("\"time\" out of bounds");
1117
+ const leftInner = await this.inner.clone();
1118
+ const rightInner = await this.inner.clone();
1119
+ const left = new GainClip(leftInner, {
1120
+ ...this.opts,
1121
+ durationUs: time
1122
+ });
1123
+ const right = new GainClip(rightInner, {
1124
+ ...this.opts,
1125
+ durationUs: this.opts.durationUs - time
1126
+ });
1127
+ await Promise.all([left.ready, right.ready]);
1128
+ return [left, right];
1129
+ }
1130
+ destroy() {
1131
+ if (this.destroyed) return;
1132
+ this.destroyed = true;
1133
+ this.inner.destroy();
1134
+ }
1135
+ };
1136
+ // src/engine/webav/coordinate.ts
1137
+ /**
1138
+ * 把工程坐标下的 transform 等比映射到输出尺寸。
1139
+ *
1140
+ * 使用同一个缩放系数(分别算宽高再取较小值会改变构图,横竖比不同时更明显),
1141
+ * 并按输出画布居中,保证「预览里居中」在导出后依然居中。
1142
+ *
1143
+ * @param transform 工程坐标系下的变换
1144
+ * @param source 工程分辨率
1145
+ * @param target 输出分辨率
1146
+ */
1147
+ function mapTransformToOutput(transform, source, target) {
1148
+ const safeSourceW = Math.max(1, source.width);
1149
+ const safeSourceH = Math.max(1, source.height);
1150
+ const scaleX = target.width / safeSourceW;
1151
+ const scaleY = target.height / safeSourceH;
1152
+ const scale = Math.min(scaleX, scaleY);
1153
+ const scaledW = transform.w * scale;
1154
+ const scaledH = transform.h * scale;
1155
+ const centerX = (transform.x + transform.w / 2) * scaleX;
1156
+ const centerY = (transform.y + transform.h / 2) * scaleY;
1157
+ const offsetX = (target.width - safeSourceW * scale) / 2;
1158
+ const offsetY = (target.height - safeSourceH * scale) / 2;
1159
+ return {
1160
+ x: centerX + offsetX - scaledW / 2,
1161
+ y: centerY + offsetY - scaledH / 2,
1162
+ w: scaledW,
1163
+ h: scaledH,
1164
+ angle: transform.angle
1165
+ };
1166
+ }
1167
+ /** 便捷版:直接返回换算后的 clip(浅拷贝,只替换 transform) */
1168
+ function mapClipToOutput(clip, source, target) {
1169
+ return {
1170
+ ...clip,
1171
+ transform: mapTransformToOutput(clip.transform, source, target)
1172
+ };
1173
+ }
1174
+ // src/engine/webav/exporter.ts
1175
+ /**
1176
+ * 导出流水线:时间轴数据 → Combinator → MP4 二进制。
1177
+ *
1178
+ * 该模块不依赖 React;`buildCombinator` 同时被主线程与 Worker 复用。
1179
+ *
1180
+ * ⚠️ `Combinator.output()` 在当前线程同步步进渲染,会阻塞 UI。
1181
+ * 主线程路径(`exportProject`)只作为 Worker 不可用时的降级方案,必须配全屏遮罩。
1182
+ */
1183
+ /**
1184
+ * 给 clip 套上音量包络。
1185
+ *
1186
+ * WebAV 的音频混音是直接相加,没有每轨音量;`MP4Clip` / `AudioClip` 的 volume
1187
+ * 只能在构造时指定,且 clone 会沿用(同一素材被多个 clip 复用时无法区分)。
1188
+ * 因此统一用 `GainClip` 包装:每个时间轴 clip 走自己的实例与包络。
1189
+ *
1190
+ * @param instance 该 clip 独占的 IClip
1191
+ * @param clip 时间轴数据(提供 volume / fadeIn / fadeOut)
1192
+ */
1193
+ function withClipGain(instance, clip) {
1194
+ if (!(clip.muted || clip.volume !== 1 || clip.fadeInUs > 0 || clip.fadeOutUs > 0)) return instance;
1195
+ if (clip.muted) return instance;
1196
+ return new GainClip(instance, {
1197
+ volume: clip.volume,
1198
+ fadeInUs: clip.fadeInUs,
1199
+ fadeOutUs: clip.fadeOutUs,
1200
+ durationUs: clip.durationUs,
1201
+ playbackRate: clip.playbackRate
1202
+ });
1203
+ }
1204
+ /**
1205
+ * 组装 Combinator。
1206
+ *
1207
+ * - 只加入未隐藏轨道的 clip;`includeAudio === false` 时跳过音频轨;
1208
+ * - 显式给 `output({ maxTime })`,因此不依赖 main sprite(静态素材不会导致
1209
+ * "Unable to determine the end time");
1210
+ * - 音频 sprite 不参与画面,但仍需加入才能混合音轨。
1211
+ */
1212
+ async function buildCombinator(inputs, opts) {
1213
+ const combinator = new _webav_av_cliper.Combinator({
1214
+ width: opts.width,
1215
+ height: opts.height,
1216
+ fps: opts.fps,
1217
+ bitrate: opts.bitrate,
1218
+ bgColor: opts.bgColor ?? "#000000",
1219
+ audio: opts.includeAudio ? void 0 : false
1220
+ });
1221
+ const skipped = [];
1222
+ const usable = inputs.filter((item) => !item.track.hidden);
1223
+ if (usable.length === 0) throw new Error("时间轴为空,无法导出");
1224
+ const ordered = [...usable].sort((a, b) => {
1225
+ const aAudio = a.track.kind === "audio" ? 1 : 0;
1226
+ const bAudio = b.track.kind === "audio" ? 1 : 0;
1227
+ if (aAudio !== bAudio) return aAudio - bAudio;
1228
+ if (a.clip.startUs !== b.clip.startUs) return a.clip.startUs - b.clip.startUs;
1229
+ return a.clip.id.localeCompare(b.clip.id);
1230
+ });
1231
+ const sourceSize = {
1232
+ width: opts.projectWidth ?? opts.width,
1233
+ height: opts.projectHeight ?? opts.height
1234
+ };
1235
+ const targetSize = {
1236
+ width: opts.width,
1237
+ height: opts.height
1238
+ };
1239
+ let maxTimeUs = 0;
1240
+ let zIndex = 0;
1241
+ for (const item of ordered) {
1242
+ const { instance, track } = item;
1243
+ const clip = mapClipToOutput(item.clip, sourceSize, targetSize);
1244
+ if (track.kind === "audio" && !opts.includeAudio) continue;
1245
+ if (track.muted) continue;
1246
+ if (clip.muted) continue;
1247
+ const duration = require_canvas_size.clampStillDuration(clip.durationUs);
1248
+ if (clip.durationUs <= 0) {
1249
+ skipped.push({
1250
+ clipId: clip.id,
1251
+ reason: "时长为 0,已跳过"
1252
+ });
1253
+ continue;
1254
+ }
1255
+ const normalized = {
1256
+ ...clip,
1257
+ durationUs: duration
1258
+ };
1259
+ const sprite = createOffscreenSprite(withClipGain(instance, normalized), normalized, { zIndex });
1260
+ applyOpacityFade(sprite, normalized);
1261
+ await combinator.addSprite(sprite, { main: false });
1262
+ maxTimeUs = Math.max(maxTimeUs, clip.startUs + duration);
1263
+ zIndex += 1;
1264
+ }
1265
+ if (!Number.isFinite(maxTimeUs) || maxTimeUs <= 0) throw new Error("无法确定导出时长:请确认时间轴上有有效片段");
1266
+ return {
1267
+ combinator,
1268
+ maxTimeUs,
1269
+ skipped
1270
+ };
1271
+ }
1272
+ /**
1273
+ * 主线程导出(Worker 不可用时的降级路径)。
1274
+ *
1275
+ * @param inputs 已构建好的 clip 实例(调用方负责 destroy)
1276
+ * @param opts 输出配置
1277
+ * @param onProgress 0~1 进度回调
1278
+ */
1279
+ async function exportProject(inputs, opts, onProgress) {
1280
+ const startedAt = performance.now();
1281
+ const { combinator, maxTimeUs, skipped } = await buildCombinator(inputs, opts);
1282
+ const unsubscribe = onProgress ? combinator.on("OutputProgress", (p) => onProgress(p)) : () => void 0;
1283
+ try {
1284
+ const stream = combinator.output({ maxTime: maxTimeUs });
1285
+ const raw = await new Response(stream).arrayBuffer();
1286
+ if (opts.fixDuration) {
1287
+ const fixed = await (0, _webav_av_cliper.fixFMP4Duration)(new Response(raw).body);
1288
+ return {
1289
+ buffer: await new Response(fixed).arrayBuffer(),
1290
+ elapsedMs: performance.now() - startedAt,
1291
+ skipped
1292
+ };
1293
+ }
1294
+ return {
1295
+ buffer: raw,
1296
+ elapsedMs: performance.now() - startedAt,
1297
+ skipped
1298
+ };
1299
+ } finally {
1300
+ unsubscribe();
1301
+ combinator.destroy();
1302
+ }
1303
+ }
1304
+ /** 检查分辨率 / 帧率 / 码率组合是否可编码 */
1305
+ async function checkExportSupport(opts) {
1306
+ return _webav_av_cliper.Combinator.isSupported({
1307
+ width: opts.width,
1308
+ height: opts.height,
1309
+ bitrate: opts.bitrate
1310
+ });
1311
+ }
1312
+ /** 时间轴结束时间(微秒) */
1313
+ function computeTimelineEnd(clips) {
1314
+ return clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
1315
+ }
1316
+ // src/engine/webav/export-runner.ts
1317
+ var export_runner_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
1318
+ buildWorkerPayload: () => buildWorkerPayload,
1319
+ canUseWorker: () => canUseWorker,
1320
+ computeExportDuration: () => computeExportDuration,
1321
+ setDefaultExportWorkerFactory: () => setDefaultExportWorkerFactory,
1322
+ startExport: () => startExport
1323
+ });
1324
+ /** 标记取消的哨兵:只在本模块内部流转,不暴露给宿主 */
1325
+ var CANCELLED = "__export_cancelled__";
1326
+ /**
1327
+ * 默认 Worker 工厂(构建形态相关)。
1328
+ *
1329
+ * core 不能知道 UI 包把导出 Worker 打成了哪种资源(独立 chunk / UMD 内联),
1330
+ * 因此由 `@cut-workbench/workbench` 在模块加载时注册一份;宿主若提供了
1331
+ * `HostBridge.createExportWorker`,那个优先级更高。两者都没有时走主线程。
1332
+ */
1333
+ var defaultWorkerFactory = null;
1334
+ function setDefaultExportWorkerFactory(factory) {
1335
+ defaultWorkerFactory = factory;
1336
+ }
1337
+ /** 判断是否可以用 Worker 导出(文字素材依赖 DOM) */
1338
+ function canUseWorker(project, assets) {
1339
+ const kinds = new Map(assets.map((a) => [a.id, a.kind]));
1340
+ return !project.clips.some((clip) => kinds.get(clip.assetId) === "text");
1341
+ }
1342
+ /** 参与导出的片段最大结束时间(跳过隐藏轨道) */
1343
+ function computeExportDuration(project, maxTimeUs) {
1344
+ if (maxTimeUs != null) return maxTimeUs;
1345
+ const hidden = new Set(project.tracks.filter((t) => t.hidden).map((t) => t.id));
1346
+ return project.clips.reduce((max, clip) => hidden.has(clip.trackId) ? max : Math.max(max, clip.startUs + clip.durationUs), 0);
1347
+ }
1348
+ /**
1349
+ * 构建 Worker 载荷。
1350
+ *
1351
+ * 音频素材需要主线程预解码 PCM(Worker 无 AudioContext)。为避免无谓解码,
1352
+ * 只有「音频轨素材」或「含音轨且会被播放的视频」才解码。
1353
+ */
1354
+ async function buildWorkerPayload(registry, project, assets, settings, maxTimeUs) {
1355
+ const usedAssetIds = new Set(project.clips.map((c) => c.assetId));
1356
+ const missing = [];
1357
+ const payloadAssets = [];
1358
+ for (const asset of assets) {
1359
+ if (!usedAssetIds.has(asset.id)) continue;
1360
+ const file = registry.getFile(asset.id);
1361
+ if (file == null && asset.kind !== "text" && asset.kind !== "subtitle") {
1362
+ missing.push(asset.name);
1363
+ continue;
1364
+ }
1365
+ let pcm;
1366
+ if (settings.includeAudio && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio)) pcm = await require_import_pipeline.loadPcmForAsset(registry, asset.id);
1367
+ payloadAssets.push({
1368
+ id: asset.id,
1369
+ kind: asset.kind,
1370
+ file,
1371
+ width: asset.width,
1372
+ height: asset.height,
1373
+ durationUs: asset.durationUs,
1374
+ hasAudio: asset.hasAudio,
1375
+ pcm,
1376
+ text: asset.text,
1377
+ textStyle: asset.textStyle,
1378
+ srt: asset.srt,
1379
+ subtitleStyle: asset.subtitleStyle
1380
+ });
1381
+ }
1382
+ const kindOf = new Map(assets.map((a) => [a.id, a.kind]));
1383
+ const clips = project.clips.map((clip) => ({
1384
+ clip,
1385
+ kind: kindOf.get(clip.assetId) ?? "video"
1386
+ }));
1387
+ return {
1388
+ assets: payloadAssets,
1389
+ timeline: {
1390
+ width: settings.width,
1391
+ height: settings.height,
1392
+ projectWidth: project.width,
1393
+ projectHeight: project.height,
1394
+ fps: settings.fps,
1395
+ bitrate: settings.bitrate,
1396
+ includeAudio: settings.includeAudio,
1397
+ bgColor: "#000000",
1398
+ maxTimeUs,
1399
+ clips
1400
+ },
1401
+ missing
1402
+ };
1403
+ }
1404
+ /**
1405
+ * 主线程回退导出:为每个 clip 从 registry 建实例后交给 core exporter。
1406
+ *
1407
+ * 注意:实例用 `export:<clipId>` 作为临时 key 注册进 registry,导出结束后统一销毁,
1408
+ * 避免污染预览用的实例(两者不可共用,因为共享 tick 状态)。
1409
+ */
1410
+ async function exportOnMainThread(options, onProgress, shouldCancel) {
1411
+ const { registry, project, settings, mimeType = "video/mp4", filename } = options;
1412
+ const trackMap = new Map(project.tracks.map((t) => [t.id, t]));
1413
+ const inputs = [];
1414
+ const skipped = [];
1415
+ const tempIds = [];
1416
+ try {
1417
+ for (const clip of project.clips) {
1418
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
1419
+ const track = trackMap.get(clip.trackId);
1420
+ if (!track || track.hidden) continue;
1421
+ const asset = registry.getAsset(clip.assetId);
1422
+ if (!asset) {
1423
+ skipped.push({
1424
+ clipId: clip.id,
1425
+ reason: "素材缺失"
1426
+ });
1427
+ continue;
1428
+ }
1429
+ const tempId = `export:${clip.id}`;
1430
+ try {
1431
+ const instance = await registry.createInstance({
1432
+ timelineClipId: tempId,
1433
+ assetId: clip.assetId,
1434
+ inPointUs: clip.inPointUs,
1435
+ durationUs: clip.durationUs * clip.playbackRate,
1436
+ text: clip.text,
1437
+ textStyle: clip.textStyle,
1438
+ srt: clip.srt,
1439
+ subtitleStyle: clip.subtitleStyle,
1440
+ projectWidth: project.width,
1441
+ projectHeight: project.height
1442
+ });
1443
+ tempIds.push(tempId);
1444
+ inputs.push({
1445
+ clip,
1446
+ instance,
1447
+ kind: asset.kind,
1448
+ track
1449
+ });
1450
+ } catch (err) {
1451
+ skipped.push({
1452
+ clipId: clip.id,
1453
+ reason: err instanceof Error ? err.message : String(err)
1454
+ });
1455
+ }
1456
+ }
1457
+ if (inputs.length === 0) throw new Error("时间轴为空,无法导出");
1458
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
1459
+ const startedAt = performance.now();
1460
+ const { buffer, skipped: buildSkipped } = await exportProject(inputs, {
1461
+ width: settings.width,
1462
+ height: settings.height,
1463
+ fps: settings.fps,
1464
+ bitrate: settings.bitrate,
1465
+ includeAudio: settings.includeAudio,
1466
+ projectWidth: project.width,
1467
+ projectHeight: project.height
1468
+ }, onProgress);
1469
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
1470
+ return {
1471
+ blob: new Blob([buffer], { type: mimeType }),
1472
+ filename: filename ?? require_waveform.createExportFilename(),
1473
+ mimeType,
1474
+ durationUs: computeExportDuration(project, options.maxTimeUs),
1475
+ width: settings.width,
1476
+ height: settings.height,
1477
+ fps: settings.fps,
1478
+ bitrate: settings.bitrate,
1479
+ elapsedMs: performance.now() - startedAt,
1480
+ skipped: [...skipped, ...buildSkipped],
1481
+ mode: "main"
1482
+ };
1483
+ } finally {
1484
+ for (const id of tempIds) registry.destroyInstance(id);
1485
+ }
1486
+ }
1487
+ async function exportViaWorker(options, worker, onProgress, shouldCancel) {
1488
+ const { registry, project, assets, settings, maxTimeUs, mimeType = "video/mp4", filename } = options;
1489
+ const { assets: payloadAssets, timeline, missing } = await buildWorkerPayload(registry, project, assets, settings, maxTimeUs);
1490
+ if (shouldCancel()) throw new require_waveform.ExportCancelledError();
1491
+ if (missing.length > 0) console.warn("[export] 以下素材缺少源文件,相关片段将被跳过:", missing.join("、"));
1492
+ return new Promise((resolve, reject) => {
1493
+ const startedAt = performance.now();
1494
+ const cleanup = () => {
1495
+ worker.onmessage = null;
1496
+ worker.onerror = null;
1497
+ worker.terminate();
1498
+ };
1499
+ worker.onmessage = (event) => {
1500
+ const message = event.data;
1501
+ if (message.type === "progress") {
1502
+ if (shouldCancel()) return;
1503
+ onProgress(message.progress);
1504
+ return;
1505
+ }
1506
+ if (message.type === "done") {
1507
+ cleanup();
1508
+ if (shouldCancel()) {
1509
+ reject(new require_waveform.ExportCancelledError());
1510
+ return;
1511
+ }
1512
+ resolve({
1513
+ blob: new Blob([message.buffer], { type: mimeType }),
1514
+ filename: filename ?? require_waveform.createExportFilename(),
1515
+ mimeType,
1516
+ durationUs: computeExportDuration(project, maxTimeUs),
1517
+ width: settings.width,
1518
+ height: settings.height,
1519
+ fps: settings.fps,
1520
+ bitrate: settings.bitrate,
1521
+ elapsedMs: performance.now() - startedAt,
1522
+ skipped: message.skipped ?? [],
1523
+ mode: "worker"
1524
+ });
1525
+ return;
1526
+ }
1527
+ if (message.type === "error") {
1528
+ cleanup();
1529
+ reject(message.message === CANCELLED || shouldCancel() ? new require_waveform.ExportCancelledError() : new Error(message.message));
1530
+ }
1531
+ };
1532
+ worker.onerror = (event) => {
1533
+ cleanup();
1534
+ reject(new Error(event.message || "导出 Worker 异常"));
1535
+ };
1536
+ if (shouldCancel()) {
1537
+ cleanup();
1538
+ reject(new require_waveform.ExportCancelledError());
1539
+ return;
1540
+ }
1541
+ worker.postMessage({
1542
+ type: "export",
1543
+ assets: payloadAssets,
1544
+ timeline,
1545
+ fixDuration: true
1546
+ });
1547
+ });
1548
+ }
1549
+ /**
1550
+ * 启动导出。
1551
+ *
1552
+ * 优先走 Worker(不阻塞 UI);含文字片段或 Worker 不可用 / 失败时回退主线程。
1553
+ * `cancel()` 后 promise 以 `ExportCancelledError` reject。
1554
+ */
1555
+ function startExport(options, onProgress = () => void 0) {
1556
+ const { project, assets, settings } = options;
1557
+ let cancelled = false;
1558
+ let worker = null;
1559
+ const cancel = () => {
1560
+ cancelled = true;
1561
+ if (worker != null) {
1562
+ try {
1563
+ worker.postMessage({ type: "cancel" });
1564
+ } catch {}
1565
+ worker.terminate();
1566
+ worker = null;
1567
+ }
1568
+ };
1569
+ return {
1570
+ promise: (async () => {
1571
+ if (!await checkExportSupport({
1572
+ width: settings.width,
1573
+ height: settings.height,
1574
+ bitrate: settings.bitrate
1575
+ })) throw new Error(`当前设备不支持编码 ${settings.width}×${settings.height} @ ${(settings.bitrate / 1e6).toFixed(1)}Mbps,请降低分辨率或码率`);
1576
+ if (cancelled) throw new require_waveform.ExportCancelledError();
1577
+ const makeWorker = () => {
1578
+ const factory = options.createWorker ?? defaultWorkerFactory;
1579
+ if (factory == null) return null;
1580
+ try {
1581
+ return factory();
1582
+ } catch (err) {
1583
+ console.warn("[export] 创建 Worker 失败,回退主线程", err);
1584
+ return null;
1585
+ }
1586
+ };
1587
+ if (!canUseWorker(project, assets)) return exportOnMainThread(options, onProgress, () => cancelled);
1588
+ const created = makeWorker();
1589
+ if (created == null) return exportOnMainThread(options, onProgress, () => cancelled);
1590
+ worker = created;
1591
+ try {
1592
+ return await exportViaWorker(options, created, onProgress, () => cancelled);
1593
+ } catch (err) {
1594
+ if (cancelled || err instanceof require_waveform.ExportCancelledError) throw new require_waveform.ExportCancelledError();
1595
+ console.warn("[export] Worker 导出失败,回退主线程", err);
1596
+ return exportOnMainThread(options, onProgress, () => cancelled);
1597
+ }
1598
+ })(),
1599
+ cancel
1600
+ };
1601
+ }
1602
+ // src/engine/webav/sprite-sync.ts
1603
+ function zIndexOf(tracks, trackId, clip) {
1604
+ const trackIndex = tracks.findIndex((t) => t.id === trackId);
1605
+ return Math.max(0, trackIndex) * 1e3 + Math.round(clip.startUs / 1e6);
1606
+ }
1607
+ /**
1608
+ * 计算需要重建实例的 clip(入点 / 时长变了,旧 IClip 的数据窗口不再匹配)。
1609
+ */
1610
+ function needsRebuild(previous, next) {
1611
+ if (!previous) return true;
1612
+ return previous.inPointUs !== next.inPointUs;
1613
+ }
1614
+ var SpriteSynchronizer = class {
1615
+ /** 由创建方注入,保证一个预览画布只操作自己的 clip 实例 */
1616
+ registry;
1617
+ sprites = /* @__PURE__ */ new Map();
1618
+ /** 记录上一次同步的数据,用于检测属性变化 */
1619
+ lastClips = /* @__PURE__ */ new Map();
1620
+ constructor(registry) {
1621
+ this.registry = registry;
1622
+ }
1623
+ getSprite(clipId) {
1624
+ return this.sprites.get(clipId);
1625
+ }
1626
+ listSprites() {
1627
+ return Array.from(this.sprites.values());
1628
+ }
1629
+ /**
1630
+ * 全量对齐:以 store 的 clips 为准增删 sprite。
1631
+ *
1632
+ * @param assets 素材表(用于判断类型)
1633
+ * @returns 变更详情,调用方据此把 sprite 挂到 AVCanvas
1634
+ */
1635
+ async sync(project, assets, handlers) {
1636
+ const assetMap = new Map(assets.map((a) => [a.id, a]));
1637
+ const nextIds = new Set(project.clips.map((c) => c.id));
1638
+ const removed = [];
1639
+ for (const [id, sprite] of Array.from(this.sprites)) {
1640
+ if (nextIds.has(id)) continue;
1641
+ handlers.onRemove(sprite);
1642
+ this.sprites.delete(id);
1643
+ this.lastClips.delete(id);
1644
+ this.registry.destroyInstance(id);
1645
+ removed.push(id);
1646
+ }
1647
+ const created = [];
1648
+ const errors = [];
1649
+ for (const clip of project.clips) {
1650
+ const track = project.tracks.find((t) => t.id === clip.trackId);
1651
+ if (!track) continue;
1652
+ const asset = assetMap.get(clip.assetId);
1653
+ if (!asset) {
1275
1654
  errors.push({
1276
1655
  clipId: clip.id,
1277
1656
  message: "引用的素材已不存在,请重新导入"
@@ -1572,253 +1951,6 @@ var PreviewCanvas = class {
1572
1951
  } catch {}
1573
1952
  }
1574
1953
  };
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
1954
  // src/engine/webav/fix-duration.ts
1823
1955
  /**
1824
1956
  * 修正 WebAV 输出的 fMP4 时长。
@@ -2126,8 +2258,8 @@ function handleWorkerMessage(event) {
2126
2258
  });
2127
2259
  }
2128
2260
  exports.ClipRegistry = ClipRegistry;
2129
- exports.DEFAULT_SUBTITLE_STYLE = DEFAULT_SUBTITLE_STYLE;
2130
- exports.DEFAULT_TEXT_STYLE = DEFAULT_TEXT_STYLE;
2261
+ exports.DEFAULT_SUBTITLE_STYLE = require_import_pipeline.DEFAULT_SUBTITLE_STYLE;
2262
+ exports.DEFAULT_TEXT_STYLE = require_import_pipeline.DEFAULT_TEXT_STYLE;
2131
2263
  exports.ENGINE_DEPENDENCIES_INSTALL_COMMAND = require_waveform.ENGINE_DEPENDENCIES_INSTALL_COMMAND;
2132
2264
  exports.GainClip = GainClip;
2133
2265
  exports.OffsetClip = OffsetClip;
@@ -2136,39 +2268,43 @@ exports.PlaybackClock = PlaybackClock;
2136
2268
  exports.PreviewAudioEngine = PreviewAudioEngine;
2137
2269
  exports.PreviewCanvas = PreviewCanvas;
2138
2270
  exports.SpriteSynchronizer = SpriteSynchronizer;
2139
- exports.TextClip = TextClip;
2271
+ exports.TextClip = require_import_pipeline.TextClip;
2140
2272
  exports.UnsupportedFileError = require_file_reader.UnsupportedFileError;
2141
2273
  exports.WorkerUnsupportedClipError = WorkerUnsupportedClipError;
2142
2274
  exports.applyClipToSprite = applyClipToSprite;
2143
2275
  exports.applyOpacityFade = applyOpacityFade;
2144
2276
  exports.buildClipInWorker = buildClipInWorker;
2145
2277
  exports.buildCombinator = buildCombinator;
2278
+ exports.buildWorkerPayload = buildWorkerPayload;
2279
+ exports.canUseWorker = canUseWorker;
2146
2280
  exports.checkExportSupport = checkExportSupport;
2281
+ exports.computeExportDuration = computeExportDuration;
2147
2282
  exports.computeTimelineEnd = computeTimelineEnd;
2148
- exports.createClipFromBuffer = createClipFromBuffer;
2149
- exports.createClipFromFile = createClipFromFile;
2283
+ exports.createClipFromBuffer = require_import_pipeline.createClipFromBuffer;
2284
+ exports.createClipFromFile = require_import_pipeline.createClipFromFile;
2150
2285
  exports.createEditor = createEditor;
2151
2286
  exports.createEditorRuntime = createEditorRuntime;
2152
2287
  exports.createEditorStore = require_waveform.createEditorStore;
2153
2288
  exports.createEventBus = require_waveform.createEventBus;
2154
2289
  exports.createHistoryStore = require_waveform.createHistoryStore;
2155
2290
  exports.createOffscreenSprite = createOffscreenSprite;
2156
- exports.createSubtitleAsset = createSubtitleAsset;
2157
- exports.createSubtitleClip = createSubtitleClip;
2158
- exports.createTextAsset = createTextAsset;
2159
- exports.createTextClip = createTextClip;
2291
+ exports.createSubtitleAsset = require_import_pipeline.createSubtitleAsset;
2292
+ exports.createSubtitleClip = require_import_pipeline.createSubtitleClip;
2293
+ exports.createTextAsset = require_import_pipeline.createTextAsset;
2294
+ exports.createTextClip = require_import_pipeline.createTextClip;
2160
2295
  exports.createVisibleSprite = createVisibleSprite;
2161
- exports.cssFromStyle = cssFromStyle;
2296
+ exports.cssFromStyle = require_import_pipeline.cssFromStyle;
2162
2297
  exports.detectDependencies = require_waveform.detectDependencies;
2163
2298
  exports.detectEnvironment = require_waveform.detectEnvironment;
2164
2299
  exports.detectOpfs = require_waveform.detectOpfs;
2165
2300
  exports.exportProject = exportProject;
2166
2301
  exports.fixFMP4DurationStream = fixFMP4DurationStream;
2167
2302
  exports.handleWorkerMessage = handleWorkerMessage;
2168
- exports.importFile = importFile;
2303
+ exports.importFile = require_import_pipeline.importFile;
2304
+ exports.importFiles = importFiles;
2169
2305
  exports.isEnvironmentUsable = require_waveform.isEnvironmentUsable;
2170
- exports.loadAudioClipForAsset = loadAudioClipForAsset;
2171
- exports.loadPcmForAsset = loadPcmForAsset;
2306
+ exports.loadAudioClipForAsset = require_import_pipeline.loadAudioClipForAsset;
2307
+ exports.loadPcmForAsset = require_import_pipeline.loadPcmForAsset;
2172
2308
  exports.mapClipToOutput = mapClipToOutput;
2173
2309
  exports.mapTransformToOutput = mapTransformToOutput;
2174
2310
  exports.runExportInWorker = runExportInWorker;
@@ -2181,6 +2317,8 @@ exports.selectProject = require_waveform.selectProject;
2181
2317
  exports.selectProjectDuration = require_waveform.selectProjectDuration;
2182
2318
  exports.selectSelectedClipIds = require_waveform.selectSelectedClipIds;
2183
2319
  exports.selectTracks = require_waveform.selectTracks;
2320
+ exports.setDefaultExportWorkerFactory = setDefaultExportWorkerFactory;
2321
+ exports.startExport = startExport;
2184
2322
  exports.summarizeCapabilities = require_waveform.summarizeCapabilities;
2185
2323
  exports.withClipGain = withClipGain;
2186
2324