Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions generative-ai/video-studio/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
outputs/

18 changes: 18 additions & 0 deletions generative-ai/video-studio/README.md
Original file line number Diff line number Diff line change
@@ -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.

11 changes: 11 additions & 0 deletions generative-ai/video-studio/data/brief.json
Original file line number Diff line number Diff line change
@@ -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}
]
}

7 changes: 7 additions & 0 deletions generative-ai/video-studio/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]

13 changes: 13 additions & 0 deletions generative-ai/video-studio/environment.yaml
Original file line number Diff line number Diff line change
@@ -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

7 changes: 7 additions & 0 deletions generative-ai/video-studio/run.sh
Original file line number Diff line number Diff line change
@@ -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"

4 changes: 4 additions & 0 deletions generative-ai/video-studio/scope.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id: ai-video-studio-scope
name: AI Video Studio local scope
environmentVersionIds: [ai-video-studio-v1]

67 changes: 67 additions & 0 deletions generative-ai/video-studio/scripts/pipeline.py
Original file line number Diff line number Diff line change
@@ -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("<h", int(7000 * envelope * math.sin(2 * math.pi * hz * n / rate))))

def compose(out):
if not shutil.which("ffmpeg"):
raise SystemExit("ffmpeg is required (or run inside the provided container)")
meta = json.loads((out / "frames.json").read_text())
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-framerate", str(meta["fps"]),
"-i", str(out / "frames/frame-%04d.ppm"), "-i", str(out / "narration.wav"), "-shortest",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(out / "final.mp4")], check=True)

def quality(out):
files = ["storyboard.json", "frames.json", "narration.wav", "final.mp4"]
write_json(out / "quality-report.json", {"status": "passed", "checks": {f: (out / f).stat().st_size for f in files}})
write_json(out / "manifest.json", {"showcase": "ai-video-studio", "status": "success", "artifacts": files + ["quality-report.json"]})

STAGES = {"storyboard": storyboard, "scenes": scenes, "narration": narration, "compose": compose, "quality-control": quality}
def main():
p = argparse.ArgumentParser(); p.add_argument("--stage", choices=[*STAGES, "all"], default="all"); p.add_argument("--output", required=True)
a = p.parse_args(); out = Path(a.output); out.mkdir(parents=True, exist_ok=True)
order = list(STAGES)
for stage in order if a.stage == "all" else [a.stage]: STAGES[stage](out)
if __name__ == "__main__": main()

3 changes: 3 additions & 0 deletions generative-ai/video-studio/topology.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
id: ai-video-studio-topology
name: AI Video Studio local topology
links: []
13 changes: 13 additions & 0 deletions generative-ai/video-studio/validate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
python3 - "$root" <<'PY'
import json, sys
from pathlib import Path
r=Path(sys.argv[1]); m=json.loads((r/'outputs/manifest.json').read_text())
assert m['status']=='success' and m['showcase']=='ai-video-studio'
assert all((r/'outputs'/p).stat().st_size > 0 for p in m['artifacts'])
assert (r/'outputs/final.mp4').stat().st_size > 1000
print('validated ai-video-studio')
PY

35 changes: 35 additions & 0 deletions generative-ai/video-studio/workflow.yaml
Original file line number Diff line number Diff line change
@@ -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]

Loading