@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.
- package/dist/chunks/{canvas-size-iWL8zZPl.js → canvas-size-CM2oKApi.js} +3 -144
- package/dist/chunks/{canvas-size-iWL8zZPl.js.map → canvas-size-CM2oKApi.js.map} +1 -1
- package/dist/chunks/{canvas-size-IzoEg2cq.cjs → canvas-size-Ct61OYjW.cjs} +26 -287
- package/dist/chunks/canvas-size-Ct61OYjW.cjs.map +1 -0
- package/dist/chunks/import-pipeline-A8KkqtZT.cjs +610 -0
- package/dist/chunks/import-pipeline-A8KkqtZT.cjs.map +1 -0
- package/dist/chunks/import-pipeline-DLIdgrIb.js +521 -0
- package/dist/chunks/import-pipeline-DLIdgrIb.js.map +1 -0
- package/dist/chunks/rolldown-runtime-B2SfzNBj.cjs +17 -0
- package/dist/chunks/rolldown-runtime-D8jQpDG7.js +12 -0
- package/dist/chunks/{snap-Dc022smD.js → snap-CBV9G4xq.js} +2 -2
- package/dist/chunks/{snap-Dc022smD.js.map → snap-CBV9G4xq.js.map} +1 -1
- package/dist/chunks/{snap-CeYuTN6d.cjs → snap-DyIuO2hU.cjs} +3 -3
- package/dist/chunks/{snap-CeYuTN6d.cjs.map → snap-DyIuO2hU.cjs.map} +1 -1
- package/dist/chunks/srt-D539yJ-n.js +51 -0
- package/dist/chunks/srt-D539yJ-n.js.map +1 -0
- package/dist/chunks/srt-DaL36mXN.cjs +73 -0
- package/dist/chunks/srt-DaL36mXN.cjs.map +1 -0
- package/dist/chunks/time-C_gvkVl1.js +145 -0
- package/dist/chunks/time-C_gvkVl1.js.map +1 -0
- package/dist/chunks/time-CnMfSVYX.cjs +264 -0
- package/dist/chunks/time-CnMfSVYX.cjs.map +1 -0
- package/dist/chunks/{waveform-CWU8Fa-n.js → waveform-DJkRtMct.js} +82 -12
- package/dist/chunks/waveform-DJkRtMct.js.map +1 -0
- package/dist/chunks/{waveform-Bhvv2t7-.cjs → waveform-Ie-G5Lle.cjs} +121 -21
- package/dist/chunks/waveform-Ie-G5Lle.cjs.map +1 -0
- package/dist/editor/create-editor.d.ts +8 -106
- package/dist/editor/editor-store.d.ts +4 -0
- package/dist/editor/handle-types.d.ts +256 -0
- package/dist/engine/index.cjs +961 -823
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.ts +3 -0
- package/dist/engine/index.js +935 -803
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/webav/export-runner.d.ts +61 -0
- package/dist/engine/webav/import-files.d.ts +43 -0
- package/dist/engine/webav/import-pipeline.d.ts +13 -1
- package/dist/errors.d.ts +12 -0
- package/dist/export/types.d.ts +49 -0
- package/dist/index.cjs +75 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +46 -43
- package/dist/index.js.map +1 -1
- package/dist/ports/host-bridge.d.ts +7 -0
- package/dist/project/file-storage.d.ts +23 -0
- package/dist/timeline/index.cjs +17 -16
- package/dist/timeline/index.js +3 -2
- package/package.json +3 -2
- package/dist/chunks/canvas-size-IzoEg2cq.cjs.map +0 -1
- package/dist/chunks/waveform-Bhvv2t7-.cjs.map +0 -1
- package/dist/chunks/waveform-CWU8Fa-n.js.map +0 -1
package/dist/engine/index.cjs
CHANGED
|
@@ -1,300 +1,12 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const
|
|
3
|
-
const
|
|
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
|
|
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
|
-
|
|
852
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
938
|
-
|
|
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-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
*
|
|
999
|
-
*
|
|
955
|
+
* - 串行处理,避免同时开辟多个解码器把内存打爆;
|
|
956
|
+
* - 单个文件失败只影响自身,最后汇总返回;
|
|
957
|
+
* - 进度由调用方通过 `onProgress` 展示(不写 store,避免高频全局重渲染);
|
|
958
|
+
* - 与「缺失源文件」的素材同名同类型时重连,复用原 assetId。
|
|
1000
959
|
*/
|
|
1001
|
-
async function
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
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:
|
|
1181
|
-
duration:
|
|
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/
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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
|
-
|
|
1245
|
-
return this.
|
|
1080
|
+
get meta() {
|
|
1081
|
+
return this.inner.meta;
|
|
1246
1082
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
|