Developer Guide

Restore the production video-export container

Runbook for restoring VideoExportContainer on the Storys.ai production Worker

VideoExportContainer production restore

This runbook covers issue #9. It is a deployment plan, not a declaration that the production binding is currently restored. Production is intentionally Docker-free until a Docker-capable build path has been reviewed and exercised.

Current state and scope

The implementation is still versioned in the repository:

  • containers/video-export/Dockerfile builds the Node 22 Debian image and installs Bun 1.3.12, node-av, and its FFmpeg payload.
  • src/lib/containers/video-export-container.ts defines the VideoExportContainer Durable Object (port 8080, five-minute idle timeout).
  • src/lib/workflows/sequence-export-workflow.ts calls the container at POST /export, validates x-export-meta and content-length, and streams the MP4 into R2.
  • src/server.ts must re-export the Durable Object class so Wrangler includes it in the Worker bundle.

Commit de06b84a removed only the production Container declaration, the VIDEO_EXPORT_CONTAINER Durable Object binding, and migration v2, because the production deployment machine had no Docker. The default and env.test blocks must stay Docker-free. Local development uses VIDEO_EXPORT_DEV_URL (bun dev:all sets it to http://localhost:8080), and the existing PR-preview workflow patches the default block and builds a container on an Ubuntu runner.

Preconditions and ownership

Before changing production configuration, the deployment owner must confirm:

  1. The selected runner has a functioning Docker engine, can build Linux node:22-bookworm-slim images, and has outbound access to bun.sh, npm package sources, and the FFmpeg download used by node-av.
  2. The Cloudflare token can deploy Worker storys, apply remote D1 migrations, update the production Durable Object/container application, and write the configured R2 bindings. In Cloudflare terms this normally means Workers Scripts edit, D1 edit, R2 edit, and Workers Containers edit permissions on the Builtopia account; verify the exact account-token labels before use.
  3. The account resources are unchanged from the checked-in production config: Worker storys, D1 storys-prd (122ee867-0478-4c9e-bc84-d718b2f4c1fa), R2 storys-public-assets, and R2 storys-storage.
  4. The change has passed independent QA. This agent does not have production deployment credentials and must not claim the container is restored.

Build and image smoke test

Run these checks from the repository root at the exact commit that will be deployed. The Dockerfile performs its own frozen install; the root install still catches lockfile or workspace drift before a deployment is attempted.

git rev-parse HEAD
bun install --frozen-lockfile

IMAGE="storys-video-export:$(git rev-parse --short HEAD)"
docker build --pull -t "$IMAGE" ./containers/video-export
docker run --rm -d --name storys-video-export-smoke -p 8787:8080 "$IMAGE"
trap 'docker rm -f storys-video-export-smoke >/dev/null 2>&1 || true' EXIT

curl --fail --silent --show-error http://127.0.0.1:8787/ping
curl --fail --silent --show-error -X POST \
  http://127.0.0.1:8787/export \
  -H 'content-type: application/json' \
  -o /tmp/storys-video-export-smoke.mp4 \
  -D /tmp/storys-video-export-smoke.headers \
  -d '{"scenes":[{"orderIndex":0,"videoUrl":"https://media.w3.org/2010/05/sintel/trailer.mp4"},{"orderIndex":1,"videoUrl":"https://media.w3.org/2010/05/sintel/trailer.mp4"}],"musicUrl":null,"musicLoudnessGainDb":null}'
grep -F 'content-type: video/mp4' /tmp/storys-video-export-smoke.headers
grep -F 'x-export-meta:' /tmp/storys-video-export-smoke.headers
grep -Eiq '^content-length:[[:space:]]*[1-9][0-9]*([[:space:]]|$)' /tmp/storys-video-export-smoke.headers
test -s /tmp/storys-video-export-smoke.mp4

The two copies of the same AVC clip deliberately exercise the supported transmux path. A mixed codec or mixed-resolution fixture is expected to fail; that is an application limitation, not a container liveness failure. Do not publish the image to a separate registry as part of this plan: the containers[] entry below makes wrangler deploy build and publish the image through Cloudflare's deployment path.

Production-only Wrangler wiring

After the Docker smoke test and independent review, restore these fields under env.production in wrangler.jsonc. Do not add them to the default or env.test blocks:

"containers": [
  {
    "class_name": "VideoExportContainer",
    "image": "./containers/video-export/Dockerfile",
    "instance_type": "standard",
    "max_instances": 3
  }
],
"durable_objects": {
  "bindings": [
    { "name": "REALTIME", "class_name": "RealtimeChannel" },
    {
      "name": "VIDEO_EXPORT_CONTAINER",
      "class_name": "VideoExportContainer"
    }
  ]
},
"migrations": [
  {
    "tag": "v1",
    "new_sqlite_classes": ["RealtimeChannel"]
  },
  {
    "tag": "v2",
    "new_sqlite_classes": ["VideoExportContainer"]
  }
]

durable_objects and migrations are not inherited by an environment block, so the production block must contain both the existing v1 RealtimeChannel entry and the new v2 class entry. v2 is a Durable Object class migration, not a D1 SQL migration; it must be applied by the Worker deployment and must not be removed or renumbered later.

Deployment order

The sequence below is intentionally ordered so a failed image build cannot leave a Worker pointing at an unavailable binding:

  1. Review the MR and confirm the Docker-capable runner, account resources, and token permissions. Keep production deployment ownership with fucheng.

  2. Merge the reviewed configuration to main.

  3. In a clean checkout of that merge, install dependencies and generate Worker types:

    bun install --frozen-lockfile
    bun cf:typegen
  4. Build with the production environment selected. This is required because @cloudflare/vite-plugin bakes the selected environment into dist/server/wrangler.json; a build without CLOUDFLARE_ENV=production would select the local placeholder D1 binding:

    CLOUDFLARE_ENV=production bun run build
  5. Flatten the nested Drizzle migrations, then apply remote D1 migrations to the production binding before deploying the Worker. The canonical project command is equivalent to the two commands shown here:

    bun scripts/flatten-migrations.ts
    bunx wrangler d1 migrations apply DB --env=production --remote

    A migration failure must stop the deployment. Wrangler backs up the D1 database and rolls back a failed SQL migration; investigate the error before retrying.

  6. Deploy the Worker and production container from the same build output:

    bunx wrangler deploy --env=production

    The repository's bun run cf:deploy:prd wraps steps 3–6 (type generation, production build, migration flatten/apply, and deploy) for an approved manual run. The deploy must complete the Container image build before the version is treated as live.

  7. Record the deployed version and inspect its state. These are the verified Wrangler commands:

    bunx wrangler deployments list --name storys --json
    bunx wrangler deployments status --name storys --json

    Confirm the new version is active, the Worker is storys, and the production binding view contains VIDEO_EXPORT_CONTAINER. Confirm the Cloudflare Containers application is healthy and /ping returns 200 ok before attempting an API export.

Production API smoke test

Use a disposable, authenticated API key and a sequence owned by that key's team. The selected videos must form a uniform AVC/transmux-compatible cut. Do not use a customer sequence for the first smoke test.

set -euo pipefail

BASE_URL="${STORYS_PRODUCTION_URL:?set the production hostname for this deploy}"
AUTH_HEADER="Authorization: Bearer ${API_KEY:?set the disposable API key}"
EXPORT_PATH="$BASE_URL/api/v1/sequences/$SEQUENCE_ID/exports"
START_JSON="$(mktemp)"
GET_JSON="$(mktemp)"
START_HEADERS="$(mktemp)"
MP4_HEADERS="$(mktemp)"
MP4_FILE="$(mktemp --suffix=.mp4)"
trap 'rm -f "$START_JSON" "$GET_JSON" "$START_HEADERS" "$MP4_HEADERS" "$MP4_FILE"' EXIT

POST_STATUS="$(curl --fail --silent --show-error -X POST \
  "$EXPORT_PATH" \
  -H "$AUTH_HEADER" \
  -H 'content-type: application/json' \
  -d '{}' \
  -D "$START_HEADERS" \
  -o "$START_JSON" \
  -w '%{http_code}')"
test "$POST_STATUS" = 202
jq -e '.export.status == "processing" and (.export.id | type == "string")' "$START_JSON" >/dev/null
EXPORT_ID="$(jq -er '.export.id' "$START_JSON")"

DEADLINE="$(( $(date +%s) + 1800 ))"
while :; do
  GET_STATUS="$(curl --fail --silent --show-error \
    "$EXPORT_PATH" \
    -H "$AUTH_HEADER" \
    -o "$GET_JSON" \
    -w '%{http_code}')"
  test "$GET_STATUS" = 200
  EXPORT_JSON="$(jq -cer --arg export_id "$EXPORT_ID" \
    '[.exports[] | select(.id == $export_id)] | if length == 1 then .[0] else error("export not found") end' \
    "$GET_JSON")"

  case "$(jq -r '.status' <<<"$EXPORT_JSON")" in
    processing)
      if [ "$(date +%s)" -ge "$DEADLINE" ]; then
        echo 'export did not become ready within 30 minutes' >&2
        exit 1
      fi
      sleep 10
      ;;
    ready)
      jq -e '.url | type == "string" and length > 0' <<<"$EXPORT_JSON" >/dev/null
      jq -e '.durationSeconds | type == "number" and . > 0' <<<"$EXPORT_JSON" >/dev/null
      EXPORT_URL="$(jq -er '.url' <<<"$EXPORT_JSON")"
      curl --fail --silent --show-error -L \
        "$EXPORT_URL" \
        -D "$MP4_HEADERS" \
        -o "$MP4_FILE"
      grep -Eiq '^content-type:[[:space:]]*video/mp4([;[:space:]]|$)' "$MP4_HEADERS"
      test -s "$MP4_FILE"
      break
      ;;
    failed)
      jq -e '.error | type == "string" and length > 0' <<<"$EXPORT_JSON" >/dev/null
      echo 'export workflow failed; use a known uniform AVC sequence and inspect the error above' >&2
      jq -r '.error' <<<"$EXPORT_JSON" >&2
      exit 1
      ;;
    *)
      echo "unexpected export status: $(jq -r '.status' <<<"$EXPORT_JSON")" >&2
      exit 1
      ;;
  esac
