Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
26 changes: 26 additions & 0 deletions src/flow/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/flow/keyframes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
69 changes: 69 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
@@ -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"
Loading