# 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.
```
