Property Video Ads
Generate 15-second vertical Reels-shaped video ads for a property from its photos, with AI camera motion and branded overlays. Powered by Replicate + FFmpeg + Meta Marketing API.
Admin only — Video ad generation is currently exposed in the admin property detail page. Per-organization self-serve will follow once the cluster concurrency cap is comfortable at higher volume.
Overview
A property video ad is a 15-second vertical (1080×1920) MP4 built from your top three property photos. The pipeline:
- Pick 3 photos — featured-first, then by display order. Duplicates are skipped. The property needs at least 3 distinct photos or the render is rejected.
- Generate 3× 5-second clips via Replicate (
prunaai/p-video) with pre-defined camera-motion prompts: slow push-in, slow orbit, lateral pan. - Composite with FFmpeg — center-crop each clip to 1080×1920,
xfade0.5s crossfade transitions, append a 1s clone-hold on the final frame, overlay two branded Canvacord PNG badges (location + price) with alpha-expression fade-in/fade-out. - Upload to DigitalOcean Spaces at
ads/video/<adCreativeId>/final.mp4plus a thumbnail frame from t=7.5s. - (Optional) push to Meta — uploads via
POST /act_{id}/advideos, polls untilstatus: ready, then creates an AdCreative usingobject_story_spec.video_data.
No audio is added on our side. Meta's "Add music" toggle handles the soundtrack at publish time.
Cost
Roughly $0.75 per video in Replicate spend — three 5-second clips at ~$0.05/second of generated video. Compositing, Spaces storage, and Meta upload are negligible.
Generating a video
- Open Admin → Property Listings and click into a property that has at least 3 photos.
- In the Details tab, find the Video Ad card.
- Click Generate video ad. The render typically takes 60–120 seconds end-to-end — most of that wall time is waiting on Replicate.
- The card polls the render job every 3 seconds. When
status === succeeded, the embedded<video>preview appears with Open MP4 and Open thumbnail links.
If the cluster is already running 2 video renders, your job lands in
the queued state. The video-render-queue-drain cronjob (every 60s)
promotes it to pending and spawns the K8s Job as soon as capacity frees.
Pushing to Meta
After a render succeeds, push it to a Meta campaign:
POST /admin/ads/push-video-to-meta/:adCreativeId
{
"campaignId": "1234567890",
"pageId": "9876543210",
"websiteUrl": "https://yourbrand.com/properties/<slug>",
"language": "en-US",
"instantFormId": "1122334455",
"dailyBudget": 10,
"status": "PAUSED"
}
This uploads the MP4 to Meta by URL (no buffer roundtrip), polls until
the video is ready, and creates the AdCreative + Ad in a single
request. If you omit adSetId, the service reuses an existing AdSet on
the campaign matching the targeting language (same rule as carousel
ads), or creates a new lead-gen AdSet if none exists.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Property must have at least 3 distinct photos | Property has fewer than 3 image URLs. | Upload more photos. Duplicates are not counted. |
Render status stuck at pending for >2 min | K8s Job didn't schedule (cluster full or image pull issue). | kubectl get jobs -l kind=video-render to inspect; the queue drain will mark it failed after 30 min. |
Render failed: Meta video processing failed | Meta rejected the MP4 (corrupt, wrong codec, expired URL). | Re-render — the Spaces URL has a 30-day TTL so this is almost always a transient transcode issue on Meta's side. |
aspect_ratio is ignored warning in logs | Expected. prunaai/p-video ignores aspect_ratio when an input image is provided; FFmpeg center-crops to vertical in compositing. | None — informational only. |
How retention works
final.mp4andthumbnail.jpg: 30-day TTL hint on Spaces (kept public).- Intermediate clips (
clip-0.mp4throughclip-2.mp4) and overlay PNGs: cleaned up by the nextfiles-cleanuppass after 24 hours.
If you need to re-render the same property, generate a new video — the old AdCreative row is left intact so historical campaigns keep working.
Hard rules baked into the pipeline
- Replicate only for clip generation. No in-cluster diffusion.
- FFmpeg only for compositing. No Remotion / Chromium.
- K8s Jobs only for rendering. One Job per render, never a long-lived worker pod.
- No audio from us. Every FFmpeg invocation passes
-an.
For the architecture details, see
docs/plans/2026-05-19-property-video-ads.md
in the monorepo.