Skip to content

Commit e3a23fd

Browse files
committed
fix: preserve playback through media processing failures
1 parent c5f22d7 commit e3a23fd

7 files changed

Lines changed: 344 additions & 10 deletions

File tree

.github/workflows/recording-reliability.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,7 @@ jobs:
4444
__tests__/unit/desktop-recording-*.test.ts \
4545
__tests__/unit/desktop-segments-*.test.ts \
4646
__tests__/unit/finalize-desktop-recording.test.ts \
47-
__tests__/unit/media-server-progress.test.ts
47+
__tests__/unit/media-server-progress.test.ts \
48+
__tests__/unit/media-processing-budget.test.ts \
49+
__tests__/unit/playback-source.test.ts \
50+
__tests__/unit/upload-progress-playback.test.ts

apps/media-server/src/__tests__/lib/media-routes-real-world.integration.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type appType from "../../app";
1919
import * as containerCpu from "../../lib/container-cpu";
2020
import * as containerMemory from "../../lib/container-memory";
2121
import type { Job, JobProgress } from "../../lib/job-manager";
22+
import { withTimeout } from "../../lib/media-common";
2223
import { probeVideoFile } from "../../lib/media-probe";
2324
import * as mediaVideo from "../../lib/media-video";
2425
import * as recordingVerification from "../../lib/recording-verification";
@@ -40,6 +41,9 @@ let baseUrl = "";
4041
let tempDir = "";
4142

