Compare commits

...

13 Commits

Author SHA1 Message Date
BenStullsBets e812f41ef1 docs: content-sourcing procedure + roadmap update + setup docstring (content pipeline Increment 1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:19:41 -07:00
BenStullsBets 8789d6a6d5 feat(sim): extend scale ring to 5 (orbit + reef) (content pipeline §5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:15:41 -07:00
BenStullsBets 2933498c51 feat(sim): client annotation-track interpolation + hand-authored forest track (content pipeline §4) 2026-06-24 08:11:40 -07:00
BenStullsBets 02c762fd45 fix(pipeline): probe duration via cv2 (no ffprobe dep); wire MASTER_MAX_W; fps-sync note
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:09:01 -07:00
BenStullsBets f1c83c1a9a feat(pipeline): resolve bundled ffmpeg fallback; integration test runs on real forest base
- run.py: add resolve_ffmpeg() mirroring setup_scales_media.py; change
  process_clip ff param to None and resolve at call time
- test_pipeline_integration.py: gate on resolve_ffmpeg() not PATH ffmpeg;
  verify proxy dims with cv2 instead of ffprobe
- ffmpeg_ops.py: add fps= to trim segments in crossfade_loop_args so xfade
  receives CFR input (xfade rejects 1/0 rate from raw trim output)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:05:11 -07:00
BenStullsBets 38a1046433 feat(pipeline): runner + opt-in integration test on real forest base (content pipeline §3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:01:25 -07:00
BenStullsBets 60d27aa662 test(pipeline): cover ring-scale append fallback + N=1 self-wrap edge 2026-06-24 07:59:34 -07:00
BenStullsBets dc122fa0bf feat(pipeline): manifest assembly — upsert clip + ring scale/edges (content pipeline §3.6) 2026-06-24 07:57:38 -07:00
BenStullsBets 0fd8b1203d feat(pipeline): strict-PD provenance record (content pipeline §3.1) 2026-06-24 07:57:07 -07:00
BenStullsBets d3b6cd7bbd test(pipeline): cover non-positive overlap guard in crossfade_loop_args 2026-06-24 07:56:03 -07:00
BenStullsBets 4b5fd20374 feat(pipeline): master/proxy transcode arg builder (content pipeline §3.4/§6) 2026-06-24 07:54:36 -07:00
BenStullsBets 7217daeb44 feat(pipeline): crossfade seamless-loop arg builder (content pipeline §3.3) 2026-06-24 07:54:01 -07:00
BenStullsBets 642451925f feat(pipeline): frame stage ffmpeg arg builder (content pipeline §3.2) 2026-06-24 07:53:28 -07:00
14 changed files with 567 additions and 31 deletions
+28 -15
View File
@@ -170,21 +170,34 @@ every transition, so fast navigation stays responsive on placeholder media.
contract** with the firmware (the keyboard/serial stand-in already works via a
`Controls` stream).
- **White-noise generation** — runtime white/pink noise for that content position.
- **Offline v2v variant pipeline** — author the pre-baked Right restyle variants
via the local flow-stabilized SD pipeline (now ~free, not a paid API — see the
scales-library/right-axis design §1/§4); a real multi-strength flow-stabilized
re-bake per base clip + the multilingual label/string tables + TTS. The
**scale-ring transitions** (forward/reverse per-edge morphs) are part of this
offline pipeline too.
- **Vertical slice done (session 0012):** the Right-variant half is
productionized and run for the **forest** scale — `simulator/bake_right_variants.py`
(the POC flow-restyle algorithm, repo-resident) bakes real strengths 14 on a
by-eye keyframe-strength ramp; forest now carries real Right variants in the
sim, so the dreamlike-axis look is judgeable on real footage. **Still
deferred:** real Right variants for cosmos/abyss (need their real strict-PD
bases sourced first) and the **real i2v ring transitions** (need a 2nd real
base + a generative-video model not in the POC). Plan:
[`2026-06-08-v2v-vertical-slice-forest-right-variants.md`](./superpowers/plans/2026-06-08-v2v-vertical-slice-forest-right-variants.md).
- **Content pipeline + label track** — source strict-PD neutral bases, run them
through the offline pipeline, and author the annotation tracks that drive the
Left-HUD overlay. The Right dream is now **real-time** (Kuwahara WebGL shader,
session 0013 — no per-clip ML bake), so the pipeline is
**source → crossfade-loop → master+proxy transcode → hand-authored /
motion-tracked labels → manifest**, not ML baking. Historical note: the
flow-stabilized SD-turbo v2v bake (session 0012, `simulator/bake_right_variants.py`)
was superseded when the operator found the baked-SD Right "too fluid" and the
design pivoted to the deterministic real-time dream; `bake_right_variants.py`
is now parked. Spec:
[`2026-06-24-content-pipeline-design.md`](./superpowers/specs/2026-06-24-content-pipeline-design.md).
- **Increment 1 — deterministic tooling + 5-scale ring (session 0014) ✅ Done.**
`tools/pipeline/` (frame/loop/transcode/manifest, pure + unit-tested + an
opt-in integration test on real footage); the backward-compatible keyframed
annotation-track schema with client interpolation; and the **full 5-scale ring**
(cosmos → orbit → forest → reef → abyss) with orbit + reef as placeholder
scales alongside the existing cosmos/abyss/forest. Plan:
[`2026-06-24-content-pipeline-increment-1.md`](./superpowers/plans/2026-06-24-content-pipeline-increment-1.md).
- **Increment 2 — hybrid motion-track pass + author mode (pending).** The
`tools/pipeline/track.py` classical OpenCV tracker + optional ML detect+track,
plus a simulator **author mode** for reviewing/editing annotation tracks in the
browser. Real strict-PD bases for all five scales sourced per
[`docs/content-sourcing.md`](./content-sourcing.md).
- **Still deferred:** real **i2v ring transitions** (need a generative-video
model + adjacent real bases); Pi renderer + serial/firmware.
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
variant" flag (sub-project 2 territory, design §13).
+29
View File
@@ -0,0 +1,29 @@
# 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; `process_clip` only replaces the media bytes.
+69 -3
View File
@@ -28,6 +28,32 @@
}
}
},
{
"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"
}
}
},
{
"id": "forest",
"title": "Yosemite Falls (neutral base, POC sample)",
@@ -41,7 +67,11 @@
"4": {"file": "forest/right4.mp4", "model": "sd-turbo+farneback-flow"}
},
"annotations": [
{"key": "detected.water", "box": [0.30, 0.10, 0.18, 0.70], "min_level": 1},
{"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]}
]},
{"key": "detected.rock_face", "box": [0.05, 0.30, 0.20, 0.55], "min_level": 2},
{"key": "detected.conifer", "box": [0.70, 0.20, 0.22, 0.45], "min_level": 3},
{"key": "measure.flow_rate", "box": [0.34, 0.55, 0.14, 0.08], "min_level": 4}
@@ -65,6 +95,38 @@
}
}
},
{
"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"
}
}
},
{
"id": "abyss",
"title": "Deep sea (NOAA Ocean Exploration, neutral base)",
@@ -97,12 +159,16 @@
"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-forest.mp4", "model": "placeholder-zoom"},
{"file": "transitions/forest-abyss.mp4", "model": "placeholder-zoom"},
{"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"}
]
}
+17 -12
View File
@@ -1,16 +1,18 @@
"""Generate cheap placeholder media for the scale RING (scales design §3).
"""Generate cheap placeholder media for the full 5-scale RING (scales design §3).
The ring needs >=2 neutral scales to be demonstrable. This script produces
cheap, offline, synthetic placeholders for the two true-PD scales the ring adds —
**cosmos** (NASA/Hubble) and **abyss** (NOAA Ocean Exploration) — plus the short
zoom/warp **transition** clips between adjacent scales. Forest reuses the real
POC base from `setup_sample_media.py` (or a synthetic placeholder if absent).
Produces offline synthetic placeholders for all five ring scales — **cosmos**
(NASA/Hubble), **orbit** (NASA/ISS), **forest** (NPS/USGS), **reef** (NOAA Ocean
Exploration), and **abyss** (NOAA Ocean Exploration) — plus the short zoom/warp
**transition** clips between adjacent scales. Forest reuses the real POC base from
`setup_sample_media.py` (or a synthetic placeholder if absent); all others are
generated as labelled solid-tint clips.
This is deliberately the CHEAP path: the manifest records the real true-PD
provenance (NASA 17 U.S.C. §105 / NOAA PD), but the bytes here are labelled
synthetic placeholders for look-tuning the ring navigation. The expensive real
multi-strength flow-stabilized Right re-bake is DEFERRED until the ring is liked,
so the new scales carry a raw base only (no Right variants).
provenance (NASA / NPS / USGS / NOAA, 17 U.S.C. §105), but the bytes here are
labelled synthetic placeholders for look-tuning the ring navigation. The Right dream
is real-time (Kuwahara WebGL shader), so no per-clip ML bake is needed; each scale
carries a raw base only. Real strict-PD bases replace these placeholders via the
content pipeline (see `docs/content-sourcing.md`).
Usage: python simulator/setup_scales_media.py
Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`).
@@ -31,12 +33,15 @@ FPS = 25
# Each scale: (dir, base color, on-screen placeholder label).
SCALES = {
"cosmos": ("0x05060f", "COSMOS — NASA/Hubble (PD placeholder)"),
"forest": ("0x12301a", "FOREST — Yosemite (POC/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)"),
}
# Ring edges (adjacent pairs + the abyss->cosmos wrap), matching the manifest.
EDGES = [("cosmos", "forest"), ("forest", "abyss"), ("abyss", "cosmos")]
EDGES = [("cosmos", "orbit"), ("orbit", "forest"), ("forest", "reef"),
("reef", "abyss"), ("abyss", "cosmos")]
def _ffmpeg() -> str:
+31 -1
View File
@@ -35,6 +35,7 @@ let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} o
let ringIndex = 0; // current scale position on the ring
let currentClipId = null; // base media currently loaded (reset to force a reload)
let busy = false; // true while a ring transition is playing
let lastOverlay = null; // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
@@ -182,6 +183,12 @@ function paintLoop() {
gl.drawArrays(gl.TRIANGLES, 0, 3);
paint.style.filter = busy ? "none" : gradeFilter;
}
// 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);
}
requestAnimationFrame(paintLoop);
}
@@ -232,6 +239,28 @@ function chip(x, y, label, conf, kind, parent) {
}
}
// 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;
}
function renderOverlay(level, intensity) {
const clip = activeClip();
overlay.innerHTML = "";
@@ -242,7 +271,7 @@ function renderOverlay(level, intensity) {
for (const a of clip.annotations) {
if (a.min_level > level) continue;
shown++;
const [x, y, w, h] = a.box.map((n) => n * 100);
const [x, y, w, h] = boxAt(a, loopT()).map((n) => n * 100);
const measure = a.key.startsWith("measure.");
const kind = measure ? "measure" : "detect";
reticle(x, y, w, h, "hud-reticle " + kind, overlay);
@@ -266,6 +295,7 @@ function renderOverlay(level, intensity) {
height: b.height + pad * 1.2, rx: 0.4, class: "hud-chip-bg detect" });
overlay.insertBefore(bg, st);
}
lastOverlay = { level, intensity };
}
// Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are
+54
View File
@@ -0,0 +1,54 @@
from tools.pipeline.ffmpeg_ops import frame_args, crossfade_loop_args, transcode_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"
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)
def test_crossfade_loop_rejects_nonpositive_overlap():
import pytest
with pytest.raises(ValueError):
crossfade_loop_args("in.mp4", "loop.mp4", duration=8.0, overlap=0.0)
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"
+42
View File
@@ -0,0 +1,42 @@
from pathlib import Path
import pytest
from tools.pipeline.run import probe_duration, process_clip, resolve_ffmpeg
_FOREST = Path("simulator/sample_media/forest/base.mp4")
def _ffmpeg_available() -> bool:
try:
resolve_ffmpeg()
return True
except Exception:
return False
_HAS_FF = _ffmpeg_available()
@pytest.mark.skipif(not _FOREST.exists(),
reason="forest POC base absent (run simulator/setup_sample_media.py)")
def test_probe_duration_reads_forest_base():
if not _FOREST.exists():
pytest.skip("forest POC base absent")
assert probe_duration(_FOREST) > 0
@pytest.mark.skipif(not _HAS_FF,
reason="neither system ffmpeg nor imageio-ffmpeg available")
@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 1920×1080 — verified with cv2, no ffprobe dependency.
import cv2
cap = cv2.VideoCapture(str(out["proxy"]))
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
assert (w, h) == (1920, 1080)
+47
View File
@@ -0,0 +1,47 @@
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
]
def test_add_ring_scale_appends_when_after_missing():
m = _base()
m = add_ring_scale(m, "abyss", "abyss", after="nope")
assert [s["id"] for s in m["ring"]["scales"]] == ["cosmos", "abyss"]
def test_rebuild_ring_edges_single_scale_self_wrap():
m = _base() # one scale: cosmos
m = rebuild_ring_edges(m)
assert [t["file"] for t in m["ring"]["transitions"]] == ["transitions/cosmos-cosmos.mp4"]
+20
View File
@@ -0,0 +1,20 @@
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
+5
View File
@@ -0,0 +1,5 @@
"""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.
"""
+74
View File
@@ -0,0 +1,74 @@
"""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),
]
def crossfade_loop_args(src, dst, *, duration: float, overlap: float,
fps: int = 30, 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.
fps is applied after each trim so xfade receives a constant-frame-rate stream
(xfade requires CFR; a raw trim output reports rate 1/0 which xfade rejects)."""
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,fps={fps}[head];"
f"[0:v]trim={o}:{d - o},setpts=PTS-STARTPTS,fps={fps}[mid];"
f"[0:v]trim={d - o}:{d},setpts=PTS-STARTPTS,fps={fps}[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),
]
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),
]
+55
View File
@@ -0,0 +1,55 @@
"""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
+29
View File
@@ -0,0 +1,29 @@
"""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"],
)
+67
View File
@@ -0,0 +1,67 @@
"""Thin runner: probe the source duration, 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 shutil
import subprocess
from pathlib import Path
from .ffmpeg_ops import crossfade_loop_args, frame_args, transcode_args
MASTER_MAX_W = 3840
def resolve_ffmpeg() -> str:
"""ffmpeg from PATH, else the bundled imageio-ffmpeg binary (matches
simulator/setup_scales_media.py). The Pi/dev box may lack a system ffmpeg."""
if shutil.which("ffmpeg"):
return "ffmpeg"
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def probe_duration(src) -> float:
"""Clip duration in seconds via cv2 (frame_count / fps) — avoids a system
ffprobe dependency (imageio-ffmpeg ships ffmpeg only; the Pi has no ffprobe)."""
import cv2
cap = cv2.VideoCapture(str(src))
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
cap.release()
if fps <= 0:
raise ValueError(f"could not read fps from {src}")
return frames / fps
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 = MASTER_MAX_W, master_h: int = 2160,
ff: str | None = None) -> dict:
"""Run stages 24 for one clip; return {'master','proxy','loop','mezzanine'} paths.
`duration` defaults to the full source length (minus the trim start).
Note: `crossfade_loop_args` uses a fixed fps only to satisfy xfade's
constant-frame-rate requirement; the final output fps is set by `transcode_args`
(both default 30 — keep in sync if overridden)."""
ff = ff or resolve_ffmpeg()
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}