Skip to content

Commit 770511b

Browse files
Merge pull request #1820 from CapSoftware/basic-web-editor
Web video trimming and cuts
2 parents 6fbbdd9 + e780156 commit 770511b

41 files changed

Lines changed: 9240 additions & 122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
buildStreamCopySegmentArgs,
4+
buildTranscodeSegmentArgs,
5+
normalizeEditRanges,
6+
} from "../../lib/ffmpeg-edit";
7+
8+
describe("ffmpeg edit helpers", () => {
9+
test("normalizes edit ranges", () => {
10+
expect(
11+
normalizeEditRanges(
12+
[
13+
{ start: 3, end: 5 },
14+
{ start: -1, end: 0.01 },
15+
{ start: 8, end: 12 },
16+
],
17+
10,
18+
),
19+
).toEqual([
20+
{ start: 3, end: 5 },
21+
{ start: 8, end: 10 },
22+
]);
23+
});
24+
25+
test("builds stream-copy segment args", () => {
26+
const args = buildStreamCopySegmentArgs(
27+
"/input.mp4",
28+
{
29+
start: 1,
30+
end: 3.25,
31+
},
32+
"/segment.mp4",
33+
);
34+
35+
expect(args).toContain("copy");
36+
expect(args).toContain("-avoid_negative_ts");
37+
expect(args).toContain("2.250");
38+
});
39+
40+
test("builds no-audio transcode args", () => {
41+
const args = buildTranscodeSegmentArgs(
42+
"/input.mp4",
43+
{ start: 0, end: 1 },
44+
"/segment.mp4",
45+
false,
46+
);
47+
48+
expect(args).toContain("libx264");
49+
expect(args).toContain(
50+
"[0:v:0]fps=30,trim=start=0.000:end=1.000,setpts=PTS-STARTPTS[v]",
51+
);
52+
expect(args).toContain("-an");
53+
expect(args).not.toContain("0:a:0?");
54+
});
55+
56+
test("builds audio transcode args", () => {
57+
const args = buildTranscodeSegmentArgs(
58+
"/input.mp4",
59+
{ start: 0, end: 1 },
60+
"/segment.mp4",
61+
true,
62+
);
63+
64+
expect(args).toContain("aac");
65+
expect(args).toContain(
66+
"[0:v:0]fps=30,trim=start=0.000:end=1.000,setpts=PTS-STARTPTS[v];[0:a:0]atrim=start=0.000:end=1.000,asetpts=PTS-STARTPTS[a]",
67+
);
68+
expect(args).toContain("[a]");
69+
});
70+
});

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,113 @@ describe("POST /video/process", () => {
465465
});
466466
});
467467

468+
describe("POST /video/edit", () => {
469+
beforeEach(() => {
470+
mock.restore();
471+
});
472+
473+
test("returns 401 without media server secret", async () => {
474+
const response = await app.fetch(
475+
unauthenticatedVideoPostRequest("/video/edit", {
476+
videoId: "test-id",
477+
userId: "user-id",
478+
sourceUrl: "https://example.com/source.mp4",
479+
outputPresignedUrl: "https://s3.example.com/output",
480+
keepRanges: [{ start: 0, end: 1 }],
481+
}),
482+
);
483+
484+
expect(response.status).toBe(401);
485+
});
486+
487+
test("returns 400 for invalid JSON", async () => {
488+
const response = await app.fetch(
489+
new Request("http://localhost/video/edit", {
490+
method: "POST",
491+
headers: AUTH_HEADERS,
492+
body: "{",
493+
}),
494+
);
495+
496+
expect(response.status).toBe(400);
497+
const data = await response.json();
498+
expect(data.code).toBe("INVALID_REQUEST");
499+
});
500+
501+
test("returns 400 for missing keep ranges", async () => {
502+
const response = await app.fetch(
503+
videoPostRequest("/video/edit", {
504+
videoId: "test-id",
505+
userId: "user-id",
506+
sourceUrl: "https://example.com/source.mp4",
507+
outputPresignedUrl: "https://s3.example.com/output",
508+
}),
509+
);
510+
511+
expect(response.status).toBe(400);
512+
const data = await response.json();
513+
expect(data.code).toBe("INVALID_REQUEST");
514+
});
515+
516+
test("returns 400 for invalid ranges", async () => {
517+
const response = await app.fetch(
518+
videoPostRequest("/video/edit", {
519+
videoId: "test-id",
520+
userId: "user-id",
521+
sourceUrl: "https://example.com/source.mp4",
522+
outputPresignedUrl: "https://s3.example.com/output",
523+
keepRanges: [{ start: 3, end: 1 }],
524+
}),
525+
);
526+
527+
expect(response.status).toBe(400);
528+
const data = await response.json();
529+
expect(data.code).toBe("INVALID_REQUEST");
530+
});
531+
532+
test("returns jobId when edit starts successfully", async () => {
533+
mock.module("../../lib/job-manager", () => ({
534+
canAcceptNewVideoProcess: () => true,
535+
getActiveVideoProcessCount: () => 0,
536+
getMaxConcurrentVideoProcesses: () => 3,
537+
getSystemResources: jobManager.getSystemResources,
538+
getAllJobs: () => [],
539+
generateJobId: () => "edit-job-id",
540+
createJob: () => ({
541+
jobId: "edit-job-id",
542+
videoId: "test-id",
543+
userId: "user-id",
544+
phase: "queued",
545+
progress: 0,
546+
createdAt: new Date(),
547+
updatedAt: new Date(),
548+
}),
549+
getJob: () => null,
550+
updateJob: () => null,
551+
deleteJob: () => {},
552+
sendWebhook: async () => {},
553+
getJobProgress: jobManager.getJobProgress,
554+
}));
555+
556+
const { default: appWithMock } = await import("../../app");
557+
558+
const response = await appWithMock.fetch(
559+
videoPostRequest("/video/edit", {
560+
videoId: "test-id",
561+
userId: "user-id",
562+
sourceUrl: "https://example.com/source.mp4",
563+
outputPresignedUrl: "https://s3.example.com/output",
564+
keepRanges: [{ start: 0, end: 1 }],
565+
}),
566+
);
567+
568+
expect(response.status).toBe(200);
569+
const data = await response.json();
570+
expect(data.jobId).toBe("edit-job-id");
571+
expect(data.status).toBe("queued");
572+
});
573+
});
574+
468575
describe("GET /video/process/:jobId/status", () => {
469576
beforeEach(() => {
470577
mock.restore();

apps/media-server/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ app.get("/", (c) => {
2727
"/video/thumbnail",
2828
"/video/convert",
2929
"/video/process",
30+
"/video/edit",
3031
"/video/process/:jobId/status",
3132
"/video/process/:jobId/cancel",
3233
"/video/cleanup",

0 commit comments

Comments
 (0)