Skip to content

Web video trimming and cuts#1820

Merged
richiemcilroy merged 29 commits into
mainfrom
basic-web-editor
May 15, 2026
Merged

Web video trimming and cuts#1820
richiemcilroy merged 29 commits into
mainfrom
basic-web-editor

Conversation

@richiemcilroy
Copy link
Copy Markdown
Member

@richiemcilroy richiemcilroy commented May 14, 2026

adds a web video editor for MP4-backed caps: trim, split, delete segments, and save. Edits are stored as a normalized keep-ranges spec in a new video_edits table, with optional local draft recovery in the browser.

Greptile Summary

This PR adds a web-based video editor for MP4-backed caps, enabling trim, split, segment-delete, and save operations. Edits are stored as a normalized keep-ranges spec in a new video_edits table and rendered server-side by a new /video/edit media-server endpoint via an editVideoWorkflow workflow that polls for completion.

  • All four issues flagged in the previous review round have been addressed: the stale-upload threshold is now 15 minutes, each webhook call in editVideoAsync reads a fresh job snapshot via getJob, the error path now schedules a deleteJob cleanup in a finally block, and the success-path saveEditResultAndComplete delete is guarded by both phase = \"complete\" and rawFileKey.
  • One related gap remains: clearEditProcessingState (the error-path cleanup) still deletes by videoId alone, without the same rawFileKey guard added to the success path, leaving a narrow race window where a concurrently-submitted second edit's row can be deleted before its workflow begins.

Confidence Score: 4/5

Safe to merge with one small fix: the error-path cleanup in the workflow can delete a concurrent edit's database row in a narrow timing window.

The four issues flagged in the prior round are cleanly resolved. One gap in the error-path cleanup — clearEditProcessingState deletes without a source-key guard, unlike the hardened success path — can cause a concurrently-submitted second edit to lose its processing state and fail silently. The fix is a one-line WHERE-clause addition.

apps/web/workflows/edit-video.ts — specifically the clearEditProcessingState function's DELETE statement

Important Files Changed

Filename Overview
apps/web/workflows/edit-video.ts New workflow orchestrating the full edit lifecycle; success-path delete correctly guarded by phase+rawFileKey, but error-path clearEditProcessingState still unconditionally deletes by videoId only
apps/media-server/src/routes/video.ts New /edit endpoint and editVideoAsync function; previously flagged stale-job-reference and missing error-path cleanup issues are now resolved
apps/media-server/src/lib/ffmpeg-edit.ts New FFmpeg editing library that normalizes ranges, segments/transcodes each keep-range, and concatenates; timeout, abort, and temp-file cleanup all handled correctly
apps/web/actions/videos/save-edits.ts Server action that validates ownership, checks for in-flight uploads, composes edit specs, copies the source file if needed, and launches the workflow
apps/web/lib/video-edit-processing.ts Stale-upload reconciliation helper; previously flagged 2-minute threshold has been increased to 15 minutes to handle the full download phase window
apps/web/lib/video-edits.ts Core edit-spec math (normalize, compose, remap timestamps, timeline state management); logic is thorough with epsilon-merge and round-trip correctness
apps/web/app/api/webhooks/media-server/progress/route.ts Progress webhook now correctly distinguishes edit uploads (via isEditSourceKey) to keep the row at phase=complete instead of deleting it, allowing the workflow poller to pick it up
packages/database/schema.ts Adds video_edits table with videoId PK, sourceKey, and editSpec JSON; cascade delete and bidirectional relations are correctly defined
apps/web/app/s/[videoId]/edit/EditVideoClient.tsx New rich client-side video timeline editor with undo/redo, trim/split/delete, draft persistence in localStorage, and progress polling during save
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/web/workflows/edit-video.ts:503-509
**Unconditional delete in error-path cleanup not guarded by source key**

`clearEditProcessingState` deletes by `videoId` alone. The success path in `saveEditResultAndComplete` was hardened to also match `phase = "complete"` and `rawFileKey` against the workflow's `sourceKey`, but the error path was not updated.

