One URL in, private adaptive video out
You have a URL to a video file that somebody else is holding, and you need it to become something your app can play: an adaptive ladder, on every device, reachable only by the people you intend. This guide covers that trip — the do-it-yourself version first, then the one-request version.
Three sources land here:
| Where the URL comes from | What you are racing |
|---|---|
| A generation model (fal, Replicate, Runway, Veo, Kling, HeyGen, Synthesia) | An expiry window on the vendor’s own storage |
| A Vercel Blob upload or another object store you already wrote to | Nothing — but the file is a single progressive MP4, not a stream |
| A meeting or session recording (LiveKit Egress, Daily, a screen recorder) | Usually nothing, but the file is long and the bitrate is wrong for the web |
The problem, in the vendors’ own words
Model hosts tell you to move the file, and several of them say so in their own documentation:
- Replicate states that prediction output files “expire after one hour” and that “you must save a copy of any files in the output” (output files).
- fal states that media URLs are “subject to your media expiration settings” and tells you to “download files before they expire”, with the window configurable per request (media expiration).
- Synthesia issues a “time-limited download link” for private videos (retrieve a video).
- HeyGen hands out presigned URLs and tells you to download what you need rather than storing the URL (video details).
We have not verified a retention window for Runway or Kling from their own documentation, so this guide does not state one. Check theirs before you rely on a number you read somewhere else.
Google Veo is the exception worth knowing about: it can write straight to a Cloud Storage bucket you own, in which case there is no expiry to race at all — the file is already yours and its lifetime is your bucket’s lifecycle rule.
The do-it-yourself path
This works, and it costs nothing but a machine to run it on. If your volume is low and you already have a box, do this.
# 1. Pull the file down inside the expiry window.
curl -fSL "$MODEL_OUTPUT_URL" -o source.mp4
# 2. Package a three-rung CMAF/HLS ladder.
ffmpeg -i source.mp4
-filter_complex "[0:v]split=3[v1][v2][v3];
[v1]scale=w=1920:h=1080[v1out];
[v2]scale=w=1280:h=720[v2out];
[v3]scale=w=854:h=480[v3out]"
-map "[v1out]" -c:v:0 libx264 -b:v:0 5000k -maxrate:v:0 5350k -bufsize:v:0 7500k
-map "[v2out]" -c:v:1 libx264 -b:v:1 2800k -maxrate:v:1 2996k -bufsize:v:1 4200k
-map "[v3out]" -c:v:2 libx264 -b:v:2 1400k -maxrate:v:2 1498k -bufsize:v:2 2100k
-map a:0 -map a:0 -map a:0 -c:a aac -b:a 128k -ac 2
-x264-params "keyint=48:min-keyint=48:scenecut=0"
-f hls -hls_time 4 -hls_playlist_type vod -hls_flags independent_segments
-hls_segment_type fmp4 -hls_segment_filename "stream_%v/seg_%03d.m4s"
-master_pl_name master.m3u8
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" "stream_%v/index.m3u8"
# 3. Put the result somewhere a browser can reach it.
aws s3 sync . s3://your-bucket/videos/$ID/ --exclude "source.mp4"What breaks when you do that at volume
- The expiry race. Step 1 has to finish inside the vendor’s window. When your queue backs up — a burst of generations, a machine that died mid-batch, a retry loop — the window closes while the job is still waiting, and the source is gone for good.
- Per-device compatibility. The ladder above is H.264-only and untested. Working out which profile, level, pixel format and audio configuration plays on an old Android and a current Safari is the part that takes a week.
- Captions. Generated clips arrive with baked-in audio and no transcript.
- Gated playback. If the video is paid or internal, a public bucket URL is not an answer.
- Storage of the master. You now own a growing pile of source files and the decision of when to delete them.
The one-request version
Disclosure: we make Transcodely, so the rest of this page is about our own product.
Hand us the URL and we do steps 1 to 3, plus the ladder design, the packaging and the delivery:
curl -X POST https://api.transcodely.com/transcodely.v1.VideoService/CreateFromUrl
-H "Authorization: Bearer {{API_KEY}}"
-H "Content-Type: application/json"
-d '{
"app_id": "app_k1l2m3n4o5",
"url": "https://replicate.delivery/pbxt/.../out.mp4",
"title": "Scene 1",
"visibility": "unlisted",
"auto_captions": true,
"hover_previews": true
}'const res = await fetch(
"https://api.transcodely.com/transcodely.v1.VideoService/CreateFromUrl",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TRANSCODELY_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
app_id: "app_k1l2m3n4o5",
url: modelOutputUrl,
title: "Scene 1",
visibility: "unlisted",
auto_captions: true,
hover_previews: true
})
}
);
const { video } = await res.json();Nothing has to exist first. The app does not need a storage origin, and managed delivery turns itself on the first time you use it — see Create a video from a URL for every parameter.
The video comes back in processing. Subscribe to the video.ready webhook, or Watch it, then read embed_url and embed_code off the ready video and put the iframe on the page. Those two are the public surface and are issued only for public and unlisted videos — for a private one, play playback_url in your own player instead. See the visibility table below.
Who can play it
visibility decides that, and it is the field to get right before you publish anything:
| Visibility | Who can reach it |
|---|---|
public | Anyone with the link; the hosted player page is indexable |
unlisted | Anyone with the link, but the player page is noindex |
private | Only your own authenticated reads are issued a URL at all |
Omit visibility and the app’s hosting default_visibility applies; when the app has none set either, the video is created unlisted.
Playback and poster URLs are signed and time-limited in every case — BunnyCDN directory tokens, valid about six hours, minted fresh on each read. An unsigned CDN link is rejected at the edge. Because the token expires, play the value from the latest Get, List or Watch response rather than caching it. The full rules are in Playback, embeds, and posters.
For a paid-content workflow, pair private visibility with DRM — Widevine, FairPlay and PlayReady, with your own keys or ours.
Captions
Generated clips arrive with baked-in audio and no transcript, so set auto_captions: true on the ingest call — as the example above does. The managed job’s output carries a generated WebVTT subtitle track with the language auto-detected, produced by the same job on the same worker as the encode, and listed in the streaming manifest so players find it natively. It surfaces as text_tracks on the ready video. Billed as the per-source-minute captions fee on that job, and only when a caption track was actually delivered.
For a video that is already hosted, point a captions-only job at it instead and you get the same track without re-encoding anything — see Retro-caption an already-hosted video.
Speech recognition runs on the encoding worker either way; the audio is not sent to a third-party transcription service.
The honest parts
Submit the URL promptly. The worker downloads your URL when the job starts, not when you make the request. If the job waits behind other work — a concurrency limit, a busy fleet — an hour-long expiry window can close before the fetch happens. Today that surfaces as input_not_found, which reads like a bad URL rather than an expired one; a distinct expired-source failure is planned, and until it ships, treat input_not_found on a model URL as “re-generate or re-fetch and submit again”.
If you can, avoid the race entirely: write the model’s output to a bucket you control first (Veo does this natively with an output bucket), and ingest from there.
Stitching is not built. If your app generates four-second clips and needs one continuous deliverable, we do not assemble timelines, join clips, or re-frame a 16:9 master to 9:16. One URL in produces one video out. Do the assembly before you send it.
Watching a bucket is not built either. There is no rule that turns a new object in your bucket into a job automatically. Today you make the call yourself when the object lands.
Next
- Create a video from a URL — every parameter and the full response
- Webhook integration — receive
video.readyinstead of polling - AI captions — caption a hosted video after the fact
- Pricing — what an ingest, a ladder and a caption track each cost