
Wraps fal.ai's video models for text-to-video, image-to-video, and video-to-video generation with three quality tiers (budget at $0.25/5s up to premium at $1.20/5s). The implementation handles a real gotcha: fal serves results with CSP headers that break browser playback, so the skill always downloads to local workspace files instead of handing users dead links. Image and video references need public URLs, which it solves by running a persistent preview server to expose assets. The polling and billing proxy logic is baked in so you just call generate_video() with a prompt and model tier. Solid if you need short AI clips without rebuilding the fal integration yourself.
npx -y skills add starchild-ai-agent/official-skills --skill video --agent claude-codeInstalls into .claude/skills of the current project.
Use this skill for all video-generation requests on Starchild.
Core principle: call the provided scripts. Do not re-implement proxy/billing/upload plumbing.
⚠️ Execution context — read this first. The code blocks below are Python, not shell commands. Starchild's
bashtool runs/bin/bash -c, which cannot parseexec(open(...))— pasting them directly into a bash command will fail withsyntax error near unexpected token 'open'. Also,exec(open(...))insidepython3 -cfails withNameError: __file__because the script uses__file__for path resolution.Use
python3 - <<'EOF'withfrom exports importwhen calling via the bash tool:python3 - <<'EOF' import sys sys.path.insert(0, "skills/video") from generate_video import generate_video result = generate_video( prompt="A cinematic drone shot over snowy mountains at sunrise", model="balanced", duration=5, ) print(result) EOFThe heredoc (
<<'EOF') preserves all quotes and newlines — no escaping needed. Note: video skill has noexports.py— import directly fromgenerate_video.
exec(open('skills/video/generate_video.py').read())
result = generate_video(
prompt="A cinematic drone shot over snowy mountains at sunrise",
model="balanced", # "budget" | "balanced" | "premium"
duration=5,
)
# result -> {"success": True, "cost": 0.70, "video_url": "...", "local_path": "output/videos/..."}
generate_video automatically: submits → polls → fetches result → downloads mp4 to output/videos/.
Never hand the user the raw video_url (e.g. https://*.fal.media/.../*.mp4). fal serves these files with Content-Security-Policy: sandbox; default-src 'none', which means:
<video> / <iframe> is blocked by CSP.Content-Disposition: attachment header, so the browser does not auto-download either.?download=1, etc.) cannot fix this — only a server-side header change would, and we don't control fal's CDN.The only reliable user-facing delivery path is the already-downloaded local file:
result["local_path"] (e.g. output/videos/xxx.mp4) — generate_video always downloads on success.output/videos/<filename> and is viewable in the workspace file panel / file browser.
(or link as [video](output/videos/<filename>.mp4) — the workspace serves these directly with the right headers).send_to_telegram(file_path="output/videos/...", message_type="video") or send_to_wechat(file_path="output/videos/...", message_type="video").If the download somehow failed (local_path missing) — re-fetch with:
curl -L -o output/videos/<filename>.mp4 "<video_url>"
Then deliver the local path. Still do not give the user the raw fal URL as the primary deliverable.
fal.ai needs the reference asset as a public https URL. fal storage upload requires a Serverless permission your key currently does not have. The reliable path is to expose the asset via a published Starchild preview.
output/fal_assets/ using publish_asset.py.fal-assets is running and published (one-time setup, see §3).<preview_base>/<filename>.generate_video(... image_url=public_url).# Step 1: publish a local image into the asset folder
exec(open('skills/video/publish_asset.py').read())
asset = publish_local('/path/to/your/photo.jpg')
# or: publish_from_url('https://example.com/photo.jpg')
filename = asset['filename']
# Step 2: combine with the preview's public base URL (see §3)
public_url = f"https://community.iamstarchild.com/<user_slug>-fal-assets/{filename}"
# Step 3: image-to-video
exec(open('skills/video/generate_video.py').read())
result = generate_video(
prompt="gentle cinematic camera push-in",
model="balanced",
duration=5,
image_url=public_url,
)
generate_video auto-rewrites the model path from */text-to-video to */image-to-video whenever image_url is provided. The same approach works for video-to-video models — pass an mp4 URL instead.
publish_asset.py).jpg .jpeg .png .webp .gif .bmp, max 10 MB.mp4 .mov .webm .mkv .m4v, max 100 MBfal-assets public preview setupRun this once per workspace. The preview keeps running across sessions.
# 3.1 ensure the asset folder exists with a placeholder index
import os, pathlib
pathlib.Path('output/fal_assets').mkdir(parents=True, exist_ok=True)
if not os.path.exists('output/fal_assets/index.html'):
open('output/fal_assets/index.html', 'w').write(
'<!doctype html><html><body><h1>fal asset host</h1></body></html>'
)
# 3.2 start the preview
preview(action='serve', dir='output/fal_assets', title='fal-assets')
# 3.3 publish to a public URL
preview(action='publish', preview_id='<id from step 3.2>', slug='fal-assets', title='fal-assets')
# → public base: https://community.iamstarchild.com/<user_slug>-fal-assets/
After publish, the public base URL is reusable for every future image-to-video / video-to-video task. Files dropped into output/fal_assets/ become reachable as <base>/<filename> immediately — no re-publish needed.
Verify with:
curl -sI https://community.iamstarchild.com/<user_slug>-fal-assets/<filename>
# expect: HTTP/2 200, content-type: image/* or video/*
If preview(action='serve') returns No available ports in pool, ask the user which existing preview can be stopped to free a port — never silently kill one.
| Tier | Model | Cost / 5s | Notes |
|---|---|---|---|
| budget | fal-ai/wan/v2.5/text-to-video | $0.25 | Fastest, cheapest; good for prompt iteration |
| balanced | alibaba/happy-horse/text-to-video | $0.70 | Default; best lip-sync, most use cases |
| premium | bytedance/seedance-2.0/fast/text-to-video | $1.20 | Best motion + camera direction |
| mini | bytedance/seedance-2.0/mini/text-to-video | $0.36 (480p) / $0.77 (720p) | Cheapest Seedance; resolution-tiered, no 1080p. Duration must be a string ("5", not 5 or "5s") — see gotcha below |
| premium-25 | bytedance/seedance-2.5/text-to-video | token-based | Supports text-to-video, image-to-video, and reference-to-video. Requires resolution (480p/720p), aspect_ratio (six supported ratios), and integer duration from 4–30 seconds. Estimate with estimate_cost(..., aspect_ratio=...). |
| — | xai/grok-imagine-video/v1.5/image-to-video | $0.41 (480p) / $0.71 (720p) per 5s | image-to-video ONLY (single required image_url, no image_urls); +$0.01 input-image surcharge included in estimate. ⚠️ resolution="1080p" is schema-valid upstream but has NO published price — the proxy rejects it 400 fail-closed |
| — | fal-ai/kling-video/v3/turbo/standard/text-to-video | $0.56 per 5s | Kling v3 Turbo Standard, flat $0.112/s; .../turbo/pro/... = $0.14/s ($0.70/5s); .../v3/4k/... = $0.42/s ($2.10/5s). i2v variants exist for all |
| — | alibaba/happy-horse/v1.1/text-to-video | $0.70 (720p) / $0.90 (1080p) per 5s | v1.1 has its own 1080p tier $0.18/s (NOT the v1.0 2× rule); also /image-to-video, /reference-to-video |
| — | fal-ai/minimax_h3/text-to-video | proxy pricing applies | Supports text-to-video, image-to-video, and reference-to-video. For reference-to-video pass image_urls=[...]; the payload is translated to upstream reference_image_urls. |
⚠️ Happy Horse default resolution is 1080p upstream (v1.0 and v1.1): omitting resolution bills the 1080p tier (v1.1 5s = $0.90; v1.0 ref2v 5s = $1.40). Pass resolution="720p" explicitly for the cheaper rate. Invalid resolution values are rejected 400 by the proxy.
Reference-to-video: pass image_urls=[...] (list of 1–9 public HTTP(S) URLs) — NOT the single image_url param. generate_video() validates count and URL scheme. Happy Horse and Seedance 2.5 submit the image_urls field; MiniMax H3 (fal-ai/minimax_h3/reference-to-video) submits upstream's reference_image_urls field.
Seedance 2.5 example:
result = generate_video(
prompt="A cinematic close-up of a paper crane unfolding",
model="bytedance/seedance-2.5/text-to-video",
duration=5,
resolution="720p",
aspect_ratio="16:9",
)
Use an integer duration from 4–30 seconds. resolution must be 480p or 720p; aspect_ratio must be one of 21:9, 16:9, 4:3, 1:1, 3:4, 9:16. The proxy rejects auto values because they cannot be priced safely.
Override by passing the full model id to generate_video(model=...). Image-to-video variants are auto-derived by replacing text-to-video with image-to-video.
Pricing details and model registry live in generate_video.py::estimate_cost. For models not yet registered there, the legacy fallback is only a rough estimate and may differ from the proxy; do not use it for budgeting new endpoints.
exec(open('skills/video/poll_status.py').read())
result = poll_video("019ded6c-d871-7290-bbf1-ddc6993f8958")
Use this when an earlier generate_video call timed out or you only have a request_id.
generate_video.py — submit → poll → download. Handles text-to-video and image-to-video.publish_asset.py — copy local files (or download remote URLs) into output/fal_assets/ so they can be served by the fal-assets preview.poll_status.py — resume polling by request_id, downloads the result on completion.| Problem | Fix |
|---|---|
image_url must be a public HTTP(S) URL | Use publish_asset.py + fal-assets preview, then pass the public URL |
No available ports in pool (preview serve) | Ask the user which preview to stop; do not auto-kill |
downstream_service_error after COMPLETED | Reference asset host failed mid-render — re-encode/resize to 16:9, re-publish, retry |
HTTP 402 insufficient_credits | Top up balance; cost is pre-charged on submit |
HTTP 403 endpoint_not_allowed | sc-proxy only allows approved fal video endpoints; pick one from the model table |
Generation FAILED upstream | Shorten prompt, drop unusual tokens, retry once before changing model |
HTTP 422 literal_error on duration (Seedance Mini) | Mini requires duration as a string ("5", "10", "auto"), not an int and not "5s". generate_video() encodes this automatically when model contains seedance-2.0/mini — only hit this if you hand-build the request body. Other Seedance variants accept int/"5s" as before. |
Seedance 2.5 rejects auto or returns resolution_not_priceable / aspect_ratio_not_priceable | Pass explicit resolution="480p" or "720p", an explicit supported aspect_ratio, and integer duration from 4–30. Seedance 2.5 uses token-based pricing; call estimate_cost(model, duration, resolution, aspect_ratio) for a local estimate. |
| MiniMax H3 reference request returns a parameter error | Use image_urls=[...] with the fal-ai/minimax_h3/reference-to-video model. generate_video() translates it to upstream reference_image_urls; do not hand-send image_urls to upstream. |
Job stuck IN_PROGRESS >15 min | Save request_id, resume later with poll_status.py |
| User reports the fal.media link "shows nothing" / "blank page" | Expected — fal serves with CSP: sandbox; default-src 'none'. Deliver the local file at result["local_path"] instead of the raw URL (see §1). |
sc-proxy → queue.fal.run (and api.fal.ai) → fal model providersAuthorization: Key fake-falai-key-12345 (proxy injects the real FAL_KEY)403 endpoint_not_allowed.https://*.fal.media/... — public CDN, no auth needed for download.generate_video.py::estimate_cost and in transparent-proxy/apis/falai.py::_VIDEO_PRICING.FAL_KEY lacks Serverless permission. Keep using the preview-based approach until that changes.