Skip to content

Upload an episode video

Video uploads never touch our servers. The admin requests a CF Stream direct upload URL from the API; your browser uploads directly to Cloudflare using the TUS resumable protocol; CF calls back over HMAC-verified webhook when the asset is ready.

sequenceDiagram
autonumber
participant U as Admin browser
participant A as TwistCue API
participant CF as Cloudflare Stream
U->>A: POST /admin/media/uploads/direct<br/>{kind, seriesId?, episodeId?, sizeBytes, meta}
A->>CF: Create direct upload (TUS)
CF-->>A: {uploadUrl, uid}
A-->>U: {uploadUrl, assetId, provider_id=uid}<br/>media_asset row (status=uploading)
U->>CF: TUS upload (resumable)
CF-->>U: 200 (bytes accepted)
CF->>A: POST /admin/media/webhook/cf-stream<br/>(HMAC-signed)
A->>A: mark asset ready<br/>store playbackId, duration, thumbs
Note over U: Poll GET /admin/media/assets/:id<br/>or subscribe via query invalidation
  1. Open the series → Episodes tabNew episode.
  2. Enter episode number, title, and (optionally) synopsis.
  3. On the Media section, click Upload video.
  4. Pick your file. The browser starts a TUS resumable upload; you get a live progress bar. You can close the tab: the upload resumes when you re-open it (TUS handles the offset).
  5. The row appears in Media → Uploads with status uploading, then processing when CF starts transcoding.
  6. When status flips to ready, the episode row auto-links to the new asset and shows the auto-extracted thumbnail candidates. Pick one, or upload a custom.
StateMeaningRecoverable?
uploadingBytes still landing at CF.Yes: resume the TUS upload.
processingCF transcoding. Nothing to do; wait.N/A
readyPlayable. Playback ID minted.N/A
failedCF rejected the source (corrupt, unsupported codec, or webhook lost).Yes: hit Retry in the uploads list, or delete + re-upload.

Signed HLS URLs are minted at play time by the mobile-facing API, not by the admin. The admin only stores the immutable playback_id. The signing service adds a short TTL and (optionally) IP scope so URLs can’t be shared.

Once the video is ready, upload captions per language on the same episode:

  • Media → Captions section on the episode editor
  • Format: SRT or VTT
  • One file per language (EN / AR / ID). The API stores each as a separate media_asset with kind=caption and lang=….

See Add captions for the deep dive.

Every episode has a cliffhanger_score (0–100). Set it based on where the story leaves off:

  • 0–30: episode wraps a beat cleanly.
  • 30–70: normal mid-arc episode.
  • 70–100: cliffhanger. The mobile app will never show a paywall or rewarded-ad prompt immediately after this episode (see Constants).

This is the single most important editorial call in the app. Get it right.

CF auto-extracts three thumbnail candidates on transcode. If none look right:

  1. Click Upload custom thumbnail on the episode editor.
  2. Recommended 1280×720 (16:9), JPEG or WebP.
  3. Saved as a separate media_asset with kind=thumb and linked as thumb_asset_id on the episode.
  • Webhook not received? Rare, but possible. The media-sync cron polls CF every minute for assets stuck in uploading or processing > 10 minutes and reconciles state. You should never need to manually poke the API.
  • Uploading before the episode exists? You can upload from Media → Library without linking to an episode, then link later on the episode editor. The media_asset will have episode_id=null until linked.
  • File too large? CF has provider limits (currently 30GB / 4 hours). The direct-upload endpoint refuses anything over your account’s plan cap.
  • Wrong codec? CF re-encodes on ingest, but H.264/H.265 in an MP4 container is by far the most reliable input. AV1 sources sometimes fail: check the failure reason in the uploads list.
ActionEndpoint
Create direct uploadPOST /v1/admin/media/uploads/direct
List assetsGET /v1/admin/media/assets
Retry failed assetPOST /v1/admin/media/assets/:id/retry
CF webhook (system)POST /v1/admin/media/webhook/cf-stream (HMAC verified)
Link asset to episodePUT /v1/admin/episodes/:id/media

Full details in API: Media.