done

The POST must return 202 and a processing export. Poll the GET endpoint until the same export is ready with a non-null URL and positive durationSeconds; download that URL and verify it is a non-empty MP4. A failed export with an incompatible fixture does not validate the deployment; repeat with a known uniform AVC sequence. A 5xx, missing VIDEO_EXPORT_CONTAINER error, missing x-export-meta, or missing content-length is an infrastructure/contract failure and blocks sign-off.

Local, test, and preview behavior

  • Default/local and env.test remain without containers[], the VIDEO_EXPORT_CONTAINER binding, and migration v2. This keeps unit tests, e2e, and bun dev hermetic and Docker-free.
  • bun dev:all starts containers/video-export with Bun and sets VIDEO_EXPORT_DEV_URL=http://localhost:8080; this is the local export path.
  • The PR-preview workflow patches the default config with the Container, binding, and both migrations, then runs on ubuntu-latest with Docker. Its per-PR Worker, D1, workflow names, and Container application must remain isolated from production.

Rollback and failure handling

Before activation

  • A failed Docker build, missing node-av/FFmpeg payload, failed typecheck, or failed D1 migration stops the sequence before wrangler deploy.
  • If the Container application cannot be created or becomes unhealthy, keep the previous Worker version serving and fix the runner/configuration. Do not remove the production D1 or R2 resources as a recovery shortcut.

