aivee 0.0.0-stage → 0.1.0-dev.59872e1
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/docs/architecture.md +401 -0
- package/docs/aspect-ratio-presets.md +72 -0
- package/docs/releasing.md +19 -0
- package/docs/usage.md +1193 -0
- package/package.json +50 -4
- package/requirements-otio.txt +1 -0
- package/src/capabilities.mjs +307 -0
- package/src/cli.mjs +646 -0
- package/src/doctor.mjs +133 -0
- package/src/edits/analysis-to-edl.mjs +43 -0
- package/src/edits/detect-silence.mjs +51 -0
- package/src/edits/find-mistake-candidates.mjs +42 -0
- package/src/edits/plan-shot-placements.mjs +105 -0
- package/src/edits/plan-silence-cuts.mjs +49 -0
- package/src/edits/retime-transcript.mjs +105 -0
- package/src/files.mjs +80 -0
- package/src/index.mjs +44 -0
- package/src/interchange/otio.mjs +204 -0
- package/src/interchange/otio_bridge.py +521 -0
- package/src/media/analysis-provider.mjs +75 -0
- package/src/media/compatibility.mjs +45 -0
- package/src/media/ffmpeg-capabilities.mjs +129 -0
- package/src/media/frame-rate.mjs +58 -0
- package/src/media/frame-time.mjs +22 -0
- package/src/media/preview-assets.mjs +350 -0
- package/src/media/probe.mjs +149 -0
- package/src/media/render-cuts.mjs +90 -0
- package/src/media/scene-detection.mjs +107 -0
- package/src/media/scene-review.mjs +205 -0
- package/src/project/assets.mjs +1194 -0
- package/src/project/create.mjs +100 -0
- package/src/project/presets.mjs +44 -0
- package/src/render/checkpoints.mjs +192 -0
- package/src/render/formats.mjs +163 -0
- package/src/render/hyperframes.mjs +590 -0
- package/src/render/process.mjs +111 -0
- package/src/render/progress.mjs +46 -0
- package/src/render/timeline-control.mjs +53 -0
- package/src/timeline/assemble.mjs +119 -0
- package/src/timeline/audio-effects.mjs +75 -0
- package/src/timeline/audio-mastering.mjs +236 -0
- package/src/timeline/audio-mix.mjs +105 -0
- package/src/timeline/audio-track-effects.mjs +139 -0
- package/src/timeline/audio-volume.mjs +63 -0
- package/src/timeline/background.mjs +8 -0
- package/src/timeline/blend-mode.mjs +34 -0
- package/src/timeline/caption-layouts.mjs +62 -0
- package/src/timeline/captions.mjs +80 -0
- package/src/timeline/edit.mjs +965 -0
- package/src/timeline/fingerprint.mjs +6 -0
- package/src/timeline/from-edl.mjs +31 -0
- package/src/timeline/history-store.mjs +281 -0
- package/src/timeline/history.mjs +69 -0
- package/src/timeline/inspect.mjs +117 -0
- package/src/timeline/keyframes.mjs +158 -0
- package/src/timeline/layers.mjs +18 -0
- package/src/timeline/mask.mjs +53 -0
- package/src/timeline/project.mjs +52 -0
- package/src/timeline/render.mjs +759 -0
- package/src/timeline/sidechain-render.mjs +209 -0
- package/src/timeline/source-fit.mjs +15 -0
- package/src/timeline/speech-bubble.mjs +26 -0
- package/src/timeline/speed-ramp.mjs +188 -0
- package/src/timeline/track-state.mjs +22 -0
- package/src/timeline/transition.mjs +34 -0
- package/src/timeline/validate.mjs +322 -0
- package/src/transcript/corrections.mjs +21 -0
- package/src/transcript/normalize.mjs +17 -0
- package/src/transcript/provider.mjs +42 -0
- package/src/transcript/subtitles.mjs +57 -0
- package/src/transcript/validate.mjs +39 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { existsSync, linkSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { renderProject, renderProjectAsync } from '../render/hyperframes.mjs';
|
|
6
|
+
import { probeMedia, probeMediaAsync } from '../media/probe.mjs';
|
|
7
|
+
import { validateTimeline } from './validate.mjs';
|
|
8
|
+
import { extractTimelineRange } from './edit.mjs';
|
|
9
|
+
import { resolveSourceFit } from './source-fit.mjs';
|
|
10
|
+
import { buildAudioVolumeAutomation } from './audio-mix.mjs';
|
|
11
|
+
import { frameRateNumber } from '../media/frame-rate.mjs';
|
|
12
|
+
import { resolveColorProfile, resolveEncoderArgs, resolveProfileEncoder, resolveRenderControlArgs, resolveRenderFormat, resolveVideoEncoder } from '../render/formats.mjs';
|
|
13
|
+
import { hasFfmpegEncoder, hasFfmpegFilter, listFfmpegFilters } from '../media/ffmpeg-capabilities.mjs';
|
|
14
|
+
import { assignRenderTrackIndices } from './layers.mjs';
|
|
15
|
+
import { masterAudioFile, masterAudioFileAsync, validateAudioMastering } from './audio-mastering.mjs';
|
|
16
|
+
import { visualMaskClipPath } from './mask.mjs';
|
|
17
|
+
import { transitionClipPath } from './transition.mjs';
|
|
18
|
+
import { buildSpeedRampAudioAutomation, speedRampPtsExpression } from './speed-ramp.mjs';
|
|
19
|
+
import { applySidechainDuckingProxies, applySidechainDuckingProxiesAsync } from './sidechain-render.mjs';
|
|
20
|
+
import { applyAudioTrackEffectProxies, applyAudioTrackEffectProxiesAsync } from './audio-track-effects.mjs';
|
|
21
|
+
import { resolveAssetProxies, resolveAssetProxiesAsync } from '../project/assets.mjs';
|
|
22
|
+
import { blendModeStyle } from './blend-mode.mjs';
|
|
23
|
+
import { timelineForRender } from './track-state.mjs';
|
|
24
|
+
import { audioClipEffectRequirements, buildAudioClipEffectFilters } from './audio-effects.mjs';
|
|
25
|
+
import { createTimelineRenderControl } from '../render/timeline-control.mjs';
|
|
26
|
+
import { runProcess } from '../render/process.mjs';
|
|
27
|
+
import { emitRenderProgress } from '../render/progress.mjs';
|
|
28
|
+
import { rm } from 'node:fs/promises';
|
|
29
|
+
|
|
30
|
+
const escapeAttr = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
31
|
+
const escapeCssString = (value) => String(value).replace(/[\\"\n\r\f]/g, (char) => `\\${char.codePointAt(0).toString(16)} `);
|
|
32
|
+
const RENDER_TIMELINE_RANGE = Symbol('renderTimelineRange');
|
|
33
|
+
|
|
34
|
+
export function renderTimeline(projectPath, timeline, outputPath, quality = 'draft', options = {}) {
|
|
35
|
+
const { [RENDER_TIMELINE_RANGE]: isRangeRender = false, ...renderOptions } = options;
|
|
36
|
+
if (renderOptions.signal !== undefined || renderOptions.timeoutSeconds !== undefined) {
|
|
37
|
+
throw new TypeError('Cancellation and render timeouts require renderTimelineAsync.');
|
|
38
|
+
}
|
|
39
|
+
if (renderOptions.onProgress !== undefined) throw new TypeError('Render progress callbacks require renderTimelineAsync.');
|
|
40
|
+
return renderTimelineWith(projectPath, timeline, outputPath, quality, renderOptions, renderProject, undefined, undefined, isRangeRender);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function renderTimelineAsync(projectPath, timeline, outputPath, quality = 'draft', options = {}) {
|
|
44
|
+
const { timeoutSeconds, signal, onProgress, [RENDER_TIMELINE_RANGE]: isRangeRender = false, ...renderOptions } = options;
|
|
45
|
+
if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Render onProgress must be a function.');
|
|
46
|
+
const renderControl = createTimelineRenderControl(signal, timeoutSeconds);
|
|
47
|
+
let prepared;
|
|
48
|
+
try {
|
|
49
|
+
if (!['draft', 'standard', 'high'].includes(quality)) throw new TypeError('Quality must be draft, standard, or high.');
|
|
50
|
+
const { useProxies, ...formatOptions } = renderOptions;
|
|
51
|
+
if (useProxies !== undefined && typeof useProxies !== 'boolean') throw new TypeError('useProxies must be a boolean.');
|
|
52
|
+
resolveRenderControlArgs(formatOptions);
|
|
53
|
+
const outputFormat = resolveRenderFormat(outputPath, formatOptions.format);
|
|
54
|
+
validateTimeline(timeline);
|
|
55
|
+
preflightOutputCapabilities(outputFormat, formatOptions);
|
|
56
|
+
const renderTimeline = timelineForRender(timeline);
|
|
57
|
+
const audioMastering = validateAudioMastering(renderTimeline);
|
|
58
|
+
if (audioMastering && ['gif', 'png-sequence'].includes(outputFormat)) throw new TypeError(`Timeline audioMastering cannot be used with ${outputFormat} output.`);
|
|
59
|
+
if (['gif', 'png-sequence'].includes(outputFormat) && renderTimeline.tracks.some((track) => track.kind === 'audio' && track.clips.length > 0)) throw new TypeError(`${outputFormat} output cannot include audio clips.`);
|
|
60
|
+
emitRenderProgress(onProgress, 'preprocessing', 'started');
|
|
61
|
+
prepared = await prepareTimelineRenderAsync(projectPath, timeline, renderOptions, renderControl, onProgress);
|
|
62
|
+
emitRenderProgress(onProgress, 'preprocessing', 'completed');
|
|
63
|
+
return await renderTimelineWith(projectPath, timeline, outputPath, quality, { ...renderOptions, ...(onProgress ? { onProgress } : {}), signal: renderControl.signal }, renderProjectAsync, renderControl, prepared, isRangeRender);
|
|
64
|
+
} finally {
|
|
65
|
+
if (prepared) await cleanupTemporaryAssetsAsync(prepared.temporaryAssets);
|
|
66
|
+
renderControl.dispose();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Render a selected project-time window without changing the source timeline. */
|
|
71
|
+
export function renderTimelineRange(projectPath, timeline, startSeconds, endSeconds, outputPath, quality = 'draft', options = {}) {
|
|
72
|
+
return renderTimeline(projectPath, extractTimelineRange(timeline, startSeconds, endSeconds), outputPath, quality, { ...options, [RENDER_TIMELINE_RANGE]: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Async, cancellable counterpart to renderTimelineRange. */
|
|
76
|
+
export function renderTimelineRangeAsync(projectPath, timeline, startSeconds, endSeconds, outputPath, quality = 'draft', options = {}) {
|
|
77
|
+
return renderTimelineAsync(projectPath, extractTimelineRange(timeline, startSeconds, endSeconds), outputPath, quality, { ...options, [RENDER_TIMELINE_RANGE]: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderTimelineWith(projectPath, timeline, outputPath, quality, options, renderProjectFunction, renderControl, prepared, isRangeRender = false) {
|
|
81
|
+
const root = resolve(projectPath);
|
|
82
|
+
if (options.useProxies !== undefined && typeof options.useProxies !== 'boolean') throw new TypeError('useProxies must be a boolean.');
|
|
83
|
+
const { useProxies, onProgress, ...renderOptions } = options;
|
|
84
|
+
validateTimeline(timeline);
|
|
85
|
+
const renderTimeline = timelineForRender(timeline);
|
|
86
|
+
const proxyRecords = prepared?.proxyRecords ?? (useProxies ? resolveAssetProxies(root, renderTimeline) : new Map());
|
|
87
|
+
const metaPath = join(root, 'meta.json');
|
|
88
|
+
if (!existsSync(metaPath)) throw new Error(`Not an Engine project: ${root}`);
|
|
89
|
+
const originalMeta = readFileSync(metaPath);
|
|
90
|
+
const meta = JSON.parse(originalMeta.toString('utf8'));
|
|
91
|
+
for (const field of ['width', 'height']) if (Math.abs(meta[field] - timeline.project[field]) > 1e-6) throw new TypeError(`Timeline project ${field} must match the Engine project.`);
|
|
92
|
+
if (Math.abs(frameRateNumber(meta.fps) - frameRateNumber(timeline.project.fps)) > 1e-9) throw new TypeError('Timeline project fps must match the Engine project.');
|
|
93
|
+
const updateDuration = Math.abs(meta.durationSeconds - timeline.project.durationSeconds) > 1e-6;
|
|
94
|
+
const temporaryMeta = updateDuration ? `${JSON.stringify({ ...meta, durationSeconds: timeline.project.durationSeconds }, null, 2)}\n` : undefined;
|
|
95
|
+
const restoreProjectMeta = () => {
|
|
96
|
+
if (temporaryMeta) writeFileAtomically(metaPath, originalMeta);
|
|
97
|
+
};
|
|
98
|
+
const format = resolveRenderFormat(outputPath, options.format);
|
|
99
|
+
if (!prepared) preflightOutputCapabilities(format, renderOptions);
|
|
100
|
+
const audioMastering = validateAudioMastering(renderTimeline);
|
|
101
|
+
if (audioMastering && ['gif', 'png-sequence'].includes(format)) throw new TypeError(`Timeline audioMastering cannot be used with ${format} output.`);
|
|
102
|
+
if (['gif', 'png-sequence'].includes(format) && renderTimeline.tracks.some((track) => track.kind === 'audio' && track.clips.length > 0)) throw new TypeError(`${format} output cannot include audio clips.`);
|
|
103
|
+
const assetPath = (relative) => {
|
|
104
|
+
const absolute = resolve(root, relative);
|
|
105
|
+
if (absolute !== root && !absolute.startsWith(`${root}${sep}`)) throw new TypeError(`Asset escapes project: ${relative}`);
|
|
106
|
+
if (!existsSync(absolute)) throw new Error(`Timeline asset does not exist: ${relative}`);
|
|
107
|
+
return relative.split(sep).join('/');
|
|
108
|
+
};
|
|
109
|
+
for (const asset of timeline.assets) assetPath(asset.path);
|
|
110
|
+
const temporaryAssets = prepared?.temporaryAssets ?? [];
|
|
111
|
+
let deferTemporaryCleanup = false;
|
|
112
|
+
const cleanupTemporaryAssets = () => {
|
|
113
|
+
for (const asset of temporaryAssets) rmSync(asset, { force: true });
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
renderControl?.assertActive();
|
|
117
|
+
let renderSpec = prepared?.renderSpec;
|
|
118
|
+
if (!renderSpec) {
|
|
119
|
+
validateTimelineFfmpegRequirements(renderTimeline);
|
|
120
|
+
const proxiedTimeline = applyPersistentVideoProxies(renderTimeline, proxyRecords);
|
|
121
|
+
const playbackTimeline = applyPlaybackRateProxies(root, alignOffsetAudioAssets(root, proxiedTimeline, temporaryAssets), temporaryAssets);
|
|
122
|
+
const effectedTimeline = applyAudioTrackEffectProxies(root, playbackTimeline, temporaryAssets);
|
|
123
|
+
renderSpec = applySidechainDuckingProxies(root, effectedTimeline, temporaryAssets);
|
|
124
|
+
}
|
|
125
|
+
const renderTrackIndices = assignRenderTrackIndices(renderSpec);
|
|
126
|
+
const clips = renderSpec.tracks.flatMap((track) => track.clips.map((clip) => ({ track, clip }))).sort((a, b) => a.track.order - b.track.order || a.clip.timelineStart - b.clip.timelineStart);
|
|
127
|
+
const usedVideoAssetIds = new Set(renderSpec.tracks.flatMap((track) => track.kind === 'video' ? track.clips.map((clip) => clip.assetId) : []));
|
|
128
|
+
const imagePreloads = renderSpec.assets
|
|
129
|
+
.filter((asset) => asset.type === 'image' && usedVideoAssetIds.has(asset.id))
|
|
130
|
+
.map((asset) => `<link rel="preload" as="image" href="${escapeAttr(assetPath(asset.path))}">`)
|
|
131
|
+
.join('');
|
|
132
|
+
const body = clips.map(({ track, clip }) => {
|
|
133
|
+
const renderTrackIndex = renderTrackIndices.get(`${track.id}/${clip.id}`);
|
|
134
|
+
const animatedMask = clip.keyframes?.some((frame) => frame.properties.maskProgress !== undefined) ?? false;
|
|
135
|
+
const maskStyle = clip.mask ? `clip-path:${visualMaskClipPath(clip.mask, animatedMask)};` : '';
|
|
136
|
+
const wipe = clip.transitionIn?.type === 'wipe' ? clip.transitionIn : clip.transitionOut?.type === 'wipe' ? clip.transitionOut : undefined;
|
|
137
|
+
const transitionStyle = wipe ? `clip-path:${transitionClipPath(wipe.direction)};` : '';
|
|
138
|
+
const blendStyle = blendModeStyle(clip);
|
|
139
|
+
const transitionData = `${clip.transitionIn ? ` data-transition-in="${escapeAttr(JSON.stringify(clip.transitionIn))}"` : ''}${clip.transitionOut ? ` data-transition-out="${escapeAttr(JSON.stringify(clip.transitionOut))}"` : ''}`;
|
|
140
|
+
const effects = clip.keyframes?.some((frame) => ['brightness', 'contrast', 'saturation', 'blurPixels', 'hueDegrees'].some((property) => frame.properties[property] !== undefined));
|
|
141
|
+
const bubble = clip.bubble;
|
|
142
|
+
const bubbleClass = bubble ? ` speech-bubble speech-bubble-${bubble.tailSide}` : '';
|
|
143
|
+
const common = `id="${escapeAttr(clip.id)}" class="clip${track.kind === 'video' ? ' media-window' : ''}${effects ? ' visual-effects' : ''}${bubbleClass}" data-start="${clip.timelineStart}" data-duration="${clip.durationSeconds}" data-track-index="${renderTrackIndex}"${transitionData}${keyframeAttributes(clip)}`;
|
|
144
|
+
if (track.kind === 'overlay') {
|
|
145
|
+
if (clip.text !== undefined) {
|
|
146
|
+
const x = (clip.x ?? 0.08) * 100, y = (clip.y ?? 0.76) * 100;
|
|
147
|
+
const width = (clip.width ?? 0.84) * 100, height = (clip.height ?? 0.16) * 100;
|
|
148
|
+
const fontSize = clip.fontSize ?? 54;
|
|
149
|
+
const color = escapeAttr(clip.color ?? '#ffffff');
|
|
150
|
+
const backgroundColor = escapeAttr(clip.backgroundColor ?? 'transparent');
|
|
151
|
+
const textAlign = clip.textAlign ?? 'center';
|
|
152
|
+
const verticalAlign = clip.verticalAlign ?? 'center';
|
|
153
|
+
const alignContent = verticalAlign === 'top' ? 'flex-start' : verticalAlign === 'bottom' ? 'flex-end' : 'center';
|
|
154
|
+
const alignItems = clip.wordWrap ? 'center' : alignContent;
|
|
155
|
+
const fontFamily = escapeAttr(clip.fontFamily ?? 'system-ui, sans-serif');
|
|
156
|
+
const fontWeight = clip.fontWeight ?? 400;
|
|
157
|
+
const lineHeight = clip.lineHeight ?? 1.15;
|
|
158
|
+
const whiteSpace = clip.wordWrap === false ? 'pre' : 'pre-wrap';
|
|
159
|
+
const paddingPixels = clip.paddingPixels ?? 0;
|
|
160
|
+
const outline = clip.textOutline ? `-webkit-text-stroke:${clip.textOutline.widthPixels}px ${escapeAttr(clip.textOutline.color)};` : '';
|
|
161
|
+
const shadow = clip.textShadow ? `text-shadow:${clip.textShadow.offsetXPixels}px ${clip.textShadow.offsetYPixels}px ${clip.textShadow.blurPixels}px ${escapeAttr(clip.textShadow.color)};` : '';
|
|
162
|
+
const bubbleStyle = bubble ? `--bubble-tail-size:${bubble.tailSizePixels}px;--bubble-tail-position:${bubble.tailPosition * 100}%;--bubble-color:${backgroundColor};` : '';
|
|
163
|
+
const words = clip.wordTimings ?? clip.text.trim().split(/\s+/).map((text) => ({ text }));
|
|
164
|
+
const wordMarkup = (word) => clip.wordTimings
|
|
165
|
+
? `<span class="caption-word" data-word-start="${clip.timelineStart + word.start}" data-word-end="${clip.timelineStart + word.end}">${escapeAttr(word.text)}</span>`
|
|
166
|
+
: escapeAttr(word.text);
|
|
167
|
+
const lines = clip.wordsPerLine === undefined
|
|
168
|
+
? [words]
|
|
169
|
+
: Array.from({ length: Math.ceil(words.length / clip.wordsPerLine) }, (_, index) => words.slice(index * clip.wordsPerLine, (index + 1) * clip.wordsPerLine));
|
|
170
|
+
const textMarkup = clip.wordsPerLine === undefined
|
|
171
|
+
? (clip.wordTimings ? words.map(wordMarkup).join(' ') : escapeAttr(clip.text))
|
|
172
|
+
: lines.map((line) => `<span class="caption-line" style="display:block;width:100%;text-align:${textAlign}">${line.map(wordMarkup).join(' ')}</span>`).join('');
|
|
173
|
+
const highlightAttributes = clip.wordTimings
|
|
174
|
+
? ` data-word-base-color="${color}" data-word-highlight-color="${escapeAttr(clip.wordHighlightColor)}" data-word-highlight-animation="${escapeAttr(clip.wordHighlightAnimation ?? 'scale')}"`
|
|
175
|
+
: '';
|
|
176
|
+
const display = clip.wordsPerLine === undefined ? 'flex' : 'flex;flex-direction:column;row-gap:0.08em';
|
|
177
|
+
const flexWrap = clip.wordsPerLine !== undefined ? 'nowrap' : clip.wordWrap ? 'wrap' : 'nowrap';
|
|
178
|
+
const itemAlignment = clip.wordsPerLine !== undefined ? 'stretch' : alignItems;
|
|
179
|
+
const bubbleContent = bubble ? `<span class="speech-bubble-content" style="display:block;position:relative;z-index:2;width:100%">${textMarkup}</span>` : textMarkup;
|
|
180
|
+
const bubblePanel = bubble ? `<span class="speech-bubble-panel" style="position:absolute;inset:0;z-index:1;border-radius:${bubble.cornerRadiusPixels}px;background:${backgroundColor}"></span>` : '';
|
|
181
|
+
return `<div ${common}${fadeAttributes(clip)}${highlightAttributes} role="text" aria-label="${escapeAttr(clip.text)}" style="position:absolute;display:${display};flex-wrap:${flexWrap};align-items:${itemAlignment};align-content:${alignContent};column-gap:${clip.wordWrap ? '0.25em' : '0'};justify-content:${textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'};left:${x}%;top:${y}%;width:${width}%;height:${height}%;padding:${paddingPixels}px;font-family:${fontFamily};font-size:${fontSize}px;font-weight:${fontWeight};line-height:${lineHeight};color:${color};background:${bubble ? 'transparent' : backgroundColor};text-align:${textAlign};white-space:${whiteSpace};${bubbleStyle}${outline}${shadow}${maskStyle}${transitionStyle}${blendStyle}">${bubblePanel}${bubbleContent}</div>`;
|
|
182
|
+
}
|
|
183
|
+
const comp = assetPath(clip.composition);
|
|
184
|
+
const compositionTimeOffset = clip.compositionTimeOffsetSeconds ?? 0;
|
|
185
|
+
const compositionTimeOffsetAttribute = compositionTimeOffset > 0
|
|
186
|
+
? ` data-playback-start="${compositionTimeOffset}"`
|
|
187
|
+
: '';
|
|
188
|
+
return `<div ${common}${fadeAttributes(clip)} data-composition-id="${escapeAttr(clip.id)}" data-composition-src="${escapeAttr(comp)}"${compositionTimeOffsetAttribute} style="position:absolute;left:0;top:0;width:100%;height:100%;${maskStyle}${transitionStyle}${blendStyle}"></div>`;
|
|
189
|
+
}
|
|
190
|
+
const asset = renderSpec.assets.find((item) => item.id === clip.assetId);
|
|
191
|
+
const assetRelativePath = assetPath(asset.path);
|
|
192
|
+
const src = escapeAttr(assetRelativePath);
|
|
193
|
+
if (track.kind === 'audio') {
|
|
194
|
+
const automation = escapeAttr(JSON.stringify(buildAudioVolumeAutomation(renderTimeline, track, clip)));
|
|
195
|
+
return `<audio ${common} src="${src}" data-media-start="${clip.sourceIn}" data-volume-automation="${automation}"></audio>`;
|
|
196
|
+
}
|
|
197
|
+
const fit = resolveSourceFit(timeline.project, clip, meta.sourceFit);
|
|
198
|
+
const x = Number.isFinite(clip.x) ? clip.x * 100 : 0;
|
|
199
|
+
const y = Number.isFinite(clip.y) ? clip.y * 100 : 0;
|
|
200
|
+
const width = Number.isFinite(clip.width) ? clip.width * 100 : 100;
|
|
201
|
+
const height = Number.isFinite(clip.height) ? clip.height * 100 : 100;
|
|
202
|
+
const objectPosition = 'object-position:var(--engine-object-position-x,50%) var(--engine-object-position-y,50%)';
|
|
203
|
+
const windowStyle = `position:absolute;left:${x}%;top:${y}%;width:${width}%;height:${height}%;overflow:hidden;${maskStyle}${transitionStyle}${blendStyle}`;
|
|
204
|
+
if (asset.type === 'image') {
|
|
205
|
+
const stillSource = escapeAttr(`url("${escapeCssString(assetRelativePath)}")`);
|
|
206
|
+
const stillFit = fit === 'fill' ? '100% 100%' : fit;
|
|
207
|
+
const stillCommon = common.replace('class="clip media-window', 'class="clip media-window still-window');
|
|
208
|
+
return `<div ${stillCommon}${fadeAttributes(clip)} data-still-image="true" style="${windowStyle}--engine-still-source:${stillSource};--engine-still-fit:${stillFit};"></div>`;
|
|
209
|
+
}
|
|
210
|
+
const engineKeyframes = clip.keyframes === undefined ? '' : ` data-engine-keyframes="${escapeAttr(JSON.stringify(clip.keyframes))}"`;
|
|
211
|
+
const engineFades = `${clip.fadeInSeconds !== undefined ? ` data-engine-fade-in-seconds="${clip.fadeInSeconds}"` : ''}${clip.fadeOutSeconds !== undefined ? ` data-engine-fade-out-seconds="${clip.fadeOutSeconds}"` : ''}`;
|
|
212
|
+
const engineTransitions = `${clip.transitionIn ? ` data-engine-transition-in="${escapeAttr(JSON.stringify(clip.transitionIn))}"` : ''}${clip.transitionOut ? ` data-engine-transition-out="${escapeAttr(JSON.stringify(clip.transitionOut))}"` : ''}`;
|
|
213
|
+
return `<div id="${escapeAttr(clip.id)}-window" class="engine-clip media-window${effects ? ' visual-effects' : ''}" data-engine-start="${clip.timelineStart}" data-engine-duration="${clip.durationSeconds}"${engineKeyframes}${engineFades}${engineTransitions} style="${windowStyle}"><video id="${escapeAttr(clip.id)}" src="${src}" muted playsinline data-start="${clip.timelineStart}" data-duration="${clip.durationSeconds}" data-track-index="${renderTrackIndex}" data-media-start="${clip.sourceIn}" data-has-audio="false" data-source-fit="${fit}" style="width:100%;height:100%;object-fit:${fit};${objectPosition}"></video></div>`;
|
|
214
|
+
}).join('\n');
|
|
215
|
+
const rootBackground = timeline.project.backgroundColor === undefined ? '#000000' : timeline.project.backgroundColor ?? 'transparent';
|
|
216
|
+
const bubbleCss = '.speech-bubble{isolation:isolate;filter:drop-shadow(0 8px 16px #17223626)}.speech-bubble:after{content:"";position:absolute;width:var(--bubble-tail-size);height:var(--bubble-tail-size);background:var(--bubble-color);border-radius:2px;z-index:0}.speech-bubble-bottom:after{left:var(--bubble-tail-position);bottom:0;transform:translate(-50%,50%) rotate(45deg)}.speech-bubble-top:after{left:var(--bubble-tail-position);top:0;transform:translate(-50%,-50%) rotate(45deg)}.speech-bubble-left:after{left:0;top:var(--bubble-tail-position);transform:translate(-50%,-50%) rotate(45deg)}.speech-bubble-right:after{right:0;top:var(--bubble-tail-position);transform:translate(50%,-50%) rotate(45deg)}';
|
|
217
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=${meta.width},height=${meta.height}"><title>${escapeAttr(timeline.project.id)}</title><link rel="icon" href="data:,">${imagePreloads}<script src="assets/gsap.min.js"></script><style>*{box-sizing:border-box}html,body,#root{margin:0;width:${meta.width}px;height:${meta.height}px;overflow:hidden;background:${escapeAttr(rootBackground)}}#root{position:relative;isolation:isolate}.engine-clip{visibility:hidden}.caption-word{display:inline-block;transform-origin:center}${bubbleCss}.media-window img,.media-window video{transform:scale(var(--engine-zoom,1));transform-origin:var(--engine-object-position-x,50%) var(--engine-object-position-y,50%)}.still-window:before{content:"";position:absolute;inset:0;background-image:var(--engine-still-source);background-repeat:no-repeat;background-size:var(--engine-still-fit);background-position:var(--engine-object-position-x,50%) var(--engine-object-position-y,50%);transform:scale(var(--engine-zoom,1));transform-origin:var(--engine-object-position-x,50%) var(--engine-object-position-y,50%)}.visual-effects{filter:brightness(var(--engine-brightness,1)) contrast(var(--engine-contrast,1)) saturate(var(--engine-saturation,1)) blur(var(--engine-blur,0px)) hue-rotate(var(--engine-hue,0deg))}</style></head><body><div id="root" data-composition-id="root" data-start="0" data-duration="${timeline.project.durationSeconds}" data-width="${meta.width}" data-height="${meta.height}">${body}</div><script>window.__timelines=window.__timelines||{};const timeline=gsap.timeline({paused:true});function visualProps(properties){const result={};for(const [key,value] of Object.entries(properties)){if(key==='x')result.left=(value*100)+'%';else if(key==='y')result.top=(value*100)+'%';else if(key==='width'||key==='height')result[key]=(value*100)+'%';else if(key==='rotationDegrees')result.rotation=value;else if(key==='zoom')result['--engine-zoom']=value;else if(key==='maskProgress')result['--engine-mask-progress']=value;else if(key==='objectPositionX')result['--engine-object-position-x']=(value*100)+'%';else if(key==='objectPositionY')result['--engine-object-position-y']=(value*100)+'%';else if(key==='brightness')result['--engine-brightness']=value;else if(key==='contrast')result['--engine-contrast']=value;else if(key==='saturation')result['--engine-saturation']=value;else if(key==='blurPixels')result['--engine-blur']=value+'px';else if(key==='hueDegrees')result['--engine-hue']=value+'deg';else result[key]=value}return result}function easeValue(ease){if(typeof ease==='string')return ease;const base=gsap.parseEase(ease.name),from=base(ease.from),to=base(ease.to);return progress=>(base(ease.from+(ease.to-ease.from)*progress)-from)/(to-from)}function addTransition(el,spec,edge,start,duration){if(!spec)return;const entering=edge==='in',from=entering?0:1,to=entering?1:0,offset=entering?start:start+duration-spec.durationSeconds,property=spec.type==='fade'?'opacity':'--engine-transition-progress';timeline.set(el,{[property]:from},offset).to(el,{[property]:to,duration:spec.durationSeconds,ease:'none'},offset)}for(const el of document.querySelectorAll('.clip,.engine-clip')){const engineClip=el.classList.contains('engine-clip'),start=Number(engineClip?el.dataset.engineStart:el.dataset.start),duration=Number(engineClip?el.dataset.engineDuration:el.dataset.duration),fadeIn=Number(engineClip?el.dataset.engineFadeInSeconds||0:el.dataset.fadeInSeconds||0),fadeOut=Number(engineClip?el.dataset.engineFadeOutSeconds||0:el.dataset.fadeOutSeconds||0);if(el instanceof HTMLAudioElement){const automation=JSON.parse(el.dataset.volumeAutomation||'[]');if(automation.length){timeline.set(el,{volume:automation[0].volume},automation[0].time);for(let i=1;i<automation.length;i++){const previous=automation[i-1],next=automation[i],span=next.time-previous.time;if(span>0)timeline.to(el,{volume:next.volume,duration:span,ease:'none'},previous.time)}}continue}const keyframes=JSON.parse(engineClip?el.dataset.engineKeyframes||'[]':el.dataset.keyframes||'[]');if(engineClip)timeline.set(el,{visibility:'visible'},start).set(el,{visibility:'hidden'},start+duration);if(keyframes.length){timeline.set(el,visualProps(keyframes[0].properties),start);const propertyTimes=Object.fromEntries(Object.keys(keyframes[0].properties).map(property=>[property,0]));for(let i=1;i<keyframes.length;i++){const next=keyframes[i];for(const [property,value] of Object.entries(next.properties)){const previousTime=propertyTimes[property]??0,span=next.time-previousTime;if(span>0)timeline.to(el,{...visualProps({[property]:value}),duration:span,ease:easeValue(next.eases?.[property]||next.ease||'none')},start+previousTime);else timeline.set(el,visualProps({[property]:value}),start+next.time);propertyTimes[property]=next.time}}}if(fadeIn>0||fadeOut>0){timeline.set(el,{opacity:fadeIn>0?0:1},start);if(fadeIn>0)timeline.to(el,{opacity:1,duration:fadeIn,ease:'none'},start);if(fadeOut>0)timeline.to(el,{opacity:0,duration:fadeOut,ease:'none'},start+duration-fadeOut)}if(el.dataset.wordHighlightColor){const baseColor=el.dataset.wordBaseColor,animation=el.dataset.wordHighlightAnimation;for(const word of el.querySelectorAll('.caption-word')){const wordStart=Number(word.dataset.wordStart),wordEnd=Number(word.dataset.wordEnd);if(animation==='pop'){const popDuration=Math.min(.14,wordEnd-wordStart);timeline.set(word,{color:el.dataset.wordHighlightColor,scale:.92},wordStart).to(word,{scale:1.08,duration:popDuration,ease:'back.out(1.6)'},wordStart)}else timeline.set(word,{color:el.dataset.wordHighlightColor,scale:1.08},wordStart);timeline.set(word,{color:baseColor,scale:1},wordEnd)}}const transitionIn=JSON.parse(engineClip?el.dataset.engineTransitionIn||'null':el.dataset.transitionIn||'null'),transitionOut=JSON.parse(engineClip?el.dataset.engineTransitionOut||'null':el.dataset.transitionOut||'null');addTransition(el,transitionIn,'in',start,duration);addTransition(el,transitionOut,'out',start,duration)}window.__timelines.root=timeline;</script></body></html>`;
|
|
218
|
+
const indexPath = join(root, 'index.html');
|
|
219
|
+
const recordPath = join(root, 'timeline.json');
|
|
220
|
+
const serializedTimeline = `${JSON.stringify(timeline, null, 2)}\n`;
|
|
221
|
+
const recordExists = existsSync(recordPath);
|
|
222
|
+
const indexExisted = existsSync(indexPath);
|
|
223
|
+
const originalHtml = indexExisted ? readFileSync(indexPath) : undefined;
|
|
224
|
+
const restoreProjectHtml = () => {
|
|
225
|
+
if (originalHtml) writeFileAtomically(indexPath, originalHtml);
|
|
226
|
+
else if (existsSync(indexPath)) unlinkSync(indexPath);
|
|
227
|
+
};
|
|
228
|
+
let renderedOutput;
|
|
229
|
+
if (!isRangeRender) {
|
|
230
|
+
if (recordExists) {
|
|
231
|
+
if (readFileSync(recordPath, 'utf8') !== serializedTimeline) throw new Error('Project already has a different timeline.json; use a new project to preserve prior edits.');
|
|
232
|
+
} else {
|
|
233
|
+
writeNewFileAtomically(recordPath, serializedTimeline);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
if (temporaryMeta) writeFileAtomically(metaPath, temporaryMeta);
|
|
238
|
+
writeFileAtomically(indexPath, html);
|
|
239
|
+
const finishResultSync = (result) => {
|
|
240
|
+
renderedOutput = result.output;
|
|
241
|
+
const expectsAudio = format !== 'gif' && renderTimeline.tracks.some((track) => track.kind === 'audio' && track.clips.length > 0);
|
|
242
|
+
if (format !== 'png-sequence') {
|
|
243
|
+
const rendered = probeMedia(result.output);
|
|
244
|
+
if (rendered.hasAudio !== expectsAudio) throw new Error(`Rendered audio stream ${rendered.hasAudio ? 'exists' : 'is missing'}, but the timeline ${expectsAudio ? 'contains' : 'has no'} audio clips.`);
|
|
245
|
+
}
|
|
246
|
+
if (audioMastering) {
|
|
247
|
+
const mastered = masterAudioFile(result.output, format, audioMastering, timeline.project.fps);
|
|
248
|
+
return { ...result, durationSeconds: mastered.durationSeconds, audioMastering: { ...audioMastering, mode: mastered.loudnessMode } };
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
};
|
|
252
|
+
const finishResultAsync = async (result) => {
|
|
253
|
+
renderedOutput = result.output;
|
|
254
|
+
renderControl.assertActive();
|
|
255
|
+
const expectsAudio = format !== 'gif' && renderTimeline.tracks.some((track) => track.kind === 'audio' && track.clips.length > 0);
|
|
256
|
+
if (format !== 'png-sequence') {
|
|
257
|
+
emitRenderProgress(onProgress, 'timeline-validation', 'started', { target: 'audio-streams' });
|
|
258
|
+
const rendered = await probeMediaAsync(result.output, { signal: renderControl.signal });
|
|
259
|
+
renderControl.assertActive();
|
|
260
|
+
if (rendered.hasAudio !== expectsAudio) throw new Error(`Rendered audio stream ${rendered.hasAudio ? 'exists' : 'is missing'}, but the timeline ${expectsAudio ? 'contains' : 'has no'} audio clips.`);
|
|
261
|
+
emitRenderProgress(onProgress, 'timeline-validation', 'completed', { target: 'audio-streams' });
|
|
262
|
+
} else {
|
|
263
|
+
emitRenderProgress(onProgress, 'timeline-validation', 'skipped', { reason: 'png-sequence-has-no-audio-stream' });
|
|
264
|
+
}
|
|
265
|
+
if (audioMastering) {
|
|
266
|
+
emitRenderProgress(onProgress, 'mastering', 'started', { target: 'audio' });
|
|
267
|
+
const mastered = await masterAudioFileAsync(result.output, format, audioMastering, timeline.project.fps, renderControl.signal, onProgress);
|
|
268
|
+
renderControl.assertActive();
|
|
269
|
+
emitRenderProgress(onProgress, 'mastering', 'completed', { target: 'audio' });
|
|
270
|
+
return { ...result, durationSeconds: mastered.durationSeconds, audioMastering: { ...audioMastering, mode: mastered.loudnessMode } };
|
|
271
|
+
}
|
|
272
|
+
emitRenderProgress(onProgress, 'mastering', 'skipped', { reason: 'audio-mastering-not-configured' });
|
|
273
|
+
return result;
|
|
274
|
+
};
|
|
275
|
+
renderControl?.assertActive();
|
|
276
|
+
const rendered = renderProjectFunction(root, outputPath, quality, { ...renderOptions, ...(onProgress ? { onProgress } : {}) });
|
|
277
|
+
if (rendered && typeof rendered.then === 'function') {
|
|
278
|
+
deferTemporaryCleanup = true;
|
|
279
|
+
return rendered.then((result) => {
|
|
280
|
+
return finishResultAsync(result).finally(() => {
|
|
281
|
+
if (isRangeRender) restoreProjectHtml();
|
|
282
|
+
restoreProjectMeta();
|
|
283
|
+
});
|
|
284
|
+
}).catch(async (error) => {
|
|
285
|
+
if (renderedOutput && existsSync(renderedOutput)) await rm(renderedOutput, { recursive: true, force: true });
|
|
286
|
+
if (!isRangeRender && !recordExists && existsSync(recordPath)) unlinkSync(recordPath);
|
|
287
|
+
restoreProjectHtml();
|
|
288
|
+
restoreProjectMeta();
|
|
289
|
+
throw error;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return finishResultSync(rendered);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (renderedOutput && existsSync(renderedOutput)) rmSync(renderedOutput, { recursive: true, force: true });
|
|
295
|
+
if (!isRangeRender && !recordExists && existsSync(recordPath)) unlinkSync(recordPath);
|
|
296
|
+
restoreProjectHtml();
|
|
297
|
+
restoreProjectMeta();
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
if (!deferTemporaryCleanup) {
|
|
302
|
+
if (!renderControl) cleanupTemporaryAssets();
|
|
303
|
+
if (isRangeRender) restoreProjectHtml();
|
|
304
|
+
restoreProjectMeta();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function prepareTimelineRenderAsync(projectPath, timeline, options, renderControl, onProgress) {
|
|
310
|
+
const root = resolve(projectPath);
|
|
311
|
+
if (options.useProxies !== undefined && typeof options.useProxies !== 'boolean') throw new TypeError('useProxies must be a boolean.');
|
|
312
|
+
validateTimeline(timeline);
|
|
313
|
+
const renderTimeline = timelineForRender(timeline);
|
|
314
|
+
let proxyRecords;
|
|
315
|
+
if (options.useProxies) {
|
|
316
|
+
proxyRecords = await runProgressStage(onProgress, 'proxy-resolution', () => resolveAssetProxiesAsync(root, renderTimeline, { signal: renderControl.signal }));
|
|
317
|
+
} else {
|
|
318
|
+
emitRenderProgress(onProgress, 'proxy-resolution', 'skipped', { reason: 'proxies-disabled' });
|
|
319
|
+
proxyRecords = new Map();
|
|
320
|
+
}
|
|
321
|
+
const temporaryAssets = [];
|
|
322
|
+
try {
|
|
323
|
+
renderControl.assertActive();
|
|
324
|
+
validateTimelineFfmpegRequirements(renderTimeline);
|
|
325
|
+
const proxiedTimeline = applyPersistentVideoProxies(renderTimeline, proxyRecords);
|
|
326
|
+
const alignedTimeline = await runProgressStage(onProgress, 'audio-alignment', () => alignOffsetAudioAssetsAsync(root, proxiedTimeline, temporaryAssets, renderControl));
|
|
327
|
+
const playbackTimeline = await runProgressStage(onProgress, 'playback-processing', () => applyPlaybackRateProxiesAsync(root, alignedTimeline, temporaryAssets, renderControl));
|
|
328
|
+
const effectedTimeline = await runProgressStage(onProgress, 'audio-effects', () => applyAudioTrackEffectProxiesAsync(root, playbackTimeline, temporaryAssets, renderControl));
|
|
329
|
+
const renderSpec = await runProgressStage(onProgress, 'sidechain-processing', () => applySidechainDuckingProxiesAsync(root, effectedTimeline, temporaryAssets, renderControl));
|
|
330
|
+
renderControl.assertActive();
|
|
331
|
+
return { renderSpec, temporaryAssets, proxyRecords };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
await cleanupTemporaryAssetsAsync(temporaryAssets);
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function runProgressStage(onProgress, stage, operation) {
|
|
339
|
+
emitRenderProgress(onProgress, stage, 'started');
|
|
340
|
+
const result = await operation();
|
|
341
|
+
emitRenderProgress(onProgress, stage, 'completed');
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cleanupTemporaryAssets(temporaryAssets) {
|
|
346
|
+
for (const asset of temporaryAssets) rmSync(asset, { force: true });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function cleanupTemporaryAssetsAsync(temporaryAssets) {
|
|
350
|
+
await Promise.all(temporaryAssets.map((asset) => rm(asset, { force: true })));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function writeNewFileAtomically(path, content) {
|
|
354
|
+
const temporaryPath = join(dirname(path), `.timeline-render-${randomUUID()}.tmp`);
|
|
355
|
+
try {
|
|
356
|
+
writeFileSync(temporaryPath, content, { flag: 'wx' });
|
|
357
|
+
// Hard-link publication is atomic and refuses an existing timeline record.
|
|
358
|
+
linkSync(temporaryPath, path);
|
|
359
|
+
} finally {
|
|
360
|
+
rmSync(temporaryPath, { force: true });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function writeFileAtomically(path, content) {
|
|
365
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
366
|
+
try {
|
|
367
|
+
writeFileSync(temporaryPath, content, { flag: 'wx' });
|
|
368
|
+
renameSync(temporaryPath, path);
|
|
369
|
+
} finally {
|
|
370
|
+
rmSync(temporaryPath, { force: true });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function alignOffsetAudioAssetsAsync(root, timeline, temporaryAssets, renderControl) {
|
|
375
|
+
const next = structuredClone(timeline);
|
|
376
|
+
const proxies = new Map();
|
|
377
|
+
const startIndex = temporaryAssets.length;
|
|
378
|
+
try {
|
|
379
|
+
for (const track of next.tracks.filter((item) => item.kind === 'audio')) {
|
|
380
|
+
for (const clip of track.clips) {
|
|
381
|
+
const asset = next.assets.find((item) => item.id === clip.assetId);
|
|
382
|
+
if (!['audio', 'video'].includes(asset?.type) || (asset.type === 'video' && !asset.hasAudio)) continue;
|
|
383
|
+
const streamIndex = clip.audioStreamIndex ?? 0;
|
|
384
|
+
const selectedStream = asset.audioStreams?.[streamIndex];
|
|
385
|
+
const streamTiming = asset.streamTiming ?? {};
|
|
386
|
+
const alignmentStart = asset.type === 'audio' ? 0 : (streamTiming.videoStartSeconds ?? 0);
|
|
387
|
+
const audioStartSeconds = selectedStream?.startSeconds ?? (streamIndex === 0 ? streamTiming.audioStartSeconds : undefined) ?? 0;
|
|
388
|
+
const videoDurationSeconds = streamTiming.videoDurationSeconds;
|
|
389
|
+
const audioDurationSeconds = selectedStream?.durationSeconds ?? (streamIndex === 0 ? streamTiming.audioDurationSeconds : undefined);
|
|
390
|
+
const startsMatch = Math.abs(alignmentStart - audioStartSeconds) <= 1e-6;
|
|
391
|
+
const durationTolerance = Math.max(0.05, 1 / frameRateNumber(timeline.project.fps));
|
|
392
|
+
const durationsMatch = asset.type === 'audio' || !Number.isFinite(videoDurationSeconds) || !Number.isFinite(audioDurationSeconds) || Math.abs(videoDurationSeconds - audioDurationSeconds) <= durationTolerance;
|
|
393
|
+
if (streamIndex === 0 && startsMatch && durationsMatch) {
|
|
394
|
+
delete clip.audioStreamIndex;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const proxyKey = `${asset.id}/${streamIndex}`;
|
|
398
|
+
let proxy = proxies.get(proxyKey);
|
|
399
|
+
if (!proxy) {
|
|
400
|
+
renderControl.assertActive();
|
|
401
|
+
const id = streamIndex === 0 ? `${asset.id}-aligned-audio` : `${asset.id}-audio-stream-${streamIndex}-aligned`;
|
|
402
|
+
if (next.assets.some((item) => item.id === id)) throw new TypeError(`Timeline already contains the reserved audio alignment asset id: ${id}`);
|
|
403
|
+
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
404
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
405
|
+
const duration = asset.durationSeconds;
|
|
406
|
+
if (!Number.isFinite(duration) || duration <= 0) throw new TypeError(`Asset ${asset.id} needs a known duration to align its offset audio stream.`);
|
|
407
|
+
const filter = `asetpts=PTS-${alignmentStart}/TB,aresample=async=1:first_pts=0,apad=whole_dur=${duration},atrim=duration=${duration}`;
|
|
408
|
+
temporaryAssets.push(absolutePath);
|
|
409
|
+
await runTimelineFfmpeg(root, [
|
|
410
|
+
'-copyts', '-i', join(root, ...asset.path.split('/')), '-map', `0:a:${streamIndex}`,
|
|
411
|
+
'-vn', '-af', filter, '-c:a', 'pcm_s16le', absolutePath,
|
|
412
|
+
], `Could not prepare audio stream ${streamIndex} for ${asset.id}`, renderControl);
|
|
413
|
+
proxy = { id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: duration };
|
|
414
|
+
proxies.set(proxyKey, proxy);
|
|
415
|
+
next.assets.push(proxy);
|
|
416
|
+
}
|
|
417
|
+
clip.assetId = proxy.id;
|
|
418
|
+
delete clip.audioStreamIndex;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
validateTimeline(next);
|
|
422
|
+
return next;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function alignOffsetAudioAssets(root, timeline, temporaryAssets, renderControl) {
|
|
430
|
+
const next = structuredClone(timeline);
|
|
431
|
+
const proxies = new Map();
|
|
432
|
+
try {
|
|
433
|
+
for (const track of next.tracks.filter((item) => item.kind === 'audio')) {
|
|
434
|
+
for (const clip of track.clips) {
|
|
435
|
+
const asset = next.assets.find((item) => item.id === clip.assetId);
|
|
436
|
+
if (!['audio', 'video'].includes(asset?.type) || (asset.type === 'video' && !asset.hasAudio)) continue;
|
|
437
|
+
const streamIndex = clip.audioStreamIndex ?? 0;
|
|
438
|
+
const selectedStream = asset.audioStreams?.[streamIndex];
|
|
439
|
+
const streamTiming = asset.streamTiming ?? {};
|
|
440
|
+
const alignmentStart = asset.type === 'audio' ? 0 : (streamTiming.videoStartSeconds ?? 0);
|
|
441
|
+
const audioStartSeconds = selectedStream?.startSeconds ?? (streamIndex === 0 ? streamTiming.audioStartSeconds : undefined) ?? 0;
|
|
442
|
+
const videoDurationSeconds = streamTiming.videoDurationSeconds;
|
|
443
|
+
const audioDurationSeconds = selectedStream?.durationSeconds ?? (streamIndex === 0 ? streamTiming.audioDurationSeconds : undefined);
|
|
444
|
+
const startsMatch = Math.abs(alignmentStart - audioStartSeconds) <= 1e-6;
|
|
445
|
+
const durationTolerance = Math.max(0.05, 1 / frameRateNumber(timeline.project.fps));
|
|
446
|
+
const durationsMatch = asset.type === 'audio' || !Number.isFinite(videoDurationSeconds) || !Number.isFinite(audioDurationSeconds) || Math.abs(videoDurationSeconds - audioDurationSeconds) <= durationTolerance;
|
|
447
|
+
if (streamIndex === 0 && startsMatch && durationsMatch) {
|
|
448
|
+
delete clip.audioStreamIndex;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const proxyKey = `${asset.id}/${streamIndex}`;
|
|
452
|
+
let proxy = proxies.get(proxyKey);
|
|
453
|
+
if (!proxy) {
|
|
454
|
+
renderControl?.assertActive();
|
|
455
|
+
const id = streamIndex === 0 ? `${asset.id}-aligned-audio` : `${asset.id}-audio-stream-${streamIndex}-aligned`;
|
|
456
|
+
if (next.assets.some((item) => item.id === id)) throw new TypeError(`Timeline already contains the reserved audio alignment asset id: ${id}`);
|
|
457
|
+
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
458
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
459
|
+
const duration = asset.durationSeconds;
|
|
460
|
+
if (!Number.isFinite(duration) || duration <= 0) throw new TypeError(`Asset ${asset.id} needs a known duration to align its offset audio stream.`);
|
|
461
|
+
const filter = `asetpts=PTS-${alignmentStart}/TB,aresample=async=1:first_pts=0,apad=whole_dur=${duration},atrim=duration=${duration}`;
|
|
462
|
+
temporaryAssets.push(absolutePath);
|
|
463
|
+
const extraction = spawnSync('ffmpeg', [
|
|
464
|
+
'-nostdin', '-hide_banner', '-v', 'error', '-y', '-copyts', '-i', join(root, ...asset.path.split('/')),
|
|
465
|
+
'-map', `0:a:${streamIndex}`, '-vn', '-af', filter, '-c:a', 'pcm_s16le', absolutePath,
|
|
466
|
+
], { encoding: 'utf8', windowsHide: true, ...(renderControl ? { timeout: renderControl.timeoutMs() } : {}) });
|
|
467
|
+
renderControl?.assertActive();
|
|
468
|
+
if (extraction.error || extraction.status !== 0) {
|
|
469
|
+
const error = renderControl?.processError(extraction.error);
|
|
470
|
+
throw error ?? new Error(`Could not prepare audio stream ${streamIndex} for ${asset.id}: ${extraction.error?.message ?? extraction.stderr.trim()}`);
|
|
471
|
+
}
|
|
472
|
+
proxy = { id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: duration };
|
|
473
|
+
proxies.set(proxyKey, proxy);
|
|
474
|
+
next.assets.push(proxy);
|
|
475
|
+
}
|
|
476
|
+
clip.assetId = proxy.id;
|
|
477
|
+
delete clip.audioStreamIndex;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
validateTimeline(next);
|
|
481
|
+
return next;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
for (const asset of temporaryAssets) rmSync(asset, { force: true });
|
|
484
|
+
temporaryAssets.length = 0;
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function applyPersistentVideoProxies(timeline, proxyRecords) {
|
|
490
|
+
if (proxyRecords.size === 0) return timeline;
|
|
491
|
+
const next = structuredClone(timeline);
|
|
492
|
+
const aliases = new Map();
|
|
493
|
+
for (const track of next.tracks.filter((item) => item.kind === 'video')) {
|
|
494
|
+
for (const clip of track.clips) {
|
|
495
|
+
if (clip.videoStreamIndex !== undefined) continue;
|
|
496
|
+
const proxy = proxyRecords.get(clip.assetId);
|
|
497
|
+
if (!proxy) continue;
|
|
498
|
+
let aliasId = aliases.get(clip.assetId);
|
|
499
|
+
if (!aliasId) {
|
|
500
|
+
const source = next.assets.find((asset) => asset.id === clip.assetId);
|
|
501
|
+
const sourceMetadata = { ...source };
|
|
502
|
+
delete sourceMetadata.proxies;
|
|
503
|
+
aliasId = `proxy-${source.id}-${randomUUID().slice(0, 8)}`;
|
|
504
|
+
aliases.set(clip.assetId, aliasId);
|
|
505
|
+
next.assets.push({
|
|
506
|
+
...sourceMetadata,
|
|
507
|
+
id: aliasId,
|
|
508
|
+
path: proxy.path,
|
|
509
|
+
width: proxy.width,
|
|
510
|
+
height: proxy.height,
|
|
511
|
+
frameRate: proxy.frameRate ?? source.frameRate,
|
|
512
|
+
nominalFrameRate: proxy.nominalFrameRate ?? proxy.frameRate ?? source.nominalFrameRate,
|
|
513
|
+
videoCodec: 'h264',
|
|
514
|
+
hasAudio: false,
|
|
515
|
+
audioCodec: null,
|
|
516
|
+
sha256: proxy.sha256,
|
|
517
|
+
sizeBytes: proxy.sizeBytes,
|
|
518
|
+
streamTiming: {
|
|
519
|
+
...source.streamTiming,
|
|
520
|
+
videoStartSeconds: proxy.streamTiming.videoStartSeconds,
|
|
521
|
+
videoDurationSeconds: proxy.streamTiming.videoDurationSeconds,
|
|
522
|
+
videoTimeBase: proxy.streamTiming.videoTimeBase,
|
|
523
|
+
audioStartSeconds: null,
|
|
524
|
+
audioDurationSeconds: null,
|
|
525
|
+
audioTimeBase: null,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
clip.assetId = aliasId;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
validateTimeline(next);
|
|
533
|
+
return next;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function applyPlaybackRateProxies(root, timeline, temporaryAssets, renderControl) {
|
|
537
|
+
const next = structuredClone(timeline);
|
|
538
|
+
try {
|
|
539
|
+
for (const track of next.tracks.filter((item) => item.kind === 'video' || item.kind === 'audio')) {
|
|
540
|
+
for (const clip of track.clips) {
|
|
541
|
+
const rate = clip.playbackRate ?? 1;
|
|
542
|
+
const hasSpeedRamp = clip.speedRamp !== undefined;
|
|
543
|
+
const reverse = clip.reverse === true;
|
|
544
|
+
const isVideo = track.kind === 'video';
|
|
545
|
+
const videoStreamIndex = isVideo ? clip.videoStreamIndex : undefined;
|
|
546
|
+
const effectFilters = isVideo ? [] : buildAudioClipEffectFilters(clip.audioEffects);
|
|
547
|
+
if (!hasSpeedRamp && rate === 1 && !reverse && effectFilters.length === 0 && videoStreamIndex === undefined) continue;
|
|
548
|
+
renderControl?.assertActive();
|
|
549
|
+
const source = next.assets.find((item) => item.id === clip.assetId);
|
|
550
|
+
const operation = hasSpeedRamp ? 'ramp' : reverse ? 'reverse' : effectFilters.length ? 'effects' : videoStreamIndex !== undefined ? 'video-stream' : 'rate';
|
|
551
|
+
const id = `${operation}-${track.id}-${clip.id}-${randomUUID().slice(0, 8)}`;
|
|
552
|
+
if (next.assets.some((item) => item.id === id)) throw new TypeError(`Timeline already contains the reserved playback proxy asset id: ${id}`);
|
|
553
|
+
const extension = isVideo ? '.mp4' : '.wav';
|
|
554
|
+
const relativePath = `assets/.${id}-${randomUUID()}${extension}`;
|
|
555
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
556
|
+
const input = join(root, ...source.path.split('/'));
|
|
557
|
+
let filters;
|
|
558
|
+
if (hasSpeedRamp && isVideo) {
|
|
559
|
+
filters = ['-vf', speedRampPtsExpression(clip.speedRamp), '-an', '-fps_mode', 'vfr', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', '-pix_fmt', 'yuv420p'];
|
|
560
|
+
} else if (hasSpeedRamp) {
|
|
561
|
+
const commandRelativePath = `assets/.${id}-${randomUUID()}.txt`;
|
|
562
|
+
const commandAbsolutePath = join(root, ...commandRelativePath.split('/'));
|
|
563
|
+
const automation = buildSpeedRampAudioAutomation(clip.speedRamp, commandRelativePath);
|
|
564
|
+
writeFileSync(commandAbsolutePath, automation.commandContents, { flag: 'wx' });
|
|
565
|
+
temporaryAssets.push(commandAbsolutePath);
|
|
566
|
+
const audioFilter = `atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,${automation.filters},aresample=48000${effectFilters.length ? `,${effectFilters.join(',')}` : ''},apad=whole_dur=${clip.durationSeconds},atrim=duration=${clip.durationSeconds}`;
|
|
567
|
+
filters = ['-af', audioFilter, '-vn', '-c:a', 'pcm_s16le'];
|
|
568
|
+
} else {
|
|
569
|
+
filters = isVideo
|
|
570
|
+
? ['-vf', `${reverse ? 'reverse,' : ''}setpts=(PTS-STARTPTS)/${rate}`, '-an', '-fps_mode', 'vfr', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', '-pix_fmt', 'yuv420p']
|
|
571
|
+
: ['-af', `atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,${reverse ? 'areverse,' : ''}${atempoFilter(rate)},aresample=48000${effectFilters.length ? `,${effectFilters.join(',')}` : ''}`, '-vn', '-c:a', 'pcm_s16le'];
|
|
572
|
+
}
|
|
573
|
+
temporaryAssets.push(absolutePath);
|
|
574
|
+
const extraction = spawnSync('ffmpeg', [
|
|
575
|
+
'-nostdin', '-hide_banner', '-v', 'error', '-y',
|
|
576
|
+
...(isVideo ? ['-ss', String(clip.sourceIn)] : []), '-i', input,
|
|
577
|
+
...(isVideo ? ['-t', String(clip.sourceOut - clip.sourceIn), '-map', `0:v:${videoStreamIndex ?? 0}`] : ['-map', `0:a:${clip.audioStreamIndex ?? 0}`]),
|
|
578
|
+
...filters, absolutePath,
|
|
579
|
+
], { encoding: 'utf8', windowsHide: true, cwd: root, ...(renderControl ? { timeout: renderControl.timeoutMs() } : {}) });
|
|
580
|
+
renderControl?.assertActive();
|
|
581
|
+
if (extraction.error || extraction.status !== 0) {
|
|
582
|
+
const error = renderControl?.processError(extraction.error);
|
|
583
|
+
throw error ?? new Error(`Could not create playback proxy for clip ${clip.id}: ${extraction.error?.message ?? extraction.stderr.trim()}`);
|
|
584
|
+
}
|
|
585
|
+
next.assets.push({ id, path: relativePath, type: isVideo ? 'video' : 'audio', hasAudio: !isVideo, durationSeconds: clip.durationSeconds });
|
|
586
|
+
clip.assetId = id;
|
|
587
|
+
clip.sourceIn = 0;
|
|
588
|
+
clip.sourceOut = clip.durationSeconds;
|
|
589
|
+
delete clip.playbackRate;
|
|
590
|
+
delete clip.speedRamp;
|
|
591
|
+
delete clip.reverse;
|
|
592
|
+
delete clip.videoStreamIndex;
|
|
593
|
+
delete clip.audioEffects;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
validateTimeline(next);
|
|
597
|
+
return next;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
for (const asset of temporaryAssets) rmSync(asset, { force: true });
|
|
600
|
+
temporaryAssets.length = 0;
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function applyPlaybackRateProxiesAsync(root, timeline, temporaryAssets, renderControl) {
|
|
606
|
+
const next = structuredClone(timeline);
|
|
607
|
+
const startIndex = temporaryAssets.length;
|
|
608
|
+
try {
|
|
609
|
+
for (const track of next.tracks.filter((item) => item.kind === 'video' || item.kind === 'audio')) {
|
|
610
|
+
for (const clip of track.clips) {
|
|
611
|
+
const rate = clip.playbackRate ?? 1;
|
|
612
|
+
const hasSpeedRamp = clip.speedRamp !== undefined;
|
|
613
|
+
const reverse = clip.reverse === true;
|
|
614
|
+
const isVideo = track.kind === 'video';
|
|
615
|
+
const videoStreamIndex = isVideo ? clip.videoStreamIndex : undefined;
|
|
616
|
+
const effectFilters = isVideo ? [] : buildAudioClipEffectFilters(clip.audioEffects);
|
|
617
|
+
if (!hasSpeedRamp && rate === 1 && !reverse && effectFilters.length === 0 && videoStreamIndex === undefined) continue;
|
|
618
|
+
renderControl.assertActive();
|
|
619
|
+
const source = next.assets.find((item) => item.id === clip.assetId);
|
|
620
|
+
const operation = hasSpeedRamp ? 'ramp' : reverse ? 'reverse' : effectFilters.length ? 'effects' : videoStreamIndex !== undefined ? 'video-stream' : 'rate';
|
|
621
|
+
const id = `${operation}-${track.id}-${clip.id}-${randomUUID().slice(0, 8)}`;
|
|
622
|
+
if (next.assets.some((item) => item.id === id)) throw new TypeError(`Timeline already contains the reserved playback proxy asset id: ${id}`);
|
|
623
|
+
const extension = isVideo ? '.mp4' : '.wav';
|
|
624
|
+
const relativePath = `assets/.${id}-${randomUUID()}${extension}`;
|
|
625
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
626
|
+
const input = join(root, ...source.path.split('/'));
|
|
627
|
+
let filters;
|
|
628
|
+
if (hasSpeedRamp && isVideo) {
|
|
629
|
+
filters = ['-vf', speedRampPtsExpression(clip.speedRamp), '-an', '-fps_mode', 'vfr', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', '-pix_fmt', 'yuv420p'];
|
|
630
|
+
} else if (hasSpeedRamp) {
|
|
631
|
+
const commandRelativePath = `assets/.${id}-${randomUUID()}.txt`;
|
|
632
|
+
const commandAbsolutePath = join(root, ...commandRelativePath.split('/'));
|
|
633
|
+
const automation = buildSpeedRampAudioAutomation(clip.speedRamp, commandRelativePath);
|
|
634
|
+
writeFileSync(commandAbsolutePath, automation.commandContents, { flag: 'wx' });
|
|
635
|
+
temporaryAssets.push(commandAbsolutePath);
|
|
636
|
+
const audioFilter = `atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,${automation.filters},aresample=48000${effectFilters.length ? `,${effectFilters.join(',')}` : ''},apad=whole_dur=${clip.durationSeconds},atrim=duration=${clip.durationSeconds}`;
|
|
637
|
+
filters = ['-af', audioFilter, '-vn', '-c:a', 'pcm_s16le'];
|
|
638
|
+
} else {
|
|
639
|
+
filters = isVideo
|
|
640
|
+
? ['-vf', `${reverse ? 'reverse,' : ''}setpts=(PTS-STARTPTS)/${rate}`, '-an', '-fps_mode', 'vfr', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', '-pix_fmt', 'yuv420p']
|
|
641
|
+
: ['-af', `atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,${reverse ? 'areverse,' : ''}${atempoFilter(rate)},aresample=48000${effectFilters.length ? `,${effectFilters.join(',')}` : ''}`, '-vn', '-c:a', 'pcm_s16le'];
|
|
642
|
+
}
|
|
643
|
+
const args = [
|
|
644
|
+
...(isVideo ? ['-ss', String(clip.sourceIn)] : []), '-i', input,
|
|
645
|
+
...(isVideo ? ['-t', String(clip.sourceOut - clip.sourceIn), '-map', `0:v:${videoStreamIndex ?? 0}`] : ['-map', `0:a:${clip.audioStreamIndex ?? 0}`]),
|
|
646
|
+
...filters, absolutePath,
|
|
647
|
+
];
|
|
648
|
+
temporaryAssets.push(absolutePath);
|
|
649
|
+
await runTimelineFfmpeg(root, args, `Could not create playback proxy for clip ${clip.id}`, renderControl);
|
|
650
|
+
next.assets.push({ id, path: relativePath, type: isVideo ? 'video' : 'audio', hasAudio: !isVideo, durationSeconds: clip.durationSeconds });
|
|
651
|
+
clip.assetId = id;
|
|
652
|
+
clip.sourceIn = 0;
|
|
653
|
+
clip.sourceOut = clip.durationSeconds;
|
|
654
|
+
delete clip.playbackRate;
|
|
655
|
+
delete clip.speedRamp;
|
|
656
|
+
delete clip.reverse;
|
|
657
|
+
delete clip.videoStreamIndex;
|
|
658
|
+
delete clip.audioEffects;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
validateTimeline(next);
|
|
662
|
+
return next;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function runTimelineFfmpeg(root, args, message, renderControl) {
|
|
670
|
+
try {
|
|
671
|
+
renderControl.assertActive();
|
|
672
|
+
await runProcess('ffmpeg', ['-nostdin', '-hide_banner', '-v', 'error', '-y', ...args], {
|
|
673
|
+
cwd: root,
|
|
674
|
+
signal: renderControl.signal,
|
|
675
|
+
stdio: 'capture',
|
|
676
|
+
});
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (renderControl.signal.aborted) throw renderControl.signal.reason ?? error;
|
|
679
|
+
throw new Error(`${message}: ${error.message}`, { cause: error });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function validateTimelineFfmpegRequirements(timeline) {
|
|
684
|
+
const requiredFilters = new Set();
|
|
685
|
+
if (timeline.audioMix?.sidechain?.length) requiredFilters.add('sidechaincompress');
|
|
686
|
+
if (timeline.audioMastering) {
|
|
687
|
+
requiredFilters.add('loudnorm');
|
|
688
|
+
if (timeline.audioMastering.equalizer?.length) requiredFilters.add('equalizer');
|
|
689
|
+
if (timeline.audioMastering.limiter) requiredFilters.add('alimiter');
|
|
690
|
+
if (timeline.audioMastering.channelLayout) requiredFilters.add('aresample');
|
|
691
|
+
}
|
|
692
|
+
for (const track of timeline.tracks) {
|
|
693
|
+
if (track.kind !== 'video' && track.kind !== 'audio') continue;
|
|
694
|
+
for (const clip of track.clips) {
|
|
695
|
+
if (clip.reverse === true) requiredFilters.add(track.kind === 'video' ? 'reverse' : 'areverse');
|
|
696
|
+
if (track.kind === 'audio') {
|
|
697
|
+
for (const filter of audioClipEffectRequirements([...(clip.audioEffects ?? []), ...(track.audioEffects ?? [])])) requiredFilters.add(filter);
|
|
698
|
+
if (clip.speedRamp) requiredFilters.add('rubberband');
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (requiredFilters.size === 0) return;
|
|
703
|
+
const availableFilters = listFfmpegFilters();
|
|
704
|
+
for (const filter of requiredFilters) {
|
|
705
|
+
if (!availableFilters.has(filter)) {
|
|
706
|
+
const feature = ({
|
|
707
|
+
rubberband: 'pitch-preserving audio speed ramps',
|
|
708
|
+
afftdn: 'audio denoise',
|
|
709
|
+
stereotools: 'audio stereo balance',
|
|
710
|
+
equalizer: 'audio equalization',
|
|
711
|
+
highpass: 'audio high-pass filtering',
|
|
712
|
+
lowpass: 'audio low-pass filtering',
|
|
713
|
+
acompressor: 'audio compression',
|
|
714
|
+
sidechaincompress: 'signal-aware sidechain compression',
|
|
715
|
+
loudnorm: 'audio loudness mastering',
|
|
716
|
+
alimiter: 'audio peak limiting',
|
|
717
|
+
aresample: 'audio output channel mapping',
|
|
718
|
+
reverse: 'video reverse playback',
|
|
719
|
+
areverse: 'audio reverse playback',
|
|
720
|
+
})[filter] ?? 'the requested audio/video effect';
|
|
721
|
+
throw new Error(`${feature} requires the FFmpeg ${filter} filter, but it is unavailable in the current build.`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function preflightOutputCapabilities(format, options) {
|
|
727
|
+
const encoderSettings = options.encoder ?? {};
|
|
728
|
+
const videoEncoder = resolveVideoEncoder(format, encoderSettings);
|
|
729
|
+
const colorProfile = resolveColorProfile(options.colorProfile, format);
|
|
730
|
+
const finalEncoder = colorProfile ? resolveProfileEncoder(format, videoEncoder, colorProfile) : videoEncoder;
|
|
731
|
+
if (finalEncoder.codec) hasFfmpegEncoder(finalEncoder.codec);
|
|
732
|
+
if (colorProfile && !hasFfmpegFilter(colorProfile.requiredFilter)) {
|
|
733
|
+
throw new Error(`The ${colorProfile.id} color profile requires an FFmpeg build with the ${colorProfile.requiredFilter} filter.`);
|
|
734
|
+
}
|
|
735
|
+
resolveEncoderArgs(format, encoderSettings);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function atempoFilter(playbackRate) {
|
|
739
|
+
const filters = [];
|
|
740
|
+
let remaining = playbackRate;
|
|
741
|
+
while (remaining > 2) {
|
|
742
|
+
filters.push('atempo=2');
|
|
743
|
+
remaining /= 2;
|
|
744
|
+
}
|
|
745
|
+
while (remaining < 0.5) {
|
|
746
|
+
filters.push('atempo=0.5');
|
|
747
|
+
remaining /= 0.5;
|
|
748
|
+
}
|
|
749
|
+
filters.push(`atempo=${remaining}`);
|
|
750
|
+
return filters.join(',');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function fadeAttributes(clip) {
|
|
754
|
+
return `${clip.fadeInSeconds !== undefined ? ` data-fade-in-seconds="${clip.fadeInSeconds}"` : ''}${clip.fadeOutSeconds !== undefined ? ` data-fade-out-seconds="${clip.fadeOutSeconds}"` : ''}`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function keyframeAttributes(clip) {
|
|
758
|
+
return clip.keyframes === undefined ? '' : ` data-keyframes="${escapeAttr(JSON.stringify(clip.keyframes))}"`;
|
|
759
|
+
}
|