diff --git a/README.md b/README.md index ef6e0cf..7cf0d42 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,39 @@ flow mcp # http://127.0.0.1:8765/mcp (Claude Code, Curs > Video generation requires a GPU backend (see `[gpu_backend]`); narration runs > free via edge-tts. The agent + MCP server are dependency-light and self-hostable. +## Use it as a library (scene-level API) + +Beyond the autonomous `Pipeline`, the engine is callable **per scene** — so you +can drive it interactively (generate, preview, regenerate one scene at a time) +instead of rendering a whole shot list in one shot: + +```python +from pathlib import Path +from flow.config import load_config +from flow.keyframes import KeyframeGenerator +from flow.generator import Generator + +cfg = load_config("config/config.toml") +kf = KeyframeGenerator(cfg) +gen = Generator(cfg) + +# Pin a scene boundary as a still, then render the clip from it. +kf.generate_keyframe("a lighthouse at dusk, opening frame", Path("kf0.png")) +clip1 = gen.generate_clip("a lighthouse at dusk, waves rolling in", scene_id=1) + +# Chain the next scene seamlessly off the previous clip's last frame (i2v). +clip2 = gen.generate_clip( + "the lamp room lights up at night", + scene_id=2, + first_frame_path=clip1.last_frame_path, +) +``` + +`generate_clip` returns a `GeneratedClip` whose `last_frame_path` you feed into +the next scene for continuity. `PostProduction` then assembles clips with +narration, captions, and music. This is the same engine the autonomous pipeline +uses — just exposed at scene granularity for interactive tools. + ## Self-Hosted vs OpenX Flow (Cloud) | | Self-Hosted (this repo) | OpenX Flow (managed) | diff --git a/src/flow/generator.py b/src/flow/generator.py index 1bdbce0..bd50a46 100644 --- a/src/flow/generator.py +++ b/src/flow/generator.py @@ -61,6 +61,32 @@ def generate_scenes(self, shot_list: ShotList) -> list[GeneratedClip]: return clips + def generate_clip( + self, + prompt: str, + *, + scene_id: int = 0, + camera: str = "", + characters: list | None = None, + first_frame_path: str | None = None, + ) -> GeneratedClip: + """Generate a single scene clip (public, scene-level API). + + Unlike :meth:`generate_scenes` (which renders a whole shot list + autonomously), this renders one scene on demand — so an interactive + caller can generate/regenerate scenes one at a time. Pass + ``first_frame_path`` (e.g. the previous clip's ``last_frame_path``) to + chain seamlessly via i2v conditioning; the returned clip carries its own + ``last_frame_path`` to feed into the next scene. + """ + return self._generate_with_retry( + scene_id=scene_id, + prompt=prompt, + camera=camera, + characters=characters or [], + first_frame_path=first_frame_path, + ) + def _generate_with_retry( self, scene_id: int, diff --git a/src/flow/keyframes.py b/src/flow/keyframes.py index 4ac023f..8720c8e 100644 --- a/src/flow/keyframes.py +++ b/src/flow/keyframes.py @@ -76,6 +76,17 @@ def _build_keyframe_prompts(self, shot_list: ShotList) -> list[str]: ) return prompts + def generate_keyframe(self, prompt: str, output_path: Path) -> Path: + """Generate a single boundary keyframe image (public, scene-level API). + + Lets an interactive caller produce one keyframe at a time (e.g. to pin a + scene boundary, preview it, or regenerate it) without running the full + :meth:`generate_keyframes` pass. + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + self._generate_image(prompt, output_path) + return output_path + def _generate_image(self, prompt: str, output_path: Path) -> None: """Generate a single keyframe image via GPU backend.""" with httpx.Client(timeout=120) as client: diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..a2830e3 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,69 @@ +"""Tests for the scene-level (per-scene) generation API.""" + +from pathlib import Path + +from flow.config import Config +from flow.generator import Generator +from flow.keyframes import KeyframeGenerator +from flow.schemas import GeneratedClip + + +def test_generate_clip_delegates_and_passes_conditioning(monkeypatch): + """generate_clip() renders one scene and forwards the first-frame for chaining.""" + g = Generator(Config()) + captured: dict = {} + + def fake_retry(*, scene_id, prompt, camera, characters, first_frame_path=None): + captured.update( + scene_id=scene_id, prompt=prompt, camera=camera, + characters=characters, first_frame_path=first_frame_path, + ) + return GeneratedClip( + scene_id=scene_id, path="clip.mp4", duration=5, + last_frame_path="clip_last.png", + ) + + monkeypatch.setattr(g, "_generate_with_retry", fake_retry) + + clip = g.generate_clip( + "a sunset over the ocean", scene_id=3, camera="slow dolly", + first_frame_path="prev_last.png", + ) + + assert isinstance(clip, GeneratedClip) + assert clip.scene_id == 3 + assert clip.last_frame_path == "clip_last.png" # fed to the next scene + assert captured["prompt"] == "a sunset over the ocean" + assert captured["first_frame_path"] == "prev_last.png" # i2v conditioning + assert captured["camera"] == "slow dolly" + + +def test_generate_clip_defaults_no_conditioning(monkeypatch): + g = Generator(Config()) + captured: dict = {} + + def fake_retry(**kw): + captured.update(kw) + return GeneratedClip( + scene_id=kw["scene_id"], path="c.mp4", duration=5, last_frame_path=None + ) + + monkeypatch.setattr(g, "_generate_with_retry", fake_retry) + g.generate_clip("opening shot") + assert captured["first_frame_path"] is None # first scene → t2v, no conditioning + assert captured["characters"] == [] + + +def test_generate_keyframe_delegates(monkeypatch, tmp_path): + kg = KeyframeGenerator(Config()) + seen: dict = {} + monkeypatch.setattr( + kg, "_generate_image", + lambda prompt, path: seen.update({"prompt": prompt, "path": Path(path)}), + ) + + out = kg.generate_keyframe("library under stars, closing frame", tmp_path / "kf.png") + + assert out == tmp_path / "kf.png" + assert seen["prompt"] == "library under stars, closing frame" + assert seen["path"] == tmp_path / "kf.png"