Skip to content

R2 + FFmpeg fallback encoding path#101

Open
aloewright wants to merge 1 commit into
mainfrom
conductor/alo-136-r2-ffmpeg-fallback-encoding-path
Open

R2 + FFmpeg fallback encoding path#101
aloewright wants to merge 1 commit into
mainfrom
conductor/alo-136-r2-ffmpeg-fallback-encoding-path

Conversation

@aloewright
Copy link
Copy Markdown
Owner

@aloewright aloewright commented May 8, 2026

Closes ALO-136.

Summary

  • Encoder dispatch (encoding.ts): when STREAM_ENABLED !== 'true', marks the row encoding and POSTs {videoId, sourceR2Key, outputR2Prefix} to FFMPEG_ENCODER_URL. Outbound is HMAC-signed with FFMPEG_ENCODER_SECRET (x-ffmpeg-signature: time=…,sig1=…). Missing URL is a graceful no-op so a fresh deploy doesn't fail uploads before the encoder Container is wired.
  • Completion webhook (/api/webhooks/ffmpeg): mirrors the Stream webhook shape — same HMAC verifier, same canonical state-machine guard. Receives {videoId, status, outputR2Prefix, masterPlaylist?} and flips the lifecycle to ready/failed, persisting the master playlist's R2 key in a new videos.playback_hls_path column.
  • HLS serving (/api/videos/:id/hls/*): streams the master playlist + variant playlists + .ts segments straight out of R2. Path traversal is rejected at resolveHlsKey, hidden / DMCA rules match the existing /stream route, manifests are short-cached and segments are immutable.
  • Playback URL plumbing: new derivePlaybackUrl helper resolves Stream URL → fallback URL → null, exposed on /api/videos/:id as playback_url so the player stays agnostic of which encoder produced the asset.
  • DB migration (0019_video_hls_path.sql): adds the playback_hls_path column (the existing playback_hls_url keeps holding Stream's videodelivery.net URL).
  • Config: FFMPEG_ENCODER_URL / FFMPEG_ENCODER_SECRET documented in wrangler.toml. README updated with the full R2-only flow.

Test plan

  • npm test — 533 tests pass (47 added across encoding.test.ts, ffmpeg-webhook.test.ts, hls.test.ts, plus 5 new cases in videos.test.ts)
  • npm run lint (oxlint + AI Gateway guard) clean
  • npm run type-check clean
  • Wire a real encoder Container and run an end-to-end upload → encode → playback against staging

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 8, 2026 14:46
@aloewright aloewright added the conductor Conductor-managed PR label May 8, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 8, 2026

Warning

Rate limit exceeded

@aloewright has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 42 minutes and 52 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4912f99f-10d5-4c76-b5c5-68621e42db14

📥 Commits

Reviewing files that changed from the base of the PR and between 4d3c13f and feed183.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • README.md
  • src/db/migrations/0019_video_hls_path.sql
  • src/workers/encoding.test.ts
  • src/workers/encoding.ts
  • src/workers/ffmpeg-webhook.test.ts
  • src/workers/ffmpeg-webhook.ts
  • src/workers/hls.test.ts
  • src/workers/hls.ts
  • src/workers/index.ts
  • src/workers/videos.test.ts
  • src/workers/videos.ts
  • wrangler.toml
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch conductor/alo-136-r2-ffmpeg-fallback-encoding-path

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 8, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an R2 and FFmpeg-based fallback encoding path, enabling video transcoding via an external worker service. The changes include a dispatcher for sending HMAC-signed encoding requests and a new webhook handler to process completion callbacks and update video metadata. Review feedback focused on improving the robustness of the implementation by suggesting atomic database updates to prevent race conditions and verifying status transitions before initiating encoding jobs.

Comment thread src/workers/ffmpeg-webhook.ts Outdated
Comment on lines +68 to +88
if (target === 'ready' && payload.playbackHlsUrl) {
// Persist the HLS manifest URL alongside the status flip.
const result = await c.env.DB.prepare(
`UPDATE videos
SET playback_hls_url = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(payload.playbackHlsUrl, payload.videoId)
.run();
if ((result.meta?.changes ?? 0) === 0) {
return c.json({ ok: true, matched: 0, status: target }, 202);
}
}

const transition = await transitionVideoStatus(c.env.DB, payload.videoId, target);
if (!transition.ok) {
return c.json({ ok: true, matched: 0, status: target }, 202);
}

return c.json({ ok: true, matched: transition.changes, status: target });
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.

high

The status transition and HLS URL update are currently performed in two separate, non-atomic database calls. This can lead to race conditions where metadata is updated even if the status transition fails (e.g., if the video is already in a terminal state).

Combining these into a single query guarded by the current status (using the 'WHERE status IN (...)' pattern) ensures atomicity and consistency, following the same pattern used in the Stream webhook handler. Additionally, logging the error message from the payload when the state is 'failed' will improve observability.

    if (target === 'failed' && payload.error) {
      console.error('FFmpeg encoding failed for video ' + payload.videoId + ': ' + payload.error);
    }

    const allowedFrom = (VIDEO_STATUSES as readonly VideoStatus[]).filter((from) =>
      canTransition(from, target),
    );
    const placeholders = allowedFrom.map(() => '?').join(', ');

    const result = await c.env.DB.prepare(
      'UPDATE videos SET status = ?, playback_hls_url = COALESCE(?, playback_hls_url), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN (' + placeholders + ')'
    )
      .bind(target, payload.playbackHlsUrl ?? null, payload.videoId, ...allowedFrom)
      .run();

    const changes = (result.meta?.changes as number | undefined) ?? 0;
    if (changes === 0) {
      return c.json({ ok: true, matched: 0, status: target }, 202);
    }

    return c.json({ ok: true, matched: changes, status: target });

Comment thread src/workers/ffmpeg-webhook.ts Outdated
import type { Context, MiddlewareHandler } from 'hono';
import { Hono } from 'hono';
import { verifyWebhookSignature } from './stream-webhook';
import { transitionVideoStatus, type VideoStatus } from './video-status';
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.

medium

Import VIDEO_STATUSES and canTransition to support the combined status transition and metadata update logic.

Suggested change
import { transitionVideoStatus, type VideoStatus } from './video-status';
import { VIDEO_STATUSES, canTransition, transitionVideoStatus, type VideoStatus } from './video-status';

Comment thread src/workers/encoding.ts Outdated
Comment on lines +136 to +140
if (env.FFMPEG_WORKER_URL) {
await transitionVideoStatus(env.DB, videoId, 'encoding');
await sendToFfmpegWorker(env, videoId, r2Key);
return;
}
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.

medium

The result of transitionVideoStatus should be checked before dispatching to the FFmpeg worker. If the transition fails (e.g., the video has already moved to a terminal state like ready or failed), we should avoid initiating a new encoding job.

Suggested change
if (env.FFMPEG_WORKER_URL) {
await transitionVideoStatus(env.DB, videoId, 'encoding');
await sendToFfmpegWorker(env, videoId, r2Key);
return;
}
if (env.FFMPEG_WORKER_URL) {
const transition = await transitionVideoStatus(env.DB, videoId, 'encoding');
if (!transition.ok) return;
await sendToFfmpegWorker(env, videoId, r2Key);
return;
}

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a fallback video encoding path using R2 and an external FFmpeg worker, alongside the existing Cloudflare Stream integration. It includes a new webhook handler to process completion callbacks from the FFmpeg worker and updates the encoding dispatcher to support this secondary path. A significant issue was identified in the webhook handler where database updates for the playback URL and video status are performed non-atomically, potentially leading to race conditions or inconsistent states if webhooks arrive out of order.

Comment thread src/workers/ffmpeg-webhook.ts Outdated
Comment on lines +68 to +88
if (target === 'ready' && payload.playbackHlsUrl) {
// Persist the HLS manifest URL alongside the status flip.
const result = await c.env.DB.prepare(
`UPDATE videos
SET playback_hls_url = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(payload.playbackHlsUrl, payload.videoId)
.run();
if ((result.meta?.changes ?? 0) === 0) {
return c.json({ ok: true, matched: 0, status: target }, 202);
}
}

const transition = await transitionVideoStatus(c.env.DB, payload.videoId, target);
if (!transition.ok) {
return c.json({ ok: true, matched: 0, status: target }, 202);
}

return c.json({ ok: true, matched: transition.changes, status: target });
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.

high

The current implementation performs two separate database updates: one for the playback_hls_url and another for the status transition via transitionVideoStatus. This is non-atomic and bypasses the state machine's safety checks for the URL update. Specifically, the first update (lines 70-77) does not verify if the video is in a valid state to receive a manifest URL, which could lead to database inconsistency if a stale or out-of-order webhook arrives (e.g., updating the URL of a video that has already failed or been deleted).

It is recommended to combine these into a single atomic UPDATE query that includes the status check in the WHERE clause, ensuring that the URL is only updated if the status transition is also valid. This follows the pattern already established in handleStreamWebhook.

    const allowedFrom = (VIDEO_STATUSES as readonly VideoStatus[]).filter((from) =>
      canTransition(from, target),
    );
    const placeholders = allowedFrom.map(() => '?').join(', ');

    const result = await c.env.DB.prepare(
      `UPDATE videos
       SET status = ?,
           playback_hls_url = COALESCE(?, playback_hls_url),
           updated_at = CURRENT_TIMESTAMP
       WHERE id = ?
         AND status IN (${placeholders})`,
    )
      .bind(target, payload.playbackHlsUrl ?? null, payload.videoId, ...allowedFrom)
      .run();

    const changes = result.meta?.changes ?? 0;
    if (changes === 0) {
      return c.json({ ok: true, matched: 0, status: target }, 202);
    }

    return c.json({ ok: true, matched: changes, status: target });

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e037341e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/workers/encoding.ts Outdated
Comment on lines +136 to +138
if (env.FFMPEG_WORKER_URL) {
await transitionVideoStatus(env.DB, videoId, 'encoding');
await sendToFfmpegWorker(env, videoId, r2Key);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep queue messages from retrying on FFmpeg misconfig

When FFMPEG_WORKER_URL is set but FFMPEG_WORKER_SECRET is missing, this branch still transitions the row to encoding and calls sendToFfmpegWorker, which throws; the catch path marks the row failed and rethrows, and queue() then calls message.retry() (src/workers/index.ts). In that configuration every encoding message becomes a poison retry loop (flipping failedencodingfailed) instead of staying queued until config is fixed, which can churn the queue and dead-letter valid jobs.

Useful? React with 👍 / 👎.

Comment thread src/workers/ffmpeg-webhook.ts Outdated
}
}

const transition = await transitionVideoStatus(c.env.DB, payload.videoId, target);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid marking FFmpeg videos ready without a playable source

This transitions FFmpeg-processed videos to ready without ever setting stream_video_id, but the watch client only uses HLS when stream_video_id is present (src/frontend/pages/Watch.tsx) and /api/videos/:id does not expose playback_hls_url. In environments using the new FFmpeg path (STREAM_ENABLED !== 'true'), ready rows for non-browser-native source formats (e.g. MOV/MKV) still fall back to /api/videos/:id/stream and remain unplayable despite successful transcoding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

When Cloudflare Stream is disabled, dispatch encoding jobs to a
containerized FFmpeg worker that writes HLS variants back to R2 and posts
to /api/webhooks/ffmpeg with an HMAC-signed completion callback. Adds
playback_hls_path column, /api/videos/:id/hls/* serve route, and a
derivePlaybackUrl helper that hides the encoder choice from the player.
@aloewright aloewright force-pushed the conductor/alo-136-r2-ffmpeg-fallback-encoding-path branch from 8e03734 to feed183 Compare May 9, 2026 03:03
@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 9, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conductor Conductor-managed PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants