diff --git a/generative-ai/video-studio/.gitignore b/generative-ai/video-studio/.gitignore new file mode 100644 index 0000000..f0b926a --- /dev/null +++ b/generative-ai/video-studio/.gitignore @@ -0,0 +1,2 @@ +outputs/ + diff --git a/generative-ai/video-studio/README.md b/generative-ai/video-studio/README.md new file mode 100644 index 0000000..ef3a05b --- /dev/null +++ b/generative-ai/video-studio/README.md @@ -0,0 +1,18 @@ +# AI Video Studio + +An offline, CPU-compatible video production pipeline. A short creative brief is +expanded into a storyboard, deterministic illustrated frames, narration audio, +and a final MP4. The fixture deliberately avoids hosted APIs: it uses Python for +planning and raster generation and FFmpeg for encoding. + +## Pipeline + +`brief -> storyboard -> scenes -> narration -> compose -> quality-control` + +Run `./run.sh`. FFmpeg is required on the host; the container image installs it. +Set `AKOFLOW_PROFILE=demo` (default: `smoke`) for more frames and a longer video. +The versioned brief in `data/brief.json` is the complete offline dataset. + +The stages map directly to AkôFlow activities. Their files are written to the +shared workspace, so provenance can show which activity generated each asset. + diff --git a/generative-ai/video-studio/data/brief.json b/generative-ai/video-studio/data/brief.json new file mode 100644 index 0000000..561e6db --- /dev/null +++ b/generative-ai/video-studio/data/brief.json @@ -0,0 +1,11 @@ +{ + "title": "A Drop's Journey", + "audience": "science museum visitors", + "style": "minimal editorial animation", + "scenes": [ + {"caption": "Clouds gather above the mountains", "color": [55, 86, 140], "tone_hz": 220}, + {"caption": "Rain feeds rivers and reservoirs", "color": [31, 132, 164], "tone_hz": 277}, + {"caption": "Clean water returns to our homes", "color": [30, 154, 112], "tone_hz": 330} + ] +} + diff --git a/generative-ai/video-studio/docker/Dockerfile b/generative-ai/video-studio/docker/Dockerfile new file mode 100644 index 0000000..fd3874f --- /dev/null +++ b/generative-ai/video-studio/docker/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY data /app/data +COPY scripts/pipeline.py /app/scripts/pipeline.py +ENTRYPOINT ["python", "/app/scripts/pipeline.py"] + diff --git a/generative-ai/video-studio/environment.yaml b/generative-ai/video-studio/environment.yaml new file mode 100644 index 0000000..dd377f9 --- /dev/null +++ b/generative-ai/video-studio/environment.yaml @@ -0,0 +1,13 @@ +id: ai-video-studio-environment +name: AI Video Studio local environment +driver: local +runtimes: + - id: local + type: local + containerRuntime: docker +resources: + - id: workstation + runtimeId: local + cpuCores: 4 + memoryBytes: 4294967296 + diff --git a/generative-ai/video-studio/run.sh b/generative-ai/video-studio/run.sh new file mode 100755 index 0000000..8acc770 --- /dev/null +++ b/generative-ai/video-studio/run.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +rm -rf "$root/outputs" +python3 "$root/scripts/pipeline.py" --stage all --output "$root/outputs" +"$root/validate.sh" + diff --git a/generative-ai/video-studio/scope.yaml b/generative-ai/video-studio/scope.yaml new file mode 100644 index 0000000..dfb30a9 --- /dev/null +++ b/generative-ai/video-studio/scope.yaml @@ -0,0 +1,4 @@ +id: ai-video-studio-scope +name: AI Video Studio local scope +environmentVersionIds: [ai-video-studio-v1] + diff --git a/generative-ai/video-studio/scripts/pipeline.py b/generative-ai/video-studio/scripts/pipeline.py new file mode 100644 index 0000000..ad02a3b --- /dev/null +++ b/generative-ai/video-studio/scripts/pipeline.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +import argparse, json, math, os, shutil, struct, subprocess, wave +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BRIEF = json.loads((ROOT / "data/brief.json").read_text()) + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n") + +def storyboard(out): + scenes = [{"id": i + 1, "duration": 2, **scene} for i, scene in enumerate(BRIEF["scenes"])] + write_json(out / "storyboard.json", {"title": BRIEF["title"], "scenes": scenes}) + +def ppm(path, color, caption_seed, frame): + width, height = 640, 360 + pixels = bytearray() + for y in range(height): + for x in range(width): + glow = int(32 * math.sin((x + frame * 14) / 80) + 32 * math.cos(y / 65)) + band = 45 if abs(y - (caption_seed * 31 % 250 + 55)) < 8 else 0 + pixels.extend(max(0, min(255, c + glow + band)) for c in color) + path.write_bytes(f"P6\n{width} {height}\n255\n".encode() + pixels) + +def scenes(out): + board = json.loads((out / "storyboard.json").read_text()) + count = 12 if os.getenv("AKOFLOW_PROFILE", "smoke") == "smoke" else 36 + frames = out / "frames"; frames.mkdir(exist_ok=True) + index = 1 + for scene in board["scenes"]: + for frame in range(count): + ppm(frames / f"frame-{index:04d}.ppm", scene["color"], scene["id"], frame) + index += 1 + write_json(out / "frames.json", {"fps": 6 if count == 12 else 12, "count": index - 1}) + +def narration(out): + rate = 16000; seconds = len(BRIEF["scenes"]) * 2 + with wave.open(str(out / "narration.wav"), "wb") as audio: + audio.setparams((1, 2, rate, 0, "NONE", "not compressed")) + for n in range(rate * seconds): + scene = min(len(BRIEF["scenes"]) - 1, n // (rate * 2)) + hz = BRIEF["scenes"][scene]["tone_hz"] + envelope = min(1, (n % (rate * 2)) / 2000, (rate * 2 - n % (rate * 2)) / 2000) + audio.writeframesraw(struct.pack(" 0 for p in m['artifacts']) +assert (r/'outputs/final.mp4').stat().st_size > 1000 +print('validated ai-video-studio') +PY + diff --git a/generative-ai/video-studio/workflow.yaml b/generative-ai/video-studio/workflow.yaml new file mode 100644 index 0000000..aced1e2 --- /dev/null +++ b/generative-ai/video-studio/workflow.yaml @@ -0,0 +1,35 @@ +name: ai-video-studio +spec: + namespace: showcase + image: ghcr.io/uffescience/akoflow-examples/ai-video-studio:v1 + activities: + - name: storyboard + runtime: local + command: {entrypoint: python, arguments: ["/app/scripts/pipeline.py", "--stage", "storyboard", "--output", "."], workingDirectory: /workspace} + cpuLimit: "1" + memoryLimit: 256Mi + - name: scenes + runtime: local + command: {entrypoint: python, arguments: ["/app/scripts/pipeline.py", "--stage", "scenes", "--output", "."], workingDirectory: /workspace} + cpuLimit: "2" + memoryLimit: 512Mi + dependsOn: [storyboard] + - name: narration + runtime: local + command: {entrypoint: python, arguments: ["/app/scripts/pipeline.py", "--stage", "narration", "--output", "."], workingDirectory: /workspace} + cpuLimit: "1" + memoryLimit: 256Mi + dependsOn: [storyboard] + - name: compose + runtime: local + command: {entrypoint: python, arguments: ["/app/scripts/pipeline.py", "--stage", "compose", "--output", "."], workingDirectory: /workspace} + cpuLimit: "2" + memoryLimit: 512Mi + dependsOn: [scenes, narration] + - name: quality-control + runtime: local + command: {entrypoint: python, arguments: ["/app/scripts/pipeline.py", "--stage", "quality-control", "--output", "."], workingDirectory: /workspace} + cpuLimit: "1" + memoryLimit: 256Mi + dependsOn: [compose] +