Spaces:
Sleeping
Sleeping
File size: 9,785 Bytes
cfe45d5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 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 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | import json
import dspy
from app.dspy_modules import ensure_dspy_configured
class BlogToScript(dspy.Signature):
"""
Given blog content, a list of image URLs from the blog, and a hero image path,
create a structured video script for an explainer video. The script should be
engaging, clear, and organized into scenes suitable for a Remotion-based video.
IMPORTANT RULES:
- The video should be AS LONG AS NEEDED to cover the blog content thoroughly.
- Do NOT artificially shorten content. Each major point deserves its own scene.
- Output at most 10 scenes. If the blog is short, fewer scenes are fine.
- Combine only truly related minor points; give major topics their own scene.
FIRST SCENE RULE:
- The FIRST scene displays the hero/banner image with the blog title overlaid
and a SHORT narration (1 sentence, ~10-15 words maximum).
- The title of this scene MUST be the actual blog/video title (e.g. "Building a Reliable Text-to-SQL Pipeline").
Do NOT use generic titles like "Hero Opening" or "Introduction" for scene 1.
- The narration should be a brief, compelling hook, e.g. "Let's explore how X works." Keep it concise.
- Set its duration to 5-7 seconds. This scene WILL have a voiceover.
- The second scene continues with the main introduction/content.
- If NO hero image is available (hero_image says "no hero image"), the first scene
should use a BOLD TEXT BANNER with the title as large centered text on a colored
background. Set visual_description to: "Title text banner: [THE TITLE] displayed
as large bold centered text on gradient background, no image needed."
Leave suggested_images as an empty list [].
PORTRAIT MODE RULE:
- If aspect_ratio is "portrait", the video will be rendered in 9:16 (1080x1920) vertical format.
- For portrait hero scenes: prefer CENTERED title text with shorter lines. Use fewer
words per line β keep the title concise (max ~6 words per line).
- For portrait image scenes: images should be described as FULL-WIDTH with text below,
NOT side-by-side layouts. Avoid "split-screen" or "side-by-side" descriptions.
- For portrait flow diagrams: use VERTICAL flows (top to bottom), not horizontal.
- For portrait comparisons: use STACKED layout (top vs bottom), not side-by-side.
- Keep narrations slightly shorter for portrait β mobile viewers prefer punchy content.
- ALL scenes should have SHORT narrations (1 sentence, 10-20 words max) as these are display texts shown on screen.
Duration calculation: Each scene's duration_seconds should be based on narration
word count: roughly 1 second per 2.5 words, minimum 5 seconds per scene.
Note: Narrations are display texts (short), voiceover will be generated separately and will be longer.
βββ VISUAL DESCRIPTION RULES (CRITICAL) βββ
The visual_description field drives which visual components get used. Be SPECIFIC:
- If the blog has CODE β write "Show code block with: [paste actual code]"
- If listing features/steps β write "Animated bullet list: 1) item, 2) item, ..."
- If describing a flow/pipeline/architecture β write "Flow diagram: Step1 β Step2 β Step3 β Output"
- If comparing two things β write "Comparison split-screen: X vs Y"
- If showing statistics β write "Big metric: 97% with animated counter"
- If a key quote/definition β write "Quote callout: [the quote]"
- If an image is relevant β write "Show image with caption: [description]"
- If describing phases or history β write "Timeline: Phase1, Phase2, Phase3"
- NEVER write vague descriptions like "display information" or "show content"
- ALWAYS include the ACTUAL content to be visualized (real items, real code, real numbers)
βββ MAXIMIZE VISUAL VARIETY βββ
Your video should feel like a polished DOCUMENTARY, not a lecture. To achieve this:
- EVERY scene should have a SPECIFIC visual layout hint in visual_description
- Strongly prefer flow diagrams, bullet lists, metrics, comparisons, and timelines
- Use plain text narration as an ABSOLUTE LAST RESORT β only if nothing else fits
- Think about what a designer would PUT ON SCREEN: charts, diagrams, lists, numbers
- If the narration describes any process or workflow, always suggest a flow diagram
- If there are any numbers, stats, or metrics, always suggest animated metric counters
- If comparing approaches, technologies, or ideas, always suggest a comparison layout
- Aim for at least 70% of scenes to use structured visuals (not plain text)
Output the scenes as a JSON array.
"""
blog_content: str = dspy.InputField(
desc="The full text content extracted from the blog post. "
"May contain 'βββ CODE BLOCKS FROM THIS BLOG βββ' section with actual code snippets. "
"Use these code blocks in visual_description when relevant."
)
blog_images: str = dspy.InputField(desc="JSON array of image URLs/paths available from the blog")
hero_image: str = dspy.InputField(desc="Path to the main hero/header image of the blog. Use this in the first (hero opening) scene.")
aspect_ratio: str = dspy.InputField(desc="Video aspect ratio: 'landscape' (16:9, 1920x1080) or 'portrait' (9:16, 1080x1920). Adjust layouts accordingly.")
title: str = dspy.OutputField(desc="A compelling title for the explainer video")
scenes_json: str = dspy.OutputField(
desc='JSON array of scene objects. Each object has keys: "title" (str), '
'"narration" (str -- concise display text, 1 sentence, 10-15 words max), '
'"visual_description" (str), "suggested_images" (list of str), '
'"duration_seconds" (int). '
'FIRST scene title must be the actual blog title (never "Hero Opening"), '
'with a concise narration hook (10-15 words max, 1 sentence) and duration_seconds=6. '
'ALL scenes should have SHORT narrations (1 sentence, 10-20 words max) - these are display texts shown on screen. '
'If a hero image exists: visual_description="Hero banner image with title overlay and fade-in", suggested_images=["hero.jpg"]. '
'If NO hero image: visual_description="Title text banner: [TITLE] displayed as large bold centered text on gradient background", suggested_images=[]. '
'Example with image: [{"title": "How AI is Changing Everything", '
'"narration": "Let\'s explore how AI transforms software development.", '
'"visual_description": "Hero banner image with title overlay and fade-in", '
'"suggested_images": ["hero.jpg"], "duration_seconds": 6}]. '
'Example without image: [{"title": "How AI is Changing Everything", '
'"narration": "Let\'s explore how AI transforms software development.", '
'"visual_description": "Title text banner: How AI is Changing Everything displayed as large bold centered text on gradient background", '
'"suggested_images": [], "duration_seconds": 6}]'
)
class ScriptGenerator:
"""Service that uses DSPy to generate video scripts from blog content."""
def __init__(self):
ensure_dspy_configured()
self._generator = dspy.ChainOfThought(BlogToScript)
self.generator = dspy.asyncify(self._generator)
async def generate(
self,
blog_content: str,
blog_images: list[str],
hero_image: str = "",
aspect_ratio: str = "landscape",
) -> dict:
"""
Generate a video script from blog content (async).
The video duration is determined by the content length -- no artificial limit.
Returns:
dict with 'title' and 'scenes' (list of scene dicts)
"""
result = await self.generator(
blog_content=blog_content,
blog_images=json.dumps(blog_images),
hero_image=hero_image or "(no hero image available)",
aspect_ratio=aspect_ratio or "landscape",
)
# Parse the scenes JSON
scenes = self._parse_scenes(result.scenes_json)
return {
"title": result.title,
"scenes": scenes,
}
def _parse_scenes(self, scenes_json: str) -> list[dict]:
"""Parse and validate the scenes JSON output."""
try:
# Try to extract JSON from the response (it might have markdown code fences)
cleaned = scenes_json.strip()
if cleaned.startswith("```"):
lines = cleaned.split("\n")
cleaned = "\n".join(lines[1:-1])
scenes = json.loads(cleaned)
if not isinstance(scenes, list):
scenes = [scenes]
# Validate each scene has required fields, cap at 10
validated = []
for i, scene in enumerate(scenes[:10]):
validated.append({
"title": scene.get("title", f"Scene {i + 1}"),
"narration": scene.get("narration", ""),
"visual_description": scene.get("visual_description", ""),
"suggested_images": scene.get("suggested_images", []),
"duration_seconds": scene.get("duration_seconds", 10),
})
return validated
except json.JSONDecodeError:
# Fallback: create a single scene from the raw text
return [
{
"title": "Main Content",
"narration": scenes_json[:500],
"visual_description": "Display blog content with images",
"suggested_images": [],
"duration_seconds": 30,
}
]
|