hyperstudy-perception 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
  2. hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
  3. hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
  4. hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
  5. hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. perception/__init__.py +0 -0
  7. perception/_pipeline/RELEASE.json +59 -0
  8. perception/_pipeline/cloud/runpod/guard.sh +37 -0
  9. perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  10. perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  11. perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  12. perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  13. perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  14. perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  15. perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  16. perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  17. perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  18. perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  19. perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  20. perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  21. perception/_pipeline/envs/api/pyproject.toml +12 -0
  22. perception/_pipeline/envs/api/uv.lock +276 -0
  23. perception/_pipeline/envs/audio/pyproject.toml +12 -0
  24. perception/_pipeline/envs/audio/uv.lock +575 -0
  25. perception/_pipeline/envs/core/pyproject.toml +12 -0
  26. perception/_pipeline/envs/core/uv.lock +157 -0
  27. perception/_pipeline/envs/faces/pyproject.toml +12 -0
  28. perception/_pipeline/envs/faces/uv.lock +1180 -0
  29. perception/_pipeline/graphs/decode.yaml +11 -0
  30. perception/_pipeline/graphs/faces.yaml +14 -0
  31. perception/_pipeline/graphs/full.yaml +31 -0
  32. perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  33. perception/_pipeline/graphs/mvp.yaml +17 -0
  34. perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  35. perception/_pipeline/nodes/audio/asr/node.py +74 -0
  36. perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  37. perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  38. perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  39. perception/_pipeline/nodes/conftest.py +64 -0
  40. perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  41. perception/_pipeline/nodes/core/decode/media.py +104 -0
  42. perception/_pipeline/nodes/core/decode/node.py +56 -0
  43. perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  44. perception/_pipeline/nodes/testdata/README.md +10 -0
  45. perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  46. perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  47. perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  48. perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  49. perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  50. perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  51. perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  52. perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  53. perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  54. perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  55. perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  56. perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  57. perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  58. perception/_pipeline/schema/README.md +54 -0
  59. perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  60. perception/_pipeline/sdk/pyproject.toml +13 -0
  61. perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  62. perception/artifacts.py +180 -0
  63. perception/backends/__init__.py +0 -0
  64. perception/backends/base.py +179 -0
  65. perception/backends/cloud.py +214 -0
  66. perception/backends/native.py +140 -0
  67. perception/backends/probe.py +44 -0
  68. perception/backends/remote.py +140 -0
  69. perception/cli.py +780 -0
  70. perception/estimates.py +68 -0
  71. perception/export.py +100 -0
  72. perception/hosts.py +201 -0
  73. perception/ir.py +245 -0
  74. perception/keys.py +259 -0
  75. perception/manifests.py +169 -0
  76. perception/plan.py +170 -0
  77. perception/release.py +61 -0
  78. perception/remote_driver.py +990 -0
  79. perception/runpod.py +100 -0
  80. perception/scheduler.py +552 -0
  81. perception/setup.py +72 -0
  82. perception/store.py +219 -0
