"""Stage 5 — Hunyuan image-to-video. cuts : every shot rendered independently from its own keyframe. chain : the previous clip's LAST frame seeds the next clip, so the whole track becomes one continuous take.""" from __future__ import annotations import logging from pathlib import Path from . import config, models from .audio_analysis import AudioTimeline from .director import Shot LOG = logging.getLogger("dwct.render") def _motion_text(shot: Shot, tl: AudioTimeline) -> str: r = tl.reactive((shot.start + shot.end) / 2) strength = ("barely moving" if r["motion"] < 0.6 else "steady" if r["motion"] < 1.2 else "fast and forceful") return f"{shot.motion_prompt}, camera {strength}, motion intensity {r['motion']:.2f}" def _i2v(image: Path, prompt: str, negative: str, seconds: float, seed: int): import torch from PIL import Image as PILImage pipe = models.hunyuan() gen = torch.Generator(device="cpu").manual_seed(seed) frames = max(5, int(round(seconds * config.FPS))) frames = frames - ((frames - 1) % 4) # Hunyuan wants 4n+1 common = dict(prompt=prompt, negative_prompt=negative, num_inference_steps=config.STEPS, generator=gen, height=config.HEIGHT, width=config.WIDTH) img = PILImage.open(image).convert("RGB") try: return pipe(image=img, num_frames=frames, **common).frames[0] except TypeError: return pipe(image=img, **common).frames[0] def _write(frames, out: Path) -> Path: import imageio.v3 as iio out.parent.mkdir(parents=True, exist_ok=True) iio.imwrite(out, frames, fps=config.FPS, codec="libx264", quality=8) return out def render_shots(shots: list[Shot], tl: AudioTimeline, out_dir: Path, mode: str = "cuts", base_seed: int = 7) -> list[Path]: out_dir.mkdir(parents=True, exist_ok=True) clips: list[Path] = [] carry = None for shot in shots: seed = base_seed + shot.index start_img = Path(shot.keyframe) if mode == "cuts" else (carry or Path(shot.keyframe)) out = out_dir / f"shot_{shot.index:04d}.mp4" LOG.info("render shot %d %.2fs mode=%s", shot.index, shot.duration, mode) frames = _i2v(start_img, _motion_text(shot, tl), shot.negative, shot.duration, seed) _write(frames, out) shot.clip = str(out) clips.append(out) if mode == "chain": from PIL import Image as PILImage carry = out_dir / f"_carry_{shot.index:04d}.png" PILImage.fromarray(frames[-1]).save(carry) return clips