4243
const uploadedArtifacts = new Map<string, Uint8Array>();
44+
const uploadFailures = new Map<string, number>();
45+
const uploadRequests: string[] = [];
46+
const fixtureReads: string[] = [];
4347
const recordingSources = new Map<string, Uint8Array>();
4448
const sourceReads: {
4549
path: string;
@@ -364,6 +368,7 @@ beforeAll(async () => {
364368
: null;
365369

366370
if (fixturePath) {
371+
if (request.method === "GET") fixtureReads.push(url.pathname);
367372
const fixture = Bun.file(fixturePath);
368373
const headers = {
369374
"Content-Type": "video/mp4",
@@ -408,6 +413,11 @@ beforeAll(async () => {
408413
}
409414

410415
if (request.method === "PUT" && url.pathname.startsWith("/uploads/")) {
416+
uploadRequests.push(url.pathname);
417+
const failureStatus = uploadFailures.get(url.pathname);
418+
if (failureStatus) {
419+
return new Response("Storage unavailable", { status: failureStatus });
420+
}
411421
uploadConditions.push(request.headers.get("if-none-match"));
412422
if (
413423
request.headers.get("if-none-match") === "*" &&
@@ -451,6 +461,9 @@ beforeEach(() => {
451461
pressure: 0.0625,
452462
});
453463
uploadedArtifacts.clear();
464+
uploadFailures.clear();
465+
uploadRequests.length = 0;
466+
fixtureReads.length = 0;
454467
transientFixtureFailures = 0;
455468
permanentFixtureFailures = 0;
456469
slowFixtureCancellations = 0;
@@ -950,6 +963,128 @@ describe("media routes real-world integration tests", () => {
950963
}
951964
}, 90000);
952965

966+
test.each([403, 503])(
967+
"keeps a playable processed video when thumbnail storage returns %s",
968+
async (status) => {
969+
uploadFailures.set("/uploads/optional-thumbnail.jpg", status);
970+
const response = await app.fetch(
971+
mediaPostRequest("/video/process", {
972+
videoId: "optional-thumbnail",
973+
userId: "real-process-user",
974+
videoUrl: fixtureUrl(),
975+
outputPresignedUrl: uploadUrl("optional-thumbnail.mp4"),
976+
thumbnailPresignedUrl: uploadUrl("optional-thumbnail.jpg"),
977+
inputExtension: ".mp4",
978+
}),
979+
);
980+
expect(response.status).toBe(200);
981+
const { jobId } = (await response.json()) as { jobId: string };
982+
try {
983+
const job = await waitForTerminalJob(jobId);
984+
expect(job.phase).toBe("complete");
985+
expect(job.error).toBeUndefined();
986+
expect(fixtureReads).toEqual(["/fixtures/test-with-audio.mp4"]);
987+
expect(uploadRequests.filter((path) => path.endsWith(".mp4"))).toEqual([
988+
"/uploads/optional-thumbnail.mp4",
989+
]);
990+
expect(
991+
uploadRequests.filter((path) => path.endsWith(".jpg")),
992+
).toHaveLength(status === 403 ? 1 : 5);
993+
const output = join(tempDir, `optional-thumbnail-${status}.mp4`);
994+
await writeFile(
995+
output,
996+
uploadedBytes("/uploads/optional-thumbnail.mp4"),
997+
);
998+
execFileSync("ffmpeg", [
999+
"-v",
1000+
"error",
1001+
"-xerror",
1002+
"-i",
1003+
output,
1004+
"-f",
1005+
"null",
1006+
"-",
1007+
]);
1008+
const metadata = await probeVideoFile(output);
1009+
expect(metadata.videoCodec).toBe("h264");
1010+
expect(metadata.audioCodec).toBe("aac");
1011+
} finally {
1012+
deleteJob(jobId);
1013+
}
1014+
},
1015+
30_000,
1016+
);
1017+
1018+
test("still fails processing when the video output cannot be uploaded", async () => {
1019+
uploadFailures.set("/uploads/failed-output.mp4", 403);
1020+
const response = await app.fetch(
1021+
mediaPostRequest("/video/process", {
1022+
videoId: "failed-output",
1023+
userId: "real-process-user",
1024+
videoUrl: fixtureUrl(),
1025+
outputPresignedUrl: uploadUrl("failed-output.mp4"),
1026+
thumbnailPresignedUrl: uploadUrl("failed-output.jpg"),
1027+
inputExtension: ".mp4",
1028+
}),
1029+
);
1030+
expect(response.status).toBe(200);
1031+
const { jobId } = (await response.json()) as { jobId: string };
1032+
try {
1033+
const job = await waitForTerminalJob(jobId);
1034+
expect(job.phase).toBe("error");
1035+
expect(job.error).toContain("403");
1036+
expect(uploadRequests).toEqual(["/uploads/failed-output.mp4"]);
1037+
expect(uploadedArtifacts.size).toBe(0);
1038+
} finally {
1039+
deleteJob(jobId);
1040+
}
1041+
}, 30_000);
1042+
1043+
test("does not complete a cancelled job during thumbnail upload", async () => {
1044+
let started: (() => void) | undefined;
1045+
const ready = new Promise<void>((resolve) => {
1046+
started = resolve;
1047+
});
1048+
const upload = spyOn(mediaVideo, "uploadToS3").mockImplementation(
1049+
async (_data, _url, _contentType, signal) => {
1050+
if (!signal) throw new Error("Missing thumbnail upload signal");
1051+
await new Promise<void>((_resolve, reject) => {
1052+
signal.addEventListener("abort", () => reject(signal.reason), {
1053+
once: true,
1054+
});
1055+
started?.();
1056+
});
1057+
},
1058+
);
1059+
let jobId: string | undefined;
1060+
try {
1061+
const response = await app.fetch(
1062+
mediaPostRequest("/video/process", {
1063+
videoId: "cancelled-thumbnail",
1064+
userId: "real-process-user",
1065+
videoUrl: fixtureUrl(),
1066+
outputPresignedUrl: uploadUrl("cancelled-thumbnail.mp4"),
1067+
thumbnailPresignedUrl: uploadUrl("cancelled-thumbnail.jpg"),
1068+
inputExtension: ".mp4",
1069+
}),
1070+
);
1071+
expect(response.status).toBe(200);
1072+
jobId = ((await response.json()) as { jobId: string }).jobId;
1073+
await withTimeout(ready, 10_000);
1074+
getJob(jobId)?.abortController?.abort(new Error("Worker cancelled"));
1075+
const job = await waitForTerminalJob(jobId);
1076+
expect(job.phase).toBe("error");
1077+
expect(job.error).toBe("Worker cancelled");
1078+
expect(upload).toHaveBeenCalledTimes(1);
1079+
} finally {
1080+
if (jobId) {
1081+
getJob(jobId)?.abortController?.abort();
1082+
deleteJob(jobId);
1083+
}
1084+
upload.mockRestore();
1085+
}
1086+
}, 15_000);
1087+
9531088
test("retries transient segment downloads and completes a real mux job", async () => {
9541089
const response = await app.fetch(
9551090
mediaPostRequest("/video/mux-segments", {

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,33 @@ describe("recording upload cancellation", () => {
302302
});
303303

304304
describe("generateThumbnail integration tests", () => {
305+
test("uses the first frame when a sparse video has no frame after the thumbnail seek", async () => {
306+
const directory = mkdtempSync(join(tmpdir(), "cap-sparse-thumbnail-"));
307+
const input = join(directory, "single-frame.mp4");
308+
try {
309+
execFileSync("ffmpeg", [
310+
"-v",
311+
"error",
312+
"-f",
313+
"lavfi",
314+
"-i",
315+
"color=c=blue:s=160x120:r=1/2",
316+
"-frames:v",
317+
"1",
318+
"-c:v",
319+
"libx264",
320+
"-pix_fmt",
321+
"yuv420p",
322+
input,
323+
]);
324+
const thumbnail = await generateThumbnail(input, 2);
325+
expect(thumbnail.length).toBeGreaterThan(0);
326+
expect([...thumbnail.subarray(0, 2)]).toEqual([0xff, 0xd8]);
327+
} finally {
328+
rmSync(directory, { recursive: true, force: true });
329+
}
330+
});
331+
305332
test("joins an in-flight thumbnail decoder when its worker is cancelled", async () => {
306333
let ready: (() => void) | undefined;
307334
const started = new Promise<void>((resolve) => {

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

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { createHash, randomUUID } from "node:crypto";
22
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3-
import { join } from "node:path";
3+
import { isAbsolute, join } from "node:path";
44
import { setTimeout as sleep } from "node:timers/promises";
55
import { type BunFile, file, spawn } from "bun";
66
import { uploadDriveResumable } from "./drive-resumable-upload";
77
import type { VideoMetadata } from "./job-manager";
88
import {
99
DOWNLOAD_TIMEOUT_MS,
10+
normalizeLocalPath,
1011
PROCESS_TIMEOUT_MS,
1112
type ProgressCallback,
1213
UPLOAD_TIMEOUT_MS,
@@ -1670,6 +1671,8 @@ function getThumbnailTimestamp(
16701671
return Math.min(Math.max(0, timestamp), Math.max(0, duration - 0.1));
16711672
}
16721673

1674+
class EmptyThumbnailError extends Error {}
1675+
16731676
export async function generateThumbnail(
16741677
inputPath: string,
16751678
duration: number,
@@ -1679,12 +1682,53 @@ export async function generateThumbnail(
16791682
abortSignal?.throwIfAborted();
16801683
const opts = { ...DEFAULT_THUMBNAIL_OPTIONS, ...options };
16811684
const timestamp = getThumbnailTimestamp(duration, opts.timestamp);
1685+
const deadline = performance.now() + THUMBNAIL_TIMEOUT_MS;
1686+
try {
1687+
return await generateThumbnailFrame(
1688+
inputPath,
1689+
opts,
1690+
timestamp,
1691+
THUMBNAIL_TIMEOUT_MS,
1692+
abortSignal,
1693+
);
1694+
} catch (error) {
1695+
abortSignal?.throwIfAborted();
1696+
const remainingMs = deadline - performance.now();
1697+
if (
1698+
!(error instanceof EmptyThumbnailError) ||
1699+
!isAbsolute(normalizeLocalPath(inputPath)) ||
1700+
timestamp <= 0 ||
1701+
remainingMs <= 0
1702+
) {
1703+
throw error;
1704+
}
1705+
return await generateThumbnailFrame(
1706+
inputPath,
1707+
opts,
1708+
0,
1709+
remainingMs,
1710+
abortSignal,
1711+
true,
1712+
);
1713+
}
1714+
}
1715+
1716+
async function generateThumbnailFrame(
1717+
inputPath: string,
1718+
opts: Required<ThumbnailOptions>,
1719+
timestamp: number,
1720+
timeoutMs: number,
1721+
abortSignal?: AbortSignal,
1722+
localOnly = false,
1723+
): Promise<Uint8Array> {
1724+
abortSignal?.throwIfAborted();
16821725
const qualityValue = Math.max(
16831726
2,
16841727
Math.min(31, Math.round(31 - (opts.quality / 100) * 29)),
16851728
);
16861729
const ffmpegArgs = [
16871730
"ffmpeg",
1731+
...(localOnly ? ["-protocol_whitelist", "file,pipe"] : []),
16881732
"-ss",
16891733
timestamp.toString(),
16901734
"-i",
@@ -1746,12 +1790,16 @@ export async function generateThumbnail(
17461790
]);
17471791
abortSignal?.throwIfAborted();
17481792

1793+
if (totalBytes === 0) {
1794+
throw new EmptyThumbnailError(
1795+
exitCode === 0
1796+
? "FFmpeg produced empty thumbnail"
1797+
: `FFmpeg thumbnail exited with code ${exitCode}`,
1798+
);
1799+
}
17491800
if (exitCode !== 0) {
17501801
throw new Error(`FFmpeg thumbnail exited with code ${exitCode}`);
17511802
}
1752-
if (totalBytes === 0) {
1753-
throw new Error("FFmpeg produced empty thumbnail");
1754-
}
17551803

17561804
const output = new Uint8Array(totalBytes);
17571805
let offset = 0;
@@ -1762,7 +1810,7 @@ export async function generateThumbnail(
17621810

17631811
return output;
17641812
})(),
1765-
THUMBNAIL_TIMEOUT_MS,
1813+
timeoutMs,
17661814
stop,
17671815
);
17681816
} catch (error) {

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,7 +1352,12 @@ async function processVideoAsync(
13521352
});
13531353
await sendWebhook(job);
13541354

1355-
await uploadFileToS3(outputTempFile.path, outputPresignedUrl, "video/mp4");
1355+
await uploadFileToS3(
1356+
outputTempFile.path,
1357+
outputPresignedUrl,
1358+
"video/mp4",
1359+
abortController.signal,
1360+
);
13561361

13571362
if (thumbnailPresignedUrl || previewGifPresignedUrl) {
13581363
updateJob(jobId, {
@@ -1367,8 +1372,23 @@ async function processVideoAsync(
13671372
const thumbnailData = await generateThumbnail(
13681373
outputTempFile.path,
13691374
metadata.duration,
1375+
{},
1376+
abortController.signal,
13701377
);
1371-
await uploadToS3(thumbnailData, thumbnailPresignedUrl, "image/jpeg");
1378+
try {
1379+
await uploadToS3(
1380+
thumbnailData,
1381+
thumbnailPresignedUrl,
1382+
"image/jpeg",
1383+
abortController.signal,
1384+
);
1385+
} catch (error) {
1386+
abortController.signal.throwIfAborted();
1387+
console.warn(
1388+
`[video/process] Thumbnail upload failed for ${jobId}:`,
1389+
error,
1390+
);
1391+
}
13721392
}
13731393

13741394
await generateAndUploadPreviewGif(

0 commit comments

Comments
 (0)