@@ -0,0 +1,11 @@
1
+ # Decode only: the smallest real graph, used for backend smoke tests (cloud/skypilot/smoke.yaml).
2
+ # perception run graphs/decode.yaml --input video=PATH/TO/VIDEO.mp4
3
+ schema_version: 1
4
+ pipeline: perception-decode
5
+ inputs:
6
+ video: {type: video.v1, uri: '../data/video.mp4'} # relative to this file; usually set with --input
7
+ policy: {placement: local, privacy: local-only, max_cost_usd: 0, retries: 1}
8
+ nodes:
9
+ - id: decode
10
+ uses: core.decode@1.0.0
11
+ inputs: {video: $inputs.video}
@@ -0,0 +1,14 @@
1
+ # Decode and face tracks: the first graph timed on an NVIDIA GPU (cloud/skypilot/gpu-faces.yaml).
2
+ # perception run graphs/faces.yaml --input video=PATH/TO/VIDEO.mp4
3
+ schema_version: 1
4
+ pipeline: perception-faces
5
+ inputs:
6
+ video: {type: video.v1, uri: '../data/video.mp4'} # relative to this file; usually set with --input
7
+ policy: {placement: local, privacy: local-only, max_cost_usd: 0, retries: 1}
8
+ nodes:
9
+ - id: decode
10
+ uses: core.decode@1.0.0
11
+ inputs: {video: $inputs.video}
12
+ - id: faces
13
+ uses: vision.face-track@1.0.0
14
+ inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
@@ -0,0 +1,31 @@
1
+ # Everything the pipeline can do: the graph the annotator app runs. The app's feature list picks what runs (--only, with
2
+ # what it needs), settings (--set) and places (--place). Nothing leaves the machine without --allow-uploads, and then
3
+ # only the kinds allow_upload names: frames, faces and transcript for the captions API, and video and audio for a step
4
+ # placed on another machine (a lab server, a cloud GPU), which receives its inputs. max_cost_usd caps the spend.
5
+ # perception plan graphs/full.yaml --input video=PATH/TO/VIDEO.mp4
6
+ # perception run graphs/full.yaml --input video=PATH --only asr,faces # the local, free features
7
+ # perception run graphs/full.yaml --input video=PATH --allow-uploads # everything, captions too
8
+ schema_version: 1
9
+ pipeline: perception-full
10
+ inputs:
11
+ video: {type: video.v1, uri: '../data/video.mp4'}
12
+ policy:
13
+ placement: hybrid
14
+ privacy: local-preferred
15
+ allow_upload: [video, audio, frames, faces, transcript]
16
+ max_cost_usd: 0.50
17
+ retries: 1
18
+ nodes:
19
+ - id: decode
20
+ uses: core.decode@1.0.0
21
+ inputs: {video: $inputs.video}
22
+ - id: asr
23
+ uses: audio.asr@1.0.0
24
+ inputs: {audio: $nodes.decode.audio, shots: $nodes.decode.shots}
25
+ - id: faces
26
+ uses: vision.face-track@1.0.0
27
+ inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
28
+ - id: captions
29
+ uses: vlm.shot-captions@1.0.0
30
+ inputs: {shots: $nodes.decode.shots, keyframes: $nodes.decode.keyframes, transcript: $nodes.asr.transcript,
31
+ faces: $nodes.faces.faces, media: $nodes.decode.media}
@@ -0,0 +1,27 @@
1
+ # The Phase 1 graph plus shot captions through the Anthropic API: the first node that sends data off the machine
2
+ # and costs money. allow_upload names exactly what may leave; max_cost_usd caps the spend.
3
+ # perception run graphs/mvp-captions.yaml --input video=PATH/TO/VIDEO.mp4 --allow-uploads
4
+ schema_version: 1
5
+ pipeline: perception-mvp-captions
6
+ inputs:
7
+ video: {type: video.v1, uri: '../data/video.mp4'}
8
+ policy:
9
+ placement: hybrid
10
+ privacy: local-preferred
11
+ allow_upload: [frames, faces, transcript]
12
+ max_cost_usd: 0.50
13
+ retries: 1
14
+ nodes:
15
+ - id: decode
16
+ uses: core.decode@1.0.0
17
+ inputs: {video: $inputs.video}
18
+ - id: asr
19
+ uses: audio.asr@1.0.0
20
+ inputs: {audio: $nodes.decode.audio, shots: $nodes.decode.shots}
21
+ - id: faces
22
+ uses: vision.face-track@1.0.0
23
+ inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
24
+ - id: captions
25
+ uses: vlm.shot-captions@1.0.0
26
+ inputs: {shots: $nodes.decode.shots, keyframes: $nodes.decode.keyframes, transcript: $nodes.asr.transcript,
27
+ faces: $nodes.faces.faces, media: $nodes.decode.media}
@@ -0,0 +1,17 @@
1
+ # Phase 1 graph: decode, face tracks and speech, published as a track bundle.
2
+ # perception run graphs/mvp.yaml --input video=PATH/TO/VIDEO.mp4
3
+ schema_version: 1
4
+ pipeline: perception-mvp
5
+ inputs:
6
+ video: {type: video.v1, uri: '../data/video.mp4'} # relative to this file; usually set with --input
7
+ policy: {placement: local, privacy: local-only, max_cost_usd: 0, retries: 1}
8
+ nodes:
9
+ - id: decode
10
+ uses: core.decode@1.0.0
11
+ inputs: {video: $inputs.video}
12
+ - id: asr
13
+ uses: audio.asr@1.0.0
14
+ inputs: {audio: $nodes.decode.audio, shots: $nodes.decode.shots}
15
+ - id: faces
16
+ uses: vision.face-track@1.0.0
17
+ inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
@@ -0,0 +1,20 @@
1
+ id: audio.asr
2
+ version: 1.0.0
3
+ stage: transcribe
4
+ title: Speech transcript
5
+ description: Word-timed transcript and utterances (Parakeet TDT v3); voices come from diarization
6
+ inputs: {audio: audio.wav.v1, shots: bundle.shots@0.1}
7
+ outputs: {transcript: bundle.transcript@0.1}
8
+ config:
9
+ model: {enum: [mlx-community/parakeet-tdt-0.6b-v3], default: mlx-community/parakeet-tdt-0.6b-v3}
10
+ runners:
11
+ - {kind: native, env: envs/audio, entry: node.py, accelerator: mps, platforms: [darwin/arm64]}
12
+ # TODO: [PERCEPTION-GRAPH] Phase 1 step 7 - a CUDA runner (NeMo nvidia/parakeet-tdt-0.6b-v3 in a pinned image)
13
+ # Spec: same output on linux/amd64 + CUDA; validate word times against the MLX conversion on the OOO clip.
14
+ # Plan: docs/plans/GRAPH_MVP_PLAN.md#phase-1--local-mvp
15
+ resources: {cpu_cores: 2, memory_gb: 6, gpu: shared}
16
+ # fetches the weights ahead of a run (perception setup)
17
+ prepare: prepare.py
18
+ models:
19
+ - {id: mlx-community/parakeet-tdt-0.6b-v3, source: hf://mlx-community/parakeet-tdt-0.6b-v3, licence: CC-BY-4.0,
20
+ licence_mode: commercial-ok, size_gb: 2.3}
@@ -0,0 +1,74 @@
1
+ """audio.asr: words with times, grouped into utterances, as the bundle's transcript layer.
2
+
3
+ Parakeet TDT 0.6B v3 (MLX, Apple silicon). Utterances split at a pause over 0.25 s, a sentence end, or a shot cut,
4
+ as the annotator prototype did (pipeline/bakeoff/label_page.py). Voices come from the diarization node, not here:
5
+ every word's voice is null.
6
+ """
7
+
8
+ import time
9
+
10
+ from perception_node import Node, NodeError
11
+ from wav import read_wav
12
+
13
+ PAUSE_SPLIT = 0.25 # seconds of silence between words that starts a new utterance
14
+ SENTENCE_END = (".", "?", "!")
15
+
16
+
17
+ def to_words(sentence) -> list[dict]:
18
+ """Merge Parakeet's subword tokens into words; a leading space starts a new word."""
19
+ words = []
20
+ for token in sentence.tokens:
21
+ if not words or token.text.startswith(" "):
22
+ words.append({"w": token.text.strip(), "start": token.start, "end": token.end})
23
+ else:
24
+ words[-1]["w"] += token.text
25
+ words[-1]["end"] = token.end
26
+ return [{**w, "start": round(w["start"], 3), "end": round(w["end"], 3)} for w in words if w["w"]]
27
+
28
+
29
+ def utterances(words: list[dict], shots: list[dict]) -> list[dict]:
30
+ cuts = [s["start"] for s in shots[1:]]
31
+ units, current = [], []
32
+ for i, word in enumerate(words):
33
+ if current:
34
+ prev = words[current[-1]]
35
+ crosses_cut = any(prev["end"] <= c <= word["start"] or prev["start"] < c <= word["start"] for c in cuts)
36
+ if word["start"] - prev["end"] > PAUSE_SPLIT or prev["w"].endswith(SENTENCE_END) or crosses_cut:
37
+ units.append(current)
38
+ current = []
39
+ current.append(i)
40
+ if current:
41
+ units.append(current)
42
+
43
+ def shot_at(t):
44
+ return next(s["id"] for s in reversed(shots) if s["start"] <= t)
45
+
46
+ return [{"id": f"utt:{n}", "start": words[u[0]]["start"], "end": words[u[-1]]["end"],
47
+ "text": " ".join(words[i]["w"] for i in u), "shot": shot_at((words[u[0]]["start"] + words[u[-1]]["end"]) / 2),
48
+ "words": [u[0], u[-1]], "voice": None} for n, u in enumerate(units)]
49
+
50
+
51
+ with Node() as node:
52
+ import mlx.core as mx
53
+ from parakeet_mlx import from_pretrained
54
+ from parakeet_mlx.audio import get_logmel
55
+
56
+ model_id = node.config["model"]
57
+ t0 = time.time()
58
+ model = from_pretrained(model_id)
59
+ # what model.transcribe(path) does with no chunking, from the samples read here (wav.py)
60
+ samples = read_wav(node.input("audio"), model.preprocessor_config.sample_rate)
61
+ if len(samples) < model.preprocessor_config.hop_length:
62
+ raise NodeError("input", f"the audio has {len(samples)} samples: too short to transcribe")
63
+ audio = mx.array(samples).astype(mx.float32) / 32768.0
64
+ result = model.generate(get_logmel(audio, model.preprocessor_config))[0]
65
+ seconds = round(time.time() - t0, 1)
66
+ node.model(model_id, licence_mode="commercial-ok", source=f"hf://{model_id}", licence="CC-BY-4.0")
67
+
68
+ words = [{**w, "voice": None} for sentence in result.sentences for w in to_words(sentence)]
69
+ shots = node.input_json("shots")["shots"]
70
+ node.write_json("transcript", "transcript.json", {
71
+ "layer": "transcript", "schema_version": "0.1.0",
72
+ "producer": node.producer(timings={"seconds": seconds}, device="mlx"),
73
+ "voices": [], "words": words, "utterances": utterances(words, shots)})
74
+ node.usage(audio_seconds=round(words[-1]["end"], 1) if words else 0, words=len(words))
@@ -0,0 +1,12 @@
1
+ """Fetch the ASR node's weights ahead of a run (`perception setup`): parakeet-mlx downloads the model from Hugging Face
2
+ the first time it is loaded, into the cache the node then reads.
3
+ python prepare.py CONFIG_JSON (the node's settings, as its request would carry them)"""
4
+
5
+ import json
6
+ import sys
7
+
8
+ from parakeet_mlx import from_pretrained
9
+
10
+ model_id = json.loads(sys.argv[1])["model"]
11
+ from_pretrained(model_id)
12
+ print(f"{model_id} is in the cache")
@@ -0,0 +1,38 @@
1
+ """Decode's audio read as samples (wav.py), with nothing on PATH: parakeet-mlx's own loader runs ffmpeg, which an app
2
+ started from the Finder does not find. Run: uv run --no-project --with pytest --with numpy --with-editable ./sdk
3
+ pytest nodes/audio/asr"""
4
+
5
+ import wave
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from perception_node import NodeError
11
+ from wav import read_wav
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def no_tools_on_path(monkeypatch):
16
+ monkeypatch.setenv("PATH", "")
17
+
18
+
19
+ def write(path, samples, rate=16000, channels=1):
20
+ with wave.open(str(path), "wb") as w:
21
+ w.setnchannels(channels)
22
+ w.setsampwidth(2)
23
+ w.setframerate(rate)
24
+ w.writeframes(samples.astype(np.int16).tobytes())
25
+
26
+
27
+ def test_reads_decodes_audio_as_it_was_written(tmp_path):
28
+ samples = (np.sin(np.arange(16000) / 5) * 12000).astype(np.int16)
29
+ write(tmp_path / "a.wav", samples)
30
+ out = read_wav(tmp_path / "a.wav", 16000)
31
+ assert out.dtype == np.int16 and np.array_equal(out, samples)
32
+
33
+
34
+ @pytest.mark.parametrize("rate, channels", [(44100, 1), (16000, 2)])
35
+ def test_refuses_audio_decode_does_not_write(tmp_path, rate, channels):
36
+ write(tmp_path / "a.wav", np.zeros(1000 * channels), rate, channels)
37
+ with pytest.raises(NodeError, match="decode writes 1 channel, 16-bit, 16000 Hz"):
38
+ read_wav(tmp_path / "a.wav", 16000)
@@ -0,0 +1,19 @@
1
+ """Decode's audio (16 kHz mono 16-bit WAV) as samples, read here rather than by parakeet-mlx: its load_audio runs the
2
+ ffmpeg program, which an app started from the Finder does not find (its PATH is /usr/bin:/bin:/usr/sbin:/sbin)."""
3
+
4
+ import wave
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from perception_node import NodeError
10
+
11
+
12
+ def read_wav(path: Path, rate: int) -> np.ndarray:
13
+ """The samples, as int16: what `ffmpeg -i path -f s16le -ac 1 -ar rate -` printed for a WAV already in that form."""
14
+ with wave.open(str(path)) as w:
15
+ got = (w.getnchannels(), w.getsampwidth(), w.getframerate())
16
+ if got != (1, 2, rate):
17
+ raise NodeError("input", f"the audio is {got[0]} channel(s), {8 * got[1]}-bit, {got[2]} Hz; "
18
+ f"decode writes 1 channel, 16-bit, {rate} Hz")
19
+ return np.frombuffer(w.readframes(w.getnframes()), np.int16)
@@ -0,0 +1,64 @@
1
+ """A small synthetic video for the node tests that read one (decode's media.py, face-track's frames.py)."""
2
+
3
+ from fractions import Fraction
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import av
8
+ import numpy as np
9
+ import pytest
10
+
11
+ FPS, SECONDS, W, H = 10, 2, 64, 48
12
+ LATE = 3 # frames the video starts after the audio (and the container)
13
+
14
+
15
+ def level(i: int) -> int:
16
+ """Frame i's grey level: frames tell apart by brightness, through lossy encoding."""
17
+ return 10 + 10 * i
18
+
19
+
20
+ def make(path):
21
+ """2 s of 64x48 H.264 at 10 fps, each frame one grey level, starting LATE frames after its 48 kHz stereo AAC (the
22
+ container's start): files cut from a longer one often start that way."""
23
+ with av.open(str(path), "w") as out:
24
+ v = out.add_stream("libx264", rate=FPS)
25
+ v.width, v.height, v.pix_fmt = W, H, "yuv420p"
26
+ a = out.add_stream("aac", rate=48000, layout="stereo")
27
+ for i in range(FPS * SECONDS):
28
+ frame = av.VideoFrame.from_ndarray(np.full((H, W, 3), level(i), np.uint8), format="rgb24")
29
+ frame.pts, frame.time_base = LATE + i, Fraction(1, FPS)
30
+ out.mux(v.encode(frame))
31
+ out.mux(v.encode())
32
+ t = np.arange(48000 * SECONDS) / 48000
33
+ tone = (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
34
+ for start in range(0, len(tone), 1024):
35
+ chunk = np.stack([tone[start:start + 1024]] * 2)
36
+ frame = av.AudioFrame.from_ndarray(np.ascontiguousarray(chunk), format="fltp", layout="stereo")
37
+ frame.sample_rate, frame.pts = 48000, start
38
+ out.mux(a.encode(frame))
39
+ out.mux(a.encode())
40
+
41
+
42
+ def decoded(path) -> list[tuple[float, np.ndarray]]:
43
+ """Every frame in order: (seconds from the container's start, RGB)."""
44
+ with av.open(str(path)) as c:
45
+ return [(float(f.pts * f.time_base), f.to_ndarray(format="rgb24")) for f in c.decode(c.streams.video[0])]
46
+
47
+
48
+ def nth(path, i: int) -> np.ndarray:
49
+ """Frame i of the video stream, counted from its first: the frame a detector sampling frame i saw."""
50
+ return [rgb for _, rgb in decoded(path)][i]
51
+
52
+
53
+ @pytest.fixture
54
+ def rotated():
55
+ """A clip stored sideways with a 90° rotation, as phones record (nodes/testdata/README.md): shown 32x64, blue on top
56
+ and red below."""
57
+ return Path(__file__).parent / "testdata" / "rotated-90.mp4"
58
+
59
+
60
+ @pytest.fixture(scope="session")
61
+ def clip(tmp_path_factory):
62
+ path = tmp_path_factory.mktemp("video") / "clip.mp4"
63
+ make(path)
64
+ return SimpleNamespace(path=path, fps=FPS, seconds=SECONDS, w=W, h=H, late=LATE, nth=lambda i: nth(path, i))
@@ -0,0 +1,19 @@
1
+ id: core.decode
2
+ version: 1.0.0
3
+ stage: extract
4
+ title: Shots and audio
5
+ description: Video identity and timing, shots, keyframes and 16 kHz mono audio
6
+ inputs: {video: video.v1}
7
+ outputs:
8
+ media: media.v1
9
+ shots: bundle.shots@0.1
10
+ keyframes: assets.v1
11
+ audio: audio.wav.v1
12
+ config:
13
+ shot_threshold: {type: number, default: 3.0, description: "AdaptiveDetector adaptive_threshold (PySceneDetect default)"}
14
+ keyframe_width: {type: integer, minimum: 64, default: 768}
15
+ runners:
16
+ - {kind: native, env: envs/core, entry: node.py, accelerator: cpu}
17
+ resources: {cpu_cores: 2, memory_gb: 2, gpu: none}
18
+ models:
19
+ - {id: PySceneDetect AdaptiveDetector, licence: BSD-3-Clause, licence_mode: commercial-ok}
@@ -0,0 +1,104 @@
1
+ """The video's facts, keyframes and audio, read with PyAV: its wheels carry FFmpeg's libraries, so the node needs no
2
+ ffmpeg or ffprobe on the machine (an app started from the Finder has only /usr/bin:/bin:/usr/sbin:/sbin on its PATH).
3
+ Each function does what the ffprobe or ffmpeg command it replaced did; the comments say which."""
4
+
5
+ import wave
6
+ from fractions import Fraction
7
+ from pathlib import Path
8
+
9
+ import av
10
+ import numpy as np
11
+
12
+ from perception_node import NodeError
13
+
14
+ KEYFRAME_POSITIONS = (0.15, 0.5, 0.85)
15
+
16
+
17
+ def ratio(r: Fraction | None) -> str:
18
+ """A rate or time base as ffprobe printed it ("30000/1001", "30/1"; "0/0" when unknown)."""
19
+ return f"{r.numerator}/{r.denominator}" if r else "0/0"
20
+
21
+
22
+ def probe(video: Path) -> dict:
23
+ """ffprobe's format=duration and the first video and audio streams' fields."""
24
+ try:
25
+ container = av.open(str(video))
26
+ except av.FFmpegError as e:
27
+ raise NodeError("input", f"cannot read the video: {str(e)[:300]}") from None
28
+ with container:
29
+ if not container.streams.video:
30
+ raise NodeError("input", "the file has no video stream")
31
+ v = container.streams.video[0]
32
+ a = container.streams.audio[0] if container.streams.audio else None
33
+ r, avg = v.base_rate, v.average_rate or v.base_rate
34
+ return {"duration": container.duration / av.time_base, "width": v.codec_context.width, "height": v.codec_context.height,
35
+ "fps": float(avg or r), "r_frame_rate": ratio(r), "avg_frame_rate": ratio(v.average_rate),
36
+ "time_base": ratio(v.time_base), "start_pts": v.start_time,
37
+ # containers report r_frame_rate as the base rate and avg_frame_rate from the frame count; they differ
38
+ # when frame durations vary
39
+ "variable_frame_rate": bool(avg) and abs(float(r) - float(avg)) > 1e-3,
40
+ "audio": {"sample_rate": a.codec_context.sample_rate, "channels": a.codec_context.channels} if a else None}
41
+
42
+
43
+ def frame_at(container, t: float) -> av.VideoFrame:
44
+ """The frame nearest `t` seconds after the video stream's first frame. Callers' times are frame times counted from
45
+ that frame (face-track's frame index / fps, decode's shots from scenedetect's frame counts), rounded to the
46
+ millisecond: on either side of the frame's own time at 29.97 fps, so neither the first frame at or after t nor the
47
+ one shown at t is always it. (`ffmpeg -ss` counted from the container's start instead: when the video starts after
48
+ the audio, it cut crops and keyframes early.) The face-track node has the same function."""
49
+ v = container.streams.video[0]
50
+ target = Fraction(v.start_time or 0) * v.time_base + Fraction(round(t * 1000), 1000)
51
+ container.seek(int(target / v.time_base), stream=v, backward=True)
52
+ before = None
53
+ for frame in container.decode(v):
54
+ if frame.pts is None:
55
+ continue
56
+ at = frame.pts * v.time_base
57
+ if at >= target:
58
+ return before if before is not None and target - before.pts * v.time_base < at - target else frame
59
+ before = frame
60
+ if before is not None and target - before.pts * v.time_base < 1 / v.average_rate: # within the last frame
61
+ return before
62
+ raise NodeError("input", f"the video has no frame at {t:.3f} s")
63
+
64
+
65
+ def upright(frame: av.VideoFrame) -> np.ndarray:
66
+ """The frame as RGB, turned the way players show it. Phone recordings store the picture sideways with a rotation
67
+ to apply: ffmpeg applied it, and so does torchcodec, which reads the frames face-track's detector sees; PyAV only
68
+ reports it. The face-track node has the same function."""
69
+ return np.ascontiguousarray(np.rot90(frame.to_ndarray(format="rgb24"), round((frame.rotation or 0) / 90) % 4))
70
+
71
+
72
+ def even_height(w: int, src_w: int, src_h: int) -> int:
73
+ """ffmpeg's `scale=w:-2`: the height that keeps the aspect ratio, to the nearest even number."""
74
+ return max(2, round(Fraction(w * src_h, src_w * 2)) * 2)
75
+
76
+
77
+ def keyframes(video: Path, shots: list[dict], out_dir: Path, width: int) -> None:
78
+ """Three frames per shot, upright, `width` wide (ffmpeg's scale=width:-2, bicubic), as JPEG."""
79
+ out_dir.mkdir(parents=True, exist_ok=True)
80
+ with av.open(str(video)) as container:
81
+ for shot in shots:
82
+ shot["keyframes"] = []
83
+ for pos in KEYFRAME_POSITIONS:
84
+ t = shot["start"] + pos * (shot["end"] - shot["start"])
85
+ name = f"shot{shot['index']:04d}_{int(pos * 100):02d}.jpg"
86
+ rgb = upright(frame_at(container, t))
87
+ h, w = rgb.shape[:2]
88
+ av.VideoFrame.from_ndarray(rgb, format="rgb24").reformat(
89
+ width=width, height=even_height(width, w, h), interpolation="BICUBIC").to_image().save(out_dir / name, quality=90)
90
+ shot["keyframes"].append({"t": round(t, 3), "file": f"assets/keyframes/{name}"})
91
+
92
+
93
+ def audio(video: Path, wav: Path) -> None:
94
+ """The first audio stream as 16 kHz mono 16-bit WAV (ffmpeg -vn -ac 1 -ar 16000 -sample_fmt s16)."""
95
+ with av.open(str(video)) as container, wave.open(str(wav), "wb") as out:
96
+ out.setnchannels(1)
97
+ out.setsampwidth(2)
98
+ out.setframerate(16000)
99
+ resampler = av.AudioResampler(format="s16", layout="mono", rate=16000)
100
+ for frame in container.decode(container.streams.audio[0]):
101
+ for f in resampler.resample(frame):
102
+ out.writeframes(f.to_ndarray().tobytes())
103
+ for f in resampler.resample(None):
104
+ out.writeframes(f.to_ndarray().tobytes())
@@ -0,0 +1,56 @@
1
+ """core.decode: what every other node starts from.
2
+
3
+ - media: the video's identity and timing (sha256, duration, size, frame rates, the stream's PTS time base, whether
4
+ the frame rate varies), so later nodes and the integer-microsecond mapping never need to re-probe the file;
5
+ - shots: cuts by PySceneDetect's AdaptiveDetector, as the bundle's shots layer;
6
+ - keyframes: three frames per shot (at 15%, 50%, 85% of it), referenced from the shots layer as assets/keyframes/...;
7
+ - audio: 16 kHz mono 16-bit WAV for the audio nodes.
8
+
9
+ From pipeline/bakeoff/extract.py (shots and keyframes unchanged), without its transcription.
10
+ """
11
+
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from perception_node import Node, NodeError
16
+ from media import audio, keyframes, probe
17
+
18
+
19
+ def detect_shots(video: Path, duration: float, threshold: float) -> list[dict]:
20
+ from scenedetect import AdaptiveDetector, detect
21
+
22
+ scenes = detect(str(video), AdaptiveDetector(adaptive_threshold=threshold))
23
+ if not scenes:
24
+ return [{"index": 0, "start": 0.0, "end": round(duration, 3)}]
25
+ return [{"index": i, "start": round(s.get_seconds(), 3), "end": round(e.get_seconds(), 3)} for i, (s, e) in enumerate(scenes)]
26
+
27
+
28
+ with Node() as node:
29
+ video = node.input("video")
30
+ c = node.config
31
+ media = {"sha256": node.request["inputs"]["video"]["sha256"], "filename": video.name, **probe(video)}
32
+ node.write_json("media", "media.json", media)
33
+
34
+ timings = {}
35
+ t0 = time.time()
36
+ shots = detect_shots(video, media["duration"], c["shot_threshold"])
37
+ timings["shots_s"] = round(time.time() - t0, 1)
38
+ node.model("PySceneDetect AdaptiveDetector", licence_mode="commercial-ok", licence="BSD-3-Clause")
39
+
40
+ t0 = time.time()
41
+ assets = node.path("keyframes", "assets")
42
+ keyframes(video, shots, assets / "keyframes", c["keyframe_width"])
43
+ timings["keyframes_s"] = round(time.time() - t0, 1)
44
+
45
+ t0 = time.time()
46
+ wav = node.path("audio", "audio.wav")
47
+ if media["audio"] is None:
48
+ raise NodeError("input", "the video has no audio stream")
49
+ audio(video, wav)
50
+ timings["audio_s"] = round(time.time() - t0, 1)
51
+
52
+ layer = [{"id": f"shot:{s['index']}", "index": s["index"], "start": s["start"], "end": s["end"],
53
+ "keyframes": s["keyframes"], "caption": None} for s in shots]
54
+ node.write_json("shots", "shots.json", {"layer": "shots", "schema_version": "0.1.0",
55
+ "producer": node.producer(timings=timings, device="cpu"), "shots": layer})
56
+ node.usage(shots=len(shots))
@@ -0,0 +1,65 @@
1
+ """The decode node's media reading (media.py), with nothing on PATH: the app runs nodes from the Finder, where ffmpeg
2
+ and ffprobe are not found. Run: uv run --project envs/core --with pytest pytest nodes/core/decode"""
3
+
4
+ import wave
5
+
6
+ import av
7
+ import numpy as np
8
+ import pytest
9
+ from PIL import Image
10
+
11
+ from media import audio, even_height, frame_at, keyframes, probe
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def no_tools_on_path(monkeypatch):
16
+ monkeypatch.setenv("PATH", "")
17
+
18
+
19
+ def test_probe(clip):
20
+ m = probe(clip.path)
21
+ assert (m["width"], m["height"], m["r_frame_rate"], m["avg_frame_rate"], m["fps"]) == (clip.w, clip.h, "10/1", "10/1", 10.0)
22
+ assert m["duration"] == pytest.approx(clip.seconds + clip.late / clip.fps, abs=0.05) # the container's: from the audio's start
23
+ assert m["audio"] == {"sample_rate": 48000, "channels": 2}
24
+ assert not m["variable_frame_rate"]
25
+
26
+
27
+ def test_frame_at_counts_from_the_video_streams_first_frame(clip):
28
+ with av.open(str(clip.path)) as c:
29
+ v = c.streams.video[0]
30
+ assert float(v.start_time * v.time_base) == clip.late / clip.fps and not c.start_time # video after the audio
31
+ for i in (0, 1, 7, 19): # frame i at i / fps, as face-track and scenedetect count (not ffmpeg -ss's i - late)
32
+ for off in (0, 0.0004, -0.0004): # rounded to the millisecond, either way (29.97 fps frame times are)
33
+ assert np.array_equal(frame_at(c, i / clip.fps + off).to_ndarray(format="rgb24"), clip.nth(i)), (i, off)
34
+
35
+
36
+ def test_frame_at_past_the_end(clip):
37
+ with av.open(str(clip.path)) as c, pytest.raises(Exception, match="no frame at 5.000 s"):
38
+ frame_at(c, 5.0)
39
+
40
+
41
+ def test_keyframes(clip, tmp_path):
42
+ shots = [{"index": 0, "start": 0.0, "end": 1.0}, {"index": 1, "start": 1.0, "end": 2.0}]
43
+ keyframes(clip.path, shots, tmp_path, 33)
44
+ assert [k["t"] for k in shots[1]["keyframes"]] == [1.15, 1.5, 1.85]
45
+ img = Image.open(tmp_path / "shot0001_50.jpg")
46
+ assert img.size == (33, even_height(33, clip.w, clip.h)) == (33, 24) # ffmpeg's scale=33:-2
47
+ assert abs(np.asarray(img).mean() - clip.nth(15).mean()) < 3 # frames are 10 grey levels apart
48
+
49
+
50
+ def test_audio(clip, tmp_path):
51
+ audio(clip.path, tmp_path / "a.wav")
52
+ with wave.open(str(tmp_path / "a.wav")) as w:
53
+ assert (w.getnchannels(), w.getsampwidth(), w.getframerate()) == (1, 2, 16000)
54
+ assert w.getnframes() == pytest.approx(16000 * clip.seconds, abs=1600)
55
+ samples = np.frombuffer(w.readframes(w.getnframes()), np.int16)
56
+ assert np.abs(samples).max() > 5000 # the tone, downmixed, not silence
57
+
58
+
59
+ def test_keyframes_are_upright(rotated, tmp_path):
60
+ shots = [{"index": 0, "start": 0.0, "end": 1.0}]
61
+ keyframes(rotated, shots, tmp_path, 16)
62
+ img = np.asarray(Image.open(tmp_path / "shot0000_50.jpg")).astype(int)
63
+ assert img.shape[:2] == (32, 16) # 16 wide, as ffmpeg shows it: 32x64 upright
64
+ top, bottom = img[:12].mean((0, 1)), img[20:].mean((0, 1))
65
+ assert top[2] > 200 > top[0] and bottom[0] > 200 > bottom[2], (top, bottom)
@@ -0,0 +1,10 @@
1
+ Synthetic clips the node tests read (made with ffmpeg, which the tests themselves do not need):
2
+
3
+ - `rotated-90.mp4`: 1 s of 64x32 H.264 at 10 fps, the left half red and the right half blue, stored with a 90° display
4
+ rotation, as phone recordings are. Players and ffmpeg show it 32x64, blue on top and red below.
5
+
6
+ ```sh
7
+ ffmpeg -f lavfi -i color=red:size=32x32:rate=10:duration=1 -f lavfi -i color=blue:size=32x32:rate=10:duration=1 \
8
+ -filter_complex hstack -c:v libx264 -pix_fmt yuv420p -map_metadata -1 -bitexact flat.mp4
9
+ ffmpeg -display_rotation 90 -i flat.mp4 -c copy -map_metadata -1 -bitexact rotated-90.mp4
10
+ ```