@vvfx/sdk 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/config.js +231 -0
  2. package/dist/exporter/config.js +18 -0
  3. package/dist/exporter/const.js +71 -0
  4. package/dist/exporter/export-media.js +878 -0
  5. package/dist/exporter/exporter.js +121 -0
  6. package/dist/exporter/index.js +1 -0
  7. package/dist/exporter/types.js +1 -0
  8. package/dist/exporter/utils.js +190 -0
  9. package/dist/exporter/wav-audio.js +50 -0
  10. package/dist/gesture-handler/gizmo/adsorption-gizmo.js +227 -0
  11. package/dist/gesture-handler/gizmo/control-gizmo.js +149 -0
  12. package/dist/gesture-handler/gizmo/gizmo.js +17 -0
  13. package/dist/gesture-handler/gizmo/icon-gizmo.js +189 -0
  14. package/dist/gesture-handler/gizmo/index.js +6 -0
  15. package/dist/gesture-handler/gizmo/item-create-gizmo.js +256 -0
  16. package/dist/gesture-handler/gizmo/loading-gizmo.js +320 -0
  17. package/dist/gesture-handler/gizmo/mask-gizmo.js +648 -0
  18. package/dist/gesture-handler/gizmo/picture-cut-gizmo.js +550 -0
  19. package/dist/gesture-handler/gizmo/picture-expand-gizmo.js +491 -0
  20. package/dist/gesture-handler/gizmo/preference-gizmo.js +148 -0
  21. package/dist/gesture-handler/gizmo/selector-gizmo.d.ts +1 -1
  22. package/dist/gesture-handler/gizmo/selector-gizmo.js +468 -0
  23. package/dist/gesture-handler/gizmo/sprite-text-edit-gizmo.js +247 -0
  24. package/dist/gesture-handler/gizmo/text-gizmo.js +1033 -0
  25. package/dist/gesture-handler/gizmo/transform-gizmo.js +1199 -0
  26. package/dist/gesture-handler/gizmo/type.js +18 -0
  27. package/dist/gesture-handler/index.js +3 -0
  28. package/dist/gesture-handler/src/gesture-handler.js +770 -0
  29. package/dist/gesture-handler/src/icons.js +24 -0
  30. package/dist/gesture-handler/utils.js +40 -0
  31. package/dist/html-overlay/anchor-navigation-runtime.js +27 -0
  32. package/dist/html-overlay/auto-height-runtime.js +132 -0
  33. package/dist/html-overlay/document-runtime.js +71 -0
  34. package/dist/html-overlay/document-transform.js +96 -0
  35. package/dist/html-overlay/dom-order.js +8 -0
  36. package/dist/html-overlay/host-message-runtime.js +24 -0
  37. package/dist/html-overlay/html-capture-runtime.js +282 -0
  38. package/dist/html-overlay/html-viewport-style.js +10 -0
  39. package/dist/html-overlay/index.js +1 -0
  40. package/dist/html-overlay/manager.js +1663 -0
  41. package/dist/html-overlay/mime-utils.js +21 -0
  42. package/dist/html-overlay/overlay-transform.js +57 -0
  43. package/dist/html-overlay/path-utils.js +35 -0
  44. package/dist/html-overlay/shell-runtime.js +12 -0
  45. package/dist/index.cjs +1 -7
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.js +13 -75317
  48. package/dist/index.mjs +75311 -0
  49. package/dist/index.mjs.map +1 -0
  50. package/dist/layer-order.js +8 -0
  51. package/dist/math/box2.js +396 -0
  52. package/dist/math/circle.js +131 -0
  53. package/dist/math/euler.js +35 -0
  54. package/dist/math/index.js +13 -0
  55. package/dist/math/line2.js +153 -0
  56. package/dist/math/line3.js +36 -0
  57. package/dist/math/matrix4.js +207 -0
  58. package/dist/math/plane.js +52 -0
  59. package/dist/math/quaternion.js +3 -0
  60. package/dist/math/ray-caster.js +25 -0
  61. package/dist/math/ray.js +47 -0
  62. package/dist/math/type.js +1 -0
  63. package/dist/math/utils.js +77 -0
  64. package/dist/math/vector2.js +70 -0
  65. package/dist/math/vector3.js +60 -0
  66. package/dist/screen-shot/index.js +1 -0
  67. package/dist/screen-shot/screen-shot.js +113 -0
  68. package/dist/sdk-item/base-item.js +160 -0
  69. package/dist/sdk-item/card-item.js +131 -0
  70. package/dist/sdk-item/effects-item.js +144 -0
  71. package/dist/sdk-item/frame-item.js +217 -0
  72. package/dist/sdk-item/generator-item.js +188 -0
  73. package/dist/sdk-item/group-item.js +116 -0
  74. package/dist/sdk-item/index.js +39 -0
  75. package/dist/sdk-item/sprite-item.js +147 -0
  76. package/dist/sdk-item/text-item.js +223 -0
  77. package/dist/sdk-item/types.d.ts +1 -11
  78. package/dist/sdk-item/types.js +15 -0
  79. package/dist/sdk-item/video-item.js +174 -0
  80. package/dist/sdk.js +1903 -0
  81. package/dist/service/UndoRedo.js +79 -0
  82. package/dist/shared/index.d.ts +1 -0
  83. package/dist/shared/index.js +2 -0
  84. package/dist/shared/player.d.ts +1 -1
  85. package/dist/shared/player.js +9 -0
  86. package/dist/shared/spec.d.ts +1 -0
  87. package/dist/shared/spec.js +1 -0
  88. package/dist/size-adapte/index.js +1 -0
  89. package/dist/size-adapte/size-adapt.js +395 -0
  90. package/dist/types.d.ts +5 -1
  91. package/dist/types.js +17 -0
  92. package/dist/utils/background-manager.js +189 -0
  93. package/dist/utils/common-utils.js +183 -0
  94. package/dist/utils/index.js +8 -0
  95. package/dist/utils/interaction-utils.js +205 -0
  96. package/dist/utils/json-data-utils.js +1529 -0
  97. package/dist/utils/layout-utils.js +1217 -0
  98. package/dist/utils/page-data-utils.js +5397 -0
  99. package/dist/utils/player-data-utils.js +48 -0
  100. package/dist/utils/types.js +8 -0
  101. package/dist/wireframe/common/box.js +18 -0
  102. package/dist/wireframe/common/dashed-line.js +21 -0
  103. package/dist/wireframe/common/line.js +17 -0
  104. package/dist/wireframe/common/pixi-ext.js +1 -0
  105. package/dist/wireframe/common/pixi.js +31 -0
  106. package/dist/wireframe/index.js +4 -0
  107. package/package.json +10 -3
  108. package/dist/index.js.map +0 -1