Race: Edit A's media server job fails, the webhook sets `phase = "error"`. Since `"error"` is absent from `ACTIVE_UPLOAD_PHASES`, Edit B can be submitted immediately — `markEditProcessing` upserts the row with `phase = "processing"` before Edit A's five-second poller fires. If the poller reads the error state first and throws, the catch block then calls `clearEditProcessingState`, which deletes Edit B's freshly-written row. Edit B's `validateEditRequest` then throws `"Edit render does not exist"`.

Scoping the DELETE to also match `rawFileKey` against the `sourceKey` already in scope would make it a no-op when a newer edit has taken ownership of the row — consistent with the guard already added to the success path.

Reviews (3): Last reviewed commit: "fix(web): guard edit upload cleanup" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 14, 2026
@polarityinc
Copy link
Copy Markdown

polarityinc Bot commented May 14, 2026

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

Comment thread apps/media-server/src/routes/video.ts Outdated
});
const currentJob = getJob(jobId);
if (currentJob) {
sendWebhook(currentJob);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendWebhook(currentJob) is called inside onProgress without being awaited or error-handled; if it rejects this can surface as an unhandled rejection.

Suggested change
sendWebhook(currentJob);
void sendWebhook(currentJob).catch((error) =>
console.warn(`[video/edit] Failed to send webhook update for job ${jobId}:`, error),
);

Comment thread apps/media-server/src/routes/video.ts Outdated
Comment on lines +537 to +538
const body = await c.req.json();
const result = editSchema.safeParse(body);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c.req.json() can throw on invalid JSON and currently becomes a 500. Returning INVALID_REQUEST here would make retries / debugging nicer.

Suggested change
const body = await c.req.json();
const result = editSchema.safeParse(body);
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json(
{ error: "Invalid request", code: "INVALID_REQUEST" },
400,
);
}
const result = editSchema.safeParse(body);

