Skip to content

Commit 40f93b8

Browse files
committed
fix: preserve direct Loom imports on the media worker
1 parent babd5e6 commit 40f93b8

2 files changed

Lines changed: 112 additions & 15 deletions

File tree

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

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -218,19 +218,23 @@ beforeAll(async () => {
218218
tempDir = mkdtempSync(join(tmpdir(), "cap-real-world-routes-"));
219219
for (const kind of ["video", "audio"] as const) {
220220
const path = join(tempDir, `${kind}-fragmented.mp4`);
221-
execFileSync("ffmpeg", [
222-
"-v",
223-
"error",
224-
"-i",
225-
TEST_VIDEO_WITH_AUDIO,
226-
"-map",
227-
kind === "video" ? "0:v:0" : "0:a:0",
228-
"-c",
229-
"copy",
230-
"-movflags",
231-
"+empty_moov+frag_keyframe+default_base_moof",
232-
path,
233-
]);
221+
execFileSync(
222+
"ffmpeg",
223+
[
224+
"-v",
225+
"error",
226+
"-i",
227+
TEST_VIDEO_WITH_AUDIO,
228+
"-map",
229+
kind === "video" ? "0:v:0" : "0:a:0",
230+
"-c",
231+
"copy",
232+
"-movflags",
233+
"+empty_moov+frag_keyframe+default_base_moof",
234+
path,
235+
],
236+
{ stdio: "inherit" },
237+
);
234238
const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
235239
const view = new DataView(bytes.buffer);
236240
let split = 0;
@@ -1145,6 +1149,80 @@ describe("media routes real-world integration tests", () => {
11451149
}
11461150
}, 15_000);
11471151

1152+
test("imports a real video and preserves the original bytes before processing", async () => {
1153+
const response = await app.fetch(
1154+
mediaPostRequest("/video/import", {
1155+
videoId: "direct-import",
1156+
userId: "import-owner",
1157+
videoUrl: fixtureUrl(),
1158+
sourcePresignedUrl: uploadUrl("import-original.mp4"),
1159+
outputPresignedUrl: uploadUrl("import-output.mp4"),
1160+
inputExtension: ".mp4",
1161+
maxWidth: 160,
1162+
maxHeight: 120,
1163+
preset: "ultrafast",
1164+
}),
1165+
);
1166+
expect(response.status).toBe(200);
1167+
const { jobId } = (await response.json()) as { jobId: string };
1168+
try {
1169+
const job = await waitForTerminalJob(jobId);
1170+
expect(job.phase).toBe("complete");
1171+
expect(uploadedBytes("/uploads/import-original.mp4")).toEqual(
1172+
new Uint8Array(await Bun.file(TEST_VIDEO_WITH_AUDIO).arrayBuffer()),
1173+
);
1174+
const metadata = await probeBytesAsMp4(
1175+
uploadedBytes("/uploads/import-output.mp4"),
1176+
"import-output.mp4",
1177+
);
1178+
expect(metadata.videoCodec).toBe("h264");
1179+
expect(metadata.audioCodec).toBe("aac");
1180+
expect(metadata.width).toBeLessThanOrEqual(160);
1181+
expect([...uploadedArtifacts.keys()]).toEqual([
1182+
"/uploads/import-original.mp4",
1183+
"/uploads/import-output.mp4",
1184+
]);
1185+
} finally {
1186+
deleteJob(jobId);
1187+
}
1188+
}, 90000);
1189+
1190+
test("fails an import when the original cannot be saved without processing it", async () => {
1191+
const process = spyOn(mediaVideo, "processVideo");
1192+
const response = await app.fetch(
1193+
mediaPostRequest("/video/import", {
1194+
videoId: "failed-import",
1195+
userId: "import-owner",
1196+
videoUrl: fixtureUrl(),
1197+
sourcePresignedUrl: `${baseUrl}/missing-original-destination`,
1198+
outputPresignedUrl: uploadUrl("failed-import-output.mp4"),
1199+
inputExtension: ".mp4",
1200+
}),
1201+
);
1202+
expect(response.status).toBe(200);
1203+
const { jobId } = (await response.json()) as { jobId: string };
1204+
try {
1205+
const job = await waitForTerminalJob(jobId);
1206+
expect(job.phase).toBe("error");
1207+
expect(process).not.toHaveBeenCalled();
1208+
expect(uploadedArtifacts.size).toBe(0);
1209+
} finally {
1210+
deleteJob(jobId);
1211+
}
1212+
}, 90000);
1213+
1214+
test("rejects imports that omit original storage", async () => {
1215+
const response = await app.fetch(
1216+
mediaPostRequest("/video/import", {
1217+
videoId: "unsafe-import",
1218+
userId: "import-owner",
1219+
videoUrl: fixtureUrl(),
1220+
outputPresignedUrl: uploadUrl("unsafe-import.mp4"),
1221+
}),
1222+
);
1223+
expect(response.status).toBe(400);
1224+
});
1225+
11481226
test("retries transient segment downloads and completes a real mux job", async () => {
11491227
const response = await app.fetch(
11501228
mediaPostRequest("/video/mux-segments", {

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ const processSchema = z.object({
114114
userId: z.string(),
115115
videoUrl: z.string().url(),
116116
outputPresignedUrl: z.string().url(),
117+
sourcePresignedUrl: z.string().url().optional(),
117118
thumbnailPresignedUrl: z.string().url().optional(),
118119
previewGifPresignedUrl: z.string().url().optional(),
119120
webhookUrl: z.string().url().optional(),
@@ -664,13 +665,17 @@ video.post("/convert", async (c) => {
664665
}
665666
});
666667

667-
video.post("/process", async (c) => {
668+
video.on("POST", ["/process", "/import"], async (c) => {
668669
if (!validateMediaServerSecret(c)) {
669670
return c.json({ error: "Unauthorized" }, 401);
670671
}
671672

672673
const body = await c.req.json();
673-
const result = processSchema.safeParse(body);
674+
const result = (
675+
c.req.path.endsWith("/import")
676+
? processSchema.extend({ sourcePresignedUrl: z.string().url() })
677+
: processSchema
678+
).safeParse(body);
674679

675680
if (!result.success) {
676681
return c.json(
@@ -1302,6 +1307,20 @@ async function processVideoAsync(
13021307
);
13031308
updateJob(jobId, { inputTempFile });
13041309

1310+
const sourcePresignedUrl = options.sourcePresignedUrl;
1311+
if (sourcePresignedUrl) {
1312+
updateJob(jobId, { message: "Saving original video..." });
1313+
await sendWebhook(job);
1314+
await withJobHeartbeat(jobId, () =>
1315+
uploadFileToS3(
1316+
inputTempFile.path,
1317+
sourcePresignedUrl,
1318+
"video/mp4",
1319+
abortController.signal,
1320+
),
1321+
);
1322+
}
1323+
13051324
const isWebm = isWebmInput(options.inputExtension);
13061325

13071326
updateJob(jobId, {

0 commit comments

Comments
 (0)