Skip to content

Commit 146f01a

Browse files
committed
fix: harden video processing queues and recovery
1 parent d34a470 commit 146f01a

19 files changed

Lines changed: 849 additions & 106 deletions

apps/media-server/src/__tests__/lib/media-probe.integration.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ describe("mediaProbe integration tests", () => {
6767
);
6868
});
6969

70+
test("rejects missing remote media before starting MediaBunny workers", async () => {
71+
const server = Bun.serve({
72+
port: 0,
73+
fetch() {
74+
return new Response("Not found", { status: 404 });
75+
},
76+
});
77+
78+
try {
79+
await expect(probeVideo(`${server.url}missing.mp4`)).rejects.toThrow(
80+
"Media input is not accessible",
81+
);
82+
} finally {
83+
await server.stop(true);
84+
}
85+
});
86+
7087
test("probes media when HEAD is forbidden but GET is allowed", async () => {
7188
const videoData = readFileSync(TEST_VIDEO_WITH_AUDIO_PATH);
7289
const server = Bun.serve({

apps/media-server/src/__tests__/lib/media-video.integration.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { probeVideo } from "../../lib/media-probe";
1515
import {
1616
buildStreamingDownloadFfmpegArgs,
1717
copyFileToMp4,
18+
estimateMaterializedStreamingDurationSeconds,
1819
generatePreviewGif,
1920
generateThumbnail,
2021
getFfmpegHlsCapabilities,
@@ -839,6 +840,42 @@ describe("processVideo integration tests", () => {
839840
});
840841

841842
describe("ffmpeg-backed media utilities integration tests", () => {
843+
test("estimates streaming duration from local manifests without probing remote segments", async () => {
844+
const workDir = mkdtempSync(join(tmpdir(), "cap-manifest-duration-"));
845+
try {
846+
writeFileSync(
847+
join(workDir, "video.m3u8"),
848+
"#EXTM3U\n#EXTINF:10.5,\nvideo-1.ts\n#EXTINF:8.25,\nvideo-2.ts\n",
849+
);
850+
writeFileSync(
851+
join(workDir, "audio.m3u8"),
852+
"#EXTM3U\n#EXTINF:7.0,\naudio-1.ts\n#EXTINF:8.0,\naudio-2.ts\n",
853+
);
854+
855+
expect(await estimateMaterializedStreamingDurationSeconds(workDir)).toBe(
856+
18.75,
857+
);
858+
} finally {
859+
rmSync(workDir, { recursive: true, force: true });
860+
}
861+
});
862+
863+
test("estimates streaming duration from a DASH presentation attribute", async () => {
864+
const workDir = mkdtempSync(join(tmpdir(), "cap-mpd-duration-"));
865+
try {
866+
writeFileSync(
867+
join(workDir, "video.mpd"),
868+
'<MPD mediaPresentationDuration="PT1M30.5S"></MPD>',
869+
);
870+
871+
expect(await estimateMaterializedStreamingDurationSeconds(workDir)).toBe(
872+
90.5,
873+
);
874+
} finally {
875+
rmSync(workDir, { recursive: true, force: true });
876+
}
877+
});
878+
842879
test("uses only legacy HLS options when newer FFmpeg options are unavailable", () => {
843880
const capabilities = parseFfmpegHlsCapabilities(`
844881
-allowed_extensions <string>

apps/media-server/src/__tests__/routes/video.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,37 @@ describe("POST /video/process", () => {
472472
expect(data.code).toBe("SERVER_BUSY");
473473
});
474474

475+
test("reserves one processing slot for normal-priority recordings", async () => {
476+
const resources = jobManager.getSystemResources();
477+
mock.module("../../lib/job-manager", () => ({
478+
...jobManager,
479+
canAcceptNewVideoProcess: () => true,
480+
getActiveVideoProcessCount: () => 3,
481+
getMaxConcurrentVideoProcesses: () => 4,
482+
getSystemResources: () => ({
483+
...resources,
484+
effectiveMax: 4,
485+
}),
486+
}));
487+
488+
const { default: appWithMock } = await import("../../app");
489+
const response = await appWithMock.fetch(
490+
videoPostRequest("/video/process", {
491+
videoId: "bulk-video",
492+
userId: "user-id",
493+
videoUrl: "https://example.com/video.mp4",
494+
outputPresignedUrl: "https://s3.example.com/output",
495+
priority: "bulk",
496+
}),
497+
);
498+
499+
expect(response.status).toBe(503);
500+
expect(response.headers.get("Retry-After")).toBe("15");
501+
const data = await response.json();
502+
expect(data.code).toBe("SERVER_BUSY");
503+
expect(data.activeVideoProcesses).toBe(3);
504+
});
505+
475506
test("returns jobId when process starts successfully", async () => {
476507
mock.module("../../lib/job-manager", () => ({
477508
canAcceptNewVideoProcess: () => true,

apps/media-server/src/lib/media-probe.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ function isHttpUrl(path: string): boolean {
4747

4848
async function hasHttpNetworkFailure(path: string): Promise<boolean> {
4949
try {
50-
await probeFetch(path, {
51-
method: "HEAD",
50+
const response = await probeFetch(path, {
51+
headers: { Range: "bytes=0-0" },
5252
signal: AbortSignal.timeout(10_000),
5353
});
54-
return false;
54+
await response.body?.cancel();
55+
return !response.ok;
5556
} catch {
5657
return true;
5758
}

apps/media-server/src/lib/media-video.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { join } from "node:path";
44
import { type BunFile, file, spawn } from "bun";
55
import type { VideoMetadata } from "./job-manager";
66
import {
7-
createMediaInput,
87
DOWNLOAD_TIMEOUT_MS,
98
PROCESS_TIMEOUT_MS,
109
type ProgressCallback,
@@ -28,7 +27,6 @@ const MAX_PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000;
2827
// long (e.g. 60+ minute) manifest with 1000+ segments.
2928
const STREAMING_DOWNLOAD_TIMEOUT_PER_SECOND_MS = 5_000;
3029
const MAX_STREAMING_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
31-
const STREAMING_DURATION_PROBE_TIMEOUT_MS = 15_000;
3230
const THUMBNAIL_TIMEOUT_MS = 60_000;
3331
const PREVIEW_GIF_TIMEOUT_MS = 30_000;
3432
const PROBE_H264_LEVEL_TIMEOUT_MS = 10_000;
@@ -1117,24 +1115,35 @@ export function buildStreamingDownloadFfmpegArgs(
11171115
];
11181116
}
11191117

1120-
async function probeStreamingDurationSeconds(
1121-
videoUrl: string,
1118+
export async function estimateMaterializedStreamingDurationSeconds(
1119+
dirPath: string,
11221120
): Promise<number | null> {
1123-
try {
1124-
const input = createMediaInput(videoUrl);
1125-
try {
1126-
const duration = await withTimeout(
1127-
input.computeDuration(),
1128-
STREAMING_DURATION_PROBE_TIMEOUT_MS,
1129-
);
1130-
return Number.isFinite(duration) && duration > 0 ? duration : null;
1131-
} finally {
1132-
input.dispose();
1121+
let longestDuration = 0;
1122+
1123+
for (const entry of await readdir(dirPath)) {
1124+
if (!entry.endsWith(".m3u8") && !entry.endsWith(".mpd")) continue;
1125+
const content = await file(join(dirPath, entry)).text();
1126+
1127+
if (entry.endsWith(".mpd")) {
1128+
const durationAttribute = content.match(
1129+
/\bmediaPresentationDuration\s*=\s*["']([^"']+)["']/i,
1130+
)?.[1];
1131+
const duration = parseIsoDurationSeconds(durationAttribute);
1132+
if (duration) longestDuration = Math.max(longestDuration, duration);
1133+
continue;
11331134
}
1134-
} catch {
1135-
// Best-effort: fall back to the flat DOWNLOAD_TIMEOUT_MS budget below.
1136-
return null;
1135+
1136+
let playlistDuration = 0;
1137+
for (const match of content.matchAll(/^#EXTINF:([\d.]+)/gm)) {
1138+
const segmentDuration = Number(match[1]);
1139+
if (Number.isFinite(segmentDuration) && segmentDuration > 0) {
1140+
playlistDuration += segmentDuration;
1141+
}
1142+
}
1143+
longestDuration = Math.max(longestDuration, playlistDuration);
11371144
}
1145+
1146+
return longestDuration > 0 ? longestDuration : null;
11381147
}
11391148

11401149
function getStreamingDownloadTimeoutMs(durationSeconds: number | null): number {
@@ -1168,7 +1177,8 @@ async function downloadStreamingVideoToTemp(
11681177
manifestDir,
11691178
abortSignal,
11701179
);
1171-
const durationSeconds = await probeStreamingDurationSeconds(videoUrl);
1180+
const durationSeconds =
1181+
await estimateMaterializedStreamingDurationSeconds(manifestDir);
11721182
const downloadTimeoutMs = getStreamingDownloadTimeoutMs(durationSeconds);
11731183

11741184
await runFfmpegCommand(

apps/media-server/src/routes/video.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const processSchema = z.object({
8989
webhookUrl: z.string().url().optional(),
9090
webhookSecret: z.string().optional(),
9191
inputExtension: z.string().optional(),
92+
priority: z.enum(["normal", "bulk"]).optional(),
9293
maxWidth: z.number().max(4096).optional(),
9394
maxHeight: z.number().max(4096).optional(),
9495
crf: z.number().min(0).max(51).optional(),
@@ -652,9 +653,14 @@ video.post("/process", async (c) => {
652653
);
653654
}
654655

655-
if (!canAcceptNewVideoProcess()) {
656+
const capacity = getVideoCapacitySnapshot();
657+
const bulkCapacityReached =
658+
result.data.priority === "bulk" &&
659+
capacity.effectiveMaxVideoProcesses > 1 &&
660+
capacity.activeVideoProcesses >= capacity.effectiveMaxVideoProcesses - 1;
661+
if (bulkCapacityReached || !canAcceptNewVideoProcess()) {
656662
c.header("Retry-After", VIDEO_BUSY_RETRY_AFTER_SECONDS.toString());
657-
return c.json(getBusyResponseBody(getVideoCapacitySnapshot()), 503);
663+
return c.json(getBusyResponseBody(capacity), 503);
658664
}
659665

660666
const {
@@ -1088,10 +1094,8 @@ async function editVideoAsync(
10881094
await sendWebhook(downloadingJob);
10891095
}
10901096

1091-
const inputTempFile = await downloadVideoToTemp(
1092-
sourceUrl,
1093-
".mp4",
1094-
abortController.signal,
1097+
const inputTempFile = await withJobHeartbeat(jobId, () =>
1098+
downloadVideoToTemp(sourceUrl, ".mp4", abortController.signal),
10951099
);
10961100
updateJob(jobId, { inputTempFile });
10971101

@@ -1254,10 +1258,12 @@ async function processVideoAsync(
12541258
});
12551259
await sendWebhook(job);
12561260

1257-
const inputTempFile = await downloadVideoToTemp(
1258-
videoUrl,
1259-
options.inputExtension,
1260-
abortController.signal,
1261+
const inputTempFile = await withJobHeartbeat(jobId, () =>
1262+
downloadVideoToTemp(
1263+
videoUrl,
1264+
options.inputExtension,
1265+
abortController.signal,
1266+
),
12611267
);
12621268
updateJob(jobId, { inputTempFile });
12631269

apps/web/__tests__/integration/transcribe-workflow.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,53 @@ describe("transcribeVideoWorkflow", () => {
245245
expect(mocks.updates.at(-1)).toEqual({ transcriptionStatus: "COMPLETE" });
246246
});
247247

248+
it("marks audio without speech as skipped without retrying transcription", async () => {
249+
mocks.transcribe.mockResolvedValueOnce({
250+
id: "silent-transcript",
251+
status: "error",
252+
error:
253+
"language_detection cannot be performed on files with no spoken audio.",
254+
});
255+
256+
const { transcribeVideoWorkflow } = await import("@/workflows/transcribe");
257+
const result = await transcribeVideoWorkflow({
258+
videoId: "video-123",
259+
userId: "user-456",
260+
aiGenerationEnabled: true,
261+
});
262+
263+
expect(result).toEqual({
264+
success: true,
265+
message: "Video has no spoken audio - skipped transcription",
266+
});
267+
expect(mocks.transcribe).toHaveBeenCalledTimes(1);
268+
expect(mocks.updates).toContainEqual({ transcriptionStatus: "NO_AUDIO" });
269+
expect(mocks.updates).not.toContainEqual({ transcriptionStatus: "ERROR" });
270+
expect(mocks.startAiGeneration).not.toHaveBeenCalled();
271+
});
272+
273+
it("preserves transcription failures unrelated to missing speech", async () => {
274+
mocks.transcribe.mockResolvedValueOnce({
275+
id: "failed-transcript",
276+
status: "error",
277+
error: "Audio could not be decoded",
278+
});
279+
280+
const { transcribeVideoWorkflow } = await import("@/workflows/transcribe");
281+
282+
await expect(
283+
transcribeVideoWorkflow({
284+
videoId: "video-123",
285+
userId: "user-456",
286+
aiGenerationEnabled: false,
287+
}),
288+
).rejects.toThrow("Audio could not be decoded");
289+
expect(mocks.updates).toContainEqual({ transcriptionStatus: "ERROR" });
290+
expect(mocks.updates).not.toContainEqual({
291+
transcriptionStatus: "NO_AUDIO",
292+
});
293+
});
294+
248295
it("never overwrites the original-timeline transcript of an edited video", async () => {
249296
state.editRows = [
250297
{
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { RetryableError } from "workflow";
3+
import {
4+
createMediaServerCapacityError,
5+
isMediaServerCapacityError,
6+
} from "@/lib/media-server-backpressure";
7+
8+
afterEach(() => {
9+
vi.useRealTimers();
10+
});
11+
12+
describe("media server backpressure", () => {
13+
it("recognizes durable workflow errors caused by exhausted media capacity", () => {
14+
expect(isMediaServerCapacityError(new Error("Server is busy"))).toBe(true);
15+
expect(isMediaServerCapacityError("SERVER_BUSY: at capacity")).toBe(true);
16+
expect(isMediaServerCapacityError(new Error("Invalid video source"))).toBe(
17+
false,
18+
);
19+
});
20+
21+
it("schedules durable retries after the server's requested delay", () => {
22+
vi.useFakeTimers();
23+
vi.setSystemTime(new Date("2026-08-25T12:00:00.000Z"));
24+
const error = createMediaServerCapacityError({
25+
response: new Response(null, {
26+
status: 503,
27+
headers: { "Retry-After": "15" },
28+
}),
29+
message: "Server is busy",
30+
videoId: "video-1",
31+
});
32+
33+
expect(error).toBeInstanceOf(RetryableError);
34+
const retryDelay = new Date(error.retryAfter).getTime() - Date.now();
35+
expect(retryDelay).toBeGreaterThanOrEqual(15_000);
36+
expect(retryDelay).toBeLessThan(35_000);
37+
});
38+
39+
it("gives ordinary recordings priority over bulk imports", () => {
40+
vi.useFakeTimers();
41+
vi.setSystemTime(new Date("2026-08-25T12:00:00.000Z"));
42+
const response = new Response(null, {
43+
status: 503,
44+
headers: { "Retry-After": "15" },
45+
});
46+
const recording = createMediaServerCapacityError({
47+
response,
48+
message: "Server is busy",
49+
videoId: "video-1",
50+
});
51+
const bulk = createMediaServerCapacityError({
52+
response,
53+
message: "Server is busy",
54+
videoId: "video-1",
55+
priority: "bulk",
56+
});
57+
58+
expect(
59+
new Date(bulk.retryAfter).getTime() -
60+
new Date(recording.retryAfter).getTime(),
61+
).toBe(15_000);
62+
});
63+
});

0 commit comments

Comments
 (0)