Comment on lines +178 to +183
.set({
phase: "complete",
processingProgress: 100,
processingMessage: payload.message,
updatedAt: new Date(),
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On successful completion for edit uploads, it might be worth explicitly clearing processingError so a previous failure doesn’t linger in the UI.

Suggested change
.set({
phase: "complete",
processingProgress: 100,
processingMessage: payload.message,
updatedAt: new Date(),
})
.set({
phase: "complete",
processingProgress: 100,
processingMessage: payload.message,
processingError: null,
updatedAt: new Date(),
})

const SECOND = 1000;
const MINUTE = 60 * SECOND;
const STALE_EDIT_PROCESSING_START_MS = 2 * MINUTE;
const STALE_EDIT_PROCESSING_PROGRESS_MS = 10 * MINUTE;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 2-minute stale threshold too short for the download phase

STALE_EDIT_PROCESSING_START_MS (2 min) governs when a processingProgress === 0 record is treated as stuck. During the media server's download phase, no granular progress webhook is fired — only a single initial webhook with the stale initial job state (progress 0). For a large video (e.g. 1 GB+ MP4), download from S3 to the media server can easily take 3–5+ minutes. If the owner (or anyone else) loads /s/[videoId] or the edit page in a second tab during that window, reconcileStaleEditUpload runs, deletes the videoUploads row, and the workflow step waitForEditCompletion throws "Edit processing state disappeared" — aborting the edit silently.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/video-edit-processing.ts
Line: 9

Comment:
**2-minute stale threshold too short for the download phase**

`STALE_EDIT_PROCESSING_START_MS` (2 min) governs when a `processingProgress === 0` record is treated as stuck. During the media server's download phase, no granular progress webhook is fired — only a single initial webhook with the stale initial job state (progress 0). For a large video (e.g. 1 GB+ MP4), download from S3 to the media server can easily take 3–5+ minutes. If the owner (or anyone else) loads `/s/[videoId]` or the edit page in a second tab during that window, `reconcileStaleEditUpload` runs, deletes the `videoUploads` row, and the workflow step `waitForEditCompletion` throws "Edit processing state disappeared" — aborting the edit silently.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/media-server/src/routes/video.ts Outdated
Comment on lines +867 to +896
try {
updateJob(jobId, {
phase: "downloading",
progress: 0,
message: "Downloading source video...",
});
await sendWebhook(job);

const inputTempFile = await downloadVideoToTemp(
sourceUrl,
".mp4",
abortController.signal,
);
updateJob(jobId, { inputTempFile });

updateJob(jobId, {
phase: "probing",
progress: 5,
message: "Analyzing source video...",
});
await sendWebhook(job);

const sourceMetadata = await probeVideoFile(inputTempFile.path);

updateJob(jobId, {
phase: "processing",
progress: 10,
message: "Applying edit...",
});
await sendWebhook(job);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale job reference passed to the first three sendWebhook calls

job is captured once via getJob(jobId) at the top of editVideoAsync. Every subsequent updateJob(jobId, ...) call modifies the store entry, but job still points to the pre-update snapshot. The "downloading", "probing", and "processing" webhooks therefore forward the original (queued) state to the web app instead of the updated phases. The success path already uses getJob(jobId) to get fresh state before calling sendWebhook — the early calls should follow the same pattern.

Suggested change
try {
updateJob(jobId, {
phase: "downloading",
progress: 0,
message: "Downloading source video...",
});
await sendWebhook(job);
const inputTempFile = await downloadVideoToTemp(
sourceUrl,
".mp4",
abortController.signal,
);
updateJob(jobId, { inputTempFile });
updateJob(jobId, {
phase: "probing",
progress: 5,
message: "Analyzing source video...",
});
await sendWebhook(job);
const sourceMetadata = await probeVideoFile(inputTempFile.path);
updateJob(jobId, {
phase: "processing",
progress: 10,
message: "Applying edit...",
});
await sendWebhook(job);
try {
updateJob(jobId, {
phase: "downloading",
progress: 0,
message: "Downloading source video...",
});
const downloadingJob = getJob(jobId);
if (downloadingJob) await sendWebhook(downloadingJob);
const inputTempFile = await downloadVideoToTemp(
sourceUrl,
".mp4",
abortController.signal,
);
updateJob(jobId, { inputTempFile });
updateJob(jobId, {
phase: "probing",
progress: 5,
message: "Analyzing source video...",
});
const probingJob = getJob(jobId);
if (probingJob) await sendWebhook(probingJob);
const sourceMetadata = await probeVideoFile(inputTempFile.path);
updateJob(jobId, {
phase: "processing",
progress: 10,
message: "Applying edit...",
});
const processingJob = getJob(jobId);
if (processingJob) await sendWebhook(processingJob);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/routes/video.ts
Line: 867-896

Comment:
**Stale `job` reference passed to the first three `sendWebhook` calls**

`job` is captured once via `getJob(jobId)` at the top of `editVideoAsync`. Every subsequent `updateJob(jobId, ...)` call modifies the store entry, but `job` still points to the pre-update snapshot. The "downloading", "probing", and "processing" webhooks therefore forward the original (queued) state to the web app instead of the updated phases. The success path already uses `getJob(jobId)` to get fresh state before calling `sendWebhook` — the early calls should follow the same pattern.

```suggestion
	try {
		updateJob(jobId, {
			phase: "downloading",
			progress: 0,
			message: "Downloading source video...",
		});
		const downloadingJob = getJob(jobId);
		if (downloadingJob) await sendWebhook(downloadingJob);

		const inputTempFile = await downloadVideoToTemp(
			sourceUrl,
			".mp4",
			abortController.signal,
		);
		updateJob(jobId, { inputTempFile });

		updateJob(jobId, {
			phase: "probing",
			progress: 5,
			message: "Analyzing source video...",
		});
		const probingJob = getJob(jobId);
		if (probingJob) await sendWebhook(probingJob);

		const sourceMetadata = await probeVideoFile(inputTempFile.path);

		updateJob(jobId, {
			phase: "processing",
			progress: 10,
			message: "Applying edit...",
		});
		const processingJob = getJob(jobId);
		if (processingJob) await sendWebhook(processingJob);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/media-server/src/routes/video.ts Outdated
Comment on lines 982 to 990
const currentJob = getJob(jobId);
if (currentJob) {
await currentJob.inputTempFile?.cleanup();
await currentJob.outputTempFile?.cleanup();
}
}
}

async function processVideoAsync(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Error path of editVideoAsync never removes the job from the registry

The success path schedules setTimeout(() => deleteJob(jobId), 5 * 60 * 1000), but the error path exits without scheduling any cleanup. Failed edit jobs remain in the in-memory job registry indefinitely, accumulating until the process restarts. On a long-running server with many transient failures this becomes an unbounded memory leak.

Suggested change
const currentJob = getJob(jobId);
if (currentJob) {
await currentJob.inputTempFile?.cleanup();
await currentJob.outputTempFile?.cleanup();
}
}
}
async function processVideoAsync(
const currentJob = getJob(jobId);
if (currentJob) {
await currentJob.inputTempFile?.cleanup();
await currentJob.outputTempFile?.cleanup();
}
setTimeout(() => deleteJob(jobId), 5 * 60 * 1000);
}
}
async function processVideoAsync(
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/routes/video.ts
Line: 982-990

Comment:
**Error path of `editVideoAsync` never removes the job from the registry**

The success path schedules `setTimeout(() => deleteJob(jobId), 5 * 60 * 1000)`, but the error path exits without scheduling any cleanup. Failed edit jobs remain in the in-memory job registry indefinitely, accumulating until the process restarts. On a long-running server with many transient failures this becomes an unbounded memory leak.

```suggestion
		const currentJob = getJob(jobId);
		if (currentJob) {
			await currentJob.inputTempFile?.cleanup();
			await currentJob.outputTempFile?.cleanup();
		}

		setTimeout(() => deleteJob(jobId), 5 * 60 * 1000);
	}
}

async function processVideoAsync(
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 194 to 196
const isMp4Source =
data.source.type === "desktopMP4" || data.source.type === "webMP4";
const isSegmentsSource = data.source.type === "desktopSegments";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Source-key format duplicated as a string literal

The string "${data.owner.id}/${data.id}/source/original.mp4" duplicates the logic from getEditSourceKey in video-edit-processing.ts. Since that module is server-only, a clean alternative is to compute the boolean on the server (in page.tsx) and forward it as a typed prop. If the S3 key format ever changes, this comparison will silently stop working.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/ShareVideo.tsx
Line: 194-196

Comment:
**Source-key format duplicated as a string literal**

The string `"${data.owner.id}/${data.id}/source/original.mp4"` duplicates the logic from `getEditSourceKey` in `video-edit-processing.ts`. Since that module is server-only, a clean alternative is to compute the boolean on the server (in `page.tsx`) and forward it as a typed prop. If the S3 key format ever changes, this comparison will silently stop working.

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Copy Markdown
Member Author

please re-review the pr @greptileai

Comment thread apps/web/workflows/edit-video.ts Outdated
Comment on lines +483 to +484
.delete(videoUploads)
.where(eq(videoUploads.videoId, videoId as Video.VideoId));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unconditional delete can clobber a concurrently-submitted edit's processing state

tx.delete(videoUploads).where(eq(videoUploads.videoId, videoId)) uses only videoId. A second saveVideoEdits call can arrive while videoUploads.phase === "complete" (after the media-server webhook marks it done but before this transaction runs). ACTIVE_UPLOAD_PHASES does not include "complete", so the guard in save-edits.ts passes, markEditProcessing upserts the row with phase = "processing", and then this transaction deletes that freshly-written row. The second workflow then fails immediately in validateEditRequest with "Edit render does not exist".

Adding a phase guard — e.g. and(eq(videoUploads.videoId, videoId), eq(videoUploads.phase, "complete")) — would make the delete a no-op when a new edit has already taken ownership of the row.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/workflows/edit-video.ts
Line: 483-484

Comment:
**Unconditional delete can clobber a concurrently-submitted edit's processing state**

`tx.delete(videoUploads).where(eq(videoUploads.videoId, videoId))` uses only `videoId`. A second `saveVideoEdits` call can arrive while `videoUploads.phase === "complete"` (after the media-server webhook marks it done but before this transaction runs). `ACTIVE_UPLOAD_PHASES` does not include `"complete"`, so the guard in `save-edits.ts` passes, `markEditProcessing` upserts the row with `phase = "processing"`, and then this transaction deletes that freshly-written row. The second workflow then fails immediately in `validateEditRequest` with "Edit render does not exist".

Adding a phase guard — e.g. `and(eq(videoUploads.videoId, videoId), eq(videoUploads.phase, "complete"))` — would make the delete a no-op when a new edit has already taken ownership of the row.

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Copy Markdown
Member Author

hey @greptileai please re-review the PR. addressed your latest claim

Comment thread apps/web/actions/videos/save-edits.ts Outdated
.where(eq(videoUploads.videoId, videoId));

if (activeUpload && ACTIVE_UPLOAD_PHASES.has(activeUpload.phase)) {
throw new Error("Video is already uploading or processing");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now blocks on phase === "complete" too, but the thrown message reads like it’s still uploading/processing. Consider tailoring the error for the complete case so it’s clearer to the user what’s happening.

Suggested change
throw new Error("Video is already uploading or processing");
if (activeUpload && ACTIVE_UPLOAD_PHASES.has(activeUpload.phase)) {
const message =
activeUpload.phase === "complete"
? "Previous edit is finishing up. Try again in a moment."
: "Video is already uploading or processing";
throw new Error(message);
}

Comment thread apps/web/workflows/edit-video.ts Outdated
Comment on lines +503 to +509
async function clearEditProcessingState(videoId: string): Promise<void> {
"use step";

await db()
.delete(videoUploads)
.where(eq(videoUploads.videoId, videoId as Video.VideoId));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unconditional delete in error-path cleanup not guarded by source key

clearEditProcessingState deletes by videoId alone. The success path in saveEditResultAndComplete was hardened to also match phase = "complete" and rawFileKey against the workflow's sourceKey, but the error path was not updated.

Race: Edit A's media server job fails, the webhook sets phase = "error". Since "error" is absent from ACTIVE_UPLOAD_PHASES, Edit B can be submitted immediately — markEditProcessing upserts the row with phase = "processing" before Edit A's five-second poller fires. If the poller reads the error state first and throws, the catch block then calls clearEditProcessingState, which deletes Edit B's freshly-written row. Edit B's validateEditRequest then throws "Edit render does not exist".

Scoping the DELETE to also match rawFileKey against the sourceKey already in scope would make it a no-op when a newer edit has taken ownership of the row — consistent with the guard already added to the success path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/workflows/edit-video.ts
Line: 503-509

Comment:
**Unconditional delete in error-path cleanup not guarded by source key**

`clearEditProcessingState` deletes by `videoId` alone. The success path in `saveEditResultAndComplete` was hardened to also match `phase = "complete"` and `rawFileKey` against the workflow's `sourceKey`, but the error path was not updated.

Race: Edit A's media server job fails, the webhook sets `phase = "error"`. Since `"error"` is absent from `ACTIVE_UPLOAD_PHASES`, Edit B can be submitted immediately — `markEditProcessing` upserts the row with `phase = "processing"` before Edit A's five-second poller fires. If the poller reads the error state first and throws, the catch block then calls `clearEditProcessingState`, which deletes Edit B's freshly-written row. Edit B's `validateEditRequest` then throws `"Edit render does not exist"`.

Scoping the DELETE to also match `rawFileKey` against the `sourceKey` already in scope would make it a no-op when a newer edit has taken ownership of the row — consistent with the guard already added to the success path.

How can I resolve this? If you propose a fix, please make it concise.

.where(
and(
eq(videoUploads.videoId, videoId as Video.VideoId),
eq(videoUploads.rawFileKey, sourceKey),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoping by rawFileKey helps, but since sourceKey looks stable per-video (getEditSourceKey(...)), this can still delete a newer edit row (phase processing) during error cleanup. Mirroring the success-path phase guard here would make the delete a no-op once a new edit starts.

Suggested change
eq(videoUploads.rawFileKey, sourceKey),
.where(
and(
eq(videoUploads.videoId, videoId as Video.VideoId),
eq(videoUploads.rawFileKey, sourceKey),
eq(videoUploads.phase, "error"),
),
);

@richiemcilroy richiemcilroy merged commit 770511b into main May 15, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant