docs(plan): content pipeline Increment 1 implementation plan (session 0014)
Bite-sized TDD plan for Increment 1 (real footage, no ML): the deterministic tools/pipeline/ package (frame → crossfade-loop → master+proxy transcode → manifest assembly, pure ffmpeg arg builders unit-tested + an opt-in integration test on the real forest POC base), the backward-compatible keyframed annotation track schema with client-side interpolation, and the 5-scale ring (orbit + reef). 9 tasks. Implements spec §3 (stages 1–4,6), §4, §5, §6, §7, and §8 Increment 1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
# Content Pipeline — Increment 1 (real footage, no ML) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the deterministic content-pipeline tooling (frame → crossfade-loop → master+proxy transcode → manifest assembly), add the backward-compatible keyframed annotation-track schema with client interpolation, and extend the scale ring to 5 scales — proving the pipeline end-to-end on real footage without any ML.
|
||||
|
||||
**Architecture:** A new pure-Python `tools/pipeline/` package whose stage functions return ffmpeg argument lists (unit-tested without running ffmpeg); a thin `run.py` executes them and is covered by an opt-in integration test gated on `ffmpeg` + the real forest POC base. Manifest assembly is pure dict transforms. The annotation `track` is optional data that rides through the existing pass-through `annotations` list (no Python model change); interpolation is client-side JS, consistent with the existing "alteration math in Python, label placement in the client" split. This implements §3 (stages 1–4, 6), §4 (track schema), §6 (format), §7 (tooling), and Increment 1 of §8 in [`docs/superpowers/specs/2026-06-24-content-pipeline-design.md`](../specs/2026-06-24-content-pipeline-design.md).
|
||||
|
||||
**Tech Stack:** Python 3.13, pytest, ffmpeg (libx264), vanilla JS (SVG overlay). Tests run with `pytest -q` from the repo root in the project `.venv`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold `tools/pipeline/` + the frame stage
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/__init__.py`
|
||||
- Create: `tools/pipeline/ffmpeg_ops.py`
|
||||
- Test: `tests/test_pipeline_ffmpeg_ops.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_ffmpeg_ops.py
|
||||
from tools.pipeline.ffmpeg_ops import frame_args
|
||||
|
||||
|
||||
def test_frame_args_trims_scales_pads_and_strips_audio():
|
||||
args = frame_args(
|
||||
"src.mp4", "out.mp4",
|
||||
start=2.0, duration=8.0, width=1920, height=1080, ff="ffmpeg",
|
||||
)
|
||||
assert args[0] == "ffmpeg"
|
||||
assert "-ss" in args and args[args.index("-ss") + 1] == "2.0"
|
||||
assert "-t" in args and args[args.index("-t") + 1] == "8.0"
|
||||
assert "-an" in args # audio stripped (bases are video-only)
|
||||
vf = args[args.index("-vf") + 1]
|
||||
assert "scale=1920:1080:force_original_aspect_ratio=decrease" in vf
|
||||
assert "pad=1920:1080" in vf
|
||||
assert "format=yuv420p" in vf
|
||||
assert args[-1] == "out.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/__init__.py
|
||||
"""Content pipeline: source -> frame -> loop -> transcode -> manifest.
|
||||
|
||||
Pure ffmpeg-argument builders (ffmpeg_ops), a thin runner (run), provenance
|
||||
records, and manifest assembly. See docs/superpowers/specs/2026-06-24-content-pipeline-design.md.
|
||||
"""
|
||||
```
|
||||
|
||||
```python
|
||||
# tools/pipeline/ffmpeg_ops.py
|
||||
"""Pure ffmpeg argument builders (no I/O) for the content pipeline.
|
||||
|
||||
Each function returns a list[str] ready for ffmpeg, so command construction is
|
||||
unit-testable without running ffmpeg. tools/pipeline/run.py executes them. The
|
||||
ffmpeg binary name is injected (default "ffmpeg").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _fit(width: int, height: int) -> str:
|
||||
"""A scale+pad filter chain fitting any input into width×height (letterbox),
|
||||
fixing SAR and pixel format so downstream concat/xfade get identical geometry."""
|
||||
return (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
||||
"setsar=1,format=yuv420p"
|
||||
)
|
||||
|
||||
|
||||
def frame_args(src, dst, *, start: float, duration: float,
|
||||
width: int, height: int, ff: str = "ffmpeg") -> list[str]:
|
||||
"""Stage 2 — trim [start, start+duration], fit to width×height, strip audio.
|
||||
Produces a high-quality mezzanine for the loop stage."""
|
||||
return [
|
||||
ff, "-y", "-ss", str(start), "-t", str(duration),
|
||||
"-i", str(src), "-vf", _fit(width, height), "-an",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "16",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/__init__.py tools/pipeline/ffmpeg_ops.py tests/test_pipeline_ffmpeg_ops.py
|
||||
git commit -m "feat(pipeline): frame stage ffmpeg arg builder (content pipeline §3.2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Crossfade seamless-loop stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/pipeline/ffmpeg_ops.py`
|
||||
- Test: `tests/test_pipeline_ffmpeg_ops.py`
|
||||
|
||||
The seamless loop (spec §3.3) crossfades the clip's tail over its head. For a clip
|
||||
of duration `D` with overlap `O`, the output (length `D − O`) is
|
||||
`concat( xfade(tail, head, O), mid )` where `head`=`[0,O]`, `mid`=`[O, D−O]`,
|
||||
`tail`=`[D−O, D]`. Both internal joins and the loop seam are then continuous, and
|
||||
the tail→head jump is blended away over `O`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# add to tests/test_pipeline_ffmpeg_ops.py
|
||||
from tools.pipeline.ffmpeg_ops import crossfade_loop_args
|
||||
|
||||
|
||||
def test_crossfade_loop_builds_head_mid_tail_xfade_concat():
|
||||
args = crossfade_loop_args("in.mp4", "loop.mp4", duration=8.0, overlap=1.0)
|
||||
fc = args[args.index("-filter_complex") + 1]
|
||||
assert "trim=0:1.0" in fc # head = first O secs
|
||||
assert "trim=1.0:7.0" in fc # mid = [O, D-O]
|
||||
assert "trim=7.0:8.0" in fc # tail = last O secs
|
||||
assert "xfade=transition=fade:duration=1.0:offset=0" in fc
|
||||
assert "concat=n=2:v=1" in fc
|
||||
assert "-an" in args
|
||||
assert args[-1] == "loop.mp4"
|
||||
|
||||
|
||||
def test_crossfade_loop_rejects_overlap_too_large():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
crossfade_loop_args("in.mp4", "loop.mp4", duration=4.0, overlap=2.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: FAIL with `ImportError: cannot import name 'crossfade_loop_args'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# add to tools/pipeline/ffmpeg_ops.py
|
||||
def crossfade_loop_args(src, dst, *, duration: float, overlap: float,
|
||||
ff: str = "ffmpeg") -> list[str]:
|
||||
"""Stage 3 — make `src` loop seamlessly by crossfading its tail over its head.
|
||||
Output length = duration − overlap. Requires overlap < duration/2 so a non-empty
|
||||
middle remains."""
|
||||
if overlap <= 0 or overlap >= duration / 2:
|
||||
raise ValueError(f"overlap {overlap} must be in (0, duration/2={duration/2})")
|
||||
d, o = duration, overlap
|
||||
fc = (
|
||||
f"[0:v]trim=0:{o},setpts=PTS-STARTPTS[head];"
|
||||
f"[0:v]trim={o}:{d - o},setpts=PTS-STARTPTS[mid];"
|
||||
f"[0:v]trim={d - o}:{d},setpts=PTS-STARTPTS[tail];"
|
||||
f"[tail][head]xfade=transition=fade:duration={o}:offset=0[xf];"
|
||||
f"[xf][mid]concat=n=2:v=1,format=yuv420p[out]"
|
||||
)
|
||||
return [
|
||||
ff, "-y", "-i", str(src), "-filter_complex", fc,
|
||||
"-map", "[out]", "-an",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "16",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/ffmpeg_ops.py tests/test_pipeline_ffmpeg_ops.py
|
||||
git commit -m "feat(pipeline): crossfade seamless-loop arg builder (content pipeline §3.3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Master + proxy transcode stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/pipeline/ffmpeg_ops.py`
|
||||
- Test: `tests/test_pipeline_ffmpeg_ops.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# add to tests/test_pipeline_ffmpeg_ops.py
|
||||
from tools.pipeline.ffmpeg_ops import transcode_args
|
||||
|
||||
|
||||
def test_transcode_args_high_profile_even_dims_faststart():
|
||||
args = transcode_args("loop.mp4", "proxy.mp4", width=1920, height=1080,
|
||||
crf=20, fps=30)
|
||||
assert "-profile:v" in args and args[args.index("-profile:v") + 1] == "high"
|
||||
assert "-crf" in args and args[args.index("-crf") + 1] == "20"
|
||||
vf = args[args.index("-vf") + 1]
|
||||
assert "scale=1920:1080:force_original_aspect_ratio=decrease" in vf
|
||||
assert "fps=30" in vf
|
||||
assert "+faststart" in args
|
||||
assert "-an" in args
|
||||
assert args[-1] == "proxy.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: FAIL with `ImportError: cannot import name 'transcode_args'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# add to tools/pipeline/ffmpeg_ops.py
|
||||
def transcode_args(src, dst, *, width: int, height: int, crf: int,
|
||||
fps: int = 30, ff: str = "ffmpeg") -> list[str]:
|
||||
"""Stage 4 — encode a final deliverable (master or proxy). Master: source res
|
||||
capped ~3840 wide, crf 18. Proxy: 1920×1080, crf 20. Both H.264 High, yuv420p,
|
||||
even dims, +faststart, video-only (spec §6)."""
|
||||
vf = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
||||
f"setsar=1,fps={fps},format=yuv420p"
|
||||
)
|
||||
return [
|
||||
ff, "-y", "-i", str(src), "-vf", vf, "-an",
|
||||
"-c:v", "libx264", "-profile:v", "high", "-preset", "slow",
|
||||
"-crf", str(crf), "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart", str(dst),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/ffmpeg_ops.py tests/test_pipeline_ffmpeg_ops.py
|
||||
git commit -m "feat(pipeline): master/proxy transcode arg builder (content pipeline §3.4/§6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Provenance record (stage 1 output)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/provenance.py`
|
||||
- Test: `tests/test_pipeline_provenance.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_provenance.py
|
||||
from tools.pipeline.provenance import Provenance
|
||||
|
||||
|
||||
def test_provenance_to_dict_roundtrip():
|
||||
p = Provenance(
|
||||
scale="abyss",
|
||||
license="public-domain (US Gov, 17 U.S.C. §105)",
|
||||
source="NOAA Ocean Exploration",
|
||||
url="https://oceanexplorer.noaa.gov/",
|
||||
fetched_at="2026-06-24",
|
||||
)
|
||||
d = p.to_dict()
|
||||
assert d == {
|
||||
"scale": "abyss",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NOAA Ocean Exploration",
|
||||
"url": "https://oceanexplorer.noaa.gov/",
|
||||
"fetched_at": "2026-06-24",
|
||||
}
|
||||
assert Provenance.from_dict(d) == p
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_provenance.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline.provenance'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/provenance.py
|
||||
"""Stage 1 — the per-clip strict-PD provenance record (spec §3.1).
|
||||
|
||||
Captured at source time so license + source + URL travel with the media. The
|
||||
'no explicit license -> assume PD, verify' trap (scales design §2.1) applies:
|
||||
"free stock" (Pexels/Pixabay) is NOT public domain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provenance:
|
||||
scale: str
|
||||
license: str
|
||||
source: str
|
||||
url: str
|
||||
fetched_at: str # ISO date; passed in (no clock here -> deterministic)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Provenance":
|
||||
return cls(
|
||||
scale=d["scale"], license=d["license"], source=d["source"],
|
||||
url=d["url"], fetched_at=d["fetched_at"],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_provenance.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/provenance.py tests/test_pipeline_provenance.py
|
||||
git commit -m "feat(pipeline): strict-PD provenance record (content pipeline §3.1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manifest assembly (stage 6)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/manifest.py`
|
||||
- Test: `tests/test_pipeline_manifest.py`
|
||||
|
||||
Pure dict transforms over a loaded manifest: upsert a clip entry, place a scale in
|
||||
the ring order, and rebuild the per-edge transition list so it always has exactly
|
||||
N entries for N scales (one transition per ring edge, including the wrap).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_manifest.py
|
||||
from tools.pipeline.manifest import upsert_clip, add_ring_scale, rebuild_ring_edges
|
||||
|
||||
|
||||
def _base():
|
||||
return {"clips": [{"id": "cosmos", "title": "Cosmos"}],
|
||||
"ring": {"scales": [{"id": "cosmos", "clip_id": "cosmos"}],
|
||||
"transitions": []}}
|
||||
|
||||
|
||||
def test_upsert_clip_replaces_by_id_else_appends():
|
||||
m = _base()
|
||||
m = upsert_clip(m, {"id": "reef", "title": "Reef"})
|
||||
assert [c["id"] for c in m["clips"]] == ["cosmos", "reef"]
|
||||
m = upsert_clip(m, {"id": "reef", "title": "Coral Reef"}) # replace, not dup
|
||||
assert [c["id"] for c in m["clips"]] == ["cosmos", "reef"]
|
||||
assert m["clips"][1]["title"] == "Coral Reef"
|
||||
|
||||
|
||||
def test_add_ring_scale_inserts_after_named_scale():
|
||||
m = _base()
|
||||
m = add_ring_scale(m, "orbit", "orbit", after="cosmos")
|
||||
assert [s["id"] for s in m["ring"]["scales"]] == ["cosmos", "orbit"]
|
||||
|
||||
|
||||
def test_rebuild_ring_edges_one_transition_per_edge_with_wrap():
|
||||
m = _base()
|
||||
m = add_ring_scale(m, "orbit", "orbit", after="cosmos")
|
||||
m = add_ring_scale(m, "abyss", "abyss", after="orbit")
|
||||
m = rebuild_ring_edges(m)
|
||||
files = [t["file"] for t in m["ring"]["transitions"]]
|
||||
assert files == [
|
||||
"transitions/cosmos-orbit.mp4",
|
||||
"transitions/orbit-abyss.mp4",
|
||||
"transitions/abyss-cosmos.mp4", # wrap edge
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline.manifest'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/manifest.py
|
||||
"""Stage 6 — assemble manifest entries (spec §3.6). Pure dict transforms over a
|
||||
loaded manifest dict; the caller does the json read/write. Idempotent by id so
|
||||
re-running earlier stages never clobbers authored labels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
|
||||
def upsert_clip(manifest: dict, clip: dict) -> dict:
|
||||
"""Replace the clip with the same id, or append it. Returns a new manifest."""
|
||||
m = copy.deepcopy(manifest)
|
||||
clips = m.setdefault("clips", [])
|
||||
for i, c in enumerate(clips):
|
||||
if c["id"] == clip["id"]:
|
||||
clips[i] = clip
|
||||
return m
|
||||
clips.append(clip)
|
||||
return m
|
||||
|
||||
|
||||
def add_ring_scale(manifest: dict, scale_id: str, clip_id: str,
|
||||
after: str | None = None) -> dict:
|
||||
"""Insert a scale into ring.scales after the named scale (or append if `after`
|
||||
is None / not found). No-op if scale_id already present. Returns a new manifest."""
|
||||
m = copy.deepcopy(manifest)
|
||||
ring = m.setdefault("ring", {"scales": [], "transitions": []})
|
||||
scales = ring.setdefault("scales", [])
|
||||
if any(s["id"] == scale_id for s in scales):
|
||||
return m
|
||||
entry = {"id": scale_id, "clip_id": clip_id}
|
||||
idx = next((i for i, s in enumerate(scales) if s["id"] == after), None)
|
||||
if idx is None:
|
||||
scales.append(entry)
|
||||
else:
|
||||
scales.insert(idx + 1, entry)
|
||||
return m
|
||||
|
||||
|
||||
def rebuild_ring_edges(manifest: dict) -> dict:
|
||||
"""Rebuild ring.transitions to one placeholder entry per ring edge (N scales ->
|
||||
N edges incl. the wrap), preserving any existing model string for an edge.
|
||||
Returns a new manifest."""
|
||||
m = copy.deepcopy(manifest)
|
||||
ring = m.setdefault("ring", {"scales": [], "transitions": []})
|
||||
scales = ring.get("scales", [])
|
||||
existing = {t["file"]: t.get("model", "") for t in ring.get("transitions", [])}
|
||||
edges = []
|
||||
n = len(scales)
|
||||
for i in range(n):
|
||||
a, b = scales[i]["id"], scales[(i + 1) % n]["id"]
|
||||
file = f"transitions/{a}-{b}.mp4"
|
||||
edges.append({"file": file, "model": existing.get(file, "placeholder-zoom")})
|
||||
ring["transitions"] = edges
|
||||
return m
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/manifest.py tests/test_pipeline_manifest.py
|
||||
git commit -m "feat(pipeline): manifest assembly — upsert clip + ring scale/edges (content pipeline §3.6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Runner + opt-in integration test on the real forest POC base
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/run.py`
|
||||
- Test: `tests/test_pipeline_integration.py`
|
||||
|
||||
The runner shells out ffmpeg with the builders from Tasks 1–3, ffprobing the source
|
||||
duration so the loop stage gets real `D`. The integration test runs the **real**
|
||||
forest POC base (`simulator/sample_media/forest/base.mp4`, populated by
|
||||
`simulator/setup_sample_media.py`) through frame → loop → transcode and ffprobes the
|
||||
proxy — gated on `ffmpeg`/`ffprobe` and on the base existing (it's gitignored media),
|
||||
matching `tests/test_tools_integration.py`'s gating.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_integration.py
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.pipeline.run import process_clip
|
||||
|
||||
_HAS_FF = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
||||
_FOREST = Path("simulator/sample_media/forest/base.mp4")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_FF, reason="ffmpeg/ffprobe not installed")
|
||||
@pytest.mark.skipif(not _FOREST.exists(),
|
||||
reason="forest POC base absent (run simulator/setup_sample_media.py)")
|
||||
def test_process_clip_emits_proxy_and_master(tmp_path):
|
||||
out = process_clip(_FOREST, tmp_path, overlap=0.5, start=0.0, duration=4.0)
|
||||
assert out["proxy"].exists() and out["master"].exists()
|
||||
# Proxy is exactly 1920x1080.
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "csv=p=0", str(out["proxy"])],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
assert probe == "1920,1080"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_integration.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline.run'`
|
||||
(or SKIP if ffmpeg/the base are absent — then verify by importing in `python -c`.)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/run.py
|
||||
"""Thin runner: ffprobe the source, then execute the frame -> loop -> transcode
|
||||
stages with tools.pipeline.ffmpeg_ops. The pure arg builders are unit-tested;
|
||||
this glue is covered by the opt-in integration test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .ffmpeg_ops import crossfade_loop_args, frame_args, transcode_args
|
||||
|
||||
MASTER_MAX_W = 3840
|
||||
|
||||
|
||||
def probe_duration(src, *, ff: str = "ffprobe") -> float:
|
||||
out = subprocess.run(
|
||||
[ff, "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", str(src)],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
return float(out)
|
||||
|
||||
|
||||
def _run(args: list[str]) -> None:
|
||||
subprocess.run(args, check=True, capture_output=True)
|
||||
|
||||
|
||||
def process_clip(src, out_dir, *, start: float = 0.0, duration: float | None = None,
|
||||
overlap: float = 1.0, master_w: int = 3840, master_h: int = 2160,
|
||||
ff: str = "ffmpeg") -> dict:
|
||||
"""Run stages 2–4 for one clip; return {'master','proxy','loop','mezzanine'} paths.
|
||||
`duration` defaults to the full source length (minus the trim start)."""
|
||||
src = Path(src)
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if duration is None:
|
||||
duration = probe_duration(src) - start
|
||||
|
||||
mezz = out_dir / "mezzanine.mp4"
|
||||
loop = out_dir / "loop.mp4"
|
||||
master = out_dir / "master.mp4"
|
||||
proxy = out_dir / "base.mp4" # the proxy is what the manifest's base_file points at
|
||||
|
||||
_run(frame_args(src, mezz, start=start, duration=duration,
|
||||
width=master_w, height=master_h, ff=ff))
|
||||
_run(crossfade_loop_args(mezz, loop, duration=duration, overlap=overlap, ff=ff))
|
||||
_run(transcode_args(loop, master, width=master_w, height=master_h, crf=18, ff=ff))
|
||||
_run(transcode_args(loop, proxy, width=1920, height=1080, crf=20, ff=ff))
|
||||
return {"mezzanine": mezz, "loop": loop, "master": master, "proxy": proxy}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes (or skips cleanly)**
|
||||
|
||||
Run: `pytest tests/test_pipeline_integration.py -q`
|
||||
Expected: PASS if ffmpeg + the forest base are present; otherwise SKIP with the gate
|
||||
reason. If skipped, also run `python -c "from tools.pipeline.run import process_clip"`
|
||||
and expect no error.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/run.py tests/test_pipeline_integration.py
|
||||
git commit -m "feat(pipeline): runner + opt-in integration test on real forest base (content pipeline §3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Client annotation-track interpolation + a hand-authored track
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (`renderOverlay` at lines ~235–269; `paintLoop` at ~173–186)
|
||||
- Modify: `simulator/sample_media/manifest.json` (add a `track` to the forest `detected.water` annotation)
|
||||
|
||||
The track is *data* on an existing annotation; the Python `Clip` already passes
|
||||
`annotations` through opaquely (`simulator/clips.py` `_clip_from_dict` does
|
||||
`d.get("annotations", [])`), so **no Python change is needed**. Interpolation is
|
||||
client-side (spec §4). Because the overlay currently re-renders only on knob change,
|
||||
a tracked box must update as the video plays — drive a re-render from the existing
|
||||
`paintLoop` rAF when the active clip has any track and the overlay is visible.
|
||||
|
||||
- [ ] **Step 1: Add the data — a hand-authored track on forest's water detection**
|
||||
|
||||
In `simulator/sample_media/manifest.json`, replace the forest `detected.water`
|
||||
annotation (currently `{"key": "detected.water", "box": [0.30, 0.10, 0.18, 0.70], "min_level": 1}`)
|
||||
with a keyframed track (a small horizontal drift, looping at t=0 and t=1 so it is
|
||||
seamless):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "detected.water",
|
||||
"min_level": 1,
|
||||
"track": [
|
||||
{"t": 0.0, "box": [0.30, 0.10, 0.18, 0.70]},
|
||||
{"t": 0.5, "box": [0.33, 0.10, 0.18, 0.70]},
|
||||
{"t": 1.0, "box": [0.30, 0.10, 0.18, 0.70]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the interpolation helper + use it in `renderOverlay`**
|
||||
|
||||
In `simulator/static/app.js`, add a helper above `renderOverlay` and use it instead
|
||||
of `a.box`:
|
||||
|
||||
```javascript
|
||||
// Interpolate an annotation's box at loop-normalized time t (0..1). Static labels
|
||||
// (no track) return their fixed box. Linear between sorted keyframes; clamps to ends.
|
||||
function boxAt(a, t) {
|
||||
if (!a.track || !a.track.length) return a.box;
|
||||
const ks = a.track;
|
||||
if (t <= ks[0].t) return ks[0].box;
|
||||
if (t >= ks[ks.length - 1].t) return ks[ks.length - 1].box;
|
||||
for (let i = 1; i < ks.length; i++) {
|
||||
if (t <= ks[i].t) {
|
||||
const a0 = ks[i - 1], a1 = ks[i];
|
||||
const f = (t - a0.t) / (a1.t - a0.t);
|
||||
return a0.box.map((v, j) => v + (a1.box[j] - v) * f);
|
||||
}
|
||||
}
|
||||
return ks[ks.length - 1].box;
|
||||
}
|
||||
|
||||
// Loop-normalized playback time of the base video (0..1), for track interpolation.
|
||||
function loopT() {
|
||||
return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0;
|
||||
}
|
||||
```
|
||||
|
||||
Then in `renderOverlay`, change the box line:
|
||||
|
||||
```javascript
|
||||
const [x, y, w, h] = boxAt(a, loopT()).map((n) => n * 100);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Re-render tracked overlays each frame from `paintLoop`**
|
||||
|
||||
Store the last overlay args and, in `paintLoop`, re-run `renderOverlay` when the
|
||||
active clip has any tracked annotation and the overlay is visible. Add near the top
|
||||
of `app.js` (module scope): `let lastOverlay = null;`. At the end of `renderOverlay`,
|
||||
record the call: set `lastOverlay = { level, intensity };` (add this line just before
|
||||
the closing brace). In `paintLoop` (after the existing shader work, before
|
||||
`requestAnimationFrame(paintLoop)`), add:
|
||||
|
||||
```javascript
|
||||
// Animate keyframed annotation tracks: re-place boxes against playback time.
|
||||
const c = activeClip();
|
||||
if (lastOverlay && lastOverlay.level > 0 && c &&
|
||||
c.annotations.some((a) => a.track && a.track.length)) {
|
||||
renderOverlay(lastOverlay.level, lastOverlay.intensity);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify by eye (no JS unit harness in this repo)**
|
||||
|
||||
Run the sim and watch the forest water reticle drift with playback, looping
|
||||
seamlessly; static labels (rock_face, conifer, the measurement) stay put.
|
||||
|
||||
```bash
|
||||
python -m simulator.app # then open the printed localhost URL
|
||||
```
|
||||
|
||||
Expected: with Left up on forest, the `flowing water` box drifts right then back
|
||||
over the loop with no jump at the loop seam; affect/measure labels unchanged.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js simulator/sample_media/manifest.json
|
||||
git commit -m "feat(sim): client annotation-track interpolation + hand-authored forest track (content pipeline §4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Extend the ring to 5 scales (orbit + reef placeholders)
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/setup_scales_media.py` (`SCALES` lines ~32–36; `EDGES` line ~39)
|
||||
- Modify: `simulator/sample_media/manifest.json` (add `orbit` + `reef` clips; reorder `ring.scales`; rebuild `ring.transitions`)
|
||||
|
||||
Real strict-PD bases are sourced separately (Task 9); to keep the 5-scale ring
|
||||
demonstrable now, generate labelled placeholders for the two new scales with the
|
||||
existing generator, exactly as cosmos/abyss are today.
|
||||
|
||||
- [ ] **Step 1: Add orbit + reef to the placeholder generator**
|
||||
|
||||
In `simulator/setup_scales_media.py`, extend `SCALES` and `EDGES` to the 5-scale
|
||||
ring (order: cosmos → orbit → forest → reef → abyss → wrap):
|
||||
|
||||
```python
|
||||
SCALES = {
|
||||
"cosmos": ("0x05060f", "COSMOS — NASA/Hubble (PD placeholder)"),
|
||||
"orbit": ("0x081427", "ORBIT — NASA/ISS (PD placeholder)"),
|
||||
"forest": ("0x12301a", "FOREST — NPS/USGS (POC/placeholder)"),
|
||||
"reef": ("0x06303a", "REEF — NOAA Ocean Exploration (PD placeholder)"),
|
||||
"abyss": ("0x021016", "ABYSS — NOAA Ocean Exploration (PD placeholder)"),
|
||||
}
|
||||
|
||||
EDGES = [("cosmos", "orbit"), ("orbit", "forest"), ("forest", "reef"),
|
||||
("reef", "abyss"), ("abyss", "cosmos")]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the orbit + reef clip entries to the manifest**
|
||||
|
||||
In `simulator/sample_media/manifest.json`, add two clip objects (after `cosmos` and
|
||||
after `forest` respectively) mirroring the existing placeholder clips' shape, e.g.
|
||||
orbit:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "orbit",
|
||||
"title": "Earth from orbit (NASA/ISS, neutral base)",
|
||||
"base_file": "orbit/base.mp4",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NASA/ISS — https://images.nasa.gov/ (true PD; placeholder bytes until ingest)",
|
||||
"right_variants": {},
|
||||
"annotations": [
|
||||
{"key": "detected.cloud_band", "box": [0.30, 0.30, 0.30, 0.20], "min_level": 1},
|
||||
{"key": "measure.altitude", "box": [0.06, 0.06, 0.18, 0.08], "min_level": 2}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.serenity", "at": [0.50, 0.46], "min_level": 1},
|
||||
{"key": "feel.fragility", "at": [0.22, 0.68], "min_level": 2},
|
||||
{"key": "feel.unity", "at": [0.66, 0.58], "min_level": 3},
|
||||
{"key": "feel.distance", "at": [0.42, 0.82], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.cloud_band": "cloud band",
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.serenity": "serenity", "feel.fragility": "fragility",
|
||||
"feel.unity": "unity", "feel.distance": "distance"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And reef:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "reef",
|
||||
"title": "Coral reef (NOAA Ocean Exploration, neutral base)",
|
||||
"base_file": "reef/base.mp4",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NOAA Ocean Exploration — https://oceanexplorer.noaa.gov/ (true PD, worldwide; placeholder bytes until ingest)",
|
||||
"right_variants": {},
|
||||
"annotations": [
|
||||
{"key": "detected.coral", "box": [0.20, 0.45, 0.30, 0.30], "min_level": 1},
|
||||
{"key": "detected.fish", "min_level": 2,
|
||||
"track": [
|
||||
{"t": 0.0, "box": [0.20, 0.30, 0.10, 0.08]},
|
||||
{"t": 0.5, "box": [0.62, 0.34, 0.10, 0.08]},
|
||||
{"t": 1.0, "box": [0.20, 0.30, 0.10, 0.08]}
|
||||
]},
|
||||
{"key": "measure.depth", "box": [0.06, 0.06, 0.16, 0.08], "min_level": 3}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.delight", "at": [0.50, 0.46], "min_level": 1},
|
||||
{"key": "feel.abundance", "at": [0.22, 0.68], "min_level": 2},
|
||||
{"key": "feel.curiosity", "at": [0.66, 0.58], "min_level": 3},
|
||||
{"key": "feel.immersion", "at": [0.42, 0.82], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.coral": "coral colony", "detected.fish": "reef fish",
|
||||
"measure.depth": "−18 m",
|
||||
"feel.delight": "delight", "feel.abundance": "abundance",
|
||||
"feel.curiosity": "curiosity", "feel.immersion": "immersion"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reorder the ring + rebuild edges in the manifest**
|
||||
|
||||
Set `ring.scales` to the 5-scale order and `ring.transitions` to the 5 edges:
|
||||
|
||||
```json
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "orbit", "clip_id": "orbit"},
|
||||
{"id": "forest", "clip_id": "forest"},
|
||||
{"id": "reef", "clip_id": "reef"},
|
||||
{"id": "abyss", "clip_id": "abyss"}
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-orbit.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/orbit-forest.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/forest-reef.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/reef-abyss.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder-zoom"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate placeholder media + verify the 5-scale ring**
|
||||
|
||||
```bash
|
||||
python simulator/setup_scales_media.py
|
||||
python -m simulator.app # open the URL; spin the encoder full circle
|
||||
```
|
||||
|
||||
Expected: the encoder walks cosmos → orbit → forest → reef → abyss → (wrap) cosmos
|
||||
with a transition on every edge; `GET /api/ring` lists 5 scales + 5 transitions; the
|
||||
reef `reef fish` box drifts with playback (Task 7 interpolation) when Left ≥ 2.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/setup_scales_media.py simulator/sample_media/manifest.json
|
||||
git commit -m "feat(sim): extend scale ring to 5 (orbit + reef) (content pipeline §5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Sourcing procedure doc + ROADMAP update
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/content-sourcing.md`
|
||||
- Modify: `docs/ROADMAP.md` (sub-project 3 "Offline v2v variant pipeline" bullet, lines ~174–187)
|
||||
|
||||
Sourcing the real strict-PD bytes is a curatorial task, not a code step. Document the
|
||||
procedure so anyone can run the pipeline per clip, and record the new content-pipeline
|
||||
work on the roadmap.
|
||||
|
||||
- [ ] **Step 1: Write the sourcing procedure**
|
||||
|
||||
```markdown
|
||||
# Content sourcing — strict-PD neutral bases
|
||||
|
||||
For each scale, acquire a calm, neutral source clip from a **strict public-domain**
|
||||
US-government source, verify the license, then run it through the pipeline.
|
||||
|
||||
| Scale | Source | Where |
|
||||
|--------|--------------------------------|------------------------------------|
|
||||
| cosmos | NASA / Hubble / JWST | https://images.nasa.gov/ |
|
||||
| orbit | NASA / ISS | https://images.nasa.gov/ |
|
||||
| forest | NPS / USGS | https://www.nps.gov/ , https://www.usgs.gov/ |
|
||||
| reef | NOAA Ocean Exploration | https://oceanexplorer.noaa.gov/ |
|
||||
| abyss | NOAA Ocean Exploration | https://oceanexplorer.noaa.gov/ |
|
||||
|
||||
**License check (mandatory):** confirm the asset is US-gov PD (17 U.S.C. §105) or
|
||||
explicitly CC0. "Free stock" (Pexels/Pixabay/etc.) is royalty-free but NOT public
|
||||
domain — do not use. Record a `Provenance` (tools/pipeline/provenance.py) per clip.
|
||||
|
||||
**Run the pipeline** (replaces the placeholder bytes for a scale):
|
||||
|
||||
```bash
|
||||
python -c "from tools.pipeline.run import process_clip; \
|
||||
process_clip('downloads/<scale>.mp4', 'simulator/sample_media/<scale>', \
|
||||
start=<in_s>, duration=<len_s>, overlap=<0.5..1.0>)"
|
||||
```
|
||||
|
||||
This writes `simulator/sample_media/<scale>/base.mp4` (proxy) + `master.mp4`. Then
|
||||
author the labels (Increment 2: the hybrid track pass + simulator author mode) or
|
||||
hand-edit the annotation track for now (spec §4). The ring + manifest entry already
|
||||
exist (Task 8); `process_clip` only replaces the media bytes.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the roadmap**
|
||||
|
||||
In `docs/ROADMAP.md`, under sub-project 3, replace the "Offline v2v variant pipeline"
|
||||
framing with the content-pipeline reality: the Right dream is real-time (no ML bake),
|
||||
so the content pipeline is **source → crossfade-loop → master+proxy transcode →
|
||||
hand-authored/motion-tracked labels → manifest**. Note Increment 1 (this plan: tooling
|
||||
+ track schema + 5-scale ring, no ML) done on merge; Increment 2 (hybrid track pass +
|
||||
simulator author mode) and real i2v ring transitions still pending. Cite the spec
|
||||
`docs/superpowers/specs/2026-06-24-content-pipeline-design.md`.
|
||||
|
||||
- [ ] **Step 3: Run the full suite**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: all prior tests still pass + the new pipeline tests (ffmpeg_ops,
|
||||
provenance, manifest) pass; the integration test passes or skips cleanly.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/content-sourcing.md docs/ROADMAP.md
|
||||
git commit -m "docs: content-sourcing procedure + roadmap update (content pipeline Increment 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Run from the main clone, not a nested worktree** — `resolve-app.py` errors
|
||||
"ambiguous" when a `.worktrees/` checkout shadows the main clone (session-0013
|
||||
gotcha). If you must use a worktree, expect the `WGL_APP_SCAN_DEPTH` / explicit
|
||||
`publish-transcript.sh` flag workarounds.
|
||||
- **PRs go through the Gitea API** (`wgl-gitea-admin` `gitea-api.sh`, host
|
||||
`git.benstull.org`) — no `tea`/`gh` here: create via `POST /repos/.../pulls`,
|
||||
merge via `.../merge`.
|
||||
- The project `.venv` has the deps (pytest, cv2/torch for later ML work). Activate it
|
||||
before running tests.
|
||||
- **Increment 2 (separate plan):** `tools/pipeline/track.py` (classical OpenCV
|
||||
tracker + optional lazy-imported ML detect+track) and the simulator **author mode**
|
||||
(draw/seed/correct boxes, assign keys/min_level/strings, place affect, write the
|
||||
manifest). Plus real i2v ring transitions once a generative-video model + adjacent
|
||||
real bases exist.
|
||||
```
|
||||
Reference in New Issue
Block a user