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