From 7909477d2f182ab8a5c0e537863667841c61f32b Mon Sep 17 00:00:00 2001 From: kocaemre Date: Fri, 17 Jul 2026 13:09:30 +0200 Subject: [PATCH] feat: add fal.ai provider support --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 5 +- README.md | 39 ++- skills/scroll-world/SKILL.md | 55 +++- skills/scroll-world/references/fal-ai.md | 165 ++++++++++++ .../scroll-world/references/fal_pipeline.py | 242 ++++++++++++++++++ skills/scroll-world/references/pipeline.md | 6 +- 7 files changed, 487 insertions(+), 29 deletions(-) create mode 100644 skills/scroll-world/references/fal-ai.md create mode 100644 skills/scroll-world/references/fal_pipeline.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1c79612..d9397d5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "scroll-world", - "description": "Immersive scroll-scrubbed 'fly through the world' landing pages, generated with Higgsfield.", + "description": "Immersive scroll-scrubbed 'fly through the world' landing pages, generated with Higgsfield or fal.ai.", "owner": { "name": "cyw", "email": "cyw@cywang.me" @@ -11,7 +11,7 @@ "name": "scroll-world", "source": "./", "skills": ["./skills/scroll-world"], - "description": "Build an immersive scroll-scrubbed landing page where a pre-rendered camera flies from outside each scene into its interior, then flows on to the next with no cuts. Interviews the user for topic, story beats, and brand kit; generates cohesive scenes + seamless camera clips with Higgsfield; wires a portable, framework-agnostic scrub engine.", + "description": "Build an immersive scroll-scrubbed landing page where a pre-rendered camera flies from outside each scene into its interior, then flows on to the next with no cuts. Interviews the user for topic, story beats, brand kit, mobile needs, and budget; generates cohesive scenes + seamless camera clips with Higgsfield or fal.ai; wires a portable, framework-agnostic scrub engine.", "category": "design" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a058aa7..c44740a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scroll-world", - "description": "Build an immersive scroll-scrubbed 'fly through the world' landing page for any industry or brand using Higgsfield. As the visitor scrolls, a pre-rendered camera flies from outside each scene into its interior, then flows on to the next scene with no cuts — one continuous connected flight. Interviews the user for the topic, story beats, and brand kit, then generates cohesive scenes + seamless camera clips and wires a portable, framework-agnostic scroll-scrub engine. Use for a '3D world' / 'browse-through-the-industry' hero, a scroll cinematic, or a diorama landing.", - "version": "0.3.0", + "description": "Build an immersive scroll-scrubbed 'fly through the world' landing page for any industry or brand using Higgsfield or fal.ai. As the visitor scrolls, a pre-rendered camera flies from outside each scene into its interior, then flows on to the next scene with no cuts — one continuous connected flight. Interviews the user for the topic, story beats, brand kit, mobile needs, and budget, then generates cohesive scenes + seamless camera clips and wires a portable, framework-agnostic scroll-scrub engine. Use for a '3D world' / 'browse-through-the-industry' hero, a scroll cinematic, or a diorama landing.", + "version": "0.4.0", "author": { "name": "cyw", "email": "cyw@cywang.me" @@ -10,6 +10,7 @@ "license": "MIT", "keywords": [ "higgsfield", + "fal-ai", "landing-page", "scroll", "video", diff --git a/README.md b/README.md index 31ed634..e54affc 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ cp -R scroll-world/skills/scroll-world ~/.codex/skills/ # Codex ## Requirements -- The [Higgsfield CLI](https://higgsfield.ai), authenticated (`higgsfield auth login`), - with credits. +- Either the [Higgsfield CLI](https://higgsfield.ai), authenticated (`higgsfield auth login`), + with credits, **or** fal.ai (`python3 -m pip install fal-client requests` + `FAL_KEY`). - `ffmpeg` / `ffprobe` for frame extraction and encoding. - Python 3 with Pillow (for the mobile portrait canvases; also the optional transparent-scene knockout). @@ -57,13 +57,13 @@ cp -R scroll-world/skills/scroll-world ~/.codex/skills/ # Codex ## What it does -It leans on [Higgsfield](https://higgsfield.ai) for the art: cohesive isometric diorama -scenes (GPT Image 2 — via Higgsfield, or the Codex CLI on a ChatGPT subscription) and the +It leans on [Higgsfield](https://higgsfield.ai) by default (or fal.ai via the included adapter) for the art: cohesive isometric diorama +scenes (GPT Image 2 — via Higgsfield, fal.ai, or the Codex CLI on a ChatGPT subscription) and the camera flights themselves (Seedance or Kling image-to-video — only models that can frame-lock a seam), scrubbed by scroll position — the same technique behind Apple's scroll-through product pages. The camera genuinely moves; scroll only drives time. It's **framework-agnostic**: you get the -Higgsfield pipeline, the prompt templates, and a portable vanilla-JS scrub engine that +provider pipeline, the prompt templates, and a portable vanilla-JS scrub engine that drops into plain HTML, Next.js, Vue, or a Python-served page — nothing assumes a stack. When invoked, the skill: @@ -74,7 +74,7 @@ When invoked, the skill: portrait — composed for phones, not a crop of the landscape film), and the **budget** — render tiers and stills source shown with estimated credit costs, approved before anything generates. -2. **Generates the assets** — one still per scene, one "dive-in" camera +2. **Generates the assets** with Higgsfield/fal.ai — one still per scene, one "dive-in" camera clip per scene, and the **connector** clips that join consecutive scenes, generated from the actual rendered frames of their neighbours so every seam is frame-identical. Mobile opt-in renders a parallel portrait chain the same way, frame-locked against its @@ -82,14 +82,29 @@ When invoked, the skill: 3. **Wires it up** — a config-driven scroll engine that plays the whole chain as one flight, serving the portrait clips and posters automatically on phones. +### The part that makes it good + +The scenes connect **seamlessly** because each connector clip is generated with the +*actual rendered frames* of its neighbours as its start/end images (not the original +stills — those re-render slightly differently and would pop at the seam). Both sides of +every seam end up frame-identical, so the camera never cuts. This is baked into the skill +as the central rule. + +It also captures the non-obvious production gotchas: blob-URL loading so scrubbing works on +hosts that don't serve HTTP byte-range requests, GOP/encoding settings that stay sharp +without bloating, mobile-native portrait chains, budget approval before spending credits, +and provider quirks. + ## What's in the skill ``` skills/scroll-world/ ├── SKILL.md the procedure + the seam rule + gotchas └── references/ - ├── prompts.md intake checklist + every Higgsfield prompt template - ├── pipeline.md copy-paste batch scripts (generate → frames → connectors → encode) + ├── prompts.md intake checklist + prompt templates + ├── pipeline.md Higgsfield copy-paste batch scripts (generate → frames → connectors → encode) + ├── fal-ai.md fal.ai provider notes + copy-paste commands + ├── fal_pipeline.py small fal-client adapter (upload → generate → download) ├── scrub-engine.js portable, config-driven scrub engine (blob-seek, lazy load, seam crossfade) ├── index-template.html a minimal standalone page that mounts the engine └── knockout.py background knockout for floating scenes @@ -97,11 +112,11 @@ skills/scroll-world/ ## Notes -- Asset generation costs Higgsfield credits (~N image gens + ~2N-1 video gens for N +- Asset generation costs provider credits (Higgsfield/fal.ai; ~N image gens + ~2N-1 video gens for N scenes; the mobile chain doubles the video gens) and takes a while — the skill runs - generations in the background and polls. Per-generation pricing isn't exposed by the - CLI, so the skill calibrates against your live balance and states the estimated total - before spending. + generations in the background and polls. Per-generation pricing can vary by provider/model, + so the skill calibrates against your live balance where possible and states the estimated + total before spending. - The generated `.mp4`/`.webp` assets are produced per project; they're not shipped here. ## Star History diff --git a/skills/scroll-world/SKILL.md b/skills/scroll-world/SKILL.md index 6792ab7..2ac0f30 100644 --- a/skills/scroll-world/SKILL.md +++ b/skills/scroll-world/SKILL.md @@ -2,12 +2,12 @@ name: scroll-world description: > Build an immersive scroll-scrubbed "fly through the world" landing page for any - industry or brand using Higgsfield. As the visitor scrolls, a pre-rendered camera + industry or brand using Higgsfield or fal.ai. As the visitor scrolls, a pre-rendered camera flies from outside each scene into its interior, then flows on to the next scene with NO cuts — one continuous connected flight (Emons-style isometric diorama world, or any art direction you pick). The skill interviews the user for the topic, the story beats/sections, and brand kit, then generates cohesive scenes + seamless camera - clips with Higgsfield and wires a portable, framework-agnostic scroll-scrub engine. + clips with Higgsfield/fal.ai and wires a portable, framework-agnostic scroll-scrub engine. Use when the user wants a "3D world" / "browse-through-the-industry" hero, a scroll cinematic, a diorama landing, or to turn a business into a scrollable world. allowed-tools: Bash, Read, Write, Edit, AskUserQuestion, Skill @@ -17,7 +17,8 @@ allowed-tools: Bash, Read, Write, Edit, AskUserQuestion, Skill Produces a landing page where **scroll drives a camera**: it dives from outside a scene into its interior, then flies out and into the next scene, continuously, with no visible -cuts. The visuals are AI-generated (Higgsfield); the page just scrubs pre-rendered video +cuts. The visuals are AI-generated (Higgsfield by default, or fal.ai via the adapter in +`references/fal-ai.md`); the page just scrubs pre-rendered video by scroll position. This is the same technique behind Apple's scroll-through product pages — the camera genuinely moves, scroll only drives time. @@ -30,23 +31,29 @@ chain as one flight. connector. Getting this wrong is the single most common failure and produces a visible "pop" between scenes. -Do not assume a frontend framework. The scrub engine in `references/scrub-engine.js` is +Do not assume a frontend framework or a generation provider. The scrub engine in `references/scrub-engine.js` is self-contained vanilla JS (it builds its own DOM + injects its own CSS into a container you give it), so it drops into plain HTML, Next.js, Vue, a Python-served page, anything. -The value of this skill is the Higgsfield pipeline, the prompts, and the seam method — -not the framework. +The value of this skill is the provider pipeline, the prompts, and the seam method — +not the framework. Higgsfield remains the default documented path; fal.ai support is the +same asset contract through a small Python helper. --- ## Step 0 — Bootstrap -1. **Higgsfield CLI.** If `higgsfield` is not on `$PATH`, install per the +1. **Choose a generation provider.** Default to Higgsfield unless the user asks for fal.ai + or Higgsfield is unavailable. For fal.ai, use `references/fal-ai.md` and + `references/fal_pipeline.py` (`python3 -m pip install fal-client requests`; `FAL_KEY` + must be set). The page/engine output is identical either way: local `.webp` stills and + local encoded `.mp4` clips. +2. **Higgsfield CLI.** If using Higgsfield and `higgsfield` is not on `$PATH`, install per the `higgsfield-generate` skill. If `higgsfield workspace list` fails auth, ask the user to run `higgsfield auth login` (interactive OAuth — you cannot run it) and, if needed, `higgsfield workspace set `. Confirm there are enough credits: a full run is roughly `N` image gens + `(2N-1)` video gens. -2. **ffmpeg / ffprobe** on `$PATH` (frame extraction + encoding). -3. **An image tool** for background knockout if you want floating scenes: PIL +3. **ffmpeg / ffprobe** on `$PATH` (frame extraction + encoding). +4. **An image tool** for background knockout if you want floating scenes: PIL (`python3 -c "import PIL"`), or `cwebp`/`sips`. Optional — see Step 3. 4. **(Optional) Codex CLI** — if `codex` is on `$PATH` (≥ 0.125) and `codex login status` reports a ChatGPT login, the scene stills can be generated @@ -197,7 +204,10 @@ Subject: . angle, palette, light). If one is off-style, regenerate it, optionally passing an approved scene as `--image` to lock style. -See `references/pipeline.md` for the exact batch script. +For Higgsfield, see `references/pipeline.md` for the exact batch script. For fal.ai, use +`references/fal-ai.md` §Scene stills; the default still endpoint there is +`fal-ai/flux-pro/v1.1-ultra`, but any fal text-to-image model that supports the needed +aspect/image size can be swapped in. --- @@ -241,6 +251,22 @@ doesn't drop into the pipeline as-is. It's not in the default roster; only reach wire it by hand, if architecture A's sequential render time is a proven bottleneck and you've benchmarked it as actually faster.) + +**fal.ai equivalents.** If the provider is fal.ai, keep the same selection rule but map the +inputs to fal field names: start image = `image_url`, connector end image = `end_image_url` +(or `tail_image_url` for endpoints whose current schema uses that name). The documented +fal path in `references/fal-ai.md` uses: + +| fal endpoint | start/end image | Notes | +|---|---|---| +| `fal-ai/bytedance/seedance/v1/pro/image-to-video` | ✓ / ✓ | Default fal video path; docs expose `image_url`, `end_image_url`, `aspect_ratio`, `resolution`, `duration`. | +| `fal-ai/bytedance/seedance/v1/lite/image-to-video` | ✓ / ✓ | Draft/previz candidate; verify seams before final. | +| `fal-ai/kling-video/v2.1/pro/image-to-video` | ✓ / schema-dependent end field | Fallback when Seedance fails; check whether the docs require `end_image_url` or `tail_image_url`. | + +Before batching fal jobs, confirm the live endpoint schema. If a model only accepts +reference images (no true start/end frame fields), skip it; it cannot preserve seams. + + Rules: - **One model for all chained clips.** Each renderer has its own motion/color/grain character; mixing models mid-chain keeps *position* continuity (frames still hand off) @@ -250,8 +276,9 @@ Rules: - Default to `seedance_2_0`; honor a user's stated preference **only if the model qualifies** (frame-locking). If it doesn't, say so and use a supported model — never ship a non-seamless build to satisfy a model request. -- The pipeline scripts take the model as `$VMODEL` with per-model flags already cased - out (`references/pipeline.md`). +- The Higgsfield pipeline scripts take the model as `$VMODEL` with per-model flags already + cased out (`references/pipeline.md`). The fal path uses `$FAL_VIDEO_MODEL` and + `references/fal_pipeline.py` (`references/fal-ai.md`). ### A) Continuous forward take — RECOMMENDED for grounded / realistic / walkthrough One camera that only ever glides **forward**, first scene through last, as a single take. @@ -596,6 +623,10 @@ is the thing most likely to be wrong: prompt template (scene still, dive, connector) with fill-in slots. - `references/pipeline.md` — copy-paste batch scripts for the whole run (generate → extract frames → connectors → encode → mobile encode), bash-3.2-safe. +- `references/fal-ai.md` — fal.ai provider notes and copy-paste commands for stills, + architecture-A legs, and architecture-B dives/connectors. +- `references/fal_pipeline.py` — small Python adapter around `fal-client` (upload local + frames, submit fal jobs, save JSON, download media). - `references/scrub-engine.js` — the portable, config-driven scrub engine (builds DOM + injects CSS; blob-seek, lazy load, seam crossfade, copy, route rail, reduced-motion, and phone hardening: mobile encodes, seek-coalescing, iOS priming, safe-area, no-jump resize). diff --git a/skills/scroll-world/references/fal-ai.md b/skills/scroll-world/references/fal-ai.md new file mode 100644 index 0000000..74868f3 --- /dev/null +++ b/skills/scroll-world/references/fal-ai.md @@ -0,0 +1,165 @@ +# fal.ai provider notes + +`scroll-world` can run on fal.ai without changing the scrub engine: fal.ai only replaces +the asset-generation layer. The output contract remains the same: + +1. scene still PNG/WebP per section +2. dive/leg MP4 per section +3. optional connector MP4s between sections +4. the same encoded assets wired into `scrub-engine.js` + +## Requirements + +```bash +python3 -m pip install fal-client requests +export FAL_KEY="..." # fal.ai API key +``` + +Use `references/fal_pipeline.py` as the thin provider adapter. It uploads local +conditioning frames with `fal_client.upload_file`, submits the job with +`fal_client.subscribe`, writes the raw JSON, and downloads the returned media URL. + +## Recommended fal models + +The hard requirement for seamless chaining is still **frame locking**: + +- every leg/dive model must accept a start image (`image_url` on fal.ai) +- every connector model must also accept an end image (`end_image_url`, or + `tail_image_url` for models whose schema names it that way) + +Confirmed API docs expose the needed fields for: + +| Purpose | fal endpoint | Key inputs | Notes | +|---|---|---|---| +| Video default | `fal-ai/bytedance/seedance/v1/pro/image-to-video` | `image_url`, `end_image_url`, `aspect_ratio`, `resolution`, `duration` | Closest fal equivalent to the Higgsfield Seedance path. Supports 1080p and 2–12s durations. | +| Video cheaper/draft | `fal-ai/bytedance/seedance/v1/lite/image-to-video` | `image_url`, `end_image_url`, `aspect_ratio`, `resolution`, `duration` | Good previz candidate; verify seams before final. | +| Video fallback | `fal-ai/kling-video/v2.1/pro/image-to-video` | `image_url`, end image field shown in docs | Use when Seedance fails a clip. Check the current schema; some Kling fal endpoints name the end frame `tail_image_url`. | +| Image stills | `fal-ai/flux-pro/v1.1-ultra` | `prompt`, `aspect_ratio` | Text-to-image fallback for cohesive scene stills. You can swap to another fal image model if its docs support 3:2 or image sizing. | + +Before batching a project, open the endpoint docs and confirm field names. fal.ai model +schemas evolve, so prefer `--extra key=value` or `--end-field tail_image_url` over editing +the skill if only a parameter name changed. + +## Scene stills + +Write one prompt per section to `$WORK/still_.txt` as usual, then: + +```bash +FAL_IMAGE_MODEL=${FAL_IMAGE_MODEL:-fal-ai/flux-pro/v1.1-ultra} + +fal_still() { # name + python3 references/fal_pipeline.py image \ + --model "$FAL_IMAGE_MODEL" \ + --prompt-file "$WORK/still_$1.txt" \ + --aspect-ratio 3:2 \ + --out-json "$WORK/still_$1.json" \ + --out-file "$WORK/still_$1.png" +} +for n in $NAMES; do fal_still "$n" & done ; wait +``` + +Then convert to site WebP exactly like the Higgsfield pipeline: + +```bash +for n in $NAMES; do cwebp -quiet -q 84 -resize 1800 0 "$WORK/still_$n.png" -o "$ASSETS/$n.webp"; done +``` + +## Architecture A — continuous forward take + +The only change from the Higgsfield flow is the command. fal's start frame is `--image` +(which becomes `image_url` after upload). Do **not** pass an end frame for architecture A. + +```bash +FAL_VIDEO_MODEL=${FAL_VIDEO_MODEL:-fal-ai/bytedance/seedance/v1/pro/image-to-video} + +fal_leg() { # index name start_png duration + python3 references/fal_pipeline.py video \ + --model "$FAL_VIDEO_MODEL" \ + --prompt-file "$WORK/leg_$2.txt" \ + --image "$3" \ + --aspect-ratio 16:9 --resolution 1080p --duration "$4" \ + --out-json "$WORK/leg_$1_$2.json" \ + --out-file "$WORK/leg_$1_$2.mp4" +} + +# leg 0 starts from still_0; each next leg starts from the previous leg's actual last frame. +set -- $NAMES +idx=0 +start="$WORK/still_$1.png" +for n in "$@"; do + fal_leg "$idx" "$n" "$start" 8 + ffmpeg -v error -sseof -0.15 -i "$WORK/leg_$idx_$n.mp4" -frames:v 1 -q:v 2 "$WORK/last_$n.png" + start="$WORK/last_$n.png" + idx=$((idx+1)) +done +``` + +Wire the generated leg clips as the section clips and leave `connectors: []`. + +## Architecture B — dives + connectors + +Dives are start-image only and can run concurrently: + +```bash +fal_dive() { # name + python3 references/fal_pipeline.py video \ + --model "$FAL_VIDEO_MODEL" \ + --prompt-file "$WORK/dive_$1.txt" \ + --image "$WORK/still_$1.png" \ + --aspect-ratio 16:9 --resolution 1080p --duration 8 \ + --out-json "$WORK/dive_$1.json" \ + --out-file "$WORK/dive_$1.mp4" +} +for n in $NAMES; do fal_dive "$n" & done ; wait +``` + +Extract real boundary frames from the rendered dives — never use the stills for connector +endpoints: + +```bash +for n in $NAMES; do + ffmpeg -v error -ss 0 -i "$WORK/dive_$n.mp4" -frames:v 1 -q:v 2 "$WORK/first_$n.png" + ffmpeg -v error -sseof -0.15 -i "$WORK/dive_$n.mp4" -frames:v 1 -q:v 2 "$WORK/last_$n.png" +done +``` + +Connectors use both actual boundary frames. If the selected fal endpoint wants +`tail_image_url` instead of `end_image_url`, add `--end-field tail_image_url`. + +```bash +fal_conn() { # i start_png end_png + python3 references/fal_pipeline.py video \ + --model "$FAL_VIDEO_MODEL" \ + --prompt-file "$WORK/conn_$1.txt" \ + --image "$2" --end-image "$3" \ + --aspect-ratio 16:9 --resolution 1080p --duration 5 \ + --out-json "$WORK/conn_$1.json" \ + --out-file "$WORK/conn_$1.mp4" +} + +set -- $NAMES ; i=0 ; prev="" +for n in "$@"; do + if [ -n "$prev" ]; then + i=$((i+1)) + fal_conn "$i" "$WORK/last_$prev.png" "$WORK/first_$n.png" & + fi + prev="$n" +done ; wait +``` + +## Encode and QA + +After fal generation, continue with the existing pipeline's encode, mobile encode, assembly, +and seam QA steps. The browser page does not know or care which provider produced the MP4s. + +Important caveats: + +- fal.ai returns hosted media URLs; always download them to local project assets before + encoding so builds are reproducible. +- Do not mix fal and Higgsfield models inside one chain unless you are intentionally + salvaging one failed clip. Different render character can read as a seam pop even when + pixels are frame-locked. +- If a fal model's current docs show only reference images and no true start/end frame + fields, skip it for scroll-world; it cannot preserve seams. +- Keep the raw JSON files. They are useful for PR/debug reports and for confirming the + seed/model/URL actually used. diff --git a/skills/scroll-world/references/fal_pipeline.py b/skills/scroll-world/references/fal_pipeline.py new file mode 100644 index 0000000..1812051 --- /dev/null +++ b/skills/scroll-world/references/fal_pipeline.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Small fal.ai helper for scroll-world asset generation. + +Requires: + pip install fal-client requests + export FAL_KEY=... + +The script uploads local conditioning frames to fal.ai, submits text-to-image or +image-to-video jobs, writes the raw JSON result, and optionally downloads the +returned media URL. It intentionally keeps provider-specific fields explicit so +new fal models can be tried without changing the scroll-world engine. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + + +def _load_deps(): + try: + import fal_client # type: ignore + import requests # type: ignore + except ImportError as exc: + print( + "Missing dependency. Install with: pip install fal-client requests", + file=sys.stderr, + ) + raise SystemExit(2) from exc + return fal_client, requests + + +def _need_key() -> None: + if not os.environ.get("FAL_KEY"): + print("FAL_KEY is not set. Export your fal.ai API key first.", file=sys.stderr) + raise SystemExit(2) + + +def _read_prompt(path: str | None, prompt: str | None) -> str: + if prompt: + return prompt + if path: + return Path(path).read_text(encoding="utf-8") + print("Provide --prompt or --prompt-file", file=sys.stderr) + raise SystemExit(2) + + +def _is_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _media_url(value: str, fal_client: Any) -> str: + """Return a public URL, uploading local files when needed.""" + if _is_url(value): + return value + path = Path(value) + if not path.exists(): + print(f"File not found: {path}", file=sys.stderr) + raise SystemExit(2) + return fal_client.upload_file(str(path)) + + +def _jsonish(value: str) -> Any: + if value[:1] in "[{\"" or value in {"true", "false", "null"}: + return json.loads(value) + return value + + +def _on_queue_update(update: Any) -> None: + if isinstance(update, dict): + status = update.get("status") + logs = update.get("logs", []) + else: + status = getattr(update, "status", None) + logs = getattr(update, "logs", []) + if status: + print(f"[fal] {status}", file=sys.stderr) + for log in logs or []: + if isinstance(log, dict): + msg = log.get("message") + else: + msg = getattr(log, "message", None) or str(log) + if msg: + print(f"[fal] {msg}", file=sys.stderr) + + +def _extract_media_url(result: dict[str, Any]) -> str | None: + # Video endpoints usually return {"video": {"url": "..."}}. + video = result.get("video") + if isinstance(video, dict) and video.get("url"): + return str(video["url"]) + # Image endpoints may return {"images": [{"url": "..."}]} or {"image": {"url": "..."}}. + images = result.get("images") + if isinstance(images, list) and images and isinstance(images[0], dict) and images[0].get("url"): + return str(images[0]["url"]) + image = result.get("image") + if isinstance(image, dict) and image.get("url"): + return str(image["url"]) + url = result.get("url") + return str(url) if url else None + + +def _download(url: str, out_file: str) -> None: + _, requests = _load_deps() + out = Path(out_file) + out.parent.mkdir(parents=True, exist_ok=True) + with requests.get(url, stream=True, timeout=120) as response: + response.raise_for_status() + with out.open("wb") as handle: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + handle.write(chunk) + + +def _write_json(path: str | None, payload: dict[str, Any]) -> None: + data = json.dumps(payload, indent=2, ensure_ascii=False) + if path: + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(data + "\n", encoding="utf-8") + else: + print(data) + + +def cmd_image(args: argparse.Namespace) -> None: + _need_key() + fal_client, _ = _load_deps() + prompt = _read_prompt(args.prompt_file, args.prompt) + payload: dict[str, Any] = {"prompt": prompt} + if args.aspect_ratio: + payload["aspect_ratio"] = args.aspect_ratio + if args.image_size: + payload["image_size"] = args.image_size + if args.num_images: + payload["num_images"] = args.num_images + if args.seed is not None: + payload["seed"] = args.seed + for item in args.extra or []: + key, value = item.split("=", 1) + payload[key] = _jsonish(value) + + result = fal_client.subscribe( + args.model, + arguments=payload, + with_logs=True, + on_queue_update=_on_queue_update, + ) + _write_json(args.out_json, result) + url = _extract_media_url(result) + if args.out_file and url: + _download(url, args.out_file) + print(f"downloaded {args.out_file}") + + +def cmd_video(args: argparse.Namespace) -> None: + _need_key() + fal_client, _ = _load_deps() + prompt = _read_prompt(args.prompt_file, args.prompt) + payload: dict[str, Any] = { + "prompt": prompt, + "image_url": _media_url(args.image, fal_client), + } + if args.end_image: + payload[args.end_field] = _media_url(args.end_image, fal_client) + if args.aspect_ratio: + payload["aspect_ratio"] = args.aspect_ratio + if args.resolution: + payload["resolution"] = args.resolution + if args.duration: + payload["duration"] = str(args.duration) + if args.seed is not None: + payload["seed"] = args.seed + if args.disable_safety_checker: + payload["enable_safety_checker"] = False + for item in args.extra or []: + key, value = item.split("=", 1) + payload[key] = _jsonish(value) + + result = fal_client.subscribe( + args.model, + arguments=payload, + with_logs=True, + on_queue_update=_on_queue_update, + ) + _write_json(args.out_json, result) + url = _extract_media_url(result) + if args.out_file and url: + _download(url, args.out_file) + print(f"downloaded {args.out_file}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="fal.ai helper for scroll-world") + sub = parser.add_subparsers(required=True) + + image = sub.add_parser("image", help="Generate a still image") + image.add_argument("--model", default="fal-ai/flux-pro/v1.1-ultra") + image.add_argument("--prompt-file") + image.add_argument("--prompt") + image.add_argument("--aspect-ratio", default="3:2") + image.add_argument("--image-size") + image.add_argument("--num-images", type=int) + image.add_argument("--seed", type=int) + image.add_argument("--extra", action="append", help="Extra JSON-ish field, e.g. --extra safety_tolerance=2") + image.add_argument("--out-json") + image.add_argument("--out-file") + image.set_defaults(func=cmd_image) + + video = sub.add_parser("video", help="Generate an image-to-video clip") + video.add_argument("--model", default="fal-ai/bytedance/seedance/v1/pro/image-to-video") + video.add_argument("--prompt-file") + video.add_argument("--prompt") + video.add_argument("--image", required=True, help="Start image URL or local path") + video.add_argument("--end-image", help="End image URL or local path for connectors") + video.add_argument("--end-field", default="end_image_url", help="Use tail_image_url for fal models that require that field") + video.add_argument("--aspect-ratio", default="16:9") + video.add_argument("--resolution", default="1080p") + video.add_argument("--duration", type=int, default=5) + video.add_argument("--seed", type=int) + video.add_argument("--disable-safety-checker", action="store_true") + video.add_argument("--extra", action="append", help="Extra JSON-ish field, e.g. --extra camera_fixed=false") + video.add_argument("--out-json") + video.add_argument("--out-file") + video.set_defaults(func=cmd_video) + + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/skills/scroll-world/references/pipeline.md b/skills/scroll-world/references/pipeline.md index 9a72f0e..8d6d58b 100644 --- a/skills/scroll-world/references/pipeline.md +++ b/skills/scroll-world/references/pipeline.md @@ -1,4 +1,8 @@ -# Pipeline: copy-paste scripts (bash 3.2 safe) +# Pipeline: Higgsfield copy-paste scripts (bash 3.2 safe) + +This file is the Higgsfield provider path. For fal.ai, use `fal-ai.md` plus +`fal_pipeline.py`; the downstream frame extraction, encoding, page assembly, and QA +contract is the same. Set these once. `NAMES` is the ordered section ids; the last is the hero/finale.