Compare commits
13 Commits
750e87eb0b
...
e812f41ef1
| Author | SHA1 | Date | |
|---|---|---|---|
| e812f41ef1 | |||
| 8789d6a6d5 | |||
| 2933498c51 | |||
| 02c762fd45 | |||
| f1c83c1a9a | |||
| 38a1046433 | |||
| 60d27aa662 | |||
| dc122fa0bf | |||
| 0fd8b1203d | |||
| d3b6cd7bbd | |||
| 4b5fd20374 | |||
| 7217daeb44 | |||
| 642451925f |
+28
-15
@@ -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 1–4 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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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 2–4 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}
|
||||
Reference in New Issue
Block a user