71 lines
2.9 KiB
Python
71 lines
2.9 KiB
Python
"""Render a Studio JSON bundle into PNG slides and optionally MP4."""
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
def validate(bundle):
|
|
if bundle.get("version") != 1 or not isinstance(bundle.get("slides"), list) or not bundle["slides"]:
|
|
raise ValueError("Expected a version 1 Studio bundle with slides")
|
|
for slide in bundle["slides"]:
|
|
if not all(isinstance(slide.get(k), str) for k in ("heading", "body", "color")):
|
|
raise ValueError("Invalid slide")
|
|
return bundle["slides"]
|
|
|
|
|
|
def wrap(draw, text, font, width):
|
|
lines = []
|
|
for paragraph in text.split("\n"):
|
|
line = ""
|
|
for char in paragraph:
|
|
if line and draw.textlength(line + char, font=font) > width:
|
|
lines.append(line)
|
|
line = ""
|
|
line += char
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
def render(bundle, output, font_path):
|
|
slides = validate(bundle)
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
heading = ImageFont.truetype(str(font_path), 76)
|
|
body = ImageFont.truetype(str(font_path), 38)
|
|
for index, slide in enumerate(slides, 1):
|
|
image = Image.new("RGB", (1080, 1440), slide["color"])
|
|
draw = ImageDraw.Draw(image)
|
|
draw.text((80, 80), f"PROMPT / {index:02}", font=body, fill="#353535")
|
|
y = 330
|
|
for text, font, gap in [(slide["heading"], heading, 108), (slide["body"], body, 65)]:
|
|
for line in wrap(draw, text, font, 920):
|
|
if y + gap > 1360:
|
|
raise ValueError(f"Slide {index} overflows; split the text into more slides")
|
|
draw.text((80, y), line, font=font, fill="#242424")
|
|
y += gap
|
|
y += 70
|
|
image.save(output / f"slide-{index:03}.png")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("bundle", type=Path)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--font", type=Path, required=True, help="CJK font TTF/OTF/TTC")
|
|
parser.add_argument("--video", action="store_true")
|
|
args = parser.parse_args()
|
|
bundle = json.loads(args.bundle.read_text())
|
|
render(bundle, args.output, args.font)
|
|
if args.video:
|
|
# Use an explicit temporary input sequence so old PNGs cannot leak into a video.
|
|
import tempfile
|
|
import shutil
|
|
with tempfile.TemporaryDirectory() as staging:
|
|
for index in range(1, len(bundle["slides"]) + 1):
|
|
shutil.copy(args.output / f"slide-{index:03}.png", Path(staging) / f"slide-{index:03}.png")
|
|
subprocess.run(["ffmpeg", "-y", "-framerate", "1/4", "-i", str(Path(staging) / "slide-%03d.png"), "-vf", "fps=30,format=yuv420p", "-c:v", "libx264", "-movflags", "+faststart", str(args.output / "video.mp4")], check=True)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|