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