Skip to content
Merged
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/

19 changes: 19 additions & 0 deletions generative-ai/video-studio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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 produces the final MP4 when available and the container
image installs it. Minimal CI workers use a standards-compliant YUV4MPEG video
as the deterministic offline smoke artifact.
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]

83 changes: 83 additions & 0 deletions generative-ai/video-studio/scripts/pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/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):
meta = json.loads((out / "frames.json").read_text())
if shutil.which("ffmpeg"):
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)
return
# YUV4MPEG is a valid, streamable video format and needs no third-party
# encoder. It keeps the smoke fixture runnable on minimal CI workers.
target = out / "final.y4m"
with target.open("wb") as video:
video.write(f"YUV4MPEG2 W640 H360 F{meta['fps']}:1 Ip A1:1 C444\n".encode())
for frame in sorted((out / "frames").glob("*.ppm")):
payload = frame.read_bytes().split(b"\n", 3)[3]
video.write(b"FRAME\n")
y_plane, u_plane, v_plane = bytearray(), bytearray(), bytearray()
for i in range(0, len(payload), 3):
r, g, b = payload[i:i+3]
y = max(0, min(255, int(.299*r + .587*g + .114*b)))
u = max(0, min(255, int(-.169*r - .331*g + .5*b + 128)))
v = max(0, min(255, int(.5*r - .419*g - .081*b + 128)))
y_plane.append(y); u_plane.append(u); v_plane.append(v)
video.write(y_plane + u_plane + v_plane)

def quality(out):
video = "final.mp4" if (out / "final.mp4").exists() else "final.y4m"
files = ["storyboard.json", "frames.json", "narration.wav", video]
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: []
12 changes: 12 additions & 0 deletions generative-ai/video-studio/validate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/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 any((r/'outputs'/name).stat().st_size > 1000 for name in ('final.mp4','final.y4m') if (r/'outputs'/name).exists())
print('validated ai-video-studio')
PY
49 changes: 49 additions & 0 deletions generative-ai/video-studio/workflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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