framerudiments 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.
- framerudiments/__init__.py +7 -0
- framerudiments/__main__.py +3 -0
- framerudiments/atlas.py +44 -0
- framerudiments/choreography.py +30 -0
- framerudiments/cli.py +54 -0
- framerudiments/compositor.py +278 -0
- framerudiments/demo.py +68 -0
- framerudiments/effects.py +51 -0
- framerudiments/encoding.py +111 -0
- framerudiments/flight.py +157 -0
- framerudiments/io.py +66 -0
- framerudiments/motion.py +137 -0
- framerudiments/project.py +124 -0
- framerudiments/render.py +111 -0
- framerudiments/stemlab.py +36 -0
- framerudiments/subtitles.py +45 -0
- framerudiments/timeline.py +132 -0
- framerudiments-0.1.0.dist-info/METADATA +173 -0
- framerudiments-0.1.0.dist-info/RECORD +23 -0
- framerudiments-0.1.0.dist-info/WHEEL +5 -0
- framerudiments-0.1.0.dist-info/entry_points.txt +2 -0
- framerudiments-0.1.0.dist-info/licenses/LICENSE +21 -0
- framerudiments-0.1.0.dist-info/top_level.txt +1 -0
framerudiments/flight.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Seeded paper-glider animation extracted from the original Shorts renderer.
|
|
2
|
+
|
|
3
|
+
This is an animation model, not CFD. Its state machine makes the energy
|
|
4
|
+
exchange visible: a fast glide rises, speed bleeds away, the aircraft mushes,
|
|
5
|
+
lift breaks, the nose drops, the dive rebuilds speed, and the plane pulls out
|
|
6
|
+
into another long swoop. A separate falling-leaf mode adds the alternating
|
|
7
|
+
slip/roll seen in a fully stalled paper glider.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
import math
|
|
14
|
+
import random
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FlightState:
|
|
19
|
+
x: float
|
|
20
|
+
y: float
|
|
21
|
+
vx: float
|
|
22
|
+
vy: float
|
|
23
|
+
pitch: float
|
|
24
|
+
pitch_rate: float = 0.0
|
|
25
|
+
roll: float = 0.0
|
|
26
|
+
yaw_scale: float = 1.0
|
|
27
|
+
state: str = "glide"
|
|
28
|
+
state_time: float = 0.0
|
|
29
|
+
age: float = 0.0
|
|
30
|
+
stall_count: int = 0
|
|
31
|
+
history: list[tuple[float, float]] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PaperPlaneFlight:
|
|
35
|
+
"""Deterministic portrait-space motion with visually explicit stall phases."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, seed: int, x: float, y: float, speed: float,
|
|
38
|
+
sprite_index: int, stall_after: float | None = None,
|
|
39
|
+
behaviour: str = "cycle"):
|
|
40
|
+
self.rng = random.Random(seed)
|
|
41
|
+
self.sprite_index = sprite_index
|
|
42
|
+
self.stall_after = stall_after
|
|
43
|
+
self.behaviour = behaviour
|
|
44
|
+
self.phase = self.rng.random() * math.tau
|
|
45
|
+
self.base_scale = self.rng.uniform(0.78, 1.08)
|
|
46
|
+
self.depth = self.rng.uniform(0.72, 1.0)
|
|
47
|
+
self.cruise_speed = speed
|
|
48
|
+
self.next_cycle = stall_after if stall_after is not None else self.rng.uniform(2.0, 3.1)
|
|
49
|
+
self.s = FlightState(x=x, y=y, vx=speed, vy=-0.025, pitch=math.radians(-7))
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def flight_angle(self) -> float:
|
|
53
|
+
return math.atan2(self.s.vy, max(1e-6, self.s.vx))
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def airspeed(self) -> float:
|
|
57
|
+
return math.hypot(self.s.vx, self.s.vy)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def angle_of_attack(self) -> float:
|
|
61
|
+
return self.flight_angle - self.s.pitch
|
|
62
|
+
|
|
63
|
+
def _enter(self, state: str) -> None:
|
|
64
|
+
self.s.state = state
|
|
65
|
+
self.s.state_time = 0.0
|
|
66
|
+
if state == "stall":
|
|
67
|
+
self.s.stall_count += 1
|
|
68
|
+
|
|
69
|
+
def _transition(self) -> None:
|
|
70
|
+
s = self.s
|
|
71
|
+
if s.state == "glide" and s.age >= self.next_cycle and self.behaviour != "stable":
|
|
72
|
+
self._enter("climb")
|
|
73
|
+
elif s.state == "climb" and s.state_time >= 1.02:
|
|
74
|
+
self._enter("mush")
|
|
75
|
+
elif s.state == "mush" and s.state_time >= 0.32:
|
|
76
|
+
self._enter("stall")
|
|
77
|
+
elif s.state == "stall" and s.state_time >= 0.22:
|
|
78
|
+
self._enter("drop")
|
|
79
|
+
elif s.state == "drop" and s.state_time >= (1.10 if self.behaviour == "leaf" else 0.78):
|
|
80
|
+
self._enter("recovery")
|
|
81
|
+
elif s.state == "recovery" and s.state_time >= 0.68:
|
|
82
|
+
self._enter("swoop")
|
|
83
|
+
elif s.state == "swoop" and s.state_time >= 0.92:
|
|
84
|
+
self._enter("glide")
|
|
85
|
+
self.next_cycle = s.age + self.rng.uniform(1.05, 1.65)
|
|
86
|
+
|
|
87
|
+
def step(self, dt: float, global_time: float) -> FlightState:
|
|
88
|
+
s = self.s
|
|
89
|
+
s.age += dt
|
|
90
|
+
s.state_time += dt
|
|
91
|
+
self._transition()
|
|
92
|
+
|
|
93
|
+
targets = {
|
|
94
|
+
"glide": (self.cruise_speed, -0.018, -6.0),
|
|
95
|
+
"climb": (self.cruise_speed * 0.68, -0.105, -25.0),
|
|
96
|
+
"mush": (self.cruise_speed * 0.38, -0.012, -20.0),
|
|
97
|
+
"stall": (self.cruise_speed * 0.25, 0.055, -14.0),
|
|
98
|
+
"drop": (self.cruise_speed * 0.76, 0.205, 36.0),
|
|
99
|
+
"recovery": (self.cruise_speed * 1.18, 0.095, 17.0),
|
|
100
|
+
"swoop": (self.cruise_speed * 1.02, -0.090, -18.0),
|
|
101
|
+
}
|
|
102
|
+
target_vx, target_vy, target_pitch_deg = targets[s.state]
|
|
103
|
+
velocity_scale = self.cruise_speed / 0.22
|
|
104
|
+
target_vy *= velocity_scale
|
|
105
|
+
|
|
106
|
+
if self.behaviour == "stable":
|
|
107
|
+
target_vy = (-0.010 + 0.018 * math.sin(global_time * 0.72 + self.phase)) * velocity_scale
|
|
108
|
+
target_pitch_deg = math.degrees(math.atan2(target_vy, target_vx)) - 2.5
|
|
109
|
+
|
|
110
|
+
leaf_weight = 0.0
|
|
111
|
+
if self.behaviour == "leaf" and s.state in {"stall", "drop"}:
|
|
112
|
+
leaf_weight = min(1.0, s.state_time / 0.32)
|
|
113
|
+
target_vx *= 0.58
|
|
114
|
+
target_vy = 0.155 * velocity_scale
|
|
115
|
+
s.x += math.sin(global_time * 5.0 + self.phase) * 0.0018 * leaf_weight
|
|
116
|
+
|
|
117
|
+
response = 1.9 if s.state in {"climb", "swoop"} else 3.2
|
|
118
|
+
if s.state == "stall":
|
|
119
|
+
response = 5.0
|
|
120
|
+
s.vx += (target_vx - s.vx) * min(1.0, dt * response)
|
|
121
|
+
s.vy += (target_vy - s.vy) * min(1.0, dt * response)
|
|
122
|
+
|
|
123
|
+
target_pitch = math.radians(target_pitch_deg)
|
|
124
|
+
pitch_response = 2.2 if s.state in {"mush", "stall"} else 4.0
|
|
125
|
+
s.pitch_rate += ((target_pitch - s.pitch) * pitch_response - s.pitch_rate * 3.0) * dt
|
|
126
|
+
s.pitch += s.pitch_rate * dt
|
|
127
|
+
|
|
128
|
+
# Bank belongs to the manoeuvre, not a permanent oscillation. Stable
|
|
129
|
+
# flight stays almost level; climbs and pull-outs use one smooth lean.
|
|
130
|
+
bank_by_state = {
|
|
131
|
+
"glide": 0.0, "climb": -7.0, "mush": -3.0,
|
|
132
|
+
"stall": 2.0, "drop": 8.0, "recovery": 5.0, "swoop": -8.0,
|
|
133
|
+
}
|
|
134
|
+
bank = bank_by_state[s.state]
|
|
135
|
+
if leaf_weight:
|
|
136
|
+
bank = 31.0 * math.sin(global_time * 5.0 + self.phase) * leaf_weight
|
|
137
|
+
s.roll += (bank - s.roll) * min(1.0, dt * 5.2)
|
|
138
|
+
s.yaw_scale = 1.0 - 0.38 * leaf_weight * abs(math.sin(global_time * 5.0 + self.phase))
|
|
139
|
+
|
|
140
|
+
s.x += s.vx * dt
|
|
141
|
+
s.y += s.vy * dt
|
|
142
|
+
if not s.history or math.hypot(s.x - s.history[-1][0], s.y - s.history[-1][1]) > 0.003:
|
|
143
|
+
s.history.append((s.x, s.y))
|
|
144
|
+
del s.history[:-70]
|
|
145
|
+
return s
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def simulate_stall(seed: int = 7, seconds: float = 10.0,
|
|
149
|
+
dt: float = 1 / 120) -> list[tuple[float, str, float, float, float]]:
|
|
150
|
+
plane = PaperPlaneFlight(seed, 0.05, 0.42, 0.15, 0, stall_after=0.5)
|
|
151
|
+
samples = []
|
|
152
|
+
t = 0.0
|
|
153
|
+
while t < seconds:
|
|
154
|
+
plane.step(dt, t)
|
|
155
|
+
samples.append((t, plane.s.state, plane.airspeed, plane.s.y, plane.s.pitch))
|
|
156
|
+
t += dt
|
|
157
|
+
return samples
|
framerudiments/io.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Strict documents and content identities."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sha256(path):
|
|
9
|
+
h = hashlib.sha256()
|
|
10
|
+
with Path(path).open("rb") as handle:
|
|
11
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
12
|
+
h.update(block)
|
|
13
|
+
return h.hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_json(path):
|
|
17
|
+
def bad(value):
|
|
18
|
+
raise ValueError(f"Non-finite JSON number: {value}")
|
|
19
|
+
def unique(pairs):
|
|
20
|
+
out = {}
|
|
21
|
+
for key, value in pairs:
|
|
22
|
+
if key in out:
|
|
23
|
+
raise ValueError(f"Duplicate JSON key: {key}")
|
|
24
|
+
out[key] = value
|
|
25
|
+
return out
|
|
26
|
+
return json.loads(Path(path).read_text(encoding="utf-8-sig"),
|
|
27
|
+
parse_constant=bad, object_pairs_hook=unique)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_json(path, value):
|
|
31
|
+
Path(path).write_text(json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n",
|
|
32
|
+
encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def number(value, name, *, minimum=None, maximum=None):
|
|
36
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
37
|
+
raise ValueError(f"{name} must be finite")
|
|
38
|
+
if (minimum is not None and value < minimum) or (maximum is not None and value > maximum):
|
|
39
|
+
raise ValueError(f"{name} outside [{minimum}, {maximum}]")
|
|
40
|
+
return float(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def integer(value, name, *, minimum=0, maximum=2**31 - 1):
|
|
44
|
+
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
|
45
|
+
raise ValueError(f"{name} must be an integer in [{minimum}, {maximum}]")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fields(data, allowed, label):
|
|
50
|
+
if not isinstance(data, dict):
|
|
51
|
+
raise ValueError(f"{label} must be an object")
|
|
52
|
+
extra = set(data) - set(allowed)
|
|
53
|
+
if extra:
|
|
54
|
+
raise ValueError(f"Unknown {label} fields: {sorted(extra)}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def file_ref(data, base):
|
|
58
|
+
fields(data, {"path", "sha256"}, "file reference")
|
|
59
|
+
path = (Path(base) / data["path"]).resolve()
|
|
60
|
+
expected = data["sha256"]
|
|
61
|
+
if not isinstance(expected, str) or len(expected) != 64 or any(c not in "0123456789abcdefABCDEF" for c in expected):
|
|
62
|
+
raise ValueError(f"A SHA-256 is required for {path}")
|
|
63
|
+
actual = sha256(path)
|
|
64
|
+
if actual != expected.lower():
|
|
65
|
+
raise ValueError(f"Input hash mismatch: {path}")
|
|
66
|
+
return {"path": str(path), "sha256": actual, "bytes": path.stat().st_size}
|
framerudiments/motion.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Native integer-pip motion. Never substitute a Python movement engine."""
|
|
2
|
+
import math
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
from .io import file_ref, read_json, fields, number, integer, sha256
|
|
7
|
+
from .stemlab import selected_match
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=1)
|
|
11
|
+
def native_identity():
|
|
12
|
+
import dancerudiments as dr
|
|
13
|
+
return dict(distribution="dancerudiments", version=version("dancerudiments"),
|
|
14
|
+
path=dr.__file__, sha256=sha256(dr.__file__), pips_per_beat=dr.PIPS_PER_BEAT)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Motion:
|
|
18
|
+
def __init__(self, config, base, clock):
|
|
19
|
+
import dancerudiments as dr
|
|
20
|
+
self.dr, self.clock = dr, clock
|
|
21
|
+
fields(config, {"kind", "pattern", "rate", "phase_beats", "origin_beat", "file",
|
|
22
|
+
"source_index", "match_index", "source_offset_seconds", "axes", "outside",
|
|
23
|
+
"allow_outside_match", "interpolate", "repeat_clock", "channels"}, "motion")
|
|
24
|
+
if dr.PIPS_PER_BEAT != 64:
|
|
25
|
+
raise ValueError("Native DanceRudiments must use 64 pips per beat")
|
|
26
|
+
self.config = config
|
|
27
|
+
self.kind = config.get("kind", "named")
|
|
28
|
+
self.rate = number(config.get("rate", 1), "motion rate", minimum=0.00001)
|
|
29
|
+
self.phase = number(config.get("phase_beats", 0), "phase")
|
|
30
|
+
self.origin = number(config.get("origin_beat", 0), "motion origin")
|
|
31
|
+
self.file = file_ref(config["file"], base) if "file" in config else None
|
|
32
|
+
self.evidence = {"native": native_identity(), "config": config, "file": self.file}
|
|
33
|
+
self.files = [self.file] if self.file else []
|
|
34
|
+
self.pattern = config.get("pattern")
|
|
35
|
+
self.recording = None
|
|
36
|
+
if config.get("repeat_clock", "continuous") not in {"source", "continuous"}:
|
|
37
|
+
raise ValueError("repeat_clock must be source or continuous")
|
|
38
|
+
if self.kind == "composite":
|
|
39
|
+
if not 1 <= len(config["channels"]) <= 3:
|
|
40
|
+
raise ValueError("Composite motion needs one to three independent output channels")
|
|
41
|
+
self.channels = []
|
|
42
|
+
for channel in config["channels"]:
|
|
43
|
+
fields(channel, {"motion", "axis"}, "composite channel")
|
|
44
|
+
child = Motion(channel["motion"], base, clock)
|
|
45
|
+
self.channels.append((child, integer(channel.get("axis", 0), "channel axis", maximum=2)))
|
|
46
|
+
self.files.extend(child.files)
|
|
47
|
+
self.evidence["channels"] = [child.evidence for child, axis in self.channels]
|
|
48
|
+
return
|
|
49
|
+
if self.kind == "match":
|
|
50
|
+
if self.file is None:
|
|
51
|
+
raise ValueError("Match needs a pinned StemLab report")
|
|
52
|
+
evidence = selected_match(self.file["path"], config["source_index"], config["match_index"])
|
|
53
|
+
match = evidence["selection"]
|
|
54
|
+
self.evidence["match"] = evidence
|
|
55
|
+
self.pattern = match["pattern"]
|
|
56
|
+
self.phase = match["phase_beats"]
|
|
57
|
+
offset = number(config.get("source_offset_seconds", 0), "match source offset")
|
|
58
|
+
self.origin = clock.beat_at(match["start_seconds"] + offset)
|
|
59
|
+
self.window = (match["start_seconds"] + offset, match["end_seconds"] + offset)
|
|
60
|
+
if self.kind in {"named", "match"}:
|
|
61
|
+
if self.pattern not in {x["name"] for x in dr.catalogue()}:
|
|
62
|
+
raise ValueError(f"Unknown native movement: {self.pattern}")
|
|
63
|
+
elif self.kind == "recording":
|
|
64
|
+
from dancerudiments_authoring.recording import load_recording
|
|
65
|
+
if self.rate != 1 or self.phase != 0 or self.origin != 0 or config.get("interpolate", False):
|
|
66
|
+
raise ValueError("Recordings use their own source clock; no rate, phase or interpolation override")
|
|
67
|
+
recording = load_recording(self.file["path"])
|
|
68
|
+
self.recording = recording.to_native()
|
|
69
|
+
self.dimensions = list(recording.dimensions)
|
|
70
|
+
self.axes = config.get("axes", self.dimensions[:3])
|
|
71
|
+
if not 1 <= len(self.axes) <= 3 or any(x not in self.dimensions for x in self.axes):
|
|
72
|
+
raise ValueError("Choose one to three existing recording axes")
|
|
73
|
+
if config.get("outside", "error") not in {"error", "neutral"}:
|
|
74
|
+
raise ValueError("Recording outside policy must be error or neutral")
|
|
75
|
+
self.evidence["clock"] = recording.to_dict()["clock"]
|
|
76
|
+
self.evidence["dimensions"] = self.dimensions
|
|
77
|
+
self.evidence["provenance"] = recording.provenance
|
|
78
|
+
elif self.kind == "table":
|
|
79
|
+
table = read_json(self.file["path"])
|
|
80
|
+
if table.get("pipsPerBeat", table.get("pips_per_beat")) != 64:
|
|
81
|
+
raise ValueError("Native export needs 64-pip identity")
|
|
82
|
+
if not table.get("version") or not (table.get("wasmSha256") or table.get("extension_sha256")):
|
|
83
|
+
raise ValueError("Native export needs version and binary hash provenance")
|
|
84
|
+
self.row = table["movements"][self.pattern]
|
|
85
|
+
self.samples = self.row["samples"]
|
|
86
|
+
period = self.row.get("periodPips", self.row.get("period_pips"))
|
|
87
|
+
if integer(period, "table period", minimum=1) != len(self.samples):
|
|
88
|
+
raise ValueError("Native table period does not match its samples")
|
|
89
|
+
for row in self.samples:
|
|
90
|
+
if not 1 <= len(row) <= 3:
|
|
91
|
+
raise ValueError("Native tables have one to three axes")
|
|
92
|
+
for v in row:
|
|
93
|
+
number(v, "native table sample", minimum=-1, maximum=1)
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError(f"Unknown motion kind: {self.kind}")
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=4096)
|
|
98
|
+
def _named(self, pip):
|
|
99
|
+
result = self.dr.sample(self.pattern, pip)
|
|
100
|
+
return (result.x, result.y, result.z)
|
|
101
|
+
|
|
102
|
+
def recording_values(self, source_seconds):
|
|
103
|
+
r = self.recording
|
|
104
|
+
pip = math.floor((source_seconds - r.beat_zero_seconds) * r.bpm / 60 * 64)
|
|
105
|
+
if not r.start_pip <= pip < r.start_pip + r.sample_count:
|
|
106
|
+
if self.config.get("outside", "error") == "neutral":
|
|
107
|
+
return [0.0] * len(self.dimensions)
|
|
108
|
+
raise ValueError(f"Source time {source_seconds} is outside non-looping recording")
|
|
109
|
+
return r.sample(pip)
|
|
110
|
+
|
|
111
|
+
def sample(self, source_seconds, *, continuous_beat=None):
|
|
112
|
+
if self.kind == "composite":
|
|
113
|
+
values = tuple(child.sample(source_seconds, continuous_beat=continuous_beat)[axis]
|
|
114
|
+
for child, axis in self.channels)
|
|
115
|
+
return (values + (0.0, 0.0))[:3]
|
|
116
|
+
if self.recording:
|
|
117
|
+
values = self.recording_values(source_seconds)
|
|
118
|
+
projected = tuple(values[self.dimensions.index(axis)] for axis in self.axes)
|
|
119
|
+
return (projected + (0.0, 0.0))[:3]
|
|
120
|
+
if self.kind == "match" and not self.config.get("allow_outside_match", False):
|
|
121
|
+
if not self.window[0] <= source_seconds < self.window[1]:
|
|
122
|
+
raise ValueError("Motion exceeds matched window; choose another fit or explicitly permit extrapolation")
|
|
123
|
+
beat = self.clock.beat_at(source_seconds)
|
|
124
|
+
if self.kind in {"named", "table"} and self.config.get("repeat_clock", "continuous") == "continuous":
|
|
125
|
+
beat = beat if continuous_beat is None else continuous_beat
|
|
126
|
+
position = ((beat - self.origin) * self.rate + self.phase) * 64
|
|
127
|
+
pip = math.floor(position)
|
|
128
|
+
def sample_at(p):
|
|
129
|
+
values = tuple(self.samples[p % len(self.samples)]) if self.kind == "table" else self._named(p)
|
|
130
|
+
return (values + (0.0, 0.0))[:3]
|
|
131
|
+
a = sample_at(pip)
|
|
132
|
+
if not self.config.get("interpolate", False):
|
|
133
|
+
return a
|
|
134
|
+
# Explicit legacy compositor mode, still only adjacent native integer samples.
|
|
135
|
+
b = sample_at(pip + 1)
|
|
136
|
+
fraction = position - pip
|
|
137
|
+
return tuple(x + (y - x) * fraction for x, y in zip(a, b))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Validated portable render projects; private media remains outside the package."""
|
|
2
|
+
from fractions import Fraction
|
|
3
|
+
import math
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .io import read_json, file_ref, fields, integer, number, sha256
|
|
7
|
+
from .timeline import BeatClock, parse_lrc, parse_cue, frame_ledger
|
|
8
|
+
from .stemlab import beat_grid, selected_loop
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Project:
|
|
12
|
+
@classmethod
|
|
13
|
+
def load(cls, path):
|
|
14
|
+
path = Path(path).resolve()
|
|
15
|
+
return cls(read_json(path), path.parent, project_path=path)
|
|
16
|
+
|
|
17
|
+
def __init__(self, data, base, *, project_path=None):
|
|
18
|
+
self.base, self.path, self.data = Path(base), project_path, data
|
|
19
|
+
fields(data, {"schema", "title", "artist", "purpose", "width", "height", "fps", "fps_reason",
|
|
20
|
+
"audio", "clock", "font", "lyrics", "cues", "background", "layers", "captions",
|
|
21
|
+
"identity", "stemlab_loop", "notes", "lyric_art", "canonical_lyrics"}, "project")
|
|
22
|
+
if data.get("schema") != "framerudiments.project.v1":
|
|
23
|
+
raise ValueError("Expected framerudiments.project.v1")
|
|
24
|
+
self.width = integer(data.get("width", 1080), "width", minimum=16, maximum=7680)
|
|
25
|
+
self.height = integer(data.get("height", 1920), "height", minimum=16, maximum=7680)
|
|
26
|
+
if self.width % 2 or self.height % 2:
|
|
27
|
+
raise ValueError("H.264 yuv420p needs even dimensions")
|
|
28
|
+
self.fps = integer(data.get("fps", 60), "fps", minimum=1, maximum=120)
|
|
29
|
+
if self.fps != 60 and not data.get("fps_reason"):
|
|
30
|
+
raise ValueError("Non-60-fps output needs a documented reason")
|
|
31
|
+
self.purpose = data.get("purpose", "short")
|
|
32
|
+
if self.purpose not in {"short", "visualiser", "canvas"}:
|
|
33
|
+
raise ValueError("purpose must be short, visualiser or canvas")
|
|
34
|
+
self.inputs = []
|
|
35
|
+
self.audio = dict(data["audio"])
|
|
36
|
+
fields(self.audio, {"source", "sample_rate", "start_sample", "end_sample", "repeats", "fade_seconds"}, "audio")
|
|
37
|
+
self.audio_ref = self.ref(self.audio["source"])
|
|
38
|
+
self.sample_rate = integer(self.audio["sample_rate"], "sample rate", minimum=1, maximum=768000)
|
|
39
|
+
self.loop = None
|
|
40
|
+
if "stemlab_loop" in data:
|
|
41
|
+
cfg = data["stemlab_loop"]
|
|
42
|
+
fields(cfg, {"file", "id"}, "StemLab loop")
|
|
43
|
+
ref = self.ref(cfg["file"])
|
|
44
|
+
self.loop = selected_loop(ref["path"], cfg["id"], self.audio_ref["sha256"])
|
|
45
|
+
for key in ("start_sample", "end_sample", "sample_rate"):
|
|
46
|
+
if key in self.audio and self.audio[key] != self.loop[key]:
|
|
47
|
+
raise ValueError(f"Explicit {key} conflicts with selected StemLab loop")
|
|
48
|
+
self.audio[key] = self.loop[key]
|
|
49
|
+
self.start_sample = integer(self.audio.get("start_sample", 0), "start sample")
|
|
50
|
+
self.end_sample = integer(self.audio["end_sample"], "end sample", minimum=self.start_sample + 1)
|
|
51
|
+
self.sample_rate = self.audio["sample_rate"]
|
|
52
|
+
self.repeats = integer(self.audio.get("repeats", 1), "repeats", minimum=1, maximum=1000)
|
|
53
|
+
self.slice_duration = Fraction(self.end_sample - self.start_sample, self.sample_rate)
|
|
54
|
+
self.duration = self.slice_duration * self.repeats
|
|
55
|
+
self.frame_count = math.ceil(self.duration * self.fps)
|
|
56
|
+
if self.frame_count > 10_000_000:
|
|
57
|
+
raise ValueError("Render exceeds ten million frames")
|
|
58
|
+
self.fade = number(self.audio.get("fade_seconds", .12 if self.purpose == "short" else 0),
|
|
59
|
+
"fade seconds", minimum=0, maximum=float(self.duration) / 2)
|
|
60
|
+
cfg = data["clock"]
|
|
61
|
+
fields(cfg, {"bpm", "origin", "authority", "status", "phase_status", "beat_grid"}, "clock")
|
|
62
|
+
if not cfg.get("authority") or cfg.get("status") not in {"confirmed", "estimated", "synthetic"}:
|
|
63
|
+
raise ValueError("Clock needs authority and confirmed/estimated/synthetic status")
|
|
64
|
+
grid = beat_grid(self.ref(cfg["beat_grid"])["path"]) if "beat_grid" in cfg else None
|
|
65
|
+
self.clock = BeatClock(bpm=cfg.get("bpm"), origin=cfg.get("origin", 0), beats=grid)
|
|
66
|
+
self.font_ref = self.ref(data["font"]) if "font" in data else None
|
|
67
|
+
if (data.get("lyrics") or data.get("identity")) and not self.font_ref:
|
|
68
|
+
raise ValueError("Pin an explicit font for all authored text")
|
|
69
|
+
self.lyric_ref = self.ref(data["lyrics"]) if "lyrics" in data else None
|
|
70
|
+
self.lyrics = self.ledger(parse_lrc(self.lyric_ref["path"])) if self.lyric_ref else []
|
|
71
|
+
if "canonical_lyrics" in data:
|
|
72
|
+
ref = self.ref(data["canonical_lyrics"])
|
|
73
|
+
canonical = [line.strip() for line in Path(ref["path"]).read_text(encoding="utf-8-sig").splitlines()
|
|
74
|
+
if line.strip() and not line.strip().startswith("[")]
|
|
75
|
+
if not self.lyric_ref:
|
|
76
|
+
raise ValueError("canonical_lyrics requires a timed lyrics file")
|
|
77
|
+
timed = [e.text for e in parse_lrc(self.lyric_ref["path"]) if e.text]
|
|
78
|
+
if timed != canonical:
|
|
79
|
+
raise ValueError("Timed lyrics do not match the pinned canonical text, including punctuation")
|
|
80
|
+
self.cues = []
|
|
81
|
+
if "cues" in data:
|
|
82
|
+
ref = self.ref(data["cues"])
|
|
83
|
+
parser = parse_cue if Path(ref["path"]).suffix.lower() == ".cue" else parse_lrc
|
|
84
|
+
events = parser(ref["path"]) if parser is parse_cue else parser(ref["path"], lyrics=False)
|
|
85
|
+
self.cues = self.ledger(events)
|
|
86
|
+
|
|
87
|
+
def ref(self, data):
|
|
88
|
+
result = file_ref(data, self.base)
|
|
89
|
+
self.inputs.append(result)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
def ledger(self, events):
|
|
93
|
+
return frame_ledger(events, start_sample=self.start_sample, end_sample=self.end_sample,
|
|
94
|
+
sample_rate=self.sample_rate, fps=self.fps, repeats=self.repeats)
|
|
95
|
+
|
|
96
|
+
def source_time(self, frame):
|
|
97
|
+
output = Fraction(frame, self.fps)
|
|
98
|
+
# Only the fractional last video frame can exceed exact audio; clamp into last source sample.
|
|
99
|
+
output = min(output, self.duration - Fraction(1, self.sample_rate))
|
|
100
|
+
return float(Fraction(self.start_sample, self.sample_rate) + output % self.slice_duration)
|
|
101
|
+
|
|
102
|
+
def continuous_beat(self, frame):
|
|
103
|
+
output = min(Fraction(frame, self.fps), self.duration - Fraction(1, self.sample_rate))
|
|
104
|
+
cycle = int(output // self.slice_duration)
|
|
105
|
+
start = float(Fraction(self.start_sample, self.sample_rate))
|
|
106
|
+
end = float(Fraction(self.end_sample, self.sample_rate))
|
|
107
|
+
return self.clock.beat_at(self.source_time(frame)) + cycle * (
|
|
108
|
+
self.clock.beat_at(end) - self.clock.beat_at(start))
|
|
109
|
+
|
|
110
|
+
def verify_inputs(self):
|
|
111
|
+
for ref in self.inputs:
|
|
112
|
+
if sha256(ref["path"]) != ref["sha256"]:
|
|
113
|
+
raise ValueError(f"Input changed since preparation: {ref['path']}")
|
|
114
|
+
|
|
115
|
+
def summary(self):
|
|
116
|
+
return dict(schema="framerudiments.plan.v1", dimensions=[self.width, self.height], fps=self.fps,
|
|
117
|
+
frames=self.frame_count, source_start_sample=self.start_sample,
|
|
118
|
+
source_end_sample_exclusive=self.end_sample, sample_rate=self.sample_rate,
|
|
119
|
+
repeats=self.repeats, duration_seconds=float(self.duration),
|
|
120
|
+
video_duration_seconds=self.frame_count / self.fps,
|
|
121
|
+
frame_rounding="nearest, ties toward positive infinity; stable source ordering",
|
|
122
|
+
clock=self.data["clock"], endpoint_fade_seconds=self.fade, endpoint_fade_curve="qsin",
|
|
123
|
+
lyrics=self.lyrics, cues=self.cues, stemlab_loop=self.loop, inputs=self.inputs,
|
|
124
|
+
state="prepared; perceptual review pending")
|
framerudiments/render.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Shared render runner, cleanup, evidence and representative encoded stills."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from . import encoding
|
|
9
|
+
from .compositor import Compositor
|
|
10
|
+
from .io import sha256, write_json
|
|
11
|
+
from .project import Project
|
|
12
|
+
from .subtitles import export_subtitles
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def checkpoints(project):
|
|
16
|
+
count = project.frame_count
|
|
17
|
+
return sorted({0, count - 1, count // 4, count // 2, 3 * count // 4,
|
|
18
|
+
*(max(0, min(count - 1, event["frame"] + delta))
|
|
19
|
+
for event in project.lyrics + project.cues for delta in (-1, 0, 1))})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prepare(project, output, ffmpeg, ffprobe, scene):
|
|
23
|
+
project = Project.load(project) if not isinstance(project, Project) else project
|
|
24
|
+
ffmpeg, ffprobe = encoding.binaries(ffmpeg, ffprobe)
|
|
25
|
+
source_probe = encoding.validate_audio(project, ffprobe)
|
|
26
|
+
# Load every dependency before making the evidence snapshot.
|
|
27
|
+
compositor = Compositor(project, scene=scene)
|
|
28
|
+
project.verify_inputs()
|
|
29
|
+
output = Path(output).resolve()
|
|
30
|
+
output.mkdir(parents=True, exist_ok=False)
|
|
31
|
+
(output / "qa").mkdir()
|
|
32
|
+
write_json(output / "plan.json", project.summary())
|
|
33
|
+
write_json(output / "source-probe.json", source_probe)
|
|
34
|
+
export_subtitles(project.lyrics, project.fps, project.frame_count, output, project.width, project.height)
|
|
35
|
+
return project, output, ffmpeg, ffprobe, compositor
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def preflight(project, output, *, ffmpeg=None, ffprobe=None, scene=None):
|
|
39
|
+
p, out, ffmpeg, ffprobe, compositor = _prepare(project, output, ffmpeg, ffprobe, scene)
|
|
40
|
+
selected = set(checkpoints(p))
|
|
41
|
+
try:
|
|
42
|
+
for n in range(p.frame_count):
|
|
43
|
+
image, _ = compositor.frame(n)
|
|
44
|
+
if n in selected:
|
|
45
|
+
image.save(out / "qa" / f"source-{n:08}.png")
|
|
46
|
+
p.verify_inputs()
|
|
47
|
+
result = dict(p.summary(), composition=compositor.evidence(), state="preflight passed; no encoded video")
|
|
48
|
+
write_json(out / "manifest.json", result)
|
|
49
|
+
return result
|
|
50
|
+
except BaseException as exc:
|
|
51
|
+
write_json(out / "failure.json", dict(state="preflight failed", error=str(exc)))
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render(project, output, *, ffmpeg=None, ffprobe=None, scene=None, preset="fast", crf=20):
|
|
56
|
+
p, out, ffmpeg, ffprobe, compositor = _prepare(project, output, ffmpeg, ffprobe, scene)
|
|
57
|
+
candidate, final = out / "candidate.mp4", out / "video.mp4"
|
|
58
|
+
cmd = encoding.command(p, candidate, ffmpeg, preset=preset, crf=crf)
|
|
59
|
+
write_json(out / "encode-command.json", cmd)
|
|
60
|
+
selected = set(checkpoints(p))
|
|
61
|
+
process = None
|
|
62
|
+
try:
|
|
63
|
+
with (out / "encode.log").open("wb") as log:
|
|
64
|
+
process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=log)
|
|
65
|
+
for n in range(p.frame_count):
|
|
66
|
+
image, _ = compositor.frame(n)
|
|
67
|
+
if n in selected:
|
|
68
|
+
image.save(out / "qa" / f"source-{n:08}.png")
|
|
69
|
+
process.stdin.write(image.tobytes())
|
|
70
|
+
process.stdin.close()
|
|
71
|
+
if process.wait() != 0:
|
|
72
|
+
raise RuntimeError(f"FFmpeg failed; see {out / 'encode.log'}")
|
|
73
|
+
p.verify_inputs()
|
|
74
|
+
verified = encoding.verify(candidate, p, ffmpeg, ffprobe)
|
|
75
|
+
source_audio = encoding.probe(p.audio_ref["path"], ffprobe)
|
|
76
|
+
source_channels = next(s["channels"] for s in source_audio["streams"] if s["codec_type"] == "audio")
|
|
77
|
+
output_channels = next(s["channels"] for s in verified["probe"]["streams"] if s["codec_type"] == "audio")
|
|
78
|
+
if source_channels != output_channels:
|
|
79
|
+
raise ValueError("Encoder changed channel count")
|
|
80
|
+
candidate.rename(final)
|
|
81
|
+
# Extract from the actual encoded file, independently of the source Pillow frames.
|
|
82
|
+
selection = "+".join(f"eq(n\\,{n})" for n in sorted(selected))
|
|
83
|
+
subprocess.run([ffmpeg, "-n", "-v", "error", "-i", str(final), "-vf", f"select={selection}",
|
|
84
|
+
"-fps_mode", "passthrough", "-start_number", "0", "-frames:v", str(len(selected)),
|
|
85
|
+
str(out / "qa" / "checkpoint-%08d.png")], check=True, capture_output=True)
|
|
86
|
+
for i, n in enumerate(sorted(selected)):
|
|
87
|
+
(out / "qa" / f"checkpoint-{i:08}.png").rename(out / "qa" / f"encoded-{n:08}.png")
|
|
88
|
+
with Image.open(out / "qa" / "encoded-00000000.png") as first:
|
|
89
|
+
first.save(out / "poster.png")
|
|
90
|
+
thumbnail = first.copy()
|
|
91
|
+
thumbnail.thumbnail((360, 640))
|
|
92
|
+
thumbnail.save(out / "phone-preview.png")
|
|
93
|
+
result = dict(p.summary(), composition=compositor.evidence(), verification=verified,
|
|
94
|
+
software={"framerudiments": "0.1.0", "Pillow": version("Pillow"),
|
|
95
|
+
"dancerudiments": version("dancerudiments"),
|
|
96
|
+
"ffmpeg": subprocess.check_output([ffmpeg, "-version"], text=True).splitlines()[0]},
|
|
97
|
+
output={"path": str(final), "sha256": sha256(final), "bytes": final.stat().st_size},
|
|
98
|
+
state="technical-QA passed; creative and attended listening review pending")
|
|
99
|
+
write_json(out / "manifest.json", result)
|
|
100
|
+
return result
|
|
101
|
+
except BaseException as exc:
|
|
102
|
+
if process and process.poll() is None:
|
|
103
|
+
if process.stdin and not process.stdin.closed:
|
|
104
|
+
process.stdin.close()
|
|
105
|
+
try:
|
|
106
|
+
process.wait(timeout=5)
|
|
107
|
+
except subprocess.TimeoutExpired:
|
|
108
|
+
process.kill() # only the encoder child owned by this render
|
|
109
|
+
process.wait()
|
|
110
|
+
write_json(out / "failure.json", dict(state="failed; any encoded file remains unapproved", error=str(exc)))
|
|
111
|
+
raise
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Read saved StemLab evidence without re-running analysis or copying DSP."""
|
|
2
|
+
from .io import read_json, integer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def selected_loop(path, loop_id, source_sha256):
|
|
6
|
+
report = read_json(path)
|
|
7
|
+
if report.get("schema") != "stemlab.loops.v1":
|
|
8
|
+
raise ValueError("Expected stemlab.loops.v1")
|
|
9
|
+
if report.get("source_sha256") != source_sha256:
|
|
10
|
+
raise ValueError("StemLab loop belongs to a different audio source")
|
|
11
|
+
pool = [x for x in report["loops"] if x["id"] == loop_id]
|
|
12
|
+
if len(pool) != 1:
|
|
13
|
+
raise ValueError("Select one accepted loop id; exploratory candidates are not accepted loops")
|
|
14
|
+
item = pool[0]
|
|
15
|
+
start = integer(item["start_sample"], "loop start")
|
|
16
|
+
end = integer(item["end_sample"], "loop end", minimum=start + 1)
|
|
17
|
+
return dict(start_sample=start, end_sample=end, sample_rate=report["sample_rate"],
|
|
18
|
+
evidence=item, listening_approved=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def selected_match(path, source_index, match_index):
|
|
22
|
+
report = read_json(path)
|
|
23
|
+
if report.get("schema") != "stemlab.dancerudiments.candidates.v1":
|
|
24
|
+
raise ValueError("Expected stemlab.dancerudiments.candidates.v1")
|
|
25
|
+
source = report["sources"][integer(source_index, "source index")]
|
|
26
|
+
match = source["matches"][integer(match_index, "match index")]
|
|
27
|
+
return {"selection": match, "source": {k: v for k, v in source.items() if k != "matches"},
|
|
28
|
+
"note": "Ranked fit in this source window; no creative or whole-song approval implied."}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def beat_grid(path):
|
|
32
|
+
data = read_json(path)
|
|
33
|
+
beats = data.get("beats")
|
|
34
|
+
if not isinstance(beats, list):
|
|
35
|
+
raise ValueError("Expected a saved beat report with beats[]")
|
|
36
|
+
return beats
|