@vertesia/workflow 1.5.0-dev.20260717.131047Z → 1.5.0-dev.20260722.120446Z
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/lib/activities/generateDocumentProperties.d.ts +54 -13
- package/lib/activities/generateDocumentProperties.d.ts.map +1 -1
- package/lib/activities/generateDocumentProperties.js +109 -34
- package/lib/activities/generateDocumentProperties.js.map +1 -1
- package/lib/activities/generateOrAssignContentType.d.ts +6 -16
- package/lib/activities/generateOrAssignContentType.d.ts.map +1 -1
- package/lib/activities/generateOrAssignContentType.js +70 -19
- package/lib/activities/generateOrAssignContentType.js.map +1 -1
- package/lib/activities/media/exec.d.ts +60 -0
- package/lib/activities/media/exec.d.ts.map +1 -0
- package/lib/activities/media/exec.js +212 -0
- package/lib/activities/media/exec.js.map +1 -0
- package/lib/activities/media/prepareAudio.d.ts.map +1 -1
- package/lib/activities/media/prepareAudio.js +7 -6
- package/lib/activities/media/prepareAudio.js.map +1 -1
- package/lib/activities/media/prepareVideo.d.ts +5 -0
- package/lib/activities/media/prepareVideo.d.ts.map +1 -1
- package/lib/activities/media/prepareVideo.js +37 -13
- package/lib/activities/media/prepareVideo.js.map +1 -1
- package/lib/activities/media/probeMediaStreams.d.ts.map +1 -1
- package/lib/activities/media/probeMediaStreams.js +7 -6
- package/lib/activities/media/probeMediaStreams.js.map +1 -1
- package/lib/activities/renditions/generateVideoRendition.d.ts.map +1 -1
- package/lib/activities/renditions/generateVideoRendition.js +7 -5
- package/lib/activities/renditions/generateVideoRendition.js.map +1 -1
- package/lib/dsl/dsl-workflow.js +3 -0
- package/lib/dsl/dsl-workflow.js.map +1 -1
- package/lib/workflows-bundle.js +16277 -6846
- package/package.json +9 -9
- package/src/activities/generateDocumentProperties.test.ts +642 -0
- package/src/activities/generateDocumentProperties.ts +187 -43
- package/src/activities/generateOrAssignContentType.test.ts +196 -0
- package/src/activities/generateOrAssignContentType.ts +115 -30
- package/src/activities/media/exec.test.ts +194 -0
- package/src/activities/media/exec.ts +265 -0
- package/src/activities/media/prepareAudio.ts +12 -7
- package/src/activities/media/prepareVideo.test.ts +27 -0
- package/src/activities/media/prepareVideo.ts +54 -18
- package/src/activities/media/probeMediaStreams.test.ts +7 -8
- package/src/activities/media/probeMediaStreams.ts +11 -7
- package/src/activities/renditions/generateVideoRendition.ts +12 -6
- package/src/dsl/dsl-workflow.test.ts +4 -0
- package/src/dsl/dsl-workflow.ts +3 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { type ExecFileOptions, execFile as execFileCallback, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { Context } from '@temporalio/activity';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFileCallback);
|
|
6
|
+
|
|
7
|
+
/** 10MB buffer for ffmpeg output. */
|
|
8
|
+
export const FFMPEG_MAX_BUFFER = 1024 * 1024 * 10;
|
|
9
|
+
|
|
10
|
+
const MAX_COMMAND_TIMEOUT_MARGIN_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
/** Terminate an ffmpeg child that makes no encoding progress for this long, so Temporal can retry it promptly. */
|
|
13
|
+
const FFMPEG_STALL_TIMEOUT_MS = 120_000;
|
|
14
|
+
|
|
15
|
+
// ffmpeg streams encoding progress as `frame=`/`time=` stats. We track the *advance* of these monotonic fields (rather
|
|
16
|
+
// than mere presence) so a wedged encoder that keeps re-emitting an identical status line is still detected as stalled.
|
|
17
|
+
const FFMPEG_TIME_PATTERN = /time=\s*(\d+):([0-5]?\d):([0-5]?\d(?:\.\d+)?)/;
|
|
18
|
+
const FFMPEG_FRAME_PATTERN = /frame=\s*(\d+)/;
|
|
19
|
+
// ffmpeg delimits stats snapshots with a carriage return, so the carry buffer normally stays tiny. These bounds are a
|
|
20
|
+
// defensive guard against an unterminated stream, retaining enough of a tail to reunite a marker split at the boundary.
|
|
21
|
+
const PROGRESS_CARRY_LIMIT = 64 * 1024;
|
|
22
|
+
const PROGRESS_CARRY_TAIL = 512;
|
|
23
|
+
|
|
24
|
+
const activityCommandDeadlines = new WeakMap<Context, number>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Anchor the child-process deadline at the moment the Activity starts, so every later command derives its timeout from a
|
|
28
|
+
* stable point regardless of how long input download took.
|
|
29
|
+
*/
|
|
30
|
+
export function initializeActivityCommandDeadline(): number {
|
|
31
|
+
const context = Context.current();
|
|
32
|
+
const existingDeadlineMs = activityCommandDeadlines.get(context);
|
|
33
|
+
if (existingDeadlineMs) {
|
|
34
|
+
return existingDeadlineMs;
|
|
35
|
+
}
|
|
36
|
+
const deadlineMs = Date.now() + context.info.startToCloseTimeoutMs;
|
|
37
|
+
activityCommandDeadlines.set(context, deadlineMs);
|
|
38
|
+
return deadlineMs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getActivityCommandTimeoutMs(): number {
|
|
42
|
+
const context = Context.current();
|
|
43
|
+
const { startToCloseTimeoutMs } = context.info;
|
|
44
|
+
const timeoutMarginMs = Math.min(MAX_COMMAND_TIMEOUT_MARGIN_MS, startToCloseTimeoutMs / 10);
|
|
45
|
+
const activityDeadlineMs = initializeActivityCommandDeadline();
|
|
46
|
+
return Math.max(1, activityDeadlineMs - Date.now() - timeoutMarginMs);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Execute media tooling with Temporal cancellation propagated to the child process.
|
|
51
|
+
*
|
|
52
|
+
* Temporal can begin a retry after an attempt times out while the original worker process is still running. Passing the
|
|
53
|
+
* Activity cancellation signal prevents an orphaned ffmpeg process from competing with its retry for CPU.
|
|
54
|
+
*/
|
|
55
|
+
export async function execActivityFile(
|
|
56
|
+
command: string,
|
|
57
|
+
args: string[],
|
|
58
|
+
options: ExecFileOptions = {},
|
|
59
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
60
|
+
const activityTimeoutMs = getActivityCommandTimeoutMs();
|
|
61
|
+
const configuredTimeoutMs = options.timeout && options.timeout > 0 ? options.timeout : activityTimeoutMs;
|
|
62
|
+
|
|
63
|
+
return (await execFileAsync(command, args, {
|
|
64
|
+
...options,
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
signal: Context.current().cancellationSignal,
|
|
67
|
+
timeout: Math.min(configuredTimeoutMs, activityTimeoutMs),
|
|
68
|
+
})) as { stdout: string; stderr: string };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ProgressAwareOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Coarse upper bound on retained stdout/stderr, counted in UTF-16 code units (not UTF-8 bytes). ffmpeg output is
|
|
74
|
+
* ASCII-dominant so this closely tracks bytes; it only exists to keep captured output from growing without bound
|
|
75
|
+
* (older output is dropped — tail retained — rather than failing the command). Not a strict byte cap.
|
|
76
|
+
*/
|
|
77
|
+
maxBuffer?: number;
|
|
78
|
+
stallTimeoutMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFfmpegTimeCentis(text: string): number | undefined {
|
|
82
|
+
const match = FFMPEG_TIME_PATTERN.exec(text);
|
|
83
|
+
if (!match) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const totalSeconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
|
|
87
|
+
return Number.isFinite(totalSeconds) ? Math.round(totalSeconds * 100) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseFfmpegFrame(text: string): number | undefined {
|
|
91
|
+
const match = FFMPEG_FRAME_PATTERN.exec(text);
|
|
92
|
+
return match ? Number(match[1]) : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ProgressTracker {
|
|
96
|
+
/** Feed a raw output chunk; returns true when a monotonic progress field advanced past its previous maximum. */
|
|
97
|
+
observe(chunk: string): boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Track ffmpeg forward progress across arbitrarily-chunked output.
|
|
102
|
+
*
|
|
103
|
+
* Node delivers stream data in arbitrary slices, so a marker such as `frame=42` can arrive split across two chunks. The
|
|
104
|
+
* tracker buffers partial records until a `\r`/`\n` delimiter completes them, then reports advancement only when the
|
|
105
|
+
* parsed frame count or media timestamp exceeds the previous maximum — repeated identical stats from a wedged encoder do
|
|
106
|
+
* not count as progress.
|
|
107
|
+
*/
|
|
108
|
+
export function createFfmpegProgressTracker(): ProgressTracker {
|
|
109
|
+
let carry = '';
|
|
110
|
+
let maxTimeCentis = -1;
|
|
111
|
+
let maxFrame = -1;
|
|
112
|
+
|
|
113
|
+
const noteSegment = (segment: string): boolean => {
|
|
114
|
+
let advanced = false;
|
|
115
|
+
const timeCentis = parseFfmpegTimeCentis(segment);
|
|
116
|
+
if (timeCentis !== undefined && timeCentis > maxTimeCentis) {
|
|
117
|
+
maxTimeCentis = timeCentis;
|
|
118
|
+
advanced = true;
|
|
119
|
+
}
|
|
120
|
+
const frame = parseFfmpegFrame(segment);
|
|
121
|
+
if (frame !== undefined && frame > maxFrame) {
|
|
122
|
+
maxFrame = frame;
|
|
123
|
+
advanced = true;
|
|
124
|
+
}
|
|
125
|
+
return advanced;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
observe(chunk: string): boolean {
|
|
130
|
+
carry += chunk;
|
|
131
|
+
const segments = carry.split(/[\r\n]+/);
|
|
132
|
+
carry = segments.pop() ?? '';
|
|
133
|
+
let advanced = false;
|
|
134
|
+
for (const segment of segments) {
|
|
135
|
+
advanced = noteSegment(segment) || advanced;
|
|
136
|
+
}
|
|
137
|
+
if (carry.length > PROGRESS_CARRY_LIMIT) {
|
|
138
|
+
advanced = noteSegment(carry) || advanced;
|
|
139
|
+
carry = carry.slice(carry.length - PROGRESS_CARRY_TAIL);
|
|
140
|
+
}
|
|
141
|
+
return advanced;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Execute a long-running media command while enforcing a progress-stall watchdog on top of the Activity deadline and
|
|
148
|
+
* cancellation.
|
|
149
|
+
*
|
|
150
|
+
* The worker heartbeats every Activity on a fixed interval, so a wedged ffmpeg child that stops emitting progress would
|
|
151
|
+
* otherwise keep the Activity alive until its multi-hour Start-to-Close timeout. Watching ffmpeg's progress markers lets
|
|
152
|
+
* us terminate a stalled encode within `stallTimeoutMs` so Temporal retries it promptly (escalating to a faster preset
|
|
153
|
+
* on the next attempt). A stall is surfaced as a `killed` error so it propagates as an Activity failure via
|
|
154
|
+
* {@link rethrowIfActivityStopped} instead of being swallowed into a missing rendition.
|
|
155
|
+
*/
|
|
156
|
+
export async function execActivityFileWithProgress(
|
|
157
|
+
command: string,
|
|
158
|
+
args: string[],
|
|
159
|
+
{ maxBuffer = FFMPEG_MAX_BUFFER, stallTimeoutMs = FFMPEG_STALL_TIMEOUT_MS }: ProgressAwareOptions = {},
|
|
160
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
161
|
+
const activityTimeoutMs = getActivityCommandTimeoutMs();
|
|
162
|
+
const stallWindowMs = Math.max(1, Math.min(stallTimeoutMs, activityTimeoutMs));
|
|
163
|
+
|
|
164
|
+
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
165
|
+
const child = spawn(command, args, {
|
|
166
|
+
signal: Context.current().cancellationSignal,
|
|
167
|
+
timeout: activityTimeoutMs,
|
|
168
|
+
killSignal: 'SIGKILL',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
let stdout = '';
|
|
172
|
+
let stderr = '';
|
|
173
|
+
let settled = false;
|
|
174
|
+
let stalled = false;
|
|
175
|
+
let stallTimer: NodeJS.Timeout | undefined;
|
|
176
|
+
|
|
177
|
+
const armStallTimer = () => {
|
|
178
|
+
if (stallTimer) {
|
|
179
|
+
clearTimeout(stallTimer);
|
|
180
|
+
}
|
|
181
|
+
stallTimer = setTimeout(() => {
|
|
182
|
+
stalled = true;
|
|
183
|
+
child.kill('SIGKILL');
|
|
184
|
+
}, stallWindowMs);
|
|
185
|
+
// Never keep the event loop alive solely for the watchdog timer.
|
|
186
|
+
stallTimer.unref?.();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const append = (current: string, chunk: string): string => {
|
|
190
|
+
const combined = current + chunk;
|
|
191
|
+
return combined.length > maxBuffer ? combined.slice(combined.length - maxBuffer) : combined;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ffmpeg writes progress to stderr by default, but track both streams so the watchdog works regardless of where
|
|
195
|
+
// a given command reports it. Each stream keeps its own line buffer.
|
|
196
|
+
const stdoutProgress = createFfmpegProgressTracker();
|
|
197
|
+
const stderrProgress = createFfmpegProgressTracker();
|
|
198
|
+
|
|
199
|
+
child.stdout?.setEncoding('utf8');
|
|
200
|
+
child.stderr?.setEncoding('utf8');
|
|
201
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
202
|
+
stdout = append(stdout, chunk);
|
|
203
|
+
if (stdoutProgress.observe(chunk)) {
|
|
204
|
+
armStallTimer();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
208
|
+
stderr = append(stderr, chunk);
|
|
209
|
+
if (stderrProgress.observe(chunk)) {
|
|
210
|
+
armStallTimer();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
armStallTimer();
|
|
215
|
+
|
|
216
|
+
const settle = (finish: () => void) => {
|
|
217
|
+
if (settled) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
settled = true;
|
|
221
|
+
if (stallTimer) {
|
|
222
|
+
clearTimeout(stallTimer);
|
|
223
|
+
}
|
|
224
|
+
finish();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
child.on('error', (error) => settle(() => reject(error)));
|
|
228
|
+
|
|
229
|
+
child.on('close', (code, signal) =>
|
|
230
|
+
settle(() => {
|
|
231
|
+
if (stalled) {
|
|
232
|
+
const seconds = Math.round(stallWindowMs / 1000);
|
|
233
|
+
reject(
|
|
234
|
+
Object.assign(new Error(`${command} made no progress for ${seconds}s and was terminated`), {
|
|
235
|
+
killed: true,
|
|
236
|
+
stalled: true,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
} else if (code === 0) {
|
|
240
|
+
resolve({ stdout, stderr });
|
|
241
|
+
} else {
|
|
242
|
+
const detail = signal ? `signal ${signal}` : `code ${code}`;
|
|
243
|
+
reject(
|
|
244
|
+
Object.assign(new Error(`${command} exited with ${detail}: ${stderr.slice(-2000)}`), {
|
|
245
|
+
killed: child.killed || signal != null,
|
|
246
|
+
code,
|
|
247
|
+
signal,
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Re-throw errors that represent the Activity being stopped (cancellation, Start-to-Close timeout, or a progress-stall
|
|
258
|
+
* kill) so they are not swallowed by rendition fallback handling. Genuine tool failures still return `null` upstream.
|
|
259
|
+
*/
|
|
260
|
+
export function rethrowIfActivityStopped(error: unknown): void {
|
|
261
|
+
const commandTimedOut = typeof error === 'object' && error !== null && 'killed' in error && error.killed === true;
|
|
262
|
+
if (Context.current().cancellationSignal.aborted || commandTimedOut) {
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import os from 'node:os';
|
|
4
3
|
import path from 'node:path';
|
|
5
|
-
import { promisify } from 'node:util';
|
|
6
4
|
import { log } from '@temporalio/activity';
|
|
7
5
|
import { RequestError } from '@vertesia/api-fetch-client';
|
|
8
6
|
import type { VertesiaClient } from '@vertesia/client';
|
|
@@ -17,12 +15,15 @@ import {
|
|
|
17
15
|
import { setupActivity } from '../../dsl/setup/ActivityContext.js';
|
|
18
16
|
import { DocumentNotFoundError, InvalidContentTypeError } from '../../errors.js';
|
|
19
17
|
import { saveBlobToTempFile } from '../../utils/blobs.js';
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
import {
|
|
19
|
+
execActivityFile,
|
|
20
|
+
execActivityFileWithProgress,
|
|
21
|
+
initializeActivityCommandDeadline,
|
|
22
|
+
rethrowIfActivityStopped,
|
|
23
|
+
} from './exec.js';
|
|
22
24
|
|
|
23
25
|
// Default configuration constants
|
|
24
26
|
const DEFAULT_AUDIO_BITRATE = '128k'; // Default audio bitrate for AAC encoding
|
|
25
|
-
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 10; // 10MB buffer for ffmpeg output
|
|
26
27
|
|
|
27
28
|
export interface PrepareAudioParams {
|
|
28
29
|
audioBitrate?: string; // Audio bitrate for AAC encoding, default '128k'
|
|
@@ -79,7 +80,7 @@ export interface PrepareAudioResult {
|
|
|
79
80
|
async function getAudioMetadata(audioPath: string): Promise<AudioMetadataExtended> {
|
|
80
81
|
try {
|
|
81
82
|
const args = ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', audioPath];
|
|
82
|
-
const { stdout } = await
|
|
83
|
+
const { stdout } = await execActivityFile('ffprobe', args);
|
|
83
84
|
const metadata = JSON.parse(stdout.toString()) as FFProbeOutput;
|
|
84
85
|
|
|
85
86
|
const audioStream = metadata.streams.find((stream) => stream.codec_type === 'audio');
|
|
@@ -96,6 +97,7 @@ async function getAudioMetadata(audioPath: string): Promise<AudioMetadataExtende
|
|
|
96
97
|
|
|
97
98
|
return { duration, codec, bitrate, sampleRate, channels };
|
|
98
99
|
} catch (error) {
|
|
100
|
+
rethrowIfActivityStopped(error);
|
|
99
101
|
log.error(`Failed to get audio metadata: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
100
102
|
throw new Error(`Failed to probe audio metadata: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
101
103
|
}
|
|
@@ -128,7 +130,7 @@ async function generateAudioRendition(
|
|
|
128
130
|
log.info('Generating web audio rendition (AAC M4A)', { command: 'ffmpeg', args: command, audioBitrate });
|
|
129
131
|
|
|
130
132
|
try {
|
|
131
|
-
const { stderr } = await
|
|
133
|
+
const { stderr } = await execActivityFileWithProgress('ffmpeg', command);
|
|
132
134
|
const stderrText = stderr.toString();
|
|
133
135
|
|
|
134
136
|
if (stderrText && !stderrText.includes('frame=')) {
|
|
@@ -145,6 +147,7 @@ async function generateAudioRendition(
|
|
|
145
147
|
return null;
|
|
146
148
|
}
|
|
147
149
|
} catch (error) {
|
|
150
|
+
rethrowIfActivityStopped(error);
|
|
148
151
|
log.error(`Failed to generate audio rendition: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
149
152
|
return null;
|
|
150
153
|
}
|
|
@@ -200,6 +203,7 @@ async function uploadAudioAsRendition(
|
|
|
200
203
|
export async function prepareAudio(
|
|
201
204
|
payload: DSLActivityExecutionPayload<PrepareAudioParams>,
|
|
202
205
|
): Promise<PrepareAudioResult> {
|
|
206
|
+
initializeActivityCommandDeadline();
|
|
203
207
|
const { client, objectId, params } = await setupActivity<PrepareAudioParams>(payload);
|
|
204
208
|
|
|
205
209
|
const audioBitrate = params.audioBitrate ?? DEFAULT_AUDIO_BITRATE;
|
|
@@ -294,6 +298,7 @@ export async function prepareAudio(
|
|
|
294
298
|
status: 'success',
|
|
295
299
|
};
|
|
296
300
|
} catch (error) {
|
|
301
|
+
rethrowIfActivityStopped(error);
|
|
297
302
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
298
303
|
log.error(`Error preparing audio: ${errorMessage}`, { error });
|
|
299
304
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveVideoPreset, shouldTranscodeVideo } from './prepareVideo.js';
|
|
3
|
+
|
|
4
|
+
describe('shouldTranscodeVideo', () => {
|
|
5
|
+
it('transcodes by default', () => {
|
|
6
|
+
expect(shouldTranscodeVideo()).toBe(true);
|
|
7
|
+
expect(shouldTranscodeVideo(false)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('skips transcoding when explicitly requested', () => {
|
|
11
|
+
expect(shouldTranscodeVideo(true)).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('resolveVideoPreset', () => {
|
|
16
|
+
it.each([
|
|
17
|
+
{ preset: 'medium', attempt: 1, expected: 'medium' },
|
|
18
|
+
{ preset: 'medium', attempt: 2, expected: 'fast' },
|
|
19
|
+
{ preset: 'medium', attempt: 3, expected: 'veryfast' },
|
|
20
|
+
{ preset: 'medium', attempt: 5, expected: 'veryfast' },
|
|
21
|
+
{ preset: 'fast', attempt: 1, expected: 'fast' },
|
|
22
|
+
{ preset: 'fast', attempt: 2, expected: 'veryfast' },
|
|
23
|
+
{ preset: 'ultrafast', attempt: 2, expected: 'ultrafast' },
|
|
24
|
+
] as const)('uses $expected for $preset on attempt $attempt', ({ preset, attempt, expected }) => {
|
|
25
|
+
expect(resolveVideoPreset(preset, attempt)).toBe(expected);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import os from 'node:os';
|
|
4
3
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import { ApplicationFailure, log } from '@temporalio/activity';
|
|
4
|
+
import { ApplicationFailure, Context, log } from '@temporalio/activity';
|
|
7
5
|
import { RequestError } from '@vertesia/api-fetch-client';
|
|
8
6
|
import type { VertesiaClient } from '@vertesia/client';
|
|
9
7
|
import {
|
|
@@ -19,8 +17,12 @@ import {
|
|
|
19
17
|
import { setupActivity } from '../../dsl/setup/ActivityContext.js';
|
|
20
18
|
import { DocumentNotFoundError, InvalidContentTypeError } from '../../errors.js';
|
|
21
19
|
import { saveBlobToTempFile } from '../../utils/blobs.js';
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
import {
|
|
21
|
+
execActivityFile,
|
|
22
|
+
execActivityFileWithProgress,
|
|
23
|
+
initializeActivityCommandDeadline,
|
|
24
|
+
rethrowIfActivityStopped,
|
|
25
|
+
} from './exec.js';
|
|
24
26
|
|
|
25
27
|
// Default configuration constants
|
|
26
28
|
const DEFAULT_MAX_RESOLUTION = 1920; // Max resolution for video rendition (produces 1080p)
|
|
@@ -35,7 +37,6 @@ const POSTER_TIMESTAMP_MAX = 2; // Maximum poster timestamp in seconds
|
|
|
35
37
|
const MIN_SCREENSHOT_TIMESTAMP = 1; // Minimum timestamp for screenshots in seconds
|
|
36
38
|
|
|
37
39
|
// FFmpeg configuration constants
|
|
38
|
-
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 10; // 10MB buffer for ffmpeg output
|
|
39
40
|
const VIDEO_CRF = '23'; // Constant Rate Factor for video quality (18-28, lower = better)
|
|
40
41
|
const AUDIO_BITRATE = '128k'; // Audio bitrate for AAC encoding
|
|
41
42
|
const JPEG_QUALITY = '2'; // JPEG quality for screenshots (1-31, lower = better)
|
|
@@ -45,6 +46,24 @@ export interface PrepareVideoParams {
|
|
|
45
46
|
thumbnailSize?: number; // Max size (longest side) for thumbnail image, default 256
|
|
46
47
|
posterSize?: number; // Max size (longest side) for poster image, default 1920
|
|
47
48
|
generateAudio?: boolean; // Generate audio-only rendition (AAC), default true
|
|
49
|
+
videoPreset?: VideoPreset; // FFmpeg encoding preset, default medium
|
|
50
|
+
skipTranscode?: boolean; // Skip the main video re-encode, default false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type VideoPreset = 'medium' | 'fast' | 'veryfast' | 'ultrafast';
|
|
54
|
+
|
|
55
|
+
export function shouldTranscodeVideo(skipTranscode?: boolean): boolean {
|
|
56
|
+
return skipTranscode !== true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveVideoPreset(preset: VideoPreset, attempt: number): VideoPreset {
|
|
60
|
+
if (preset === 'ultrafast') {
|
|
61
|
+
return preset;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const retryPresets: VideoPreset[] = ['medium', 'fast', 'veryfast'];
|
|
65
|
+
const initialPresetIndex = retryPresets.indexOf(preset);
|
|
66
|
+
return retryPresets[Math.min(initialPresetIndex + attempt - 1, retryPresets.length - 1)];
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export interface PrepareVideo extends DSLActivitySpec<PrepareVideoParams> {
|
|
@@ -85,7 +104,7 @@ interface FFProbeOutput {
|
|
|
85
104
|
async function getVideoMetadata(videoPath: string): Promise<VideoMetadataExtended> {
|
|
86
105
|
try {
|
|
87
106
|
const args = ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', videoPath];
|
|
88
|
-
const { stdout } = await
|
|
107
|
+
const { stdout } = await execActivityFile('ffprobe', args);
|
|
89
108
|
const metadata = JSON.parse(stdout.toString()) as FFProbeOutput;
|
|
90
109
|
|
|
91
110
|
const videoStream = metadata.streams.find((stream) => stream.codec_type === 'video');
|
|
@@ -113,6 +132,7 @@ async function getVideoMetadata(videoPath: string): Promise<VideoMetadataExtende
|
|
|
113
132
|
|
|
114
133
|
return { duration, width, height, codec, bitrate, fps, hasAudio };
|
|
115
134
|
} catch (error) {
|
|
135
|
+
rethrowIfActivityStopped(error);
|
|
116
136
|
log.error(`Failed to get video metadata: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
117
137
|
if (error instanceof ApplicationFailure) {
|
|
118
138
|
throw error;
|
|
@@ -184,6 +204,7 @@ async function generateRenditionWithDimensions(
|
|
|
184
204
|
outputDir: string,
|
|
185
205
|
metadata: VideoMetadataExtended,
|
|
186
206
|
maxResolution: number,
|
|
207
|
+
videoPreset: VideoPreset,
|
|
187
208
|
): Promise<MediaResult | null> {
|
|
188
209
|
const outputFile = path.join(outputDir, `rendition_${maxResolution}p.mp4`);
|
|
189
210
|
|
|
@@ -207,7 +228,7 @@ async function generateRenditionWithDimensions(
|
|
|
207
228
|
'-c:v',
|
|
208
229
|
'libx264', // H.264 codec
|
|
209
230
|
'-preset',
|
|
210
|
-
|
|
231
|
+
videoPreset,
|
|
211
232
|
'-crf',
|
|
212
233
|
VIDEO_CRF, // Constant Rate Factor (18-28, lower = better quality)
|
|
213
234
|
'-pix_fmt',
|
|
@@ -226,7 +247,7 @@ async function generateRenditionWithDimensions(
|
|
|
226
247
|
log.info(`Generating ${maxResolution}p video rendition`, { command: 'ffmpeg', args: command });
|
|
227
248
|
|
|
228
249
|
try {
|
|
229
|
-
const { stderr } = await
|
|
250
|
+
const { stderr } = await execActivityFileWithProgress('ffmpeg', command);
|
|
230
251
|
const stderrText = stderr.toString();
|
|
231
252
|
|
|
232
253
|
if (stderrText && !stderrText.includes('frame=')) {
|
|
@@ -247,6 +268,7 @@ async function generateRenditionWithDimensions(
|
|
|
247
268
|
return null;
|
|
248
269
|
}
|
|
249
270
|
} catch (error) {
|
|
271
|
+
rethrowIfActivityStopped(error);
|
|
250
272
|
log.error(`Failed to generate video rendition: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
251
273
|
return null;
|
|
252
274
|
}
|
|
@@ -273,7 +295,7 @@ async function generateAudioRendition(videoPath: string, outputDir: string): Pro
|
|
|
273
295
|
log.info('Generating audio-only rendition', { command: 'ffmpeg', args: command });
|
|
274
296
|
|
|
275
297
|
try {
|
|
276
|
-
const { stderr } = await
|
|
298
|
+
const { stderr } = await execActivityFileWithProgress('ffmpeg', command);
|
|
277
299
|
const stderrText = stderr.toString();
|
|
278
300
|
|
|
279
301
|
if (stderrText && !stderrText.includes('frame=')) {
|
|
@@ -290,6 +312,7 @@ async function generateAudioRendition(videoPath: string, outputDir: string): Pro
|
|
|
290
312
|
return null;
|
|
291
313
|
}
|
|
292
314
|
} catch (error) {
|
|
315
|
+
rethrowIfActivityStopped(error);
|
|
293
316
|
log.error(`Failed to generate audio rendition: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
294
317
|
return null;
|
|
295
318
|
}
|
|
@@ -331,7 +354,7 @@ async function generateScreenshot(
|
|
|
331
354
|
log.info(`Generating ${name} at ${timestamp}s`, { command: 'ffmpeg', args: command });
|
|
332
355
|
|
|
333
356
|
try {
|
|
334
|
-
const { stderr } = await
|
|
357
|
+
const { stderr } = await execActivityFile('ffmpeg', command);
|
|
335
358
|
const stderrText = stderr.toString();
|
|
336
359
|
|
|
337
360
|
if (stderrText && !stderrText.includes('frame=')) {
|
|
@@ -352,6 +375,7 @@ async function generateScreenshot(
|
|
|
352
375
|
return null;
|
|
353
376
|
}
|
|
354
377
|
} catch (error) {
|
|
378
|
+
rethrowIfActivityStopped(error);
|
|
355
379
|
log.error(`Failed to generate ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
356
380
|
return null;
|
|
357
381
|
}
|
|
@@ -412,17 +436,22 @@ async function uploadMediaAsRendition(
|
|
|
412
436
|
export async function prepareVideo(
|
|
413
437
|
payload: DSLActivityExecutionPayload<PrepareVideoParams>,
|
|
414
438
|
): Promise<PrepareVideoResult> {
|
|
439
|
+
initializeActivityCommandDeadline();
|
|
415
440
|
const { client, objectId, params } = await setupActivity<PrepareVideoParams>(payload);
|
|
416
441
|
|
|
417
442
|
const maxResolution = params.maxResolution ?? DEFAULT_MAX_RESOLUTION;
|
|
418
443
|
const thumbnailSize = params.thumbnailSize ?? DEFAULT_THUMBNAIL_SIZE;
|
|
419
444
|
const posterSize = params.posterSize ?? DEFAULT_POSTER_SIZE;
|
|
420
445
|
const generateAudio = params.generateAudio ?? DEFAULT_GENERATE_AUDIO;
|
|
446
|
+
const skipTranscode = params.skipTranscode ?? false;
|
|
447
|
+
const videoPreset = resolveVideoPreset(params.videoPreset ?? 'medium', Context.current().info.attempt);
|
|
421
448
|
|
|
422
449
|
log.info(`Preparing video for ${objectId}`, {
|
|
423
450
|
maxResolution,
|
|
424
451
|
thumbnailSize,
|
|
425
452
|
posterSize,
|
|
453
|
+
skipTranscode,
|
|
454
|
+
videoPreset,
|
|
426
455
|
});
|
|
427
456
|
|
|
428
457
|
// Retrieve the content object
|
|
@@ -454,13 +483,19 @@ export async function prepareVideo(
|
|
|
454
483
|
const metadata = await getVideoMetadata(videoFile);
|
|
455
484
|
|
|
456
485
|
// Step 2: Generate video rendition
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
486
|
+
let renditionResult: MediaResult | null = null;
|
|
487
|
+
if (!shouldTranscodeVideo(skipTranscode)) {
|
|
488
|
+
log.info('Skipping video rendition transcode by configuration');
|
|
489
|
+
} else {
|
|
490
|
+
log.info('Generating video rendition');
|
|
491
|
+
renditionResult = await generateRenditionWithDimensions(
|
|
492
|
+
videoFile,
|
|
493
|
+
tempOutputDir,
|
|
494
|
+
metadata,
|
|
495
|
+
maxResolution,
|
|
496
|
+
videoPreset,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
464
499
|
|
|
465
500
|
// Step 3 & 4: Generate thumbnail and poster in parallel
|
|
466
501
|
log.info('Generating thumbnail and poster');
|
|
@@ -578,6 +613,7 @@ export async function prepareVideo(
|
|
|
578
613
|
status: 'success',
|
|
579
614
|
};
|
|
580
615
|
} catch (error) {
|
|
616
|
+
rethrowIfActivityStopped(error);
|
|
581
617
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
582
618
|
log.error(`Error preparing video: ${errorMessage}`, { error });
|
|
583
619
|
|
|
@@ -3,6 +3,7 @@ import type { VertesiaClient } from '@vertesia/client';
|
|
|
3
3
|
import { ContentEventName, type DSLActivityExecutionPayload } from '@vertesia/common';
|
|
4
4
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import type { ActivityContext } from '../../dsl/setup/ActivityContext.js';
|
|
6
|
+
import { execActivityFile } from './exec.js';
|
|
6
7
|
import { type ProbeMediaStreamsParams, type ProbeMediaStreamsResult, probeMediaStreams } from './probeMediaStreams.js';
|
|
7
8
|
|
|
8
9
|
vi.mock('../../dsl/setup/ActivityContext.js', async (importOriginal) => {
|
|
@@ -10,14 +11,12 @@ vi.mock('../../dsl/setup/ActivityContext.js', async (importOriginal) => {
|
|
|
10
11
|
return { ...actual, setupActivity: vi.fn() };
|
|
11
12
|
});
|
|
12
13
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
return { execMock: mock, execCustom: custom };
|
|
14
|
+
// probeMediaStreams delegates ffprobe to the shared exec helper, so mock that seam and drive the parsed output
|
|
15
|
+
// directly. The helper's cancellation/timeout wrapping is covered by exec.test.ts.
|
|
16
|
+
vi.mock('./exec.js', async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import('./exec.js')>();
|
|
18
|
+
return { ...actual, execActivityFile: vi.fn() };
|
|
19
19
|
});
|
|
20
|
-
vi.mock('child_process', () => ({ exec: execMock }));
|
|
21
20
|
|
|
22
21
|
let testEnv: MockActivityEnvironment;
|
|
23
22
|
|
|
@@ -44,7 +43,7 @@ const createPayload = (objectId = 'test-object-id'): DSLActivityExecutionPayload
|
|
|
44
43
|
});
|
|
45
44
|
|
|
46
45
|
function mockExec(stdout: string) {
|
|
47
|
-
|
|
46
|
+
vi.mocked(execActivityFile).mockResolvedValue({ stdout, stderr: '' });
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
async function setupMockContext(objectId: string, signedUrl: string): Promise<void> {
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import { exec } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
1
|
import { ApplicationFailure, log } from '@temporalio/activity';
|
|
4
2
|
import { RequestError } from '@vertesia/api-fetch-client';
|
|
5
3
|
import type { DSLActivityExecutionPayload, DSLActivitySpec } from '@vertesia/common';
|
|
6
4
|
import { setupActivity } from '../../dsl/setup/ActivityContext.js';
|
|
7
5
|
import { DocumentNotFoundError } from '../../errors.js';
|
|
8
|
-
|
|
9
|
-
const execAsync = promisify(exec);
|
|
6
|
+
import { execActivityFile, initializeActivityCommandDeadline, rethrowIfActivityStopped } from './exec.js';
|
|
10
7
|
|
|
11
8
|
const FFPROBE_MAX_BUFFER = 1024 * 1024; // 1MB is more than enough for stream metadata JSON
|
|
12
9
|
|
|
@@ -32,6 +29,7 @@ interface FFProbeOutput {
|
|
|
32
29
|
export async function probeMediaStreams(
|
|
33
30
|
payload: DSLActivityExecutionPayload<ProbeMediaStreamsParams>,
|
|
34
31
|
): Promise<ProbeMediaStreamsResult> {
|
|
32
|
+
initializeActivityCommandDeadline();
|
|
35
33
|
const { client, objectId } = await setupActivity<ProbeMediaStreamsParams>(payload);
|
|
36
34
|
|
|
37
35
|
const inputObject = await client.objects.retrieve(objectId).catch((err: unknown) => {
|
|
@@ -54,12 +52,18 @@ export async function probeMediaStreams(
|
|
|
54
52
|
|
|
55
53
|
// ffprobe reads only the container headers via HTTP range requests.
|
|
56
54
|
// -probesize 32k caps the amount read from the network to ~32 KB.
|
|
55
|
+
// ffprobe emits no frame=/time= progress, so the stall watchdog does not apply here; the shared exec wrapper still
|
|
56
|
+
// propagates Activity cancellation and bounds the read by the Activity deadline, and passing the URL as an argv
|
|
57
|
+
// entry (not a shell string) removes the shell-interpolation surface.
|
|
57
58
|
let stdout: string;
|
|
58
59
|
try {
|
|
59
|
-
({ stdout } = await
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
({ stdout } = await execActivityFile(
|
|
61
|
+
'ffprobe',
|
|
62
|
+
['-v', 'quiet', '-probesize', '32k', '-print_format', 'json', '-show_streams', url],
|
|
63
|
+
{ maxBuffer: FFPROBE_MAX_BUFFER },
|
|
64
|
+
));
|
|
62
65
|
} catch (err: unknown) {
|
|
66
|
+
rethrowIfActivityStopped(err);
|
|
63
67
|
const message = err instanceof Error ? err.message : String(err);
|
|
64
68
|
log.error(`ffprobe failed for object ${objectId}: ${message}`);
|
|
65
69
|
throw new Error(`Failed to probe media streams for object ${objectId}: ${message}`);
|