Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e356a2b
feat(database): add VideoEditSpec types
richiemcilroy May 14, 2026
8fd3cf6
feat(database): add video_edits table
richiemcilroy May 14, 2026
4116dc6
chore(database): add video_edits migration
richiemcilroy May 14, 2026
d6a9561
feat(web): add video timeline edit utilities
richiemcilroy May 14, 2026
f6cc918
feat(web): add timeline draft localStorage helpers
richiemcilroy May 14, 2026
e8042c1
feat(web): add stale edit upload reconciliation
richiemcilroy May 14, 2026
8f3fe3b
feat(web): add view transition navigation helper
richiemcilroy May 14, 2026
494f30c
feat(web): add edit video workflow
richiemcilroy May 14, 2026
1de4fc5
chore(web): register edit-video in workflow manifest
richiemcilroy May 14, 2026
1ee7034
feat(web): add save video edits server action
richiemcilroy May 14, 2026
a74936e
feat(web): add dismissible option to upgrade modal
richiemcilroy May 14, 2026
6ee8d33
feat(media-server): add ffmpeg video edit rendering
richiemcilroy May 14, 2026
779bb30
feat(media-server): add video edit HTTP routes
richiemcilroy May 14, 2026
3594fca
test(media-server): add ffmpeg edit unit tests
richiemcilroy May 14, 2026
691d078
test(media-server): extend video route tests for edits
richiemcilroy May 14, 2026
e13631c
feat(web): complete edit uploads in media-server webhook
richiemcilroy May 14, 2026
191aa18
feat(web): include source on folder video listings
richiemcilroy May 14, 2026
a88584a
feat(web): pass video source through caps list query
richiemcilroy May 14, 2026
7c3f526
feat(web): add edit video action to dashboard cap card
richiemcilroy May 14, 2026
d8c0c68
feat(web): add share page support for edit upload state
richiemcilroy May 14, 2026
7911c02
feat(web): improve player handling for edit processing
richiemcilroy May 14, 2026
99d08eb
feat(web): add edit entry control to share header
richiemcilroy May 14, 2026
950cdee
style(web): add view transition CSS for video edit
richiemcilroy May 14, 2026
d070efd
feat(web): add video edit page and client UI
richiemcilroy May 14, 2026
7a4e97d
test(web): add unit tests for video edit utilities
richiemcilroy May 14, 2026
9fdc42e
test(web): update playback tests for edit processing
richiemcilroy May 14, 2026
0ed699f
fix(web): address editor review feedback
richiemcilroy May 14, 2026
3f3ec30
fix(web): guard edit upload cleanup
richiemcilroy May 15, 2026
e780156
fix(web): scope failed edit cleanup
richiemcilroy May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/media-server/src/__tests__/lib/ffmpeg-edit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import {
buildStreamCopySegmentArgs,
buildTranscodeSegmentArgs,
normalizeEditRanges,
} from "../../lib/ffmpeg-edit";

describe("ffmpeg edit helpers", () => {
test("normalizes edit ranges", () => {
expect(
normalizeEditRanges(
[
{ start: 3, end: 5 },
{ start: -1, end: 0.01 },
{ start: 8, end: 12 },
],
10,
),
).toEqual([
{ start: 3, end: 5 },
{ start: 8, end: 10 },
]);
});

test("builds stream-copy segment args", () => {
const args = buildStreamCopySegmentArgs(
"/input.mp4",
{
start: 1,
end: 3.25,
},
"/segment.mp4",
);

expect(args).toContain("copy");
expect(args).toContain("-avoid_negative_ts");
expect(args).toContain("2.250");
});

test("builds no-audio transcode args", () => {
const args = buildTranscodeSegmentArgs(
"/input.mp4",
{ start: 0, end: 1 },
"/segment.mp4",
false,
);

expect(args).toContain("libx264");
expect(args).toContain(
"[0:v:0]fps=30,trim=start=0.000:end=1.000,setpts=PTS-STARTPTS[v]",
);
expect(args).toContain("-an");
expect(args).not.toContain("0:a:0?");
});

test("builds audio transcode args", () => {
const args = buildTranscodeSegmentArgs(
"/input.mp4",
{ start: 0, end: 1 },
"/segment.mp4",
true,
);

expect(args).toContain("aac");
expect(args).toContain(
"[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]",
);
expect(args).toContain("[a]");
});
});
107 changes: 107 additions & 0 deletions apps/media-server/src/__tests__/routes/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,113 @@ describe("POST /video/process", () => {
});
});

describe("POST /video/edit", () => {
beforeEach(() => {
mock.restore();
});

test("returns 401 without media server secret", async () => {
const response = await app.fetch(
unauthenticatedVideoPostRequest("/video/edit", {
videoId: "test-id",
userId: "user-id",
sourceUrl: "https://example.com/source.mp4",
outputPresignedUrl: "https://s3.example.com/output",
keepRanges: [{ start: 0, end: 1 }],
}),
);

expect(response.status).toBe(401);
});

test("returns 400 for invalid JSON", async () => {
const response = await app.fetch(
new Request("http://localhost/video/edit", {
method: "POST",
headers: AUTH_HEADERS,
body: "{",
}),
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.code).toBe("INVALID_REQUEST");
});

test("returns 400 for missing keep ranges", async () => {
const response = await app.fetch(
videoPostRequest("/video/edit", {
videoId: "test-id",
userId: "user-id",
sourceUrl: "https://example.com/source.mp4",
outputPresignedUrl: "https://s3.example.com/output",
}),
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.code).toBe("INVALID_REQUEST");
});

test("returns 400 for invalid ranges", async () => {
const response = await app.fetch(
videoPostRequest("/video/edit", {
videoId: "test-id",
userId: "user-id",
sourceUrl: "https://example.com/source.mp4",
outputPresignedUrl: "https://s3.example.com/output",
keepRanges: [{ start: 3, end: 1 }],
}),
);

expect(response.status).toBe(400);
const data = await response.json();
expect(data.code).toBe("INVALID_REQUEST");
});

test("returns jobId when edit starts successfully", async () => {
mock.module("../../lib/job-manager", () => ({
canAcceptNewVideoProcess: () => true,
getActiveVideoProcessCount: () => 0,
getMaxConcurrentVideoProcesses: () => 3,
getSystemResources: jobManager.getSystemResources,
getAllJobs: () => [],
generateJobId: () => "edit-job-id",
createJob: () => ({
jobId: "edit-job-id",
videoId: "test-id",
userId: "user-id",
phase: "queued",
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
}),
getJob: () => null,
updateJob: () => null,
deleteJob: () => {},
sendWebhook: async () => {},
getJobProgress: jobManager.getJobProgress,
}));

const { default: appWithMock } = await import("../../app");

const response = await appWithMock.fetch(
videoPostRequest("/video/edit", {
videoId: "test-id",
userId: "user-id",
sourceUrl: "https://example.com/source.mp4",
outputPresignedUrl: "https://s3.example.com/output",
keepRanges: [{ start: 0, end: 1 }],
}),
);

expect(response.status).toBe(200);
const data = await response.json();
expect(data.jobId).toBe("edit-job-id");
expect(data.status).toBe("queued");
});
});

describe("GET /video/process/:jobId/status", () => {
beforeEach(() => {
mock.restore();
Expand Down
1 change: 1 addition & 0 deletions apps/media-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ app.get("/", (c) => {
"/video/thumbnail",
"/video/convert",
"/video/process",
"/video/edit",
"/video/process/:jobId/status",
"/video/process/:jobId/cancel",
"/video/cleanup",
Expand Down
Loading
Loading