@cut-workbench/core 0.4.0 → 0.4.2
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-Ct61OYjW.cjs → canvas-size-DIABzEW2.cjs} +212 -44
- package/dist/chunks/canvas-size-DIABzEW2.cjs.map +1 -0
- package/dist/chunks/{canvas-size-CM2oKApi.js → canvas-size-Dlnng74w.js} +168 -30
- package/dist/chunks/canvas-size-Dlnng74w.js.map +1 -0
- package/dist/chunks/id-CJsdlGei.js +35 -0
- package/dist/chunks/id-CJsdlGei.js.map +1 -0
- package/dist/chunks/id-DaCxD4-5.cjs +70 -0
- package/dist/chunks/id-DaCxD4-5.cjs.map +1 -0
- package/dist/chunks/{snap-DyIuO2hU.cjs → snap-Cfk15JVg.cjs} +2 -22
- package/dist/chunks/{snap-DyIuO2hU.cjs.map → snap-Cfk15JVg.cjs.map} +1 -1
- package/dist/chunks/{snap-CBV9G4xq.js → snap-DpkVPVJN.js} +3 -17
- package/dist/chunks/{snap-CBV9G4xq.js.map → snap-DpkVPVJN.js.map} +1 -1
- package/dist/chunks/thumbnails-DEjnao5r.cjs +81 -0
- package/dist/chunks/thumbnails-DEjnao5r.cjs.map +1 -0
- package/dist/chunks/thumbnails-qH0dX_Zs.js +64 -0
- package/dist/chunks/thumbnails-qH0dX_Zs.js.map +1 -0
- package/dist/chunks/{time-CnMfSVYX.cjs → time-CYEd91qN.cjs} +1 -69
- package/dist/chunks/time-CYEd91qN.cjs.map +1 -0
- package/dist/chunks/{time-C_gvkVl1.js → time-ChF1GwCy.js} +2 -34
- package/dist/chunks/time-ChF1GwCy.js.map +1 -0
- package/dist/chunks/{waveform-Ie-G5Lle.cjs → waveform-BYwDdKpC.cjs} +34 -15
- package/dist/chunks/waveform-BYwDdKpC.cjs.map +1 -0
- package/dist/chunks/{waveform-DJkRtMct.js → waveform-uyGNE01H.js} +32 -13
- package/dist/chunks/waveform-uyGNE01H.js.map +1 -0
- package/dist/engine/index.cjs +668 -76
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.js +648 -57
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/webav/exporter.d.ts +3 -1
- package/dist/engine/webav/import-files.d.ts +17 -2
- package/dist/engine/webav/import-pipeline.d.ts +10 -0
- package/dist/engine/webav/types.d.ts +15 -0
- package/dist/index.cjs +18 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/lib/error.d.ts +6 -0
- package/dist/media/thumbnails.d.ts +8 -0
- package/dist/project/schema.d.ts +16 -4
- package/dist/timeline/index.cjs +10 -6
- package/dist/timeline/index.d.ts +1 -0
- package/dist/timeline/index.js +4 -4
- package/dist/timeline/normalize.d.ts +28 -0
- package/dist/timeline/ops.d.ts +0 -4
- package/dist/timeline/snap.d.ts +0 -2
- package/dist/types.d.ts +14 -2
- package/package.json +1 -1
- package/dist/chunks/canvas-size-CM2oKApi.js.map +0 -1
- package/dist/chunks/canvas-size-Ct61OYjW.cjs.map +0 -1
- package/dist/chunks/import-pipeline-A8KkqtZT.cjs +0 -610
- package/dist/chunks/import-pipeline-A8KkqtZT.cjs.map +0 -1
- package/dist/chunks/import-pipeline-DLIdgrIb.js +0 -521
- package/dist/chunks/import-pipeline-DLIdgrIb.js.map +0 -1
- package/dist/chunks/time-C_gvkVl1.js.map +0 -1
- package/dist/chunks/time-CnMfSVYX.cjs.map +0 -1
- package/dist/chunks/waveform-DJkRtMct.js.map +0 -1
- package/dist/chunks/waveform-Ie-G5Lle.cjs.map +0 -1
package/dist/engine/index.js
CHANGED
|
@@ -1,11 +1,303 @@
|
|
|
1
1
|
import { t as __exportAll } from "../chunks/rolldown-runtime-D8jQpDG7.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { r as DEFAULT_STILL_DURATION_US, t as createId } from "../chunks/id-CJsdlGei.js";
|
|
3
|
+
import { i as asUs } from "../chunks/time-ChF1GwCy.js";
|
|
4
|
+
import { F as createEmptyProject, N as clampStillDuration, R as getProjectDuration, m as pointInTransform } from "../chunks/canvas-size-Dlnng74w.js";
|
|
5
|
+
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, 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-uyGNE01H.js";
|
|
6
|
+
import { i as fileToStream, n as bufferToStream, r as detectFileType, t as UnsupportedFileError } from "../chunks/file-reader-OjeI7HTg.js";
|
|
7
|
+
import { n as releaseThumbnails, t as generateThumbnails } from "../chunks/thumbnails-qH0dX_Zs.js";
|
|
8
|
+
import { AudioClip, Combinator, EmbedSubtitlesClip, ImgClip, MP4Clip, OffscreenSprite, VisibleSprite, fixFMP4Duration, renderTxt2ImgBitmap } from "@webav/av-cliper";
|
|
8
9
|
import { AVCanvas } from "@webav/av-canvas";
|
|
10
|
+
// src/engine/webav/styles.ts
|
|
11
|
+
var DEFAULT_TEXT_STYLE = {
|
|
12
|
+
fontSize: 64,
|
|
13
|
+
color: "#ffffff",
|
|
14
|
+
fontFamily: "sans-serif",
|
|
15
|
+
fontWeight: 600,
|
|
16
|
+
fontStyle: "normal",
|
|
17
|
+
strokeColor: "#000000",
|
|
18
|
+
strokeWidth: 0,
|
|
19
|
+
shadowColor: "rgba(0,0,0,0.6)",
|
|
20
|
+
shadowBlur: 8,
|
|
21
|
+
align: "center",
|
|
22
|
+
background: "transparent"
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* 字幕默认字体。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ 不要写 'Noto Sans SC' —— 大多数系统并未安装该字体,`EmbedSubtitlesClip` 会把
|
|
28
|
+
* `ctx.font` 设为 `30px Noto Sans SC`,此时浏览器回退到默认字体,中文可能显示为方框。
|
|
29
|
+
* 使用系统字体栈(sans-serif)由浏览器挑选可用的中文字体,最稳。
|
|
30
|
+
*/
|
|
31
|
+
var DEFAULT_SUBTITLE_STYLE = {
|
|
32
|
+
color: "#ffffff",
|
|
33
|
+
textBgColor: null,
|
|
34
|
+
fontFamily: "sans-serif",
|
|
35
|
+
fontSize: 30,
|
|
36
|
+
strokeStyle: "#000000",
|
|
37
|
+
lineWidth: null,
|
|
38
|
+
bottomOffset: 30
|
|
39
|
+
};
|
|
40
|
+
// src/engine/webav/text-clip.ts
|
|
41
|
+
function cssFromStyle(style) {
|
|
42
|
+
const shadow = style.shadowBlur > 0 ? `text-shadow: 0 2px ${style.shadowBlur}px ${style.shadowColor};` : "";
|
|
43
|
+
const stroke = style.strokeWidth > 0 ? `-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};` : "";
|
|
44
|
+
return [
|
|
45
|
+
`font-size:${style.fontSize}px`,
|
|
46
|
+
`color:${style.color}`,
|
|
47
|
+
`font-family:${style.fontFamily}`,
|
|
48
|
+
`font-weight:${style.fontWeight}`,
|
|
49
|
+
`font-style:${style.fontStyle}`,
|
|
50
|
+
`text-align:${style.align}`,
|
|
51
|
+
`background:${style.background}`,
|
|
52
|
+
"line-height:1.25",
|
|
53
|
+
"padding:8px 16px",
|
|
54
|
+
"white-space:pre-wrap",
|
|
55
|
+
"max-width:80%",
|
|
56
|
+
stroke,
|
|
57
|
+
shadow
|
|
58
|
+
].filter(Boolean).join(";");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 文字素材。内部缓存最近一次渲染的 ImageBitmap,按「文本 + 样式」做键避免重复渲染。
|
|
62
|
+
*/
|
|
63
|
+
var TextClip = class TextClip {
|
|
64
|
+
ready;
|
|
65
|
+
opts;
|
|
66
|
+
metaValue;
|
|
67
|
+
cacheKey = "";
|
|
68
|
+
cachedBitmap = null;
|
|
69
|
+
destroyed = false;
|
|
70
|
+
constructor(options) {
|
|
71
|
+
this.opts = {
|
|
72
|
+
...options,
|
|
73
|
+
style: {
|
|
74
|
+
...DEFAULT_TEXT_STYLE,
|
|
75
|
+
...options.style
|
|
76
|
+
},
|
|
77
|
+
animation: options.animation ?? "none",
|
|
78
|
+
charsPerSecond: options.charsPerSecond ?? 12,
|
|
79
|
+
animOffsetUs: options.animOffsetUs ?? 0
|
|
80
|
+
};
|
|
81
|
+
this.metaValue = {
|
|
82
|
+
width: options.width,
|
|
83
|
+
height: options.height,
|
|
84
|
+
duration: Math.max(1, options.durationUs)
|
|
85
|
+
};
|
|
86
|
+
this.ready = Promise.resolve(this.metaValue);
|
|
87
|
+
}
|
|
88
|
+
get meta() {
|
|
89
|
+
return this.metaValue;
|
|
90
|
+
}
|
|
91
|
+
visibleTextAt(timeUs) {
|
|
92
|
+
const { text, animation, charsPerSecond } = this.opts;
|
|
93
|
+
if (animation === "none") return text;
|
|
94
|
+
const phaseUs = timeUs + this.opts.animOffsetUs;
|
|
95
|
+
if (animation === "typewriter") {
|
|
96
|
+
const visible = Math.floor(phaseUs / 1e6 * charsPerSecond);
|
|
97
|
+
if (visible <= 0) return null;
|
|
98
|
+
return text.slice(0, visible);
|
|
99
|
+
}
|
|
100
|
+
return Math.floor(phaseUs / 1e6 * 2) % 2 === 0 ? text : null;
|
|
101
|
+
}
|
|
102
|
+
async tick(time) {
|
|
103
|
+
if (this.destroyed) return { state: "done" };
|
|
104
|
+
if (time >= this.opts.durationUs) return { state: "done" };
|
|
105
|
+
const visible = this.visibleTextAt(time);
|
|
106
|
+
if (visible == null || visible.length === 0) return {
|
|
107
|
+
video: await this.renderEmpty(),
|
|
108
|
+
state: "success"
|
|
109
|
+
};
|
|
110
|
+
const key = `${visible}|${JSON.stringify(this.opts.style)}`;
|
|
111
|
+
if (key !== this.cacheKey || this.cachedBitmap == null) {
|
|
112
|
+
this.cachedBitmap?.close();
|
|
113
|
+
this.cachedBitmap = await this.renderTextToFrame(visible);
|
|
114
|
+
this.cacheKey = key;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
video: await createImageBitmap(this.cachedBitmap),
|
|
118
|
+
state: "success"
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 把文字渲染成「整帧」位图(工程分辨率)。
|
|
123
|
+
*
|
|
124
|
+
* 为什么不能直接把 renderTxt2ImgBitmap 的结果当帧用:
|
|
125
|
+
* 它返回的是文字自身尺寸的小图(几十到几百像素宽),Sprite 会把它拉伸到 rect 的
|
|
126
|
+
* 1280×720 —— 文字被放大后严重模糊,且居中位置由 Sprite 决定而非文字块决定。
|
|
127
|
+
* 因此这里先渲染文字小图,再按比例居中合成到工程尺寸的画布上。
|
|
128
|
+
*/
|
|
129
|
+
async renderTextToFrame(text) {
|
|
130
|
+
const { width, height } = this.opts;
|
|
131
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
132
|
+
const ctx = canvas.getContext("2d");
|
|
133
|
+
if (ctx == null) throw new Error("无法创建文字渲染上下文");
|
|
134
|
+
const layer = await renderTxt2ImgBitmap(text, cssFromStyle(this.opts.style));
|
|
135
|
+
const align = this.opts.style.align;
|
|
136
|
+
const margin = Math.round(width * .05);
|
|
137
|
+
const maxWidth = width - margin * 2;
|
|
138
|
+
const scale = layer.width > maxWidth ? maxWidth / layer.width : 1;
|
|
139
|
+
const drawW = Math.round(layer.width * scale);
|
|
140
|
+
const drawH = Math.round(layer.height * scale);
|
|
141
|
+
const x = align === "left" ? margin : align === "right" ? width - margin - drawW : (width - drawW) / 2;
|
|
142
|
+
const y = Math.round((height - drawH) / 2);
|
|
143
|
+
ctx.drawImage(layer, x, y, drawW, drawH);
|
|
144
|
+
layer.close();
|
|
145
|
+
return canvas.transferToImageBitmap();
|
|
146
|
+
}
|
|
147
|
+
emptyBitmap = null;
|
|
148
|
+
async renderEmpty() {
|
|
149
|
+
if (this.emptyBitmap != null) return this.emptyBitmap;
|
|
150
|
+
const canvas = new OffscreenCanvas(this.opts.width, this.opts.height);
|
|
151
|
+
this.emptyBitmap = canvas.transferToImageBitmap();
|
|
152
|
+
return this.emptyBitmap;
|
|
153
|
+
}
|
|
154
|
+
async split(time) {
|
|
155
|
+
if (time <= 0 || time >= this.opts.durationUs) throw new Error("\"time\" out of bounds");
|
|
156
|
+
const left = new TextClip({
|
|
157
|
+
...this.opts,
|
|
158
|
+
durationUs: time
|
|
159
|
+
});
|
|
160
|
+
const right = new TextClip({
|
|
161
|
+
...this.opts,
|
|
162
|
+
durationUs: this.opts.durationUs - time,
|
|
163
|
+
animOffsetUs: this.opts.animOffsetUs + time
|
|
164
|
+
});
|
|
165
|
+
await Promise.all([left.ready, right.ready]);
|
|
166
|
+
return [left, right];
|
|
167
|
+
}
|
|
168
|
+
async clone() {
|
|
169
|
+
const copy = new TextClip({ ...this.opts });
|
|
170
|
+
await copy.ready;
|
|
171
|
+
return copy;
|
|
172
|
+
}
|
|
173
|
+
destroy() {
|
|
174
|
+
this.destroyed = true;
|
|
175
|
+
this.cachedBitmap?.close();
|
|
176
|
+
this.cachedBitmap = null;
|
|
177
|
+
this.emptyBitmap?.close();
|
|
178
|
+
this.emptyBitmap = null;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
// src/engine/webav/clip-factory.ts
|
|
182
|
+
/**
|
|
183
|
+
* File → IClip 的分派工厂。
|
|
184
|
+
*
|
|
185
|
+
* ⚠️ 本文件是 `@webav/*` 的合法引用点之一(另一个是 core/webav 下的其他模块)。
|
|
186
|
+
* 不要在 features / store / components 中直接 import WebAV。
|
|
187
|
+
*
|
|
188
|
+
* 所有 clip 实例的生命周期由 `ClipRegistry` 统一管理,这里只负责创建。
|
|
189
|
+
*/
|
|
190
|
+
/**
|
|
191
|
+
* 创建素材的「母版」clip 并读取 meta。
|
|
192
|
+
*
|
|
193
|
+
* 对静态图/文字/字幕,`meta.duration` 可能是 Infinity,这里会归一成有限值:
|
|
194
|
+
* 真正的时长由时间轴 clip 决定,母版只用于 clone 与抽帧。
|
|
195
|
+
*/
|
|
196
|
+
async function createClipFromFile(file, kind) {
|
|
197
|
+
switch (kind) {
|
|
198
|
+
case "video": {
|
|
199
|
+
const clip = new MP4Clip(fileToStream(file));
|
|
200
|
+
const meta = await clip.ready;
|
|
201
|
+
return {
|
|
202
|
+
clip,
|
|
203
|
+
meta: {
|
|
204
|
+
durationUs: meta.duration,
|
|
205
|
+
width: meta.width,
|
|
206
|
+
height: meta.height,
|
|
207
|
+
hasAudio: meta.audioChanCount > 0
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
case "audio": {
|
|
212
|
+
const clip = new AudioClip(fileToStream(file));
|
|
213
|
+
return {
|
|
214
|
+
clip,
|
|
215
|
+
meta: {
|
|
216
|
+
durationUs: (await clip.ready).duration,
|
|
217
|
+
width: 0,
|
|
218
|
+
height: 0,
|
|
219
|
+
hasAudio: true
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
case "image": {
|
|
224
|
+
const clip = new ImgClip(fileToStream(file));
|
|
225
|
+
const meta = await clip.ready;
|
|
226
|
+
return {
|
|
227
|
+
clip,
|
|
228
|
+
meta: {
|
|
229
|
+
durationUs: Number.isFinite(meta.duration) ? meta.duration : DEFAULT_STILL_DURATION_US,
|
|
230
|
+
width: meta.width,
|
|
231
|
+
height: meta.height,
|
|
232
|
+
hasAudio: false
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
case "subtitle": {
|
|
237
|
+
const text = await file.text();
|
|
238
|
+
const clip = new EmbedSubtitlesClip(text, {
|
|
239
|
+
videoWidth: 1280,
|
|
240
|
+
videoHeight: 720
|
|
241
|
+
});
|
|
242
|
+
const meta = await clip.ready;
|
|
243
|
+
return {
|
|
244
|
+
clip,
|
|
245
|
+
meta: {
|
|
246
|
+
durationUs: Number.isFinite(meta.duration) ? meta.duration : DEFAULT_STILL_DURATION_US,
|
|
247
|
+
width: meta.width,
|
|
248
|
+
height: meta.height,
|
|
249
|
+
hasAudio: false
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
default: throw new Error(`Unsupported asset kind: ${kind}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** 文字素材:不走文件,直接由 TextClip 生成 */
|
|
257
|
+
function createTextClip(init) {
|
|
258
|
+
return new TextClip({
|
|
259
|
+
text: init.text,
|
|
260
|
+
style: init.style,
|
|
261
|
+
width: init.width,
|
|
262
|
+
height: init.height,
|
|
263
|
+
durationUs: init.durationUs
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function createSubtitleClip(init) {
|
|
267
|
+
return new EmbedSubtitlesClip(init.srt, {
|
|
268
|
+
videoWidth: init.width,
|
|
269
|
+
videoHeight: init.height,
|
|
270
|
+
color: init.style.color,
|
|
271
|
+
textBgColor: init.style.textBgColor,
|
|
272
|
+
fontFamily: init.style.fontFamily,
|
|
273
|
+
fontSize: init.style.fontSize,
|
|
274
|
+
strokeStyle: init.style.strokeStyle,
|
|
275
|
+
lineWidth: init.style.lineWidth,
|
|
276
|
+
bottomOffset: init.style.bottomOffset
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/** Worker 内重建 clip:只支持可序列化的素材类型 */
|
|
280
|
+
function createClipFromBuffer(buffer, kind, opts) {
|
|
281
|
+
switch (kind) {
|
|
282
|
+
case "video": return new MP4Clip(bufferToStream(buffer));
|
|
283
|
+
case "audio": return new AudioClip(bufferToStream(buffer));
|
|
284
|
+
case "image": return new ImgClip(bufferToStream(buffer));
|
|
285
|
+
case "subtitle": return createSubtitleClip({
|
|
286
|
+
srt: opts?.srt ?? "",
|
|
287
|
+
style: opts?.subtitleStyle ?? DEFAULT_SUBTITLE_STYLE,
|
|
288
|
+
width: opts?.width ?? 1280,
|
|
289
|
+
height: opts?.height ?? 720
|
|
290
|
+
});
|
|
291
|
+
case "text": return createTextClip({
|
|
292
|
+
text: opts?.text ?? "",
|
|
293
|
+
style: opts?.textStyle ?? DEFAULT_TEXT_STYLE,
|
|
294
|
+
width: opts?.width ?? 1280,
|
|
295
|
+
height: opts?.height ?? 720,
|
|
296
|
+
durationUs: opts?.durationUs ?? 5e6
|
|
297
|
+
});
|
|
298
|
+
default: throw new Error(`Worker 内不支持重建素材类型:${kind}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
9
301
|
// src/engine/webav/offset-clip.ts
|
|
10
302
|
var OffsetClip = class OffsetClip {
|
|
11
303
|
ready;
|
|
@@ -21,7 +313,10 @@ var OffsetClip = class OffsetClip {
|
|
|
21
313
|
height: 0,
|
|
22
314
|
duration: opts.durationUs
|
|
23
315
|
};
|
|
24
|
-
this.ready = inner.ready.then((meta) => {
|
|
316
|
+
this.ready = inner.ready.then(async (meta) => {
|
|
317
|
+
try {
|
|
318
|
+
await inner.tick(opts.inPointUs);
|
|
319
|
+
} catch {}
|
|
25
320
|
this.metaValue = {
|
|
26
321
|
width: meta.width,
|
|
27
322
|
height: meta.height,
|
|
@@ -502,17 +797,23 @@ function createEditorRuntime() {
|
|
|
502
797
|
}
|
|
503
798
|
};
|
|
504
799
|
}
|
|
800
|
+
// src/lib/error.ts
|
|
801
|
+
/**
|
|
802
|
+
* 异常 → 可展示文本。
|
|
803
|
+
*
|
|
804
|
+
* 各模块都会在 catch 里把 `unknown` 转成字符串,集中一处避免多份实现漂移。
|
|
805
|
+
*/
|
|
806
|
+
function errorMessage(err) {
|
|
807
|
+
return err instanceof Error ? err.message : String(err);
|
|
808
|
+
}
|
|
505
809
|
// src/editor/create-editor.ts
|
|
506
810
|
function resolveBridge(bridge) {
|
|
507
811
|
if (bridge != null) return bridge;
|
|
508
812
|
return createWebBridge();
|
|
509
813
|
}
|
|
510
814
|
var loadImportFiles = () => Promise.resolve().then(() => import_files_exports);
|
|
511
|
-
var loadImportPipeline = () =>
|
|
815
|
+
var loadImportPipeline = () => Promise.resolve().then(() => import_pipeline_exports);
|
|
512
816
|
var loadExportRunner = () => Promise.resolve().then(() => export_runner_exports);
|
|
513
|
-
function errorMessage$1(err) {
|
|
514
|
-
return err instanceof Error ? err.message : String(err);
|
|
515
|
-
}
|
|
516
817
|
/**
|
|
517
818
|
* 创建编辑器实例。
|
|
518
819
|
*
|
|
@@ -545,7 +846,10 @@ function createEditor(options = {}) {
|
|
|
545
846
|
let disposed = false;
|
|
546
847
|
let lastPlaying = false;
|
|
547
848
|
let capabilityPromise = null;
|
|
849
|
+
/** 防抖计时器:最后一次变更后等待 intervalMs 再写盘 */
|
|
548
850
|
let autoSaveTimer = null;
|
|
851
|
+
/** 串行化写盘:自动保存与手动保存永不并发(并发会交错写同一 namespace) */
|
|
852
|
+
let saveChain = Promise.resolve({ ok: true });
|
|
549
853
|
let prevProject = store.getState().project;
|
|
550
854
|
let prevAssets = store.getState().assets;
|
|
551
855
|
let prevSelection = store.getState().selectedClipIds;
|
|
@@ -564,11 +868,20 @@ function createEditor(options = {}) {
|
|
|
564
868
|
ns: namespace
|
|
565
869
|
};
|
|
566
870
|
}
|
|
871
|
+
/**
|
|
872
|
+
* 自动保存调度(防抖)。
|
|
873
|
+
*
|
|
874
|
+
* 语义是「最后一次结构性变更后静置 intervalMs 才写盘」,而不是
|
|
875
|
+
* 「第一次变更后固定 intervalMs 写盘」:
|
|
876
|
+
* 后者在持续编辑(拖滑块、连续裁剪)时会中途写一次,既浪费 I/O,
|
|
877
|
+
* 又会在宿主展示「保存中」时产生无意义的闪烁。每次新变更都重置计时器。
|
|
878
|
+
*/
|
|
567
879
|
function scheduleAutoSave() {
|
|
568
|
-
if (autoSave == null || disposed
|
|
880
|
+
if (autoSave == null || disposed) return;
|
|
881
|
+
if (autoSaveTimer != null) clearTimeout(autoSaveTimer);
|
|
569
882
|
autoSaveTimer = setTimeout(() => {
|
|
570
883
|
autoSaveTimer = null;
|
|
571
|
-
|
|
884
|
+
enqueueSave().catch((err) => {
|
|
572
885
|
bus.emit("error", {
|
|
573
886
|
scope: "auto-save",
|
|
574
887
|
error: err
|
|
@@ -576,6 +889,40 @@ function createEditor(options = {}) {
|
|
|
576
889
|
});
|
|
577
890
|
}, autoSave.intervalMs ?? 2e3);
|
|
578
891
|
}
|
|
892
|
+
/** 把一次保存排进队列,返回该次保存的结果 */
|
|
893
|
+
function enqueueSave() {
|
|
894
|
+
const run = saveChain.then(() => writeProject());
|
|
895
|
+
saveChain = run.catch(() => ({ ok: false }));
|
|
896
|
+
return run;
|
|
897
|
+
}
|
|
898
|
+
/** 真正写盘 + 广播 project:saved */
|
|
899
|
+
async function writeProject() {
|
|
900
|
+
const { port, ns } = saveTarget();
|
|
901
|
+
const projectAtStart = store.getState().project;
|
|
902
|
+
const result = await port.save(ns, editor.serialize());
|
|
903
|
+
const changedDuringSave = store.getState().project !== projectAtStart;
|
|
904
|
+
if (result.ok) {
|
|
905
|
+
if (!changedDuringSave) store.getState().markSaved();
|
|
906
|
+
else scheduleAutoSave();
|
|
907
|
+
bus.emit("project:saved", result.path != null ? {
|
|
908
|
+
ok: true,
|
|
909
|
+
path: result.path
|
|
910
|
+
} : { ok: true });
|
|
911
|
+
} else bus.emit("project:saved", {
|
|
912
|
+
ok: false,
|
|
913
|
+
...result.error != null ? { error: result.error } : {},
|
|
914
|
+
...result.path != null ? { path: result.path } : {}
|
|
915
|
+
});
|
|
916
|
+
return result;
|
|
917
|
+
}
|
|
918
|
+
/** 卸载 / 关闭前把待写的防抖任务立刻执行 */
|
|
919
|
+
function flushAutoSave() {
|
|
920
|
+
if (autoSaveTimer != null) {
|
|
921
|
+
clearTimeout(autoSaveTimer);
|
|
922
|
+
autoSaveTimer = null;
|
|
923
|
+
}
|
|
924
|
+
return enqueueSave();
|
|
925
|
+
}
|
|
579
926
|
const unsubscribeStore = store.subscribe((state) => {
|
|
580
927
|
if (disposed) return;
|
|
581
928
|
if (state.project !== prevProject) {
|
|
@@ -584,11 +931,11 @@ function createEditor(options = {}) {
|
|
|
584
931
|
project: state.project,
|
|
585
932
|
dirty: state.dirty
|
|
586
933
|
});
|
|
934
|
+
if (autoSave != null) scheduleAutoSave();
|
|
587
935
|
}
|
|
588
936
|
if (state.dirty !== prevDirty) {
|
|
589
937
|
prevDirty = state.dirty;
|
|
590
938
|
bus.emit("project:dirty", { dirty: state.dirty });
|
|
591
|
-
if (state.dirty && autoSave != null) scheduleAutoSave();
|
|
592
939
|
}
|
|
593
940
|
if (state.assets !== prevAssets) {
|
|
594
941
|
prevAssets = state.assets;
|
|
@@ -694,8 +1041,10 @@ function createEditor(options = {}) {
|
|
|
694
1041
|
if (file == null) continue;
|
|
695
1042
|
try {
|
|
696
1043
|
const result = await importFile(runtime.clips, file, void 0, { id: asset.id });
|
|
1044
|
+
const { regenerateDerived } = await loadImportPipeline();
|
|
1045
|
+
const withDerived = await regenerateDerived(runtime.clips, result.asset, file);
|
|
697
1046
|
store.getState().replaceAsset(asset.id, {
|
|
698
|
-
...
|
|
1047
|
+
...withDerived,
|
|
699
1048
|
id: asset.id
|
|
700
1049
|
});
|
|
701
1050
|
stillMissing.delete(asset.id);
|
|
@@ -717,20 +1066,7 @@ function createEditor(options = {}) {
|
|
|
717
1066
|
return { missingAssetIds: remaining };
|
|
718
1067
|
},
|
|
719
1068
|
async save() {
|
|
720
|
-
|
|
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;
|
|
1069
|
+
return enqueueSave();
|
|
734
1070
|
},
|
|
735
1071
|
async saveAs(nextStorage, nextNamespace) {
|
|
736
1072
|
storage = nextStorage;
|
|
@@ -740,7 +1076,7 @@ function createEditor(options = {}) {
|
|
|
740
1076
|
storage: nextStorage,
|
|
741
1077
|
namespace: nextNamespace
|
|
742
1078
|
};
|
|
743
|
-
return
|
|
1079
|
+
return enqueueSave();
|
|
744
1080
|
},
|
|
745
1081
|
isDirty: () => store.getState().dirty,
|
|
746
1082
|
isEmpty: () => store.getState().project.clips.length === 0,
|
|
@@ -782,8 +1118,10 @@ function createEditor(options = {}) {
|
|
|
782
1118
|
const { importFile } = await loadImportPipeline();
|
|
783
1119
|
if (!store.getState().assets.some((a) => a.id === assetId)) throw new Error(`素材不存在:${assetId}`);
|
|
784
1120
|
const result = await importFile(runtime.clips, file, void 0, { id: assetId });
|
|
1121
|
+
const { regenerateDerived } = await loadImportPipeline();
|
|
1122
|
+
const withDerived = await regenerateDerived(runtime.clips, result.asset, file);
|
|
785
1123
|
store.getState().replaceAsset(assetId, {
|
|
786
|
-
...
|
|
1124
|
+
...withDerived,
|
|
787
1125
|
id: assetId
|
|
788
1126
|
});
|
|
789
1127
|
store.getState().clearMissing(assetId);
|
|
@@ -895,7 +1233,7 @@ function createEditor(options = {}) {
|
|
|
895
1233
|
} catch (err) {
|
|
896
1234
|
if (cancelled || err instanceof ExportCancelledError) throw err instanceof ExportCancelledError ? err : new ExportCancelledError();
|
|
897
1235
|
bus.emit("export:error", {
|
|
898
|
-
message: errorMessage
|
|
1236
|
+
message: errorMessage(err),
|
|
899
1237
|
error: err
|
|
900
1238
|
});
|
|
901
1239
|
throw err;
|
|
@@ -915,7 +1253,7 @@ function createEditor(options = {}) {
|
|
|
915
1253
|
} catch (err) {
|
|
916
1254
|
return {
|
|
917
1255
|
ok: false,
|
|
918
|
-
error: errorMessage
|
|
1256
|
+
error: errorMessage(err)
|
|
919
1257
|
};
|
|
920
1258
|
}
|
|
921
1259
|
},
|
|
@@ -926,18 +1264,229 @@ function createEditor(options = {}) {
|
|
|
926
1264
|
async dispose() {
|
|
927
1265
|
if (disposed) return;
|
|
928
1266
|
disposed = true;
|
|
929
|
-
|
|
930
|
-
clearTimeout(autoSaveTimer);
|
|
931
|
-
autoSaveTimer = null;
|
|
932
|
-
}
|
|
1267
|
+
const pending = flushAutoSave();
|
|
933
1268
|
unsubscribeStore();
|
|
934
1269
|
bus.emit("dispose", {});
|
|
935
1270
|
await runtime.dispose();
|
|
1271
|
+
try {
|
|
1272
|
+
await pending;
|
|
1273
|
+
} catch {}
|
|
936
1274
|
bus.clear();
|
|
937
1275
|
}
|
|
938
1276
|
};
|
|
939
1277
|
return editor;
|
|
940
1278
|
}
|
|
1279
|
+
// src/engine/webav/import-pipeline.ts
|
|
1280
|
+
/**
|
|
1281
|
+
* 素材导入流水线(供 feature 与 Lab 共用)。
|
|
1282
|
+
*
|
|
1283
|
+
* 步骤:校验类型 → 创建母版 IClip → 读 meta → 抽缩略图 / 波形 → 写入 registry 与 store。
|
|
1284
|
+
*
|
|
1285
|
+
* ⚠️ 所有 WebAV 调用都落在 core 内;本文件位于 core/webav 下,允许 import '@webav/*'。
|
|
1286
|
+
*/
|
|
1287
|
+
var import_pipeline_exports = /* @__PURE__ */ __exportAll({
|
|
1288
|
+
UnsupportedFileError: () => UnsupportedFileError,
|
|
1289
|
+
createSubtitleAsset: () => createSubtitleAsset,
|
|
1290
|
+
createTextAsset: () => createTextAsset,
|
|
1291
|
+
importFile: () => importFile,
|
|
1292
|
+
loadAudioClipForAsset: () => loadAudioClipForAsset,
|
|
1293
|
+
loadPcmForAsset: () => loadPcmForAsset,
|
|
1294
|
+
regenerateDerived: () => regenerateDerived
|
|
1295
|
+
});
|
|
1296
|
+
var THUMBNAIL_COUNT = 4;
|
|
1297
|
+
var THUMBNAIL_WIDTH = 120;
|
|
1298
|
+
var WAVEFORM_BUCKETS = 400;
|
|
1299
|
+
/**
|
|
1300
|
+
* 导入单个文件,生成素材记录。
|
|
1301
|
+
*
|
|
1302
|
+
* @param onProgress 0~1 进度(用于卡片骨架/进度条)
|
|
1303
|
+
* @throws UnsupportedFileError 类型不支持时
|
|
1304
|
+
*/
|
|
1305
|
+
async function importFile(registry, file, onProgress, options = {}) {
|
|
1306
|
+
const detected = detectFileType(file);
|
|
1307
|
+
onProgress?.(.1);
|
|
1308
|
+
const { clip, meta } = await createClipFromFile(file, detected.kind);
|
|
1309
|
+
onProgress?.(.4);
|
|
1310
|
+
const assetId = options.id ?? createId("asset");
|
|
1311
|
+
const asset = {
|
|
1312
|
+
id: assetId,
|
|
1313
|
+
name: file.name,
|
|
1314
|
+
kind: detected.kind,
|
|
1315
|
+
mimeType: detected.mimeType,
|
|
1316
|
+
size: file.size,
|
|
1317
|
+
durationUs: asUs(meta.durationUs),
|
|
1318
|
+
width: meta.width,
|
|
1319
|
+
height: meta.height,
|
|
1320
|
+
hasAudio: meta.hasAudio,
|
|
1321
|
+
createdAt: Date.now()
|
|
1322
|
+
};
|
|
1323
|
+
if (detected.kind === "video") {
|
|
1324
|
+
try {
|
|
1325
|
+
asset.thumbnailUrls = (await generateThumbnails(clip, assetId, THUMBNAIL_COUNT, meta.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
console.warn("[importFile] 缩略图生成失败", err);
|
|
1328
|
+
}
|
|
1329
|
+
onProgress?.(.7);
|
|
1330
|
+
}
|
|
1331
|
+
if (detected.kind === "audio" || detected.kind === "video" && meta.hasAudio) {
|
|
1332
|
+
try {
|
|
1333
|
+
if (detected.kind === "audio") {
|
|
1334
|
+
const pcm = clip.getPCMData();
|
|
1335
|
+
asset.waveform = computeWaveform(pcm, WAVEFORM_BUCKETS);
|
|
1336
|
+
}
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
console.warn("[importFile] 波形生成失败", err);
|
|
1339
|
+
}
|
|
1340
|
+
onProgress?.(.85);
|
|
1341
|
+
}
|
|
1342
|
+
registry.registerAsset(asset, file, clip);
|
|
1343
|
+
onProgress?.(1);
|
|
1344
|
+
return {
|
|
1345
|
+
asset,
|
|
1346
|
+
clipId: assetId,
|
|
1347
|
+
master: clip
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* 为一个「已有元数据」的素材重新生成派生数据(缩略图 / 波形)。
|
|
1352
|
+
*
|
|
1353
|
+
* 用于重新打开工程后 `resolveFile` / `reconnectAsset` 找回文件时:
|
|
1354
|
+
* 元数据里不含 thumbnailUrls(那是会话内 object URL,刷新即失效),
|
|
1355
|
+
* 如果不补这一步,素材卡片会一直显示图标占位 —— 用户看到的就是「资源坏了」。
|
|
1356
|
+
*
|
|
1357
|
+
* 失败只记警告:派生数据缺失不影响素材可用性。
|
|
1358
|
+
*/
|
|
1359
|
+
async function regenerateDerived(registry, asset, file) {
|
|
1360
|
+
const master = registry.getMaster(asset.id);
|
|
1361
|
+
if (master == null) return asset;
|
|
1362
|
+
const next = { ...asset };
|
|
1363
|
+
if (asset.kind === "video") {
|
|
1364
|
+
const { regenerateThumbnails } = await import("../chunks/thumbnails-qH0dX_Zs.js").then((n) => n.r);
|
|
1365
|
+
try {
|
|
1366
|
+
next.thumbnailUrls = (await regenerateThumbnails(master, asset.id, THUMBNAIL_COUNT, asset.durationUs, THUMBNAIL_WIDTH)).map((f) => f.url);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
console.warn("[regenerateDerived] 缩略图生成失败", asset.id, err);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
if (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio) try {
|
|
1372
|
+
if (asset.kind === "audio") {
|
|
1373
|
+
const pcm = master.getPCMData();
|
|
1374
|
+
next.waveform = computeWaveform(pcm, WAVEFORM_BUCKETS);
|
|
1375
|
+
}
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
console.warn("[regenerateDerived] 波形生成失败", asset.id, err);
|
|
1378
|
+
}
|
|
1379
|
+
return next;
|
|
1380
|
+
}
|
|
1381
|
+
/** 新建文字素材(非文件来源,无需二进制) */
|
|
1382
|
+
function createTextAsset(params) {
|
|
1383
|
+
const assetId = params.id ?? createId("asset");
|
|
1384
|
+
const style = {
|
|
1385
|
+
...DEFAULT_TEXT_STYLE,
|
|
1386
|
+
...params.style
|
|
1387
|
+
};
|
|
1388
|
+
const durationUs = params.durationUs ?? 5e6;
|
|
1389
|
+
const master = createTextClip({
|
|
1390
|
+
text: params.text,
|
|
1391
|
+
style,
|
|
1392
|
+
width: params.width,
|
|
1393
|
+
height: params.height,
|
|
1394
|
+
durationUs
|
|
1395
|
+
});
|
|
1396
|
+
const asset = {
|
|
1397
|
+
id: assetId,
|
|
1398
|
+
name: params.text.slice(0, 24) || "文字",
|
|
1399
|
+
kind: "text",
|
|
1400
|
+
mimeType: "text/x-title",
|
|
1401
|
+
size: 0,
|
|
1402
|
+
durationUs: asUs(durationUs),
|
|
1403
|
+
width: params.width,
|
|
1404
|
+
height: params.height,
|
|
1405
|
+
hasAudio: false,
|
|
1406
|
+
createdAt: Date.now(),
|
|
1407
|
+
text: params.text,
|
|
1408
|
+
textStyle: style
|
|
1409
|
+
};
|
|
1410
|
+
params.registry.registerAsset(asset, null, master);
|
|
1411
|
+
return {
|
|
1412
|
+
asset,
|
|
1413
|
+
clipId: assetId
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* 解码素材的完整 PCM(48kHz 双声道),供 Worker 导出使用。
|
|
1418
|
+
*
|
|
1419
|
+
* ⚠️ 实测:Worker 内没有 AudioContext / OfflineAudioContext,
|
|
1420
|
+
* `new AudioClip(stream)` 会在 `decodeAudioData` 处抛错,因此必须在主线程解码后
|
|
1421
|
+
* 把 Float32Array 传过去(`new AudioClip(Float32Array[])` 走纯归一化分支)。
|
|
1422
|
+
*
|
|
1423
|
+
* @returns 声道数组;失败或素材无音轨时返回 undefined
|
|
1424
|
+
*/
|
|
1425
|
+
async function loadPcmForAsset(registry, assetId) {
|
|
1426
|
+
if (registry.getAsset(assetId) == null) return void 0;
|
|
1427
|
+
const file = registry.getFile(assetId);
|
|
1428
|
+
if (file == null) return void 0;
|
|
1429
|
+
try {
|
|
1430
|
+
const clip = new AudioClip(fileToStream(file));
|
|
1431
|
+
await clip.ready;
|
|
1432
|
+
const pcm = clip.getPCMData().map((channel) => new Float32Array(channel));
|
|
1433
|
+
clip.destroy();
|
|
1434
|
+
return pcm;
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
console.warn("[loadPcmForAsset] 音频解码失败", err);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
/** 解析素材的音频 PCM(供预览音频引擎注册) */
|
|
1441
|
+
async function loadAudioClipForAsset(registry, assetId) {
|
|
1442
|
+
const file = registry.getFile(assetId);
|
|
1443
|
+
if (file == null) return null;
|
|
1444
|
+
const asset = registry.getAsset(assetId);
|
|
1445
|
+
if (asset == null) return null;
|
|
1446
|
+
if (asset.kind !== "audio" && asset.kind !== "video") return null;
|
|
1447
|
+
const clip = new AudioClip(fileToStream(file));
|
|
1448
|
+
await clip.ready;
|
|
1449
|
+
return clip;
|
|
1450
|
+
}
|
|
1451
|
+
/** 用第一条字幕文本给素材命名(无内容时回退为「字幕」) */
|
|
1452
|
+
function subtitleName(srt) {
|
|
1453
|
+
const base = (srt.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0 && !line.includes("-->") && !/^\d+$/.test(line)) ?? "") || "字幕";
|
|
1454
|
+
return base.length > 20 ? `${base.slice(0, 20)}…` : base;
|
|
1455
|
+
}
|
|
1456
|
+
/** 新建字幕素材(内容来自粘贴的 SRT,无源文件) */
|
|
1457
|
+
function createSubtitleAsset(params) {
|
|
1458
|
+
const assetId = params.id ?? createId("asset");
|
|
1459
|
+
const style = {
|
|
1460
|
+
...DEFAULT_SUBTITLE_STYLE,
|
|
1461
|
+
...params.style
|
|
1462
|
+
};
|
|
1463
|
+
const durationUs = params.durationUs ?? 5e6;
|
|
1464
|
+
const master = createSubtitleClip({
|
|
1465
|
+
srt: params.srt,
|
|
1466
|
+
style,
|
|
1467
|
+
width: params.width,
|
|
1468
|
+
height: params.height
|
|
1469
|
+
});
|
|
1470
|
+
const asset = {
|
|
1471
|
+
id: assetId,
|
|
1472
|
+
name: subtitleName(params.srt),
|
|
1473
|
+
kind: "subtitle",
|
|
1474
|
+
mimeType: "text/plain",
|
|
1475
|
+
size: params.srt.length,
|
|
1476
|
+
durationUs: asUs(durationUs),
|
|
1477
|
+
width: params.width,
|
|
1478
|
+
height: params.height,
|
|
1479
|
+
hasAudio: false,
|
|
1480
|
+
createdAt: Date.now(),
|
|
1481
|
+
srt: params.srt,
|
|
1482
|
+
subtitleStyle: style
|
|
1483
|
+
};
|
|
1484
|
+
params.registry.registerAsset(asset, null, master);
|
|
1485
|
+
return {
|
|
1486
|
+
asset,
|
|
1487
|
+
clipId: assetId
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
941
1490
|
// src/engine/webav/import-files.ts
|
|
942
1491
|
var import_files_exports = /* @__PURE__ */ __exportAll({ importFiles: () => importFiles });
|
|
943
1492
|
/** 找出可以「重连」的素材 id(同名 + 同类型 + 当前确实缺少二进制) */
|
|
@@ -945,9 +1494,6 @@ function findReconnectableId(state, registry, file, kind) {
|
|
|
945
1494
|
if (state.missingAssetIds.length === 0) return null;
|
|
946
1495
|
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
1496
|
}
|
|
948
|
-
function errorMessage(err) {
|
|
949
|
-
return err instanceof Error ? err.message : String(err);
|
|
950
|
-
}
|
|
951
1497
|
/**
|
|
952
1498
|
* 批量导入素材。
|
|
953
1499
|
*
|
|
@@ -1189,11 +1735,12 @@ function mapClipToOutput(clip, source, target) {
|
|
|
1189
1735
|
* @param instance 该 clip 独占的 IClip
|
|
1190
1736
|
* @param clip 时间轴数据(提供 volume / fadeIn / fadeOut)
|
|
1191
1737
|
*/
|
|
1192
|
-
function withClipGain(instance, clip) {
|
|
1193
|
-
|
|
1738
|
+
function withClipGain(instance, clip, options = {}) {
|
|
1739
|
+
const volume = options.trackMuted === true ? 0 : clip.volume;
|
|
1740
|
+
if (!(clip.muted || options.trackMuted === true || volume !== 1 || clip.fadeInUs > 0 || clip.fadeOutUs > 0)) return instance;
|
|
1194
1741
|
if (clip.muted) return instance;
|
|
1195
1742
|
return new GainClip(instance, {
|
|
1196
|
-
volume
|
|
1743
|
+
volume,
|
|
1197
1744
|
fadeInUs: clip.fadeInUs,
|
|
1198
1745
|
fadeOutUs: clip.fadeOutUs,
|
|
1199
1746
|
durationUs: clip.durationUs,
|
|
@@ -1241,7 +1788,7 @@ async function buildCombinator(inputs, opts) {
|
|
|
1241
1788
|
const { instance, track } = item;
|
|
1242
1789
|
const clip = mapClipToOutput(item.clip, sourceSize, targetSize);
|
|
1243
1790
|
if (track.kind === "audio" && !opts.includeAudio) continue;
|
|
1244
|
-
|
|
1791
|
+
const trackMuted = track.muted;
|
|
1245
1792
|
if (clip.muted) continue;
|
|
1246
1793
|
const duration = clampStillDuration(clip.durationUs);
|
|
1247
1794
|
if (clip.durationUs <= 0) {
|
|
@@ -1255,7 +1802,7 @@ async function buildCombinator(inputs, opts) {
|
|
|
1255
1802
|
...clip,
|
|
1256
1803
|
durationUs: duration
|
|
1257
1804
|
};
|
|
1258
|
-
const sprite = createOffscreenSprite(withClipGain(instance, normalized), normalized, { zIndex });
|
|
1805
|
+
const sprite = createOffscreenSprite(withClipGain(instance, normalized, { trackMuted }), normalized, { zIndex });
|
|
1259
1806
|
applyOpacityFade(sprite, normalized);
|
|
1260
1807
|
await combinator.addSprite(sprite, { main: false });
|
|
1261
1808
|
maxTimeUs = Math.max(maxTimeUs, clip.startUs + duration);
|
|
@@ -1395,6 +1942,12 @@ async function buildWorkerPayload(registry, project, assets, settings, maxTimeUs
|
|
|
1395
1942
|
includeAudio: settings.includeAudio,
|
|
1396
1943
|
bgColor: "#000000",
|
|
1397
1944
|
maxTimeUs,
|
|
1945
|
+
tracks: project.tracks.map((t) => ({
|
|
1946
|
+
id: t.id,
|
|
1947
|
+
kind: t.kind,
|
|
1948
|
+
hidden: t.hidden,
|
|
1949
|
+
muted: t.muted
|
|
1950
|
+
})),
|
|
1398
1951
|
clips
|
|
1399
1952
|
},
|
|
1400
1953
|
missing
|
|
@@ -1669,6 +2222,7 @@ var SpriteSynchronizer = class {
|
|
|
1669
2222
|
applyClipToSprite(existing, clip);
|
|
1670
2223
|
existing.zIndex = zIndexOf(project.tracks, clip.trackId, clip);
|
|
1671
2224
|
existing.visible = !track.hidden;
|
|
2225
|
+
if (previous == null || previous.durationUs !== clip.durationUs || previous.fadeInUs !== clip.fadeInUs || previous.fadeOutUs !== clip.fadeOutUs || previous.opacity !== clip.opacity) applyOpacityFade(existing, clip);
|
|
1672
2226
|
this.lastClips.set(clip.id, clip);
|
|
1673
2227
|
continue;
|
|
1674
2228
|
}
|
|
@@ -1678,7 +2232,7 @@ var SpriteSynchronizer = class {
|
|
|
1678
2232
|
this.registry.destroyInstance(clip.id);
|
|
1679
2233
|
}
|
|
1680
2234
|
try {
|
|
1681
|
-
const
|
|
2235
|
+
const sprite = createVisibleSprite(await this.registry.createInstance({
|
|
1682
2236
|
timelineClipId: clip.id,
|
|
1683
2237
|
assetId: clip.assetId,
|
|
1684
2238
|
inPointUs: clip.inPointUs,
|
|
@@ -1689,9 +2243,9 @@ var SpriteSynchronizer = class {
|
|
|
1689
2243
|
subtitleStyle: clip.subtitleStyle,
|
|
1690
2244
|
projectWidth: project.width,
|
|
1691
2245
|
projectHeight: project.height
|
|
1692
|
-
});
|
|
1693
|
-
const sprite = createVisibleSprite(track.kind === "audio" ? instance : instance, clip, { zIndex: zIndexOf(project.tracks, clip.trackId, clip) });
|
|
2246
|
+
}), clip, { zIndex: zIndexOf(project.tracks, clip.trackId, clip) });
|
|
1694
2247
|
sprite.visible = !track.hidden;
|
|
2248
|
+
applyOpacityFade(sprite, clip);
|
|
1695
2249
|
if (track.kind === "audio" && asset.kind === "audio") {
|
|
1696
2250
|
sprite.rect.w = 0;
|
|
1697
2251
|
sprite.rect.h = 0;
|
|
@@ -2062,6 +2616,16 @@ var cancelled = false;
|
|
|
2062
2616
|
function toStream(blob) {
|
|
2063
2617
|
return blob.stream();
|
|
2064
2618
|
}
|
|
2619
|
+
/** 轨道静音:音量置 0(画面不受影响) */
|
|
2620
|
+
function withSilence(clip) {
|
|
2621
|
+
return new GainClip(clip, {
|
|
2622
|
+
volume: 0,
|
|
2623
|
+
fadeInUs: 0,
|
|
2624
|
+
fadeOutUs: 0,
|
|
2625
|
+
durationUs: Number.MAX_SAFE_INTEGER,
|
|
2626
|
+
playbackRate: 1
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2065
2629
|
/** 音量 / 淡入淡出包装(与主线程 exporter.withClipGain 保持一致的语义) */
|
|
2066
2630
|
function withGain(clip, timeline) {
|
|
2067
2631
|
if (timeline.volume === 1 && timeline.fadeInUs === 0 && timeline.fadeOutUs === 0) return clip;
|
|
@@ -2124,6 +2688,7 @@ async function buildClipInWorker(asset) {
|
|
|
2124
2688
|
*/
|
|
2125
2689
|
async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
|
|
2126
2690
|
const assetMap = new Map(assets.map((a) => [a.id, a]));
|
|
2691
|
+
const trackMap = new Map((timeline.tracks ?? []).map((t) => [t.id, t]));
|
|
2127
2692
|
const combinator = new Combinator({
|
|
2128
2693
|
width: timeline.width,
|
|
2129
2694
|
height: timeline.height,
|
|
@@ -2132,7 +2697,8 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
|
|
|
2132
2697
|
bgColor: timeline.bgColor,
|
|
2133
2698
|
audio: timeline.includeAudio ? void 0 : false
|
|
2134
2699
|
});
|
|
2135
|
-
const
|
|
2700
|
+
const masterCache = /* @__PURE__ */ new Map();
|
|
2701
|
+
const derived = [];
|
|
2136
2702
|
const skipped = [];
|
|
2137
2703
|
const sourceSize = {
|
|
2138
2704
|
width: timeline.projectWidth ?? timeline.width,
|
|
@@ -2164,11 +2730,14 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
|
|
|
2164
2730
|
});
|
|
2165
2731
|
continue;
|
|
2166
2732
|
}
|
|
2733
|
+
const track = trackMap.get(item.clip.trackId);
|
|
2734
|
+
if (track?.hidden === true) continue;
|
|
2167
2735
|
if (item.clip.muted) continue;
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2736
|
+
if (item.kind === "audio" && !timeline.includeAudio) continue;
|
|
2737
|
+
let master = masterCache.get(asset.id);
|
|
2738
|
+
if (master == null) try {
|
|
2739
|
+
master = await buildClipInWorker(asset);
|
|
2740
|
+
masterCache.set(asset.id, master);
|
|
2172
2741
|
} catch (err) {
|
|
2173
2742
|
const reason = err instanceof Error ? err.message : String(err);
|
|
2174
2743
|
console.warn("[export.worker] clip skipped:", item.clip.id, reason);
|
|
@@ -2179,7 +2748,24 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
|
|
|
2179
2748
|
continue;
|
|
2180
2749
|
}
|
|
2181
2750
|
const tc = item.clip;
|
|
2182
|
-
|
|
2751
|
+
let instance;
|
|
2752
|
+
try {
|
|
2753
|
+
instance = await master.clone();
|
|
2754
|
+
} catch (err) {
|
|
2755
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2756
|
+
skipped.push({
|
|
2757
|
+
clipId: tc.id,
|
|
2758
|
+
reason
|
|
2759
|
+
});
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
if (tc.inPointUs > 0) instance = new OffsetClip(instance, {
|
|
2763
|
+
inPointUs: tc.inPointUs,
|
|
2764
|
+
durationUs: tc.durationUs * tc.playbackRate
|
|
2765
|
+
});
|
|
2766
|
+
derived.push(instance);
|
|
2767
|
+
const gainInput = track?.muted === true ? withSilence(instance) : instance;
|
|
2768
|
+
const sprite = new OffscreenSprite(withGain(gainInput, tc));
|
|
2183
2769
|
const mapped = mapTransformToOutput(tc.transform, sourceSize, targetSize);
|
|
2184
2770
|
sprite.rect.x = mapped.x;
|
|
2185
2771
|
sprite.rect.y = mapped.y;
|
|
@@ -2194,6 +2780,7 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
|
|
|
2194
2780
|
duration: tc.durationUs,
|
|
2195
2781
|
playbackRate: tc.playbackRate
|
|
2196
2782
|
};
|
|
2783
|
+
applyOpacityFade(sprite, tc);
|
|
2197
2784
|
zIndex += 1;
|
|
2198
2785
|
maxTimeUs = Math.max(maxTimeUs, tc.startUs + tc.durationUs);
|
|
2199
2786
|
await combinator.addSprite(sprite, { main: false });
|
|
@@ -2215,10 +2802,14 @@ async function runExportInWorker(assets, timeline, onProgress, shouldCancel) {
|
|
|
2215
2802
|
skipped
|
|
2216
2803
|
};
|
|
2217
2804
|
} finally {
|
|
2218
|
-
for (const clip of
|
|
2805
|
+
for (const clip of derived) try {
|
|
2806
|
+
clip.destroy();
|
|
2807
|
+
} catch {}
|
|
2808
|
+
derived.length = 0;
|
|
2809
|
+
for (const clip of masterCache.values()) try {
|
|
2219
2810
|
clip.destroy();
|
|
2220
2811
|
} catch {}
|
|
2221
|
-
|
|
2812
|
+
masterCache.clear();
|
|
2222
2813
|
try {
|
|
2223
2814
|
combinator.destroy();
|
|
2224
2815
|
} catch {}
|
|
@@ -2256,6 +2847,6 @@ function handleWorkerMessage(event) {
|
|
|
2256
2847
|
});
|
|
2257
2848
|
});
|
|
2258
2849
|
}
|
|
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 };
|
|
2850
|
+
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, regenerateDerived, runExportInWorker, selectAssetById, selectAssets, selectClipById, selectClips, selectPlayheadUs, selectProject, selectProjectDuration, selectSelectedClipIds, selectTracks, setDefaultExportWorkerFactory, startExport, summarizeCapabilities, withClipGain };
|
|
2260
2851
|
|
|
2261
2852
|
//# sourceMappingURL=index.js.map
|