After a Worker version is active

  1. Capture the active and previous version IDs:

    bunx wrangler deployments list --name storys --json
  2. Roll back only the Worker version if the new release causes a regression:

    bunx wrangler rollback <previous-version-id> \
      --name storys --yes \
      --message 'Rollback video-export restore after smoke-test failure'
  3. Keep the production source configuration's v2 migration and VIDEO_EXPORT_CONTAINER declaration in subsequent deploys. There is no safe reverse migration for a Durable Object class; removing or renumbering v2 does not undo the registered class and can strand future deployments. Rolling the Worker code back may temporarily leave the binding unused, which is safe. Do not delete the Container application while in-flight export workflows may still reference it.

  4. Re-run the /ping and authenticated export smoke tests after the rollback, then record the failed version, rollback version, error, and follow-up MR.

The D1 migration and Worker rollback are separate operations: a successful D1 migration is not reversed by wrangler rollback. D1 schema changes must remain backwards-compatible with the previous Worker version. The v2 DO class registration is retained permanently once applied.

Handoff and completion criteria

This issue is complete only when the runbook is merged, an independent QA review has confirmed the commands and production-only wiring, and fucheng has performed the Docker-capable deployment plus the live smoke tests. The agent implementing this plan should then report the commit/MR and explicitly request the deployment handoff; it must not claim production restoration without the live binding and export evidence.