@@ -0,0 +1,878 @@
1
+ /**
2
+ * @description 导出多媒体素材
3
+ * 如:导出 MP4 / AlphaMaskVideo / GIF / Images
4
+ */
5
+ import JSZip from '@progress/jszip-esm';
6
+ import { merge } from 'lodash-es';
7
+ import { EventEmitter, Player, assertExist } from '../shared';
8
+ import { asserts, logger } from '../utils';
9
+ import { STATUS_MAP, MEDIA_TYPE, STAGE_MAP, EXPORT_NEED_TRANS_IMAGES_MAP, GIF_QUALITY_TO_FFMPEG_ARGS, APNG_QUALITY_TO_FFMPEG_ARGS, } from './const';
10
+ import { WavAudio } from './wav-audio';
11
+ import { getTwoDecimal, getAlphaMaskImage, canvasToArrayBuffer, sleepForOneFrame, padZero, drawToOutputCanvas, parseArgs, loadFFmpegHelper, loadWebpHelper, clearPlayerAssets, } from './utils';
12
+ const loggerPrefix = ['@vvfx/sdk', 'exporter'];
13
+ export class ExportMedia extends EventEmitter {
14
+ // 当前状态,存在 准备/可执行/销毁 三种状态
15
+ status = STATUS_MAP.READY;
16
+ // 当前阶段,存在 空闲/转帧图片/视频转码/音频处理 四种阶段
17
+ stage = STAGE_MAP.IDLE;
18
+ player;
19
+ #options;
20
+ #timeOuter;
21
+ #requestAnimationFrameId = 0;
22
+ #playerCanvas;
23
+ #ffmpegCore;
24
+ #img2WebPCore;
25
+ #mediaRecorder;
26
+ #recordedChunks = [];
27
+ #wavAudio;
28
+ #audioFileName;
29
+ #imageFileNames = [];
30
+ // 当前处理的任务索引
31
+ #currentTaskIndex = 0;
32
+ // 当前处理的任务
33
+ #currentTask;
34
+ // 已完成任务的进度集合
35
+ #completedTaskPercentArray = [];
36
+ // 当前任务的帧索引
37
+ #currentTaskFrameIndex = 0;
38
+ // 当前任务的总帧数
39
+ #currentTaskTotalFrames = 0;
40
+ // 所有任务的总帧数
41
+ #allTaskFrames = 0;
42
+ // 当前任务的转码帧索引
43
+ #currentTaskInTranscodingFrameIndex = 0;
44
+ #allSuccessTaskInfos = [];
45
+ // 所有成功任务的输出集合
46
+ #allSuccessTaskBuffers = [];
47
+ // 所有成功任务的额外输出集合
48
+ #allSuccessTaskExtraBuffers = null;
49
+ // 所有任务是否成功
50
+ #allTaskSuccess = true;
51
+ constructor(options) {
52
+ super();
53
+ this.#options = merge({ loggerInTranscoding: false, multiThreading: false }, options);
54
+ void this.onInit();
55
+ }
56
+ setMediaType(curMediaType) {
57
+ if (curMediaType !== this.#options.mediaType) {
58
+ this.#options.mediaType = curMediaType;
59
+ this.onReset();
60
+ this.clearAllListeners();
61
+ void this.onInit();
62
+ }
63
+ }
64
+ setOptions(options) {
65
+ const newOptions = merge({}, this.#options, options);
66
+ if (this.#options.mediaType !== newOptions.mediaType) {
67
+ this.setMediaType(newOptions.mediaType);
68
+ }
69
+ this.#options = newOptions;
70
+ }
71
+ get canceled() {
72
+ return this.status === STATUS_MAP.DISPOSE;
73
+ }
74
+ get config() {
75
+ const compositions = this.player?.getCompositions() ?? [];
76
+ const composition = compositions?.[0];
77
+ const name = composition?.name ?? new Date().getTime().toString();
78
+ const oldTotalTime = composition?.getDuration() ?? 0;
79
+ const startTime = composition?.startTime ?? 0;
80
+ const newTotalTime = this.#currentTask?.time;
81
+ const fps = this.#currentTask?.fps ?? 30;
82
+ const backgroundColor = this.#currentTask?.backgroundColor ?? '#000000';
83
+ const loop = this.#currentTask?.loop ?? true;
84
+ const audioEnable = this.#currentTask?.audioEnable ?? false;
85
+ return { name, startTime, oldTotalTime, newTotalTime, fps, backgroundColor, loop, audioEnable };
86
+ }
87
+ get mp4Config() {
88
+ return this.#currentTask?.mp4Config;
89
+ }
90
+ get gifConfig() {
91
+ return this.#currentTask?.gifConfig;
92
+ }
93
+ get apngConfig() {
94
+ return this.#currentTask?.apngConfig;
95
+ }
96
+ get progress() {
97
+ const CONVERT_IMAGE_PROPORTION = 0.4;
98
+ const TRANSCODING_PROPORTION = 0.6;
99
+ const totalFrames = this.#currentTaskTotalFrames || 1;
100
+ const allTaskFrames = this.#allTaskFrames || 1;
101
+ // 计算当前任务所占总任务的百分比
102
+ const percent = Math.min(totalFrames / allTaskFrames, 1);
103
+ // 计算当前任务获取序列帧进度,占2/5
104
+ const convertImageProgress = (Math.min(this.#currentTaskFrameIndex, totalFrames) / totalFrames) * CONVERT_IMAGE_PROPORTION;
105
+ // 计算当前任务转码进度,占3/5
106
+ const transcodingProgress = (Math.min(this.#currentTaskInTranscodingFrameIndex, totalFrames) / totalFrames) * TRANSCODING_PROPORTION;
107
+ const currentCompleted = Math.min(convertImageProgress + transcodingProgress, 1);
108
+ // 将当前任务的占比更新到数组中
109
+ this.#completedTaskPercentArray[this.#currentTaskIndex] = percent;
110
+ // 默认当前任务之前的任务已经完成,进行累加
111
+ const completedPercent = this.#completedTaskPercentArray.reduce((sum, p, idx) => (idx < this.#currentTaskIndex ? sum + (p || 0) : sum), 0);
112
+ const result = completedPercent + currentCompleted * percent;
113
+ return Math.min(getTwoDecimal(result), 1);
114
+ }
115
+ get transImageType() {
116
+ if (this.#options.mediaType === MEDIA_TYPE.MP4) {
117
+ return EXPORT_NEED_TRANS_IMAGES_MAP.JPEG;
118
+ }
119
+ if (this.#options.mediaType === MEDIA_TYPE.WebP) {
120
+ return EXPORT_NEED_TRANS_IMAGES_MAP.WEBP;
121
+ }
122
+ return EXPORT_NEED_TRANS_IMAGES_MAP.PNG;
123
+ }
124
+ async onInit() {
125
+ try {
126
+ logger('log', [...loggerPrefix, 'ready'], 'starting');
127
+ this.status = STATUS_MAP.READY;
128
+ await this.loadHelper(this.#options.mediaType);
129
+ this.status = STATUS_MAP.EXECUTABLE;
130
+ logger('log', [...loggerPrefix, 'ready'], 'success');
131
+ this.emit('ready', this.#options.mediaType);
132
+ }
133
+ catch (e) {
134
+ this.status = STATUS_MAP.DISPOSE;
135
+ this.onError(e);
136
+ }
137
+ finally {
138
+ return this.status;
139
+ }
140
+ }
141
+ onReset() {
142
+ this.stage = STAGE_MAP.IDLE;
143
+ clearTimeout(this.#timeOuter);
144
+ window.cancelAnimationFrame(this.#requestAnimationFrameId);
145
+ this.#requestAnimationFrameId = 0;
146
+ this.#timeOuter = undefined;
147
+ this.player?.pause();
148
+ this.clearTaskInfo();
149
+ this.setProgress();
150
+ }
151
+ onError(error) {
152
+ logger('log', [...loggerPrefix, 'error'], error);
153
+ this.onFinish(false, this.#allSuccessTaskInfos, this.#allSuccessTaskBuffers, this.#allSuccessTaskExtraBuffers);
154
+ this.emit('error', error);
155
+ this.dispose();
156
+ }
157
+ onFinish(success, taskInfos, buffers, extras) {
158
+ if (success) {
159
+ logger('log', [...loggerPrefix, 'finish'], 'success');
160
+ }
161
+ else {
162
+ logger('log', [...loggerPrefix, 'finish'], 'failed');
163
+ }
164
+ this.emit('progress', 1);
165
+ this.emit('finish', success, taskInfos, buffers, extras);
166
+ }
167
+ async loadHelper(mediaType) {
168
+ const isLoadWebP = mediaType === 'WebP' && !this.#img2WebPCore;
169
+ const isLoadFFMpeg =
170
+ //@ts-expect-error pick some types
171
+ [MEDIA_TYPE.MP4, MEDIA_TYPE.APNG, MEDIA_TYPE.GIF, MEDIA_TYPE.AlphaMaskVideo].includes(mediaType) &&
172
+ !this.#ffmpegCore;
173
+ if (isLoadWebP) {
174
+ this.#img2WebPCore = await loadWebpHelper();
175
+ }
176
+ if (isLoadFFMpeg) {
177
+ this.#ffmpegCore = await loadFFmpegHelper({ multiThreading: this.#options.multiThreading });
178
+ assertExist(this.#ffmpegCore);
179
+ this.#ffmpegCore.on('log', ({ message }) => {
180
+ if (this.#options.loggerInTranscoding) {
181
+ logger('log', [...loggerPrefix, 'transcoding log'], message);
182
+ }
183
+ // 取消或当前阶段不是转码阶段,则不处理
184
+ if (this.canceled || this.stage !== STAGE_MAP.TRANSCODING) {
185
+ return;
186
+ }
187
+ const match = /frame=\s*(\d+)/.exec(message);
188
+ const target = match?.[1];
189
+ const frame = Number(target);
190
+ if (frame >= this.#currentTaskInTranscodingFrameIndex) {
191
+ this.setProgress({
192
+ currentTaskInTranscodingFrameIndex: frame,
193
+ });
194
+ }
195
+ });
196
+ }
197
+ }
198
+ getAllTaskFrames(options) {
199
+ return options.reduce((sum, opt) => {
200
+ const { scene, fps } = opt;
201
+ const duration = scene.compositions?.[0]?.duration ?? 0;
202
+ return sum + Math.round(duration * (fps ?? 30));
203
+ }, 0);
204
+ }
205
+ setProgress(options) {
206
+ if (options?.completedTaskPercentArray !== undefined) {
207
+ this.#completedTaskPercentArray = options.completedTaskPercentArray;
208
+ }
209
+ if (options?.currentTaskFrameIndex !== undefined) {
210
+ this.#currentTaskFrameIndex = options.currentTaskFrameIndex;
211
+ }
212
+ if (options?.currentTaskTotalFrames !== undefined) {
213
+ this.#currentTaskTotalFrames = options.currentTaskTotalFrames;
214
+ }
215
+ if (options?.currentTaskInTranscodingFrameIndex !== undefined) {
216
+ this.#currentTaskInTranscodingFrameIndex = options.currentTaskInTranscodingFrameIndex;
217
+ }
218
+ if (options?.allTaskFrames !== undefined) {
219
+ this.#allTaskFrames = options.allTaskFrames;
220
+ }
221
+ this.emit('progress', this.progress);
222
+ }
223
+ forwardPlayerTime(seconds, playing = false) {
224
+ assertExist(this.player);
225
+ const { oldTotalTime, startTime } = this.config;
226
+ assertExist(oldTotalTime);
227
+ // 兼容player时间为最后时,会播放回第一帧的问题(详见https://baiyan.antfin.com/task/397835?bqlKey=c4583a1)
228
+ const maxTime = oldTotalTime * 1000 - ((oldTotalTime * 1000) % 15);
229
+ seconds = Math.min(seconds, maxTime / 1000);
230
+ if (playing) {
231
+ this.player.gotoAndPlay(seconds - startTime);
232
+ }
233
+ else {
234
+ this.player.gotoAndStop(seconds - startTime);
235
+ }
236
+ return seconds;
237
+ }
238
+ getWavAudio(name) {
239
+ this.#wavAudio = new WavAudio();
240
+ if (this.#ffmpegCore) {
241
+ void this.#ffmpegCore.writeFile(name, this.#wavAudio.getBuffer());
242
+ }
243
+ else {
244
+ logger('log', [...loggerPrefix, 'getWavAudio'], 'ffmpegCore is not ready');
245
+ }
246
+ }
247
+ async getImagesZip(folderName, imageBuffers) {
248
+ const zip = new JSZip();
249
+ const folder = zip.folder(folderName);
250
+ assertExist(folder);
251
+ this.#imageFileNames.forEach((imageName, index) => {
252
+ folder.file(imageName, imageBuffers[index]);
253
+ });
254
+ return zip.generateAsync({ type: 'arraybuffer' });
255
+ }
256
+ getWebP() {
257
+ assertExist(this.#img2WebPCore);
258
+ // 先固定 speed 为 1,后续有诉求再支持
259
+ const speed = 1;
260
+ const duration = Math.round(1000 / this.config.fps / speed);
261
+ const outputFileName = 'out.webp';
262
+ // @docs https://developers.google.com/speed/webp/docs/img2webp
263
+ const argsLine = `${this.#imageFileNames.join(` -d ${duration} `)} -d ${duration} -o ${outputFileName} -loop ${this.config.loop ? 0 : 1}`;
264
+ const args = argsLine.split(' ');
265
+ const main = this.#img2WebPCore.cwrap('main', 'string', ['number', 'number']);
266
+ const [argc, argv] = parseArgs(this.#img2WebPCore, args);
267
+ asserts(argc && argv, 'getWebP has error');
268
+ main(argc, argv);
269
+ const outBuffer = this.#img2WebPCore.FS.readFile(outputFileName);
270
+ this.#imageFileNames.forEach((fileName) => {
271
+ this.#img2WebPCore?.FS.unlink(fileName);
272
+ });
273
+ return outBuffer;
274
+ }
275
+ async getAPNG() {
276
+ // apng 需要透明度
277
+ assertExist(this.#ffmpegCore);
278
+ const startNumber = 1;
279
+ this.stage = STAGE_MAP.TRANSCODING;
280
+ const output = 'export.apng';
281
+ const { fps, scale = '-1:-1', quality = 'highest' } = this.apngConfig ?? {};
282
+ const vfArgs = [`scale=${scale}:force_original_aspect_ratio=decrease:flags=lanczos`];
283
+ if (fps) {
284
+ vfArgs.push(`fps=${fps}`);
285
+ }
286
+ const response = await this.#ffmpegCore.exec([
287
+ '-framerate',
288
+ `${this.config.fps}`,
289
+ '-start_number',
290
+ `${startNumber}`,
291
+ '-i',
292
+ `image-%4d.${this.transImageType}`,
293
+ '-vf',
294
+ vfArgs.join(','),
295
+ '-plays',
296
+ this.config.loop ? '0' : '1',
297
+ '-compression_level',
298
+ APNG_QUALITY_TO_FFMPEG_ARGS[quality],
299
+ '-y',
300
+ output,
301
+ ]);
302
+ if (response !== 0) {
303
+ throw new Error('the command executed by ffmpeg to generate a apng failed.');
304
+ }
305
+ return output;
306
+ }
307
+ async getGIF() {
308
+ // gif 需要透明度
309
+ assertExist(this.#ffmpegCore);
310
+ const startNumber = 1;
311
+ this.stage = STAGE_MAP.TRANSCODING;
312
+ const output = 'export.gif';
313
+ const { fps, scale = '-1:-1', quality = 'highest' } = this.gifConfig ?? {};
314
+ const vfArgs = [`scale=${scale}:${GIF_QUALITY_TO_FFMPEG_ARGS[quality]}`];
315
+ if (fps) {
316
+ vfArgs.unshift(`fps=${fps}`);
317
+ }
318
+ const response = await this.#ffmpegCore.exec([
319
+ '-framerate',
320
+ `${this.config.fps}`,
321
+ '-start_number',
322
+ `${startNumber}`,
323
+ '-i',
324
+ `image-%4d.${this.transImageType}`,
325
+ '-loop',
326
+ this.config.loop ? '0' : '-1',
327
+ '-vf',
328
+ vfArgs.join(','),
329
+ '-y',
330
+ output,
331
+ ]);
332
+ if (response !== 0) {
333
+ throw new Error('the command executed by ffmpeg to generate a gif failed.');
334
+ }
335
+ return output;
336
+ }
337
+ async getMP4(oldTotalTime, newTotalTime) {
338
+ assertExist(this.#ffmpegCore);
339
+ const startNumber = 1;
340
+ this.stage = STAGE_MAP.TRANSCODING;
341
+ const videoOutput = 'export.mp4';
342
+ let videoWithAudioOutput;
343
+ const setpts = newTotalTime ? `,setpts=PTS/${Math.round((oldTotalTime / newTotalTime) * 100) / 100}` : '';
344
+ const duration = newTotalTime ?? oldTotalTime;
345
+ const { isExportLastFrameJPEG } = this.mp4Config ?? {};
346
+ // 生成音频文件
347
+ if (this.config.audioEnable && !this.#wavAudio) {
348
+ this.#audioFileName = 'audio.wav';
349
+ this.getWavAudio(this.#audioFileName);
350
+ }
351
+ // NOTE: 这里分步执行,优化同一时间导出大文件的性能问题
352
+ const videoOfResponse = await this.#ffmpegCore.exec([
353
+ '-framerate',
354
+ String(this.config.fps),
355
+ '-start_number',
356
+ String(startNumber),
357
+ '-i',
358
+ `image-%4d.${this.transImageType}`,
359
+ '-c:v',
360
+ 'libx264',
361
+ '-crf',
362
+ '23',
363
+ '-pix_fmt',
364
+ 'yuv420p',
365
+ '-vf',
366
+ `pad=ceil(iw/2)*2:ceil(ih/2)*2${setpts}`,
367
+ '-y',
368
+ videoOutput,
369
+ ]);
370
+ if (videoOfResponse !== 0) {
371
+ throw new Error('the command executed by ffmpeg to generate a mp4 failed.');
372
+ }
373
+ if (this.#audioFileName && this.#wavAudio) {
374
+ this.stage = STAGE_MAP.AUDIO;
375
+ videoWithAudioOutput = 'export-with-audio.mp4';
376
+ const audioOfResponse = await this.#ffmpegCore.exec([
377
+ '-i',
378
+ videoOutput,
379
+ '-i',
380
+ this.#audioFileName,
381
+ '-c:v',
382
+ 'copy',
383
+ '-c:a',
384
+ 'aac',
385
+ '-t',
386
+ `${duration}`,
387
+ '-y',
388
+ videoWithAudioOutput,
389
+ ]);
390
+ if (audioOfResponse !== 0) {
391
+ throw new Error('the command executed by ffmpeg to generate a audio of video failed.');
392
+ }
393
+ }
394
+ const finalVideoFile = videoWithAudioOutput ?? videoOutput;
395
+ let lastFrameOutput = '';
396
+ if (isExportLastFrameJPEG) {
397
+ lastFrameOutput = `image-${padZero(this.#currentTaskTotalFrames, 4)}.${this.transImageType}`;
398
+ }
399
+ // 清理无用视频文件
400
+ if (videoWithAudioOutput) {
401
+ void this.#ffmpegCore.deleteFile(videoOutput).catch(() => {
402
+ logger('log', [...loggerPrefix, 'ffmpeg delete file error']);
403
+ });
404
+ }
405
+ return {
406
+ video: finalVideoFile,
407
+ lastFrame: lastFrameOutput
408
+ };
409
+ }
410
+ async getAlphaMaskVideo(oldTotalTime, newTotalTime) {
411
+ // 透明视频需要透明度
412
+ assertExist(this.#ffmpegCore);
413
+ const startNumber = 1;
414
+ this.stage = STAGE_MAP.TRANSCODING;
415
+ const output = 'export.mp4';
416
+ const setpts = newTotalTime ? `,setpts=PTS/${Math.round((oldTotalTime / newTotalTime) * 100) / 100}` : '';
417
+ await this.#ffmpegCore.exec([
418
+ '-framerate',
419
+ String(this.config.fps),
420
+ '-start_number',
421
+ String(startNumber),
422
+ '-i',
423
+ `image-%4d.${this.transImageType}`,
424
+ '-c:v',
425
+ 'libx264',
426
+ '-crf',
427
+ '23',
428
+ '-pix_fmt',
429
+ 'yuv420p',
430
+ '-vf',
431
+ `pad=ceil(iw/2)*2:ceil(ih/2)*2${setpts}`,
432
+ '-y',
433
+ output,
434
+ ]);
435
+ return output;
436
+ }
437
+ async getVideoByType(oldTotalTime, newTotalTime) {
438
+ assertExist(this.#ffmpegCore);
439
+ const isAPNG = this.#options.mediaType === MEDIA_TYPE.APNG;
440
+ const isGIF = this.#options.mediaType === MEDIA_TYPE.GIF;
441
+ const isAlphaMaskVideo = this.#options.mediaType === MEDIA_TYPE.AlphaMaskVideo;
442
+ const isMP4 = this.#options.mediaType === MEDIA_TYPE.MP4;
443
+ let outputStr = '';
444
+ let extraStr = '';
445
+ try {
446
+ if (isAPNG) {
447
+ outputStr = await this.getAPNG();
448
+ }
449
+ if (isGIF) {
450
+ outputStr = await this.getGIF();
451
+ }
452
+ if (isMP4) {
453
+ const { video, lastFrame } = await this.getMP4(oldTotalTime, newTotalTime);
454
+ outputStr = video;
455
+ extraStr = lastFrame;
456
+ }
457
+ if (isAlphaMaskVideo) {
458
+ outputStr = await this.getAlphaMaskVideo(oldTotalTime, newTotalTime);
459
+ }
460
+ this.stage = STAGE_MAP.IDLE;
461
+ }
462
+ catch (e) {
463
+ logger('log', [...loggerPrefix, 'getVideoByType', 'failed'], e);
464
+ throw e;
465
+ }
466
+ let data = null;
467
+ let extra = null;
468
+ try {
469
+ // 处理视频文件
470
+ assertExist(outputStr);
471
+ const fileData = await this.#ffmpegCore.readFile(outputStr);
472
+ data = typeof fileData === 'string' ? new TextEncoder().encode(fileData) : new Uint8Array(fileData);
473
+ // 处理额外文件
474
+ if (extraStr) {
475
+ const fileData = await this.#ffmpegCore.readFile(extraStr).catch(() => {
476
+ logger('log', [...loggerPrefix, 'ffmpeg read extra file error']);
477
+ });
478
+ extra = typeof fileData === 'string' ? new TextEncoder().encode(fileData) : new Uint8Array(fileData);
479
+ }
480
+ }
481
+ finally {
482
+ void this.#ffmpegCore.deleteFile(outputStr).catch(() => {
483
+ logger('log', [...loggerPrefix, 'ffmpeg delete file error']);
484
+ });
485
+ for (const fileName of this.#imageFileNames) {
486
+ if (fileName) {
487
+ void this.#ffmpegCore.deleteFile(fileName);
488
+ }
489
+ }
490
+ }
491
+ return { data, extra };
492
+ }
493
+ onRecordWebM(canvas) {
494
+ const mediaRecorder = new MediaRecorder(canvas.captureStream(this.config.fps), {
495
+ mimeType: 'video/webm;codecs=vp9',
496
+ });
497
+ this.#mediaRecorder = mediaRecorder;
498
+ this.#mediaRecorder.start(0);
499
+ const recordedChunks = [];
500
+ this.#mediaRecorder.ondataavailable = (event) => {
501
+ if (event.data.size > 0) {
502
+ recordedChunks.push(event.data);
503
+ }
504
+ };
505
+ this.#recordedChunks = recordedChunks;
506
+ }
507
+ async onExportImageFrame(options) {
508
+ try {
509
+ if (this.canceled) {
510
+ return;
511
+ }
512
+ const { canvas, imageBuffers, startTime, oldTotalTime, newTotalTime } = options;
513
+ assertExist(this.player);
514
+ assertExist(this.#playerCanvas);
515
+ const index = this.#currentTaskFrameIndex + 1;
516
+ this.stage = STAGE_MAP.CONVERT_IMAGE;
517
+ this.setProgress({
518
+ currentTaskInTranscodingFrameIndex: 0,
519
+ currentTaskFrameIndex: index,
520
+ });
521
+ if (this.#options.loggerInTranscoding) {
522
+ logger('log', [...loggerPrefix, 'converting log'], `converting ${index} frame image`);
523
+ }
524
+ if (index >= 1) {
525
+ this.forwardPlayerTime(index / this.config.fps + startTime);
526
+ }
527
+ drawToOutputCanvas(this.#playerCanvas, canvas, this.config.backgroundColor);
528
+ let bufferCanvas = canvas;
529
+ if (this.#options.mediaType === MEDIA_TYPE.AlphaMaskVideo) {
530
+ assertExist(this.#options.extraCanvas);
531
+ /**
532
+ * AlphaMaskVideo 按以下文档格式要求重新生成帧图
533
+ * @docs https://yuque.antfin.com/alipay-fe-bj/ymopt8/kry3oht7ztyue1pg#plsCq
534
+ */
535
+ getAlphaMaskImage(canvas, this.#options.extraCanvas);
536
+ bufferCanvas = this.#options.extraCanvas;
537
+ }
538
+ const buffer = await canvasToArrayBuffer(bufferCanvas, `image/${this.transImageType}`, 1);
539
+ const fileName = `image-${padZero(index, 4)}.${this.transImageType}`;
540
+ if (this.#options.mediaType === MEDIA_TYPE.Images) {
541
+ imageBuffers.push(buffer);
542
+ }
543
+ else if (this.#options.mediaType === MEDIA_TYPE.WebP) {
544
+ assertExist(this.#img2WebPCore);
545
+ this.#img2WebPCore.FS.writeFile(fileName, new Uint8Array(buffer));
546
+ }
547
+ else {
548
+ assertExist(this.#ffmpegCore);
549
+ await this.#ffmpegCore.writeFile(fileName, new Uint8Array(buffer));
550
+ }
551
+ this.#imageFileNames.push(fileName);
552
+ const ended = index === this.#currentTaskTotalFrames;
553
+ if (ended) {
554
+ // 帧绘制结束,则暂停播放器,节约性能开销
555
+ this.player?.pause();
556
+ await sleepForOneFrame();
557
+ let outputFile = null;
558
+ let extraFile = null;
559
+ // 导出序列帧
560
+ if (this.#options.mediaType === 'Images') {
561
+ outputFile = await this.getImagesZip(this.config.name, imageBuffers);
562
+ }
563
+ else if (this.#options.mediaType === 'WebP') {
564
+ outputFile = this.getWebP();
565
+ }
566
+ else if (
567
+ // @ts-expect-error pick some types
568
+ [MEDIA_TYPE.MP4, MEDIA_TYPE.APNG, MEDIA_TYPE.GIF, MEDIA_TYPE.AlphaMaskVideo].includes(this.#options.mediaType)) {
569
+ const { data, extra } = (await this.getVideoByType(oldTotalTime, newTotalTime)) ?? null;
570
+ outputFile = data;
571
+ extraFile = extra;
572
+ }
573
+ if (outputFile) {
574
+ logger('log', [...loggerPrefix, 'done'], 'success');
575
+ this.emit('done', this.#currentTask, true, outputFile, extraFile);
576
+ }
577
+ }
578
+ else {
579
+ this.#requestAnimationFrameId = window.requestAnimationFrame(() => {
580
+ if (this.canceled) {
581
+ return;
582
+ }
583
+ void this.onExportImageFrame({
584
+ canvas,
585
+ imageBuffers,
586
+ startTime,
587
+ oldTotalTime,
588
+ newTotalTime,
589
+ });
590
+ });
591
+ }
592
+ }
593
+ catch (e) {
594
+ logger('log', [...loggerPrefix, 'onExportImageFrame', 'failed'], e);
595
+ this.emit('done', this.#currentTask, false, undefined, undefined);
596
+ }
597
+ }
598
+ async onExportWebMFrame(canvas) {
599
+ try {
600
+ if (this.canceled) {
601
+ return;
602
+ }
603
+ assertExist(this.player);
604
+ assertExist(this.#playerCanvas);
605
+ const index = this.#currentTaskFrameIndex + 1;
606
+ this.stage = STAGE_MAP.CONVERT_IMAGE;
607
+ this.setProgress({
608
+ currentTaskInTranscodingFrameIndex: 0,
609
+ currentTaskFrameIndex: index,
610
+ });
611
+ if (this.#options.loggerInTranscoding) {
612
+ logger('log', [...loggerPrefix, 'converting log'], `converting ${index} frame image`);
613
+ }
614
+ drawToOutputCanvas(this.#playerCanvas, canvas, this.config.backgroundColor);
615
+ if (index === 1) {
616
+ this.player.play();
617
+ this.onRecordWebM(canvas);
618
+ }
619
+ if (index < this.#currentTaskTotalFrames) {
620
+ this.#requestAnimationFrameId = window.requestAnimationFrame(() => {
621
+ if (this.canceled) {
622
+ return;
623
+ }
624
+ void this.onExportWebMFrame(canvas);
625
+ });
626
+ }
627
+ else {
628
+ this.player?.pause();
629
+ this.#mediaRecorder?.stop();
630
+ const blob = new Blob(this.#recordedChunks, { type: 'video/webm' });
631
+ const buffer = await blob.arrayBuffer();
632
+ logger('log', [...loggerPrefix, 'done'], 'success');
633
+ this.emit('done', this.#currentTask, true, buffer, undefined);
634
+ }
635
+ }
636
+ catch (e) {
637
+ logger('log', [...loggerPrefix, 'done'], 'failed', e);
638
+ this.emit('done', this.#currentTask, false, undefined, undefined);
639
+ }
640
+ }
641
+ async runTask(options) {
642
+ try {
643
+ if (this.canceled) {
644
+ return;
645
+ }
646
+ assertExist(this.player);
647
+ assertExist(this.#playerCanvas);
648
+ const { size, startTime, containerCanvas } = options;
649
+ const { oldTotalTime, newTotalTime } = this.config;
650
+ const [width, height] = size;
651
+ this.#playerCanvas.width = width;
652
+ this.#playerCanvas.height = height;
653
+ containerCanvas.width = width;
654
+ containerCanvas.height = height;
655
+ this.player.resize();
656
+ this.#imageFileNames = [];
657
+ if ([
658
+ MEDIA_TYPE.MP4,
659
+ MEDIA_TYPE.APNG,
660
+ MEDIA_TYPE.Images,
661
+ MEDIA_TYPE.WebP,
662
+ MEDIA_TYPE.GIF,
663
+ MEDIA_TYPE.AlphaMaskVideo,
664
+ // @ts-expect-error pick some types
665
+ ].includes(this.#options.mediaType)) {
666
+ const exportImageFrameOptions = {
667
+ canvas: containerCanvas,
668
+ imageBuffers: [],
669
+ startTime,
670
+ oldTotalTime,
671
+ newTotalTime,
672
+ };
673
+ await this.onExportImageFrame(exportImageFrameOptions);
674
+ }
675
+ else if (this.#options.mediaType === 'WebM') {
676
+ await this.onExportWebMFrame(containerCanvas);
677
+ }
678
+ }
679
+ catch (e) {
680
+ logger('log', [...loggerPrefix, 'run task error'], e);
681
+ this.emit('done', this.#currentTask, false, undefined, undefined);
682
+ }
683
+ }
684
+ clearTaskInfo() {
685
+ this.#currentTaskIndex = 0;
686
+ this.#currentTask = undefined;
687
+ this.#completedTaskPercentArray = [];
688
+ this.#currentTaskFrameIndex = 0;
689
+ this.#currentTaskTotalFrames = 0;
690
+ this.#allTaskFrames = 0;
691
+ this.#currentTaskInTranscodingFrameIndex = 0;
692
+ this.#allSuccessTaskInfos = [];
693
+ this.#allSuccessTaskBuffers = [];
694
+ this.#allSuccessTaskExtraBuffers = [];
695
+ this.#allTaskSuccess = true;
696
+ }
697
+ clearFFMpegCore() {
698
+ if (this.#ffmpegCore) {
699
+ for (const fileName of this.#imageFileNames) {
700
+ if (fileName) {
701
+ void this.#ffmpegCore.deleteFile(fileName);
702
+ }
703
+ }
704
+ this.#imageFileNames = [];
705
+ this.#ffmpegCore.terminate();
706
+ this.#ffmpegCore = undefined;
707
+ }
708
+ }
709
+ clearImage2WebPCore() {
710
+ if (this.#img2WebPCore) {
711
+ this.#img2WebPCore.FS.quit();
712
+ this.#img2WebPCore = undefined;
713
+ }
714
+ }
715
+ clearMediaRecorder() {
716
+ if (this.#mediaRecorder) {
717
+ this.#mediaRecorder.stop();
718
+ this.#mediaRecorder = undefined;
719
+ }
720
+ this.#recordedChunks = [];
721
+ }
722
+ clearAllListeners() {
723
+ for (const listener of this.getListeners('ready')) {
724
+ this.off('ready', listener);
725
+ }
726
+ for (const listener of this.getListeners('progress')) {
727
+ this.off('progress', listener);
728
+ }
729
+ for (const listener of this.getListeners('done')) {
730
+ this.off('done', listener);
731
+ }
732
+ for (const listener of this.getListeners('finish')) {
733
+ this.off('finish', listener);
734
+ }
735
+ for (const listener of this.getListeners('error')) {
736
+ this.off('error', listener);
737
+ }
738
+ }
739
+ onCancel() {
740
+ if (this.canceled) {
741
+ return;
742
+ }
743
+ this.onFinish(false, this.#allSuccessTaskInfos, this.#allSuccessTaskBuffers, this.#allSuccessTaskExtraBuffers);
744
+ this.status = STATUS_MAP.DISPOSE;
745
+ this.onReset();
746
+ this.clearMediaRecorder();
747
+ this.clearFFMpegCore();
748
+ this.clearImage2WebPCore();
749
+ this.clearAllListeners();
750
+ logger('log', loggerPrefix, 'canceled');
751
+ }
752
+ async onExport(options) {
753
+ const totalTasks = options.length;
754
+ try {
755
+ if (this.status === STATUS_MAP.READY) {
756
+ logger('log', [...loggerPrefix], 'readying');
757
+ this.on('ready', () => {
758
+ void this.onExport(options);
759
+ });
760
+ return;
761
+ }
762
+ if (this.status === STATUS_MAP.DISPOSE) {
763
+ const status = await this.onInit();
764
+ if (status === STATUS_MAP.DISPOSE) {
765
+ throw new Error('init environment failed');
766
+ }
767
+ }
768
+ this.clearTaskInfo();
769
+ if (!this.#playerCanvas) {
770
+ const playerCanvas = document.createElement('canvas');
771
+ playerCanvas.dataset.key = 'player-canvas';
772
+ this.#playerCanvas = playerCanvas;
773
+ }
774
+ if (!this.player) {
775
+ this.player = new Player({
776
+ canvas: this.#playerCanvas,
777
+ pixelRatio: 2,
778
+ transparentBackground: true,
779
+ env: 'editor',
780
+ renderOptions: {
781
+ willCaptureImage: true,
782
+ },
783
+ });
784
+ this.player.on('rendererror', (err) => {
785
+ this.onError(err);
786
+ });
787
+ this.player.on('webglcontextlost', (err) => {
788
+ this.onError(err);
789
+ });
790
+ }
791
+ let containerCanvas = null;
792
+ containerCanvas ??= document.querySelector('canvas[data-key="container-canvas"]');
793
+ containerCanvas ??= document.createElement('canvas');
794
+ containerCanvas.dataset.key = 'container-canvas';
795
+ containerCanvas.style.display = 'none';
796
+ this.setProgress({ allTaskFrames: this.getAllTaskFrames(options) });
797
+ const taskInfos = [];
798
+ const buffers = [];
799
+ const extraBuffers = [];
800
+ let allSuccess = true;
801
+ for (const [i, option] of options.entries()) {
802
+ if (this.canceled) {
803
+ allSuccess = false;
804
+ break;
805
+ }
806
+ this.#currentTaskIndex = i;
807
+ this.#currentTask = option;
808
+ const { size, scene } = option;
809
+ await new Promise(async (resolve) => {
810
+ try {
811
+ if (!this.player) {
812
+ throw new Error('player not found');
813
+ }
814
+ logger('log', [...loggerPrefix, 'export'], 'start', option);
815
+ clearPlayerAssets(this.player);
816
+ const playScene = JSON.parse(JSON.stringify(scene));
817
+ await this.player.loadScene(playScene, { env: 'editor', autoplay: false });
818
+ const duration = this.config.oldTotalTime;
819
+ const startTime = 0;
820
+ this.player.pause();
821
+ this.setProgress({
822
+ currentTaskFrameIndex: 0,
823
+ currentTaskTotalFrames: Math.round(duration * this.config.fps),
824
+ });
825
+ const onDone = (_, success, buffer, extraBuffer) => {
826
+ this.off('done', onDone);
827
+ taskInfos[i] = option;
828
+ if (success && buffer) {
829
+ buffers[i] = buffer;
830
+ extraBuffers[i] = extraBuffer ?? null;
831
+ }
832
+ else {
833
+ allSuccess = false;
834
+ }
835
+ resolve();
836
+ };
837
+ this.on('done', onDone);
838
+ const taskOptions = { size, startTime, containerCanvas };
839
+ await this.runTask(taskOptions);
840
+ }
841
+ catch (e) {
842
+ logger('log', [...loggerPrefix, 'export error'], e);
843
+ allSuccess = false;
844
+ resolve();
845
+ }
846
+ });
847
+ }
848
+ this.#allSuccessTaskInfos = taskInfos;
849
+ this.#allSuccessTaskBuffers = buffers;
850
+ this.#allSuccessTaskExtraBuffers = extraBuffers;
851
+ this.#allTaskSuccess = allSuccess;
852
+ }
853
+ catch (e) {
854
+ this.onError(e);
855
+ this.#allTaskSuccess = false;
856
+ }
857
+ if (totalTasks >= 1 && this.#allTaskSuccess) {
858
+ this.onFinish(true, this.#allSuccessTaskInfos, this.#allSuccessTaskBuffers, this.#allSuccessTaskExtraBuffers);
859
+ }
860
+ else {
861
+ this.onFinish(false, this.#allSuccessTaskInfos, this.#allSuccessTaskBuffers, this.#allSuccessTaskExtraBuffers);
862
+ }
863
+ }
864
+ dispose() {
865
+ this.status = STATUS_MAP.DISPOSE;
866
+ this.onReset();
867
+ this.clearMediaRecorder();
868
+ this.clearFFMpegCore();
869
+ this.clearImage2WebPCore();
870
+ this.clearAllListeners();
871
+ this.player?.dispose();
872
+ this.player = undefined;
873
+ this.#playerCanvas?.remove();
874
+ this.#playerCanvas = undefined;
875
+ this.#audioFileName = undefined;
876
+ this.#wavAudio = undefined;
877
+ }
878
+ }