Spaces:
Runtime error
Runtime error
Commit
·
55b3b1b
1
Parent(s):
61eaaf8
init
Browse files- README.md +55 -1
- app.py +252 -138
- cinegen/.DS_Store +0 -0
- cinegen/__init__.py +13 -0
- cinegen/__pycache__/__init__.cpython-312.pyc +0 -0
- cinegen/__pycache__/__init__.cpython-313.pyc +0 -0
- cinegen/__pycache__/character_engine.cpython-312.pyc +0 -0
- cinegen/__pycache__/character_engine.cpython-313.pyc +0 -0
- cinegen/__pycache__/models.cpython-312.pyc +0 -0
- cinegen/__pycache__/models.cpython-313.pyc +0 -0
- cinegen/__pycache__/placeholders.cpython-312.pyc +0 -0
- cinegen/__pycache__/placeholders.cpython-313.pyc +0 -0
- cinegen/__pycache__/story_engine.cpython-312.pyc +0 -0
- cinegen/__pycache__/story_engine.cpython-313.pyc +0 -0
- cinegen/__pycache__/video_engine.cpython-312.pyc +0 -0
- cinegen/__pycache__/video_engine.cpython-313.pyc +0 -0
- cinegen/character_engine.py +72 -0
- cinegen/models.py +61 -0
- cinegen/placeholders.py +172 -0
- cinegen/story_engine.py +148 -0
- cinegen/video_engine.py +122 -0
- requirements.txt +9 -6
README.md
CHANGED
|
@@ -7,6 +7,60 @@ sdk: gradio
|
|
| 7 |
sdk_version: 5.44.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
sdk_version: 5.44.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: automate the process of short movie creation
|
| 11 |
+
tags:
|
| 12 |
+
- mcp-in-action-track-creative
|
| 13 |
---
|
| 14 |
|
| 15 |
+
**CineGen AI Director** is an AI agent designed to automate the process of short movie creation. It transforms a simple text or image idea into a fully realized video production by handling scriptwriting, storyboard generation, character design, and video synthesis using a multi-model approach.
|
| 16 |
+
|
| 17 |
+
- **Sponsor Platforms**: Uses Google Gemini (story + character prompts) and Hugging Face Inference Client with fal.ai hosting for Wan 2.2 TI2V video renders;
|
| 18 |
+
- **Autonomous Agent Flow**: StoryGenerator → CharacterDesigner → VideoDirector pipeline runs sequentially inside a single Gradio Blocks app, with MCP-friendly abstractions (`StoryGenerator`, `CharacterDesigner`, `VideoDirector`) designed for tool-call orchestration.
|
| 19 |
+
- **Evaluation Notes**: Covers reasoning (Gemini JSON storyboard spec), planning (scene/character tables that feed downstream steps), and execution (queued video renders with serialized HF jobs).
|
| 20 |
+
|
| 21 |
+
## Artifacts for Reviewers
|
| 22 |
+
|
| 23 |
+
- **Social Media Proof**: Replace `<SOCIAL_LINK_HERE>` with your live tweet/thread/LinkedIn post so judges can verify community sharing.
|
| 24 |
+
- **Video Recording**: Upload a walkthrough of the Gradio agent (screen + narration) and swap `<DEMO_VIDEO_LINK>` with the shareable link.
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
## 🚀 Key Features
|
| 28 |
+
|
| 29 |
+
* **End-to-End Automation**: Converts a single sentence idea into a complete short film (approx. 30s-60s runtime).
|
| 30 |
+
* **Intelligent Storyboarding**: Breaks down concepts into scene-by-scene visual prompts and narrative descriptions.
|
| 31 |
+
* **Character Consistency System**:
|
| 32 |
+
* Automatically identifies main characters.
|
| 33 |
+
* Generates visual reference sheets (Character Anchors).
|
| 34 |
+
* Allows users to "tag" specific characters in specific scenes to ensure visual consistency in the video generation prompt.
|
| 35 |
+
* **Multi-Model Video Generation**: Supports multiple state-of-the-art open-source video models via Hugging Face.
|
| 36 |
+
* **Robust Fallback System**: If the selected video model fails (e.g., server overload), the system automatically tries alternative models until generation succeeds.
|
| 37 |
+
* **Interactive Editing**:
|
| 38 |
+
* Edit visual prompts manually.
|
| 39 |
+
* Add, Insert, or Delete scenes during production.
|
| 40 |
+
* Regenerate specific clips or character looks.
|
| 41 |
+
* **Client-Side Video Merging**: Combines individual generated clips into a single continuous movie file directly in the browser without requiring a backend video processing server.
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
## 🤖 AI Models & API Usage
|
| 45 |
+
|
| 46 |
+
The application orchestrates two primary AI services:
|
| 47 |
+
|
| 48 |
+
### 1. Google Gemini API (`@google/genai`)
|
| 49 |
+
Used for the "Brain" and "Art Department" of the application.
|
| 50 |
+
|
| 51 |
+
* **Logic & Scripting**: `gemini-2.5-flash`
|
| 52 |
+
* **Role**: Analyzes the user's idea, generates the title, creates character profiles, and writes the JSON-structured storyboard with visual prompts.
|
| 53 |
+
* **Technique**: Uses Structured Output (JSON Schema) to ensure the app can parse the story data reliably.
|
| 54 |
+
* **Character Design**: `gemini-2.5-flash-image`
|
| 55 |
+
* **Role**: Generates static reference images for characters based on the script's descriptions.
|
| 56 |
+
* **Role**: Acts as the visual anchor for the user to verify character appearance before video generation.
|
| 57 |
+
|
| 58 |
+
### 2. Hugging Face Inference API (`@huggingface/inference`)
|
| 59 |
+
Used for the "Production/Camera" department.
|
| 60 |
+
|
| 61 |
+
* **Video Generation Models**:
|
| 62 |
+
* **Wan 2.1 (Wan-AI)**: `Wan-AI/Wan2.1-T2V-14B` (Primary/Default)
|
| 63 |
+
* **LTX Video (Lightricks)**: `Lightricks/LTX-Video-0.9.7-distilled`
|
| 64 |
+
* **Hunyuan Video 1.5**: `tencent/HunyuanVideo-1.5`
|
| 65 |
+
* **CogVideoX**: `THUDM/CogVideoX-5b`
|
| 66 |
+
* **Provider**: Defaults to `fal-ai` via Hugging Face Inference for high-performance GPU access.
|
app.py
CHANGED
|
@@ -1,154 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
-
import numpy as np
|
| 3 |
-
import random
|
| 4 |
-
|
| 5 |
-
# import spaces #[uncomment to use ZeroGPU]
|
| 6 |
-
from diffusers import DiffusionPipeline
|
| 7 |
-
import torch
|
| 8 |
-
|
| 9 |
-
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 10 |
-
model_repo_id = "stabilityai/sdxl-turbo" # Replace to the model you would like to use
|
| 11 |
-
|
| 12 |
-
if torch.cuda.is_available():
|
| 13 |
-
torch_dtype = torch.float16
|
| 14 |
-
else:
|
| 15 |
-
torch_dtype = torch.float32
|
| 16 |
-
|
| 17 |
-
pipe = DiffusionPipeline.from_pretrained(model_repo_id, torch_dtype=torch_dtype)
|
| 18 |
-
pipe = pipe.to(device)
|
| 19 |
-
|
| 20 |
-
MAX_SEED = np.iinfo(np.int32).max
|
| 21 |
-
MAX_IMAGE_SIZE = 1024
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
# @spaces.GPU #[uncomment to use ZeroGPU]
|
| 25 |
-
def infer(
|
| 26 |
-
prompt,
|
| 27 |
-
negative_prompt,
|
| 28 |
-
seed,
|
| 29 |
-
randomize_seed,
|
| 30 |
-
width,
|
| 31 |
-
height,
|
| 32 |
-
guidance_scale,
|
| 33 |
-
num_inference_steps,
|
| 34 |
-
progress=gr.Progress(track_tqdm=True),
|
| 35 |
-
):
|
| 36 |
-
if randomize_seed:
|
| 37 |
-
seed = random.randint(0, MAX_SEED)
|
| 38 |
|
| 39 |
-
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
num_inference_steps=num_inference_steps,
|
| 46 |
-
width=width,
|
| 47 |
-
height=height,
|
| 48 |
-
generator=generator,
|
| 49 |
-
).images[0]
|
| 50 |
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
-
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"
|
|
|
|
|
|
|
| 58 |
]
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
css = """
|
| 61 |
-
#
|
|
|
|
| 62 |
margin: 0 auto;
|
| 63 |
-
max-width: 640px;
|
| 64 |
}
|
| 65 |
"""
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
gr.
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
)
|
| 152 |
|
| 153 |
if __name__ == "__main__":
|
| 154 |
-
demo.launch()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Tuple
|
| 4 |
+
|
| 5 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
from cinegen import CharacterDesigner, StoryGenerator, VideoDirector
|
| 8 |
+
from cinegen.models import Storyboard
|
| 9 |
|
| 10 |
+
try: # pragma: no cover - spaces is only available inside HF Spaces
|
| 11 |
+
import spaces # type: ignore
|
| 12 |
+
except Exception: # pragma: no cover - keep local dev working without spaces pkg
|
| 13 |
+
spaces = None # type: ignore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
if spaces:
|
| 16 |
+
@spaces.GPU(duration=60) # short duration is enough
|
| 17 |
+
def __cinegen_gpu_warmup():
|
| 18 |
+
"""Dummy function — never called, only exists to satisfy HF Spaces GPU detection"""
|
| 19 |
+
pass
|
| 20 |
|
| 21 |
|
| 22 |
+
STYLE_CHOICES = [
|
| 23 |
+
"Cinematic Realism",
|
| 24 |
+
"Neo-Noir Animation",
|
| 25 |
+
"Analog Horror",
|
| 26 |
+
"Retro-Futuristic",
|
| 27 |
+
"Dreamlike Documentary",
|
| 28 |
]
|
| 29 |
|
| 30 |
+
VIDEO_MODEL_CHOICES = [
|
| 31 |
+
("Wan 2.2 TI2V (fal-ai)", "Wan-AI/Wan2.2-TI2V-5B"),
|
| 32 |
+
("LTX Video 0.9.7", "Lightricks/LTX-Video-0.9.7-distilled"),
|
| 33 |
+
("Hunyuan Video 1.5", "tencent/HunyuanVideo-1.5"),
|
| 34 |
+
("CogVideoX 5B", "THUDM/CogVideoX-5b"),
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
SCENE_COLUMNS = ["Scene", "Title", "Action", "Visuals", "Characters", "Duration (s)"]
|
| 38 |
+
CHARACTER_COLUMNS = ["ID", "Name", "Role", "Traits"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def gpu_guard(duration: int = 120):
|
| 42 |
+
def decorator(fn):
|
| 43 |
+
if not spaces:
|
| 44 |
+
return fn
|
| 45 |
+
return spaces.GPU(duration=duration)(fn)
|
| 46 |
+
return decorator
|
| 47 |
+
|
| 48 |
+
def _character_dropdown_update(board: Storyboard | None):
|
| 49 |
+
if not board or not board.characters:
|
| 50 |
+
return gr.update(choices=[], value=None, interactive=False)
|
| 51 |
+
choices = [character.identifier for character in board.characters]
|
| 52 |
+
return gr.update(choices=choices, value=choices[0], interactive=True)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _gallery_from_board(board: Storyboard) -> List[Tuple[str, str]]:
|
| 56 |
+
gallery: List[Tuple[str, str]] = []
|
| 57 |
+
for character in board.characters:
|
| 58 |
+
if not character.reference_image:
|
| 59 |
+
continue
|
| 60 |
+
caption = f"{character.name} — {character.role}"
|
| 61 |
+
gallery.append((character.reference_image, caption))
|
| 62 |
+
return gallery
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _ensure_storyboard(board: Storyboard | None) -> Storyboard:
|
| 66 |
+
if not board:
|
| 67 |
+
raise gr.Error("Create a storyboard first.")
|
| 68 |
+
return board
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _validate_inputs(idea: str | None, image_path: str | None):
|
| 72 |
+
if not idea and not image_path:
|
| 73 |
+
raise gr.Error("Provide either a story idea or upload a reference image.")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def handle_storyboard(
|
| 77 |
+
idea: str,
|
| 78 |
+
inspiration_image: str | None,
|
| 79 |
+
style: str,
|
| 80 |
+
scene_count: int,
|
| 81 |
+
google_api_key: str,
|
| 82 |
+
) -> Tuple[str, List[List[str]], List[List[str]], Storyboard, dict]:
|
| 83 |
+
_validate_inputs(idea, inspiration_image)
|
| 84 |
+
generator = StoryGenerator(api_key=google_api_key or None)
|
| 85 |
+
storyboard = generator.generate(
|
| 86 |
+
idea=idea,
|
| 87 |
+
style=style,
|
| 88 |
+
scene_count=scene_count,
|
| 89 |
+
inspiration_path=inspiration_image,
|
| 90 |
+
)
|
| 91 |
+
summary_md = f"### {storyboard.title}\n{storyboard.synopsis}"
|
| 92 |
+
scene_rows = storyboard.scenes_table()
|
| 93 |
+
character_rows = storyboard.characters_table()
|
| 94 |
+
dropdown_update = _character_dropdown_update(storyboard)
|
| 95 |
+
return (
|
| 96 |
+
summary_md,
|
| 97 |
+
[[row[col] for col in SCENE_COLUMNS] for row in scene_rows],
|
| 98 |
+
[[row[col] for col in CHARACTER_COLUMNS] for row in character_rows],
|
| 99 |
+
storyboard,
|
| 100 |
+
dropdown_update,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def handle_character_design(
|
| 105 |
+
storyboard: Storyboard | None,
|
| 106 |
+
google_api_key: str,
|
| 107 |
+
):
|
| 108 |
+
board = _ensure_storyboard(storyboard)
|
| 109 |
+
designer = CharacterDesigner(api_key=google_api_key or None)
|
| 110 |
+
_, updated_board = designer.design(board)
|
| 111 |
+
gallery = _gallery_from_board(updated_board)
|
| 112 |
+
if not gallery:
|
| 113 |
+
raise gr.Error("Failed to design characters.")
|
| 114 |
+
return gallery, updated_board
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def handle_character_regen(
|
| 118 |
+
storyboard: Storyboard | None,
|
| 119 |
+
character_id: str | None,
|
| 120 |
+
google_api_key: str,
|
| 121 |
+
):
|
| 122 |
+
board = _ensure_storyboard(storyboard)
|
| 123 |
+
if not character_id:
|
| 124 |
+
raise gr.Error("Select a character ID to regenerate.")
|
| 125 |
+
designer = CharacterDesigner(api_key=google_api_key or None)
|
| 126 |
+
try:
|
| 127 |
+
_, updated_board = designer.redesign_character(board, character_id)
|
| 128 |
+
except ValueError as exc:
|
| 129 |
+
raise gr.Error(str(exc)) from exc
|
| 130 |
+
gallery = _gallery_from_board(updated_board)
|
| 131 |
+
if not gallery:
|
| 132 |
+
raise gr.Error("Failed to refresh character art.")
|
| 133 |
+
return gallery, updated_board
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@gpu_guard(duration=300)
|
| 137 |
+
def handle_video_render(
|
| 138 |
+
storyboard: Storyboard | None,
|
| 139 |
+
hf_token: str,
|
| 140 |
+
model_choice: str,
|
| 141 |
+
):
|
| 142 |
+
board = _ensure_storyboard(storyboard)
|
| 143 |
+
prioritized_models = [model_choice] + [
|
| 144 |
+
model for _, model in VIDEO_MODEL_CHOICES if model != model_choice
|
| 145 |
+
]
|
| 146 |
+
director = VideoDirector(token=hf_token or None, models=prioritized_models)
|
| 147 |
+
final_cut, logs = director.render(board)
|
| 148 |
+
log_md = "\n".join(f"- {line}" for line in logs)
|
| 149 |
+
return final_cut, log_md
|
| 150 |
+
|
| 151 |
+
|
| 152 |
css = """
|
| 153 |
+
#cinegen-app {
|
| 154 |
+
max-width: 1080px;
|
| 155 |
margin: 0 auto;
|
|
|
|
| 156 |
}
|
| 157 |
"""
|
| 158 |
|
| 159 |
+
|
| 160 |
+
with gr.Blocks(fill_height=True, elem_id="cinegen-app") as demo:
|
| 161 |
+
gr.Markdown(
|
| 162 |
+
"## 🎬 CineGen AI Director\n"
|
| 163 |
+
"Drop an idea or inspiration image and let CineGen produce a storyboard, character boards, "
|
| 164 |
+
"and a compiled short film using Hugging Face video models."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
story_state = gr.State()
|
| 168 |
+
|
| 169 |
+
with gr.Row():
|
| 170 |
+
idea_box = gr.Textbox(
|
| 171 |
+
label="Movie Idea",
|
| 172 |
+
placeholder="E.g. A time loop love story set in a neon bazaar.",
|
| 173 |
+
lines=3,
|
| 174 |
+
)
|
| 175 |
+
inspiration = gr.Image(label="Reference Image (optional)", type="filepath")
|
| 176 |
+
|
| 177 |
+
with gr.Row():
|
| 178 |
+
style_dropdown = gr.Dropdown(
|
| 179 |
+
label="Visual Style",
|
| 180 |
+
choices=STYLE_CHOICES,
|
| 181 |
+
value=STYLE_CHOICES[0],
|
| 182 |
+
)
|
| 183 |
+
scene_slider = gr.Slider(
|
| 184 |
+
label="Scene Count",
|
| 185 |
+
minimum=3,
|
| 186 |
+
maximum=8,
|
| 187 |
+
value=4,
|
| 188 |
+
step=1,
|
| 189 |
+
)
|
| 190 |
+
video_model_dropdown = gr.Dropdown(
|
| 191 |
+
label="Preferred Video Model",
|
| 192 |
+
choices=[choice for choice, _ in VIDEO_MODEL_CHOICES],
|
| 193 |
+
value=VIDEO_MODEL_CHOICES[0][0],
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
with gr.Accordion("API Keys", open=True):
|
| 197 |
+
gr.Markdown(
|
| 198 |
+
"Provide your own API credentials for live Gemini and Hugging Face calls. "
|
| 199 |
+
"Keys stay within your browser session and are not stored on the server."
|
| 200 |
+
)
|
| 201 |
+
google_key_input = gr.Textbox(
|
| 202 |
+
label="Google API Key (Gemini)",
|
| 203 |
+
type="password",
|
| 204 |
+
placeholder="Required for live Gemini calls. Leave blank to use offline stubs.",
|
| 205 |
+
)
|
| 206 |
+
hf_token_input = gr.Textbox(
|
| 207 |
+
label="Hugging Face Token",
|
| 208 |
+
type="password",
|
| 209 |
+
placeholder="Needed for Wan/LTX/Hunyuan video generation.",
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
storyboard_btn = gr.Button("Create Storyboard", variant="primary")
|
| 213 |
+
summary_md = gr.Markdown("Storyboard output will appear here.")
|
| 214 |
+
scenes_df = gr.Dataframe(headers=SCENE_COLUMNS, wrap=True)
|
| 215 |
+
characters_df = gr.Dataframe(headers=CHARACTER_COLUMNS, wrap=True)
|
| 216 |
+
|
| 217 |
+
with gr.Row():
|
| 218 |
+
design_btn = gr.Button("Design Characters", variant="secondary")
|
| 219 |
+
render_btn = gr.Button("Render Short Film", variant="primary")
|
| 220 |
+
|
| 221 |
+
with gr.Row():
|
| 222 |
+
character_select = gr.Dropdown(
|
| 223 |
+
label="Character Slot",
|
| 224 |
+
choices=[],
|
| 225 |
+
interactive=False,
|
| 226 |
+
info="Select an ID from the storyboard table to regenerate its portrait.",
|
| 227 |
+
)
|
| 228 |
+
regen_btn = gr.Button("Regenerate Selected Character", variant="secondary")
|
| 229 |
+
|
| 230 |
+
gallery = gr.Gallery(label="Character References", columns=4, height=320)
|
| 231 |
+
render_logs = gr.Markdown(label="Render Log")
|
| 232 |
+
final_video = gr.Video(label="CineGen Short Film", interactive=False)
|
| 233 |
+
|
| 234 |
+
storyboard_btn.click(
|
| 235 |
+
fn=handle_storyboard,
|
| 236 |
+
inputs=[idea_box, inspiration, style_dropdown, scene_slider, google_key_input],
|
| 237 |
+
outputs=[summary_md, scenes_df, characters_df, story_state, character_select],
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
design_btn.click(
|
| 241 |
+
fn=handle_character_design,
|
| 242 |
+
inputs=[story_state, google_key_input],
|
| 243 |
+
outputs=[gallery, story_state],
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
regen_btn.click(
|
| 247 |
+
fn=handle_character_regen,
|
| 248 |
+
inputs=[story_state, character_select, google_key_input],
|
| 249 |
+
outputs=[gallery, story_state],
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
def _model_value(label: str) -> str:
|
| 253 |
+
lookup = dict(VIDEO_MODEL_CHOICES)
|
| 254 |
+
return lookup.get(label, VIDEO_MODEL_CHOICES[0][1])
|
| 255 |
+
|
| 256 |
+
def render_wrapper(board, token, label):
|
| 257 |
+
return handle_video_render(board, token, _model_value(label))
|
| 258 |
+
|
| 259 |
+
render_btn.click(
|
| 260 |
+
fn=render_wrapper,
|
| 261 |
+
inputs=[story_state, hf_token_input, video_model_dropdown],
|
| 262 |
+
outputs=[final_video, render_logs],
|
| 263 |
+
queue=True,
|
| 264 |
+
concurrency_limit=1,
|
| 265 |
)
|
| 266 |
|
| 267 |
if __name__ == "__main__":
|
| 268 |
+
demo.launch(theme=gr.themes.Soft(), css=css)
|
cinegen/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
cinegen/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .models import Storyboard, SceneBeat, CharacterSpec
|
| 2 |
+
from .story_engine import StoryGenerator
|
| 3 |
+
from .character_engine import CharacterDesigner
|
| 4 |
+
from .video_engine import VideoDirector
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"Storyboard",
|
| 8 |
+
"SceneBeat",
|
| 9 |
+
"CharacterSpec",
|
| 10 |
+
"StoryGenerator",
|
| 11 |
+
"CharacterDesigner",
|
| 12 |
+
"VideoDirector",
|
| 13 |
+
]
|
cinegen/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (474 Bytes). View file
|
|
|
cinegen/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (434 Bytes). View file
|
|
|
cinegen/__pycache__/character_engine.cpython-312.pyc
ADDED
|
Binary file (4.72 kB). View file
|
|
|
cinegen/__pycache__/character_engine.cpython-313.pyc
ADDED
|
Binary file (3.75 kB). View file
|
|
|
cinegen/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (3.17 kB). View file
|
|
|
cinegen/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (3.25 kB). View file
|
|
|
cinegen/__pycache__/placeholders.cpython-312.pyc
ADDED
|
Binary file (9.28 kB). View file
|
|
|
cinegen/__pycache__/placeholders.cpython-313.pyc
ADDED
|
Binary file (8.87 kB). View file
|
|
|
cinegen/__pycache__/story_engine.cpython-312.pyc
ADDED
|
Binary file (6.79 kB). View file
|
|
|
cinegen/__pycache__/story_engine.cpython-313.pyc
ADDED
|
Binary file (6.8 kB). View file
|
|
|
cinegen/__pycache__/video_engine.cpython-312.pyc
ADDED
|
Binary file (7.33 kB). View file
|
|
|
cinegen/__pycache__/video_engine.cpython-313.pyc
ADDED
|
Binary file (7.33 kB). View file
|
|
|
cinegen/character_engine.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import List, Optional, Tuple
|
| 5 |
+
|
| 6 |
+
from .models import Storyboard
|
| 7 |
+
from .placeholders import synthesize_character_card
|
| 8 |
+
|
| 9 |
+
DEFAULT_IMAGE_MODEL = os.environ.get("CINEGEN_CHARACTER_MODEL", "gemini-2.5-flash-image")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _load_google_client(api_key: Optional[str]):
|
| 13 |
+
if not api_key:
|
| 14 |
+
return None
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from google import genai
|
| 18 |
+
|
| 19 |
+
return genai.Client(api_key=api_key)
|
| 20 |
+
except Exception: # pragma: no cover - optional dependency
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CharacterDesigner:
|
| 25 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 26 |
+
self.api_key = api_key or os.environ.get("GOOGLE_API_KEY")
|
| 27 |
+
self.client = _load_google_client(self.api_key)
|
| 28 |
+
|
| 29 |
+
def design(self, storyboard: Storyboard) -> Tuple[List[Tuple[str, str]], Storyboard]:
|
| 30 |
+
gallery: List[Tuple[str, str]] = []
|
| 31 |
+
for character in storyboard.characters:
|
| 32 |
+
gallery.append(self._refresh_reference(character, storyboard.style))
|
| 33 |
+
return gallery, storyboard
|
| 34 |
+
|
| 35 |
+
def redesign_character(self, storyboard: Storyboard, character_id: str) -> Tuple[Tuple[str, str], Storyboard]:
|
| 36 |
+
target = next((char for char in storyboard.characters if char.identifier == character_id), None)
|
| 37 |
+
if not target:
|
| 38 |
+
raise ValueError(f"Character {character_id} not found.")
|
| 39 |
+
card = self._refresh_reference(target, storyboard.style)
|
| 40 |
+
return card, storyboard
|
| 41 |
+
|
| 42 |
+
def _refresh_reference(self, character, style: str) -> Tuple[str, str]:
|
| 43 |
+
image_path = None
|
| 44 |
+
if self.client:
|
| 45 |
+
image_path = self._try_generate(character, style)
|
| 46 |
+
if not image_path:
|
| 47 |
+
image_path = synthesize_character_card(character, style)
|
| 48 |
+
character.reference_image = image_path
|
| 49 |
+
caption = f"{character.name} — {character.role}"
|
| 50 |
+
return image_path, caption
|
| 51 |
+
|
| 52 |
+
def _try_generate(self, character, style: str) -> Optional[str]: # pragma: no cover
|
| 53 |
+
prompt = (
|
| 54 |
+
f"Create a portrait for {character.name}, a {character.role} in a {style} short film. "
|
| 55 |
+
f"Traits: {', '.join(character.traits)}. Description: {character.description}."
|
| 56 |
+
)
|
| 57 |
+
try:
|
| 58 |
+
response = self.client.models.generate_content(
|
| 59 |
+
model=DEFAULT_IMAGE_MODEL,
|
| 60 |
+
contents=[prompt],
|
| 61 |
+
)
|
| 62 |
+
for part in response.parts:
|
| 63 |
+
if getattr(part, "inline_data", None):
|
| 64 |
+
image = part.as_image()
|
| 65 |
+
tmp_dir = os.path.join("/tmp", "cinegen-characters")
|
| 66 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 67 |
+
path = os.path.join(tmp_dir, f"{character.identifier.lower()}.png")
|
| 68 |
+
image.save(path)
|
| 69 |
+
return path
|
| 70 |
+
except Exception:
|
| 71 |
+
return None
|
| 72 |
+
return None
|
cinegen/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class CharacterSpec:
|
| 9 |
+
identifier: str
|
| 10 |
+
name: str
|
| 11 |
+
role: str
|
| 12 |
+
description: str
|
| 13 |
+
traits: List[str] = field(default_factory=list)
|
| 14 |
+
reference_image: Optional[str] = None
|
| 15 |
+
|
| 16 |
+
def to_row(self) -> dict:
|
| 17 |
+
traits = ", ".join(self.traits)
|
| 18 |
+
return {
|
| 19 |
+
"ID": self.identifier,
|
| 20 |
+
"Name": self.name,
|
| 21 |
+
"Role": self.role,
|
| 22 |
+
"Traits": traits or "—",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class SceneBeat:
|
| 28 |
+
scene_id: str
|
| 29 |
+
title: str
|
| 30 |
+
visuals: str
|
| 31 |
+
action: str
|
| 32 |
+
characters: List[str] = field(default_factory=list)
|
| 33 |
+
duration: int = 6
|
| 34 |
+
mood: str = ""
|
| 35 |
+
camera: str = ""
|
| 36 |
+
|
| 37 |
+
def to_row(self) -> dict:
|
| 38 |
+
return {
|
| 39 |
+
"Scene": self.scene_id,
|
| 40 |
+
"Title": self.title,
|
| 41 |
+
"Action": self.action,
|
| 42 |
+
"Visuals": self.visuals,
|
| 43 |
+
"Characters": ", ".join(self.characters) or "—",
|
| 44 |
+
"Duration (s)": self.duration,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class Storyboard:
|
| 50 |
+
title: str
|
| 51 |
+
synopsis: str
|
| 52 |
+
style: str
|
| 53 |
+
inspiration_hint: Optional[str]
|
| 54 |
+
characters: List[CharacterSpec] = field(default_factory=list)
|
| 55 |
+
scenes: List[SceneBeat] = field(default_factory=list)
|
| 56 |
+
|
| 57 |
+
def characters_table(self) -> List[dict]:
|
| 58 |
+
return [char.to_row() for char in self.characters]
|
| 59 |
+
|
| 60 |
+
def scenes_table(self) -> List[dict]:
|
| 61 |
+
return [scene.to_row() for scene in self.scenes]
|
cinegen/placeholders.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import random
|
| 5 |
+
import string
|
| 6 |
+
import tempfile
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
import imageio
|
| 10 |
+
import numpy as np
|
| 11 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 12 |
+
|
| 13 |
+
from .models import CharacterSpec, SceneBeat, Storyboard
|
| 14 |
+
|
| 15 |
+
SCENE_TITLES = [
|
| 16 |
+
"Opening Beat",
|
| 17 |
+
"Inciting Incident",
|
| 18 |
+
"Turning Point",
|
| 19 |
+
"Climactic Push",
|
| 20 |
+
"Final Shot",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
CHARACTER_ARCHETYPES = [
|
| 24 |
+
("Lead", "Curious protagonist who drives the story."),
|
| 25 |
+
("Ally", "Supportive partner offering heart and humor."),
|
| 26 |
+
("Antagonist", "Force of tension that keeps the stakes high."),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
PALETTE = [
|
| 30 |
+
(28, 35, 51),
|
| 31 |
+
(44, 106, 116),
|
| 32 |
+
(96, 108, 56),
|
| 33 |
+
(224, 142, 73),
|
| 34 |
+
(211, 86, 97),
|
| 35 |
+
(123, 74, 173),
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _slugify(text: str) -> str:
|
| 40 |
+
safe = "".join(ch for ch in text if ch.isalnum() or ch in (" ", "-")).strip()
|
| 41 |
+
safe = safe.replace(" ", "-")
|
| 42 |
+
safe = safe.lower()
|
| 43 |
+
return safe or "cinegen"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def normalize_scene_count(scene_count: int | float | str | None) -> int:
|
| 47 |
+
try:
|
| 48 |
+
value = int(float(scene_count))
|
| 49 |
+
except (TypeError, ValueError):
|
| 50 |
+
return 3
|
| 51 |
+
return max(1, value)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def build_stub_storyboard(
|
| 55 |
+
idea: str,
|
| 56 |
+
style: str,
|
| 57 |
+
scene_count: int | float | str,
|
| 58 |
+
inspiration_hint: str | None,
|
| 59 |
+
) -> Storyboard:
|
| 60 |
+
normalized_scenes = normalize_scene_count(scene_count)
|
| 61 |
+
random.seed(_slugify(idea) + style + str(normalized_scenes))
|
| 62 |
+
title = idea.title() if idea else f"{style} Short"
|
| 63 |
+
synopsis = (
|
| 64 |
+
f"A {style.lower()} short that transforms the idea '{idea or 'mystery cue'}' "
|
| 65 |
+
"into a compact cinematic arc."
|
| 66 |
+
)
|
| 67 |
+
characters: List[CharacterSpec] = []
|
| 68 |
+
for idx, (role, desc) in enumerate(CHARACTER_ARCHETYPES):
|
| 69 |
+
if idx >= 3 and normalized_scenes <= 3:
|
| 70 |
+
break
|
| 71 |
+
identifier = f"CHAR-{idx+1}"
|
| 72 |
+
name = f"{role} {random.choice(string.ascii_uppercase)}"
|
| 73 |
+
traits = random.sample(
|
| 74 |
+
["brave", "witty", "restless", "tactical", "empathetic", "curious"], 2
|
| 75 |
+
)
|
| 76 |
+
characters.append(
|
| 77 |
+
CharacterSpec(
|
| 78 |
+
identifier=identifier,
|
| 79 |
+
name=name,
|
| 80 |
+
role=role,
|
| 81 |
+
description=desc,
|
| 82 |
+
traits=traits,
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
scenes: List[SceneBeat] = []
|
| 87 |
+
for idx in range(normalized_scenes):
|
| 88 |
+
label = SCENE_TITLES[idx % len(SCENE_TITLES)]
|
| 89 |
+
scene_id = f"SCENE-{idx+1}"
|
| 90 |
+
visuals = (
|
| 91 |
+
f"{style} framing with {random.choice(['soft neon', 'moody shadows', 'bold silhouettes'])}."
|
| 92 |
+
)
|
| 93 |
+
action = f"{characters[0].name if characters else 'The hero'} faces {random.choice(['an unseen threat', 'a tough decision', 'their reflection'])}."
|
| 94 |
+
involved = [char.name for char in characters if random.random() > 0.3][:2] or [
|
| 95 |
+
characters[0].name if characters else "Narrator"
|
| 96 |
+
]
|
| 97 |
+
scenes.append(
|
| 98 |
+
SceneBeat(
|
| 99 |
+
scene_id=scene_id,
|
| 100 |
+
title=label,
|
| 101 |
+
visuals=visuals,
|
| 102 |
+
action=action,
|
| 103 |
+
characters=involved,
|
| 104 |
+
duration=6,
|
| 105 |
+
mood=random.choice(["hopeful", "tense", "whimsical"]),
|
| 106 |
+
camera=random.choice(["slow push", "steady wide", "handheld close-up"]),
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
appendix = (
|
| 111 |
+
f"Aim for motifs inspired by the uploaded reference: {inspiration_hint}."
|
| 112 |
+
if inspiration_hint
|
| 113 |
+
else ""
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
return Storyboard(
|
| 117 |
+
title=title,
|
| 118 |
+
synopsis=f"{synopsis} {appendix}".strip(),
|
| 119 |
+
style=style,
|
| 120 |
+
inspiration_hint=inspiration_hint,
|
| 121 |
+
characters=characters,
|
| 122 |
+
scenes=scenes,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def synthesize_character_card(character: CharacterSpec, style: str) -> str:
|
| 127 |
+
width, height = 640, 640
|
| 128 |
+
color = random.choice(PALETTE)
|
| 129 |
+
image = Image.new("RGB", (width, height), color=color)
|
| 130 |
+
draw = ImageDraw.Draw(image)
|
| 131 |
+
font = ImageFont.load_default()
|
| 132 |
+
text = f"{character.name}\n{character.role}\n{', '.join(character.traits)}"
|
| 133 |
+
draw.multiline_text((40, 80), text, fill=(255, 255, 255), font=font, spacing=6)
|
| 134 |
+
draw.text((40, height - 60), f"Style: {style}", fill=(255, 255, 255), font=font)
|
| 135 |
+
tmp_dir = tempfile.mkdtemp(prefix="cinegen-character-")
|
| 136 |
+
path = os.path.join(tmp_dir, f"{_slugify(character.name)}.png")
|
| 137 |
+
image.save(path, format="PNG")
|
| 138 |
+
return path
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def create_placeholder_video(scene: SceneBeat, style: str, seconds: int = 4) -> str:
|
| 142 |
+
fps = 6
|
| 143 |
+
frames = fps * seconds
|
| 144 |
+
width, height = 512, 512
|
| 145 |
+
tmp_dir = tempfile.mkdtemp(prefix="cinegen-scene-")
|
| 146 |
+
path = os.path.join(tmp_dir, f"{scene.scene_id.lower()}.mp4")
|
| 147 |
+
rng = np.random.default_rng(sum(ord(c) for c in scene.scene_id))
|
| 148 |
+
with imageio.get_writer(path, fps=fps) as writer:
|
| 149 |
+
for _ in range(frames):
|
| 150 |
+
base_color = rng.integers(60, 220, size=3, dtype=np.uint8)
|
| 151 |
+
frame = np.zeros((height, width, 3), dtype=np.uint8)
|
| 152 |
+
frame[:] = base_color
|
| 153 |
+
image = Image.fromarray(frame)
|
| 154 |
+
draw = ImageDraw.Draw(image)
|
| 155 |
+
font = ImageFont.load_default()
|
| 156 |
+
overlay = f"{scene.title}\n{scene.action[:60]}..."
|
| 157 |
+
draw.multiline_text((24, 24), overlay, fill=(255, 255, 255), font=font, spacing=4)
|
| 158 |
+
draw.text(
|
| 159 |
+
(24, height - 40),
|
| 160 |
+
f"{style} • {scene.characters[0] if scene.characters else 'Solo'}",
|
| 161 |
+
fill=(255, 255, 255),
|
| 162 |
+
font=font,
|
| 163 |
+
)
|
| 164 |
+
writer.append_data(np.array(image))
|
| 165 |
+
return path
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def describe_image_reference(image_path: str | None) -> str | None:
|
| 169 |
+
if not image_path or not os.path.exists(image_path):
|
| 170 |
+
return None
|
| 171 |
+
size = os.path.getsize(image_path)
|
| 172 |
+
return f"{os.path.basename(image_path)} ({round(size / 1024, 1)}KB)"
|
cinegen/story_engine.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from typing import Any, Dict, Optional
|
| 6 |
+
|
| 7 |
+
from .models import Storyboard, CharacterSpec, SceneBeat
|
| 8 |
+
from .placeholders import (
|
| 9 |
+
build_stub_storyboard,
|
| 10 |
+
describe_image_reference,
|
| 11 |
+
normalize_scene_count,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
DEFAULT_STORY_MODEL = os.environ.get("CINEGEN_STORY_MODEL", "gemini-2.5-flash")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _load_google_client(api_key: Optional[str]):
|
| 18 |
+
if not api_key:
|
| 19 |
+
return None, "Missing API key"
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from google import genai
|
| 23 |
+
|
| 24 |
+
client = genai.Client(api_key=api_key)
|
| 25 |
+
return client, None
|
| 26 |
+
except Exception as exc: # pragma: no cover - depends on optional deps
|
| 27 |
+
return None, str(exc)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class StoryGenerator:
|
| 31 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 32 |
+
self.api_key = api_key or os.environ.get("GOOGLE_API_KEY")
|
| 33 |
+
self.client, self.client_error = _load_google_client(self.api_key)
|
| 34 |
+
|
| 35 |
+
def generate(
|
| 36 |
+
self,
|
| 37 |
+
idea: str,
|
| 38 |
+
style: str,
|
| 39 |
+
scene_count: int | float | str,
|
| 40 |
+
inspiration_path: Optional[str] = None,
|
| 41 |
+
) -> Storyboard:
|
| 42 |
+
scene_total = normalize_scene_count(scene_count)
|
| 43 |
+
if not self.client:
|
| 44 |
+
return build_stub_storyboard(
|
| 45 |
+
idea=idea,
|
| 46 |
+
style=style,
|
| 47 |
+
scene_count=scene_total,
|
| 48 |
+
inspiration_hint=describe_image_reference(inspiration_path),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
prompt = self._build_prompt(idea, style, scene_total)
|
| 52 |
+
contents = [prompt]
|
| 53 |
+
parts = self._maybe_add_image_part(inspiration_path)
|
| 54 |
+
contents = parts + contents if parts else contents
|
| 55 |
+
|
| 56 |
+
try: # pragma: no cover - relies on remote API
|
| 57 |
+
response = self.client.models.generate_content(
|
| 58 |
+
model=DEFAULT_STORY_MODEL,
|
| 59 |
+
contents=contents,
|
| 60 |
+
config={"response_mime_type": "application/json"},
|
| 61 |
+
)
|
| 62 |
+
payload = json.loads(response.text)
|
| 63 |
+
return self._parse_payload(
|
| 64 |
+
payload,
|
| 65 |
+
style=style,
|
| 66 |
+
inspiration_hint=describe_image_reference(inspiration_path),
|
| 67 |
+
)
|
| 68 |
+
except Exception:
|
| 69 |
+
return build_stub_storyboard(
|
| 70 |
+
idea=idea,
|
| 71 |
+
style=style,
|
| 72 |
+
scene_count=scene_total,
|
| 73 |
+
inspiration_hint=describe_image_reference(inspiration_path),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def _build_prompt(idea: str, style: str, scene_count: int) -> str:
|
| 78 |
+
return (
|
| 79 |
+
"You are CineGen, an AI film director. Convert the provided idea into a "
|
| 80 |
+
"structured storyboard JSON with the following keys:\n"
|
| 81 |
+
"{\n"
|
| 82 |
+
' "title": str,\n'
|
| 83 |
+
' "synopsis": str,\n'
|
| 84 |
+
' "characters": [\n'
|
| 85 |
+
' {"id": "CHAR-1", "name": str, "role": str, "description": str, "traits": [str, ...]}\n'
|
| 86 |
+
" ],\n"
|
| 87 |
+
' "scenes": [\n'
|
| 88 |
+
' {"id": "SCENE-1", "title": str, "visuals": str, "action": str, "characters": [str], "duration": int, "mood": str, "camera": str}\n'
|
| 89 |
+
" ]\n"
|
| 90 |
+
"}\n"
|
| 91 |
+
f"Idea: {idea or 'Use the inspiration image only.'}\n"
|
| 92 |
+
f"Visual Style: {style}\n"
|
| 93 |
+
f"Scene Count: {scene_count}\n"
|
| 94 |
+
"Ensure every scene references at least one character ID."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def _maybe_add_image_part(self, inspiration_path: Optional[str]):
|
| 98 |
+
if not inspiration_path or not os.path.exists(inspiration_path):
|
| 99 |
+
return None
|
| 100 |
+
try:
|
| 101 |
+
from google.genai import types # pragma: no cover - optional dependency
|
| 102 |
+
|
| 103 |
+
with open(inspiration_path, "rb") as handle:
|
| 104 |
+
data = handle.read()
|
| 105 |
+
mime = "image/png" if inspiration_path.endswith(".png") else "image/jpeg"
|
| 106 |
+
return [types.Part.from_bytes(data=data, mime_type=mime)]
|
| 107 |
+
except Exception:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
def _parse_payload(
|
| 112 |
+
payload: Dict[str, Any],
|
| 113 |
+
style: str,
|
| 114 |
+
inspiration_hint: Optional[str],
|
| 115 |
+
) -> Storyboard:
|
| 116 |
+
characters = [
|
| 117 |
+
CharacterSpec(
|
| 118 |
+
identifier=item.get("id", f"CHAR-{idx+1}"),
|
| 119 |
+
name=item.get("name", f"Character {idx+1}"),
|
| 120 |
+
role=item.get("role", "Supporting"),
|
| 121 |
+
description=item.get("description", ""),
|
| 122 |
+
traits=item.get("traits", []),
|
| 123 |
+
)
|
| 124 |
+
for idx, item in enumerate(payload.get("characters", []))
|
| 125 |
+
]
|
| 126 |
+
scenes = [
|
| 127 |
+
SceneBeat(
|
| 128 |
+
scene_id=item.get("id", f"SCENE-{idx+1}"),
|
| 129 |
+
title=item.get("title", f"Scene {idx+1}"),
|
| 130 |
+
visuals=item.get("visuals", ""),
|
| 131 |
+
action=item.get("action", ""),
|
| 132 |
+
characters=item.get("characters", []),
|
| 133 |
+
duration=int(item.get("duration", 6)),
|
| 134 |
+
mood=item.get("mood", ""),
|
| 135 |
+
camera=item.get("camera", ""),
|
| 136 |
+
)
|
| 137 |
+
for idx, item in enumerate(payload.get("scenes", []))
|
| 138 |
+
]
|
| 139 |
+
if not characters or not scenes:
|
| 140 |
+
raise ValueError("Incomplete payload")
|
| 141 |
+
return Storyboard(
|
| 142 |
+
title=payload.get("title", "Untitled Short"),
|
| 143 |
+
synopsis=payload.get("synopsis", ""),
|
| 144 |
+
style=style,
|
| 145 |
+
inspiration_hint=inspiration_hint,
|
| 146 |
+
characters=characters,
|
| 147 |
+
scenes=scenes,
|
| 148 |
+
)
|
cinegen/video_engine.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 6 |
+
|
| 7 |
+
from huggingface_hub import InferenceClient
|
| 8 |
+
|
| 9 |
+
from .models import SceneBeat, Storyboard
|
| 10 |
+
from .placeholders import create_placeholder_video
|
| 11 |
+
|
| 12 |
+
DEFAULT_VIDEO_MODELS = [
|
| 13 |
+
"Wan-AI/Wan2.2-TI2V-5B",
|
| 14 |
+
"Lightricks/LTX-Video-0.9.7-distilled",
|
| 15 |
+
"tencent/HunyuanVideo-1.5",
|
| 16 |
+
"THUDM/CogVideoX-5b",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
MODEL_PROVIDER_OVERRIDES: Dict[str, Optional[str]] = {
|
| 20 |
+
"Wan-AI/Wan2.2-TI2V-5B": "fal-ai",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
MIN_FRAMES = 16
|
| 24 |
+
MAX_FRAMES = 240
|
| 25 |
+
FRAMES_PER_SECOND = 8
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class VideoDirector:
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
token: Optional[str] = None,
|
| 32 |
+
models: Optional[Sequence[str]] = None,
|
| 33 |
+
):
|
| 34 |
+
env_token = (
|
| 35 |
+
token
|
| 36 |
+
or os.environ.get("HF_TOKEN")
|
| 37 |
+
or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
|
| 38 |
+
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 39 |
+
)
|
| 40 |
+
self.token = env_token
|
| 41 |
+
self.models = list(models or DEFAULT_VIDEO_MODELS)
|
| 42 |
+
|
| 43 |
+
def render(self, storyboard: Storyboard) -> Tuple[str, List[str]]:
|
| 44 |
+
logs: List[str] = []
|
| 45 |
+
clip_paths: List[str] = []
|
| 46 |
+
for scene in storyboard.scenes:
|
| 47 |
+
video = self._produce_scene(storyboard, scene, logs)
|
| 48 |
+
clip_paths.append(video)
|
| 49 |
+
final_cut = self._merge_clips(clip_paths, logs)
|
| 50 |
+
return final_cut, logs
|
| 51 |
+
|
| 52 |
+
def _produce_scene(self, storyboard: Storyboard, scene: SceneBeat, logs: List[str]) -> str:
|
| 53 |
+
composed_prompt = self._compose_prompt(storyboard, scene)
|
| 54 |
+
if self.token:
|
| 55 |
+
for model in self.models:
|
| 56 |
+
try:
|
| 57 |
+
clip = self._call_hf_inference(composed_prompt, model, scene.duration)
|
| 58 |
+
logs.append(f"Scene {scene.scene_id}: generated via {model}")
|
| 59 |
+
return clip
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
logs.append(f"Scene {scene.scene_id}: {model} failed ({exc})")
|
| 62 |
+
clip = create_placeholder_video(scene, storyboard.style)
|
| 63 |
+
logs.append(f"Scene {scene.scene_id}: fallback placeholder clip used.")
|
| 64 |
+
return clip
|
| 65 |
+
|
| 66 |
+
def _call_hf_inference(self, prompt: str, model_id: str, duration: int) -> str:
|
| 67 |
+
if not self.token:
|
| 68 |
+
raise RuntimeError("Missing Hugging Face token")
|
| 69 |
+
client = self._build_client(model_id)
|
| 70 |
+
frames = max(MIN_FRAMES, min(MAX_FRAMES, int(duration * FRAMES_PER_SECOND)))
|
| 71 |
+
video_bytes = client.text_to_video(
|
| 72 |
+
prompt,
|
| 73 |
+
model=model_id,
|
| 74 |
+
num_frames=frames,
|
| 75 |
+
)
|
| 76 |
+
tmp_dir = tempfile.mkdtemp(prefix="cinegen-video-")
|
| 77 |
+
path = os.path.join(tmp_dir, f"{model_id.split('/')[-1]}.mp4")
|
| 78 |
+
with open(path, "wb") as handle:
|
| 79 |
+
handle.write(video_bytes)
|
| 80 |
+
return path
|
| 81 |
+
|
| 82 |
+
def _build_client(self, model_id: str) -> InferenceClient:
|
| 83 |
+
provider = MODEL_PROVIDER_OVERRIDES.get(model_id)
|
| 84 |
+
kwargs = {"token": self.token}
|
| 85 |
+
if provider:
|
| 86 |
+
kwargs["provider"] = provider
|
| 87 |
+
return InferenceClient(**kwargs)
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _compose_prompt(storyboard: Storyboard, scene: SceneBeat) -> str:
|
| 91 |
+
characters = "; ".join(scene.characters)
|
| 92 |
+
return (
|
| 93 |
+
f"Title: {storyboard.title}. Style: {storyboard.style}. "
|
| 94 |
+
f"Scene {scene.scene_id} - {scene.title}: {scene.action} "
|
| 95 |
+
f"Visual cues: {scene.visuals}. Mood: {scene.mood}. "
|
| 96 |
+
f"Camera: {scene.camera}. Characters: {characters or 'solo sequence'}."
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
def _merge_clips(self, clip_paths: Sequence[str], logs: List[str]) -> str:
|
| 100 |
+
try:
|
| 101 |
+
from moviepy.editor import VideoFileClip, concatenate_videoclips # type: ignore
|
| 102 |
+
except Exception as exc:
|
| 103 |
+
logs.append(f"MoviePy unavailable ({exc}); returning first clip only.")
|
| 104 |
+
return clip_paths[0]
|
| 105 |
+
|
| 106 |
+
clips = []
|
| 107 |
+
for path in clip_paths:
|
| 108 |
+
try:
|
| 109 |
+
clip = VideoFileClip(path)
|
| 110 |
+
clips.append(clip)
|
| 111 |
+
except Exception as exc:
|
| 112 |
+
logs.append(f"Failed to read clip {path}: {exc}")
|
| 113 |
+
if not clips:
|
| 114 |
+
raise RuntimeError("No clips to merge")
|
| 115 |
+
final = concatenate_videoclips(clips, method="compose")
|
| 116 |
+
tmp_dir = tempfile.mkdtemp(prefix="cinegen-final-")
|
| 117 |
+
final_path = os.path.join(tmp_dir, "cinegen_short.mp4")
|
| 118 |
+
final.write_videofile(final_path, fps=clips[0].fps, codec="libx264", audio=False, verbose=False, logger=None)
|
| 119 |
+
for clip in clips:
|
| 120 |
+
clip.close()
|
| 121 |
+
logs.append(f"Merged {len(clips)} clips into final cut.")
|
| 122 |
+
return final_path
|
requirements.txt
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio
|
| 2 |
+
google-genai
|
| 3 |
+
torch>=2.2.0
|
| 4 |
+
huggingface-hub>=0.26.0
|
| 5 |
+
pillow>=10.2.0
|
| 6 |
+
numpy>=1.24.0
|
| 7 |
+
requests>=2.31.0
|
| 8 |
+
imageio>=2.34
|
| 9 |
+
moviepy>=1.0.3
|