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.
@@ -0,0 +1,7 @@
1
+ """Offline frame composition on a traceable musical clock."""
2
+ __version__ = "0.1.0"
3
+
4
+ from .project import Project
5
+ from .render import render, preflight
6
+
7
+ __all__ = ["Project", "render", "preflight", "__version__"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,44 @@
1
+ """Connected alpha-component cleaning extracted from the sprite preparation tools."""
2
+ from collections import deque
3
+ from PIL import Image
4
+
5
+ def components(alpha: Image.Image, threshold: int = 18) -> list[list[tuple[int, int]]]:
6
+ width, height = alpha.size
7
+ pixels = alpha.load()
8
+ seen: set[tuple[int, int]] = set()
9
+ result: list[list[tuple[int, int]]] = []
10
+ for y in range(height):
11
+ for x in range(width):
12
+ if (x, y) in seen or pixels[x, y] < threshold:
13
+ continue
14
+ queue = deque([(x, y)])
15
+ seen.add((x, y))
16
+ group: list[tuple[int, int]] = []
17
+ while queue:
18
+ px, py = queue.popleft()
19
+ group.append((px, py))
20
+ for ny in range(max(0, py - 1), min(height, py + 2)):
21
+ for nx in range(max(0, px - 1), min(width, px + 2)):
22
+ if (nx, ny) not in seen and pixels[nx, ny] >= threshold:
23
+ seen.add((nx, ny))
24
+ queue.append((nx, ny))
25
+ result.append(group)
26
+ return sorted(result, key=len, reverse=True)
27
+
28
+ def isolate_one_object(cell: Image.Image) -> tuple[Image.Image, int, int]:
29
+ rgba = cell.convert("RGBA")
30
+ groups = components(rgba.getchannel("A"))
31
+ if not groups:
32
+ raise ValueError("atlas cell contains no opaque object")
33
+ keep = set(groups[0])
34
+ removed = sum(len(group) for group in groups[1:])
35
+ src = rgba.load()
36
+ for y in range(rgba.height):
37
+ for x in range(rgba.width):
38
+ if (x, y) not in keep:
39
+ r, g, b, _ = src[x, y]
40
+ src[x, y] = (r, g, b, 0)
41
+ post = [group for group in components(rgba.getchannel("A")) if len(group) >= 8]
42
+ if len(post) != 1:
43
+ raise ValueError(f"cleaned cell has {len(post)} meaningful objects")
44
+ return rgba, len(groups), removed
@@ -0,0 +1,30 @@
1
+ """Frame-addressed entrance/hold/exit mechanics extracted from lyric-plane rendering."""
2
+ from .timeline import half_up
3
+ from .io import number, integer
4
+
5
+
6
+ def lyric_placements(events, frame, *, fps, bpm, transition_ticks=8, width=1920, centre=960,
7
+ margin=650, programme_frames):
8
+ """Enter by onset, hold to explicit end_frame, then leave; no copied song BPM."""
9
+ fps = integer(fps, "fps", minimum=1)
10
+ bpm = number(bpm, "bpm", minimum=.00001)
11
+ transition = max(1, half_up(number(transition_ticks, "transition ticks", minimum=0) * 3.75 / bpm * fps))
12
+ for index, event in enumerate(events):
13
+ onset = event["frame"]
14
+ if "end_frame" not in event:
15
+ raise ValueError("Lyric-art choreography requires explicit end_frame")
16
+ following = event["end_frame"]
17
+ if following < onset:
18
+ raise ValueError("Lyric-art end precedes onset")
19
+ enter, end = max(0, onset - transition), min(programme_frames, following + transition)
20
+ if not enter <= frame < end:
21
+ continue
22
+ if frame < onset:
23
+ u = 1 - (1 - (frame - enter) / max(1, onset - enter)) ** 3
24
+ x, phase = -margin + (centre + margin) * u, "enter"
25
+ elif frame < following:
26
+ x, phase = centre, "hold"
27
+ else:
28
+ u = 1 - (1 - min(1, (frame - following) / transition)) ** 3
29
+ x, phase = centre + (width + margin - centre) * u, "exit"
30
+ yield index, round(x), phase
framerudiments/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+
5
+ from . import __version__
6
+ from .demo import create_demo
7
+ from .io import write_json
8
+ from .project import Project
9
+ from .render import preflight, render
10
+
11
+
12
+ def main(argv=None):
13
+ parser = argparse.ArgumentParser(description="FrameRudiments: traceable Shorts and lyric visualisers")
14
+ parser.add_argument("--version", action="version", version=__version__)
15
+ commands = parser.add_subparsers(dest="command", required=True)
16
+ demo = commands.add_parser("demo", help="Create a synthetic audio/image/recording project")
17
+ demo.add_argument("directory")
18
+ demo.add_argument("--font", required=True)
19
+ demo.add_argument("--width", type=int, default=360)
20
+ demo.add_argument("--height", type=int, default=640)
21
+ plan = commands.add_parser("plan", help="Validate pinned inputs and export the frame ledger")
22
+ plan.add_argument("project")
23
+ plan.add_argument("--output", required=True)
24
+ for name in ("preflight", "render"):
25
+ item = commands.add_parser(name)
26
+ item.add_argument("project")
27
+ item.add_argument("--output", required=True, help="A new evidence/output directory; never overwrites")
28
+ item.add_argument("--ffmpeg")
29
+ item.add_argument("--ffprobe")
30
+ if name == "render":
31
+ item.add_argument("--preset", default="fast")
32
+ item.add_argument("--crf", type=int, default=20)
33
+ args = parser.parse_args(argv)
34
+ try:
35
+ if args.command == "demo":
36
+ print(create_demo(args.directory, args.font, width=args.width, height=args.height))
37
+ elif args.command == "plan":
38
+ output = __import__("pathlib").Path(args.output)
39
+ if output.exists():
40
+ raise ValueError("Plan output already exists")
41
+ project = Project.load(args.project)
42
+ from .compositor import Compositor
43
+ Compositor(project)
44
+ write_json(output, project.summary())
45
+ print(output)
46
+ else:
47
+ runner = preflight if args.command == "preflight" else render
48
+ extra = {"preset": args.preset, "crf": args.crf} if args.command == "render" else {}
49
+ result = runner(args.project, args.output, ffmpeg=args.ffmpeg, ffprobe=args.ffprobe, **extra)
50
+ print(json.dumps({"state": result["state"], "frames": result["frames"], "output": args.output}))
51
+ return 0
52
+ except (ValueError, OSError, RuntimeError, KeyError, IndexError) as exc:
53
+ print(f"FrameRudiments: {exc}", file=sys.stderr)
54
+ return 2
@@ -0,0 +1,278 @@
1
+ """Pillow scene composition; per-release artwork and appearance stay declarative."""
2
+ from dataclasses import dataclass
3
+ from functools import lru_cache
4
+ import math
5
+
6
+ from PIL import Image, ImageDraw, ImageFont, ImageOps
7
+
8
+ from .effects import alpha_scaled, transformed_bounds, within, directional_trail, atlas_cell
9
+ from .io import fields, number, integer
10
+ from .motion import Motion
11
+ from .timeline import EventTrack
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class FrameContext:
16
+ frame: int
17
+ output_seconds: float
18
+ source_seconds: float
19
+ beat: float
20
+ lyric: dict | None
21
+ cue: dict | None
22
+
23
+
24
+ def rectangle(value, name, width, height):
25
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
26
+ raise ValueError(f"{name} needs [left, top, right, bottom]")
27
+ value = tuple(number(x, name) for x in value)
28
+ if not (0 <= value[0] < value[2] <= width and 0 <= value[1] < value[3] <= height):
29
+ raise ValueError(f"{name} must be an ordered rectangle inside the frame")
30
+ return value
31
+
32
+
33
+ class TextBlock:
34
+ def __init__(self, project, config, *, label):
35
+ fields(config, {"box", "font_size", "min_font_size", "colour", "panel", "stroke", "mode",
36
+ "line_spacing", "neighbour_colour"}, label)
37
+ self.project, self.config, self.label = project, config, label
38
+ self.box = rectangle(config["box"], label, project.width, project.height)
39
+ self.maximum = integer(config.get("font_size", 60), "font size", minimum=1, maximum=512)
40
+ self.minimum = integer(config.get("min_font_size", self.maximum), "min font size", minimum=1,
41
+ maximum=self.maximum)
42
+ self.spacing = integer(config.get("line_spacing", 8), "line spacing", maximum=512)
43
+ self.stroke = integer(config.get("stroke", 1), "text stroke", maximum=20)
44
+ if not project.font_ref:
45
+ raise ValueError("Text needs an explicit font")
46
+ self.colour = config.get("colour", "#f5eddf")
47
+ self.mode = config.get("mode", "current")
48
+ if self.mode not in {"current", "neighbours"}:
49
+ raise ValueError("Caption mode must be current or neighbours")
50
+
51
+ @lru_cache(maxsize=256)
52
+ def layout(self, text):
53
+ draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
54
+ left, top, right, bottom = self.box
55
+ for size in range(self.maximum, self.minimum - 1, -1):
56
+ font = ImageFont.truetype(self.project.font_ref["path"], size)
57
+ rows = []
58
+ for paragraph in text.split("\n"):
59
+ line = ""
60
+ for word in paragraph.split():
61
+ trial = (line + " " + word).strip()
62
+ bb = draw.textbbox((0, 0), trial, font=font, stroke_width=self.stroke)
63
+ if bb[2] - bb[0] > right - left and line:
64
+ rows.append(line)
65
+ line = word
66
+ else:
67
+ line = trial
68
+ rows.append(line)
69
+ boxes = [draw.textbbox((0, 0), row, font=font, stroke_width=self.stroke) for row in rows]
70
+ height = sum(bb[3] - bb[1] for bb in boxes) + self.spacing * (len(rows) - 1)
71
+ if height <= bottom - top and all(bb[2] - bb[0] <= right - left for bb in boxes):
72
+ return font, tuple(rows), tuple(boxes), height
73
+ raise ValueError(f"{self.label} cannot fit without clipping: {text!r}")
74
+
75
+ def draw(self, image, text, *, colour=None):
76
+ if not text:
77
+ return []
78
+ font, rows, boxes, height = self.layout(text)
79
+ left, top, right, bottom = self.box
80
+ draw = ImageDraw.Draw(image)
81
+ if panel := self.config.get("panel"):
82
+ draw.rounded_rectangle(self.box, radius=12, fill=panel)
83
+ y = top + (bottom - top - height) / 2
84
+ bounds = []
85
+ for row, bb in zip(rows, boxes):
86
+ x = left + (right - left - (bb[2] - bb[0])) / 2 - bb[0]
87
+ actual = (x + bb[0], y, x + bb[2], y + bb[3] - bb[1])
88
+ if not within(actual, self.box):
89
+ raise ValueError(f"Text outside {self.label} box")
90
+ draw.text((x, y - bb[1]), row, font=font, fill=colour or self.colour,
91
+ stroke_width=self.stroke, stroke_fill="#12111c")
92
+ bounds.append([round(v, 3) for v in actual])
93
+ y += bb[3] - bb[1] + self.spacing
94
+ return bounds
95
+
96
+
97
+ class Sprite:
98
+ def __init__(self, project, cfg):
99
+ fields(cfg, {"id", "type", "asset", "center", "width", "bounds", "crop_alpha", "motion", "mapping",
100
+ "atlas", "opacity", "trail", "start_seconds", "end_seconds", "cue_labels"}, "layer")
101
+ self.project, self.cfg = project, cfg
102
+ self.id = cfg["id"]
103
+ self.kind = cfg.get("type", "sprite")
104
+ if self.kind not in {"sprite", "bars"}:
105
+ raise ValueError("Supported declarative layers: sprite and bars; use scene hook for custom drawing")
106
+ self.bounds = rectangle(cfg.get("bounds", [0, 0, project.width, project.height]),
107
+ "layer bounds", project.width, project.height)
108
+ self.opacity = number(cfg.get("opacity", 1), "opacity", minimum=0, maximum=1)
109
+ self.motion = Motion(cfg["motion"], project.base, project.clock) if "motion" in cfg else None
110
+ if self.motion:
111
+ project.inputs.extend(self.motion.files)
112
+ mapping = cfg.get("mapping", {})
113
+ fields(mapping, {"dx", "dy", "rotation", "scale", "x_axis", "y_axis", "rotation_axis", "scale_axis"}, "appearance mapping")
114
+ self.mapping = {k: number(mapping.get(k, 0), k) for k in ("dx", "dy", "rotation", "scale")}
115
+ self.axes = {key: integer(mapping.get(key, default), key, maximum=2)
116
+ for key, default in (("x_axis", 0), ("y_axis", 1), ("rotation_axis", 2), ("scale_axis", 2))}
117
+ if abs(self.mapping["scale"]) >= 1:
118
+ raise ValueError("Scale amplitude must keep the sprite's size positive")
119
+ if self.kind == "bars":
120
+ if not self.motion or not self.motion.recording:
121
+ raise ValueError("Bars require a native N-dimensional recording")
122
+ self.image = None
123
+ return
124
+ self.asset = project.ref(cfg["asset"])
125
+ with Image.open(self.asset["path"]) as image:
126
+ self.image = image.convert("RGBA")
127
+ if cfg.get("crop_alpha", False):
128
+ box = self.image.getchannel("A").getbbox()
129
+ if box is None:
130
+ raise ValueError("Empty sprite")
131
+ self.image = self.image.crop(box)
132
+ self.width = integer(cfg["width"], "sprite width", minimum=1, maximum=16384)
133
+ self.center = tuple(number(x, "sprite center") for x in cfg["center"])
134
+ if len(self.center) != 2:
135
+ raise ValueError("Sprite center needs two coordinates")
136
+ self.atlas = cfg.get("atlas")
137
+ if self.atlas:
138
+ fields(self.atlas, {"columns", "rows", "count", "cycle_ticks", "phase_beats"}, "atlas")
139
+ cols = integer(self.atlas["columns"], "atlas columns", minimum=1)
140
+ rows = integer(self.atlas["rows"], "atlas rows", minimum=1)
141
+ integer(self.atlas["count"], "atlas count", minimum=1, maximum=cols * rows)
142
+ number(self.atlas["cycle_ticks"], "atlas cycle ticks", minimum=.0001)
143
+ number(self.atlas.get("phase_beats", 0), "atlas phase")
144
+ atlas_cell(self.image, columns=cols, rows=rows, index=0)
145
+
146
+ @lru_cache(maxsize=16)
147
+ def transformed(self, index, width, rotation):
148
+ im = self.image
149
+ if self.atlas:
150
+ im = atlas_cell(im, columns=self.atlas["columns"], rows=self.atlas["rows"], index=index)
151
+ im = im.resize((width, max(1, round(im.height * width / im.width))), Image.Resampling.LANCZOS)
152
+ im = im.rotate(rotation, Image.Resampling.BICUBIC, expand=True)
153
+ if trail := self.cfg.get("trail"):
154
+ fields(trail, {"offset", "opacity", "radius"}, "trail")
155
+ im = directional_trail(im, **trail)
156
+ return alpha_scaled(im, self.opacity)
157
+
158
+ def draw(self, image, ctx):
159
+ if not self.cfg.get("start_seconds", 0) <= ctx.source_seconds < self.cfg.get("end_seconds", math.inf):
160
+ return None
161
+ if "cue_labels" in self.cfg and (not ctx.cue or ctx.cue["text"] not in self.cfg["cue_labels"]):
162
+ return None
163
+ if self.kind == "bars":
164
+ values = self.motion.recording_values(ctx.source_seconds)
165
+ left, top, right, bottom = self.bounds
166
+ draw = ImageDraw.Draw(image)
167
+ mid = (top + bottom) / 2
168
+ draw.line((left, mid, right, mid), fill="#9ca4bc", width=1)
169
+ width = (right - left) / len(values)
170
+ if width < 2:
171
+ raise ValueError("Too many recording dimensions for the selected bar width")
172
+ for j, value in enumerate(values):
173
+ end = mid - value * (bottom - top) / 2
174
+ draw.rectangle((left + j * width, min(mid, end), left + (j + 1) * width - 1,
175
+ max(mid, end)), fill="#76d4cf" if value >= 0 else "#edb56d")
176
+ return self.bounds
177
+ v = self.motion.sample(ctx.source_seconds, continuous_beat=ctx.beat) if self.motion else (0, 0, 0)
178
+ index = 0
179
+ if self.atlas:
180
+ phase = (ctx.beat + self.atlas.get("phase_beats", 0)) * 16 / self.atlas["cycle_ticks"]
181
+ index = math.floor((phase % 1) * self.atlas["count"])
182
+ width = max(1, round(self.width * (1 + v[self.axes["scale_axis"]] * self.mapping["scale"])))
183
+ im = self.transformed(index, width, round(v[self.axes["rotation_axis"]] * self.mapping["rotation"], 3))
184
+ x = round(self.center[0] + v[self.axes["x_axis"]] * self.mapping["dx"] - im.width / 2)
185
+ y = round(self.center[1] + v[self.axes["y_axis"]] * self.mapping["dy"] - im.height / 2)
186
+ bounds = transformed_bounds(im, x, y)
187
+ if not within(bounds, self.bounds):
188
+ raise ValueError(f"Frame {ctx.frame}: {self.id} transformed alpha {bounds} exceeds {self.bounds}")
189
+ image.alpha_composite(im, (x, y))
190
+ return bounds
191
+
192
+
193
+ class Compositor:
194
+ def __init__(self, project, *, scene=None):
195
+ self.project, self.scene = project, scene
196
+ bg = project.data.get("background", {})
197
+ fields(bg, {"colour", "image", "fit"}, "background")
198
+ self.background = Image.new("RGBA", (project.width, project.height), bg.get("colour", "#101b2e"))
199
+ if "image" in bg:
200
+ ref = project.ref(bg["image"])
201
+ with Image.open(ref["path"]) as source:
202
+ source = source.convert("RGBA")
203
+ fit = bg.get("fit", "cover")
204
+ if fit == "cover":
205
+ source = ImageOps.fit(source, self.background.size, method=Image.Resampling.LANCZOS)
206
+ elif fit == "contain":
207
+ source = ImageOps.contain(source, self.background.size, method=Image.Resampling.LANCZOS)
208
+ else:
209
+ raise ValueError("Background fit must be cover or contain")
210
+ self.background.alpha_composite(source, ((project.width - source.width) // 2,
211
+ (project.height - source.height) // 2))
212
+ self.lyrics, self.cues = EventTrack(project.lyrics), EventTrack(project.cues)
213
+ self.layers = [Sprite(project, cfg) for cfg in project.data.get("layers", [])]
214
+ ids = [x.id for x in self.layers]
215
+ if len(set(ids)) != len(ids):
216
+ raise ValueError("Layer ids must be unique")
217
+ self.caption = TextBlock(project, project.data["captions"], label="captions") if "lyrics" in project.data else None
218
+ self.lyric_art = {}
219
+ self.lyric_art_fallback = "text"
220
+ if art := project.data.get("lyric_art"):
221
+ fields(art, {"items", "placement", "fallback"}, "lyric art")
222
+ self.lyric_art_fallback = art.get("fallback", "error")
223
+ if self.lyric_art_fallback not in {"text", "error"}:
224
+ raise ValueError("Lyric art fallback must be text or error")
225
+ for i, item in enumerate(art["items"]):
226
+ fields(item, {"text", "asset"}, "lyric art item")
227
+ if item["text"] in self.lyric_art:
228
+ raise ValueError("Duplicate lyric art text mapping")
229
+ self.lyric_art[item["text"]] = Sprite(project, dict(art["placement"],
230
+ id=f"lyric-art-{i}", asset=item["asset"]))
231
+ if self.lyric_art_fallback == "error":
232
+ missing = {e["text"] for e in project.lyrics if e["text"]} - set(self.lyric_art)
233
+ if missing:
234
+ raise ValueError(f"Missing lyric artwork: {sorted(missing)}")
235
+ self.identity = []
236
+ for item in project.data.get("identity", []):
237
+ fields(item, {"text", "style"}, "identity")
238
+ self.identity.append((item["text"], TextBlock(project, item["style"], label="identity")))
239
+ self.extents, self.text_states = {}, {}
240
+
241
+ def frame(self, frame):
242
+ p = self.project
243
+ ctx = FrameContext(frame, frame / p.fps, p.source_time(frame), p.continuous_beat(frame),
244
+ self.lyrics.at(frame), self.cues.at(frame))
245
+ image = self.background.copy()
246
+ if self.scene:
247
+ image = self.scene(image, ctx)
248
+ if image.size != self.background.size or image.mode != "RGBA":
249
+ raise ValueError("Custom scene hook must return frame-sized RGBA")
250
+ for layer in self.layers:
251
+ bounds = layer.draw(image, ctx)
252
+ if bounds:
253
+ previous = self.extents.get(layer.id, bounds)
254
+ self.extents[layer.id] = [min(bounds[0], previous[0]), min(bounds[1], previous[1]),
255
+ max(bounds[2], previous[2]), max(bounds[3], previous[3])]
256
+ text_bounds = []
257
+ for text, style in self.identity:
258
+ text_bounds.extend(style.draw(image, text))
259
+ if self.caption:
260
+ text = ctx.lyric["text"] if ctx.lyric else ""
261
+ art_layer = self.lyric_art.get(text)
262
+ if art_layer:
263
+ bounds = art_layer.draw(image, ctx)
264
+ if bounds:
265
+ text_bounds.append(bounds)
266
+ elif self.caption.mode == "neighbours":
267
+ before, current, after = self.lyrics.neighbours(frame)
268
+ text = "\n".join((before["text"] if before else "", current["text"] if current else "",
269
+ after["text"] if after else ""))
270
+ if not art_layer:
271
+ text_bounds.extend(self.caption.draw(image, text))
272
+ self.text_states.setdefault(text, dict(first_frame=frame, bounds=text_bounds))
273
+ return image.convert("RGB"), ctx
274
+
275
+ def evidence(self):
276
+ return dict(transformed_alpha_extents=self.extents, text_states=self.text_states,
277
+ motions={x.id: x.motion.evidence for x in [*self.layers, *self.lyric_art.values()] if x.motion},
278
+ custom_scene=bool(self.scene))
framerudiments/demo.py ADDED
@@ -0,0 +1,68 @@
1
+ """Self-contained synthetic demonstration: no private songs or third-party artwork."""
2
+ import math
3
+ from pathlib import Path
4
+ import struct
5
+ import wave
6
+
7
+ from PIL import Image, ImageDraw
8
+ from dancerudiments_authoring.recording import Recording, RecordingClock, write_recording
9
+
10
+ from .io import sha256, write_json
11
+
12
+
13
+ def create_demo(directory, font, *, width=360, height=640):
14
+ directory = Path(directory).resolve()
15
+ directory.mkdir(parents=True, exist_ok=False)
16
+ font = Path(font).resolve()
17
+ if not font.is_file():
18
+ raise ValueError("Pass an installed font with --font")
19
+ rate = 48000
20
+ with wave.open(str(directory / "tone.wav"), "wb") as handle:
21
+ handle.setparams((2, 2, rate, 0, "NONE", "not compressed"))
22
+ data = bytearray()
23
+ for i in range(rate * 4):
24
+ t = i / rate
25
+ envelope = min(1, t / .02, (4 - t) / .02)
26
+ value = round(9000 * envelope * math.sin(2 * math.pi * 220 * t))
27
+ data.extend(struct.pack("<hh", value, value))
28
+ handle.writeframes(data)
29
+ sprite = Image.new("RGBA", (160, 160))
30
+ draw = ImageDraw.Draw(sprite)
31
+ draw.ellipse((20, 20, 140, 140), fill="#76d4cf", outline="#f9edca", width=5)
32
+ draw.line((50, 80, 70, 105, 115, 55), fill="#101b2e", width=12)
33
+ sprite.save(directory / "sprite.png")
34
+ (directory / "lyrics.lrc").write_text("[00:00.00]Signals into motion\n[00:01.25]Words into light\n"
35
+ "[00:02.50]Keep the clock in sight\n[00:03.75]\n", encoding="utf-8")
36
+ (directory / "sections.cue").write_text('FILE "tone.wav" WAVE\nTRACK 01 AUDIO\nTITLE "opening"\n'
37
+ 'INDEX 01 00:00:00\nTRACK 02 AUDIO\nTITLE "closing"\nINDEX 01 00:02:00\n',
38
+ encoding="utf-8")
39
+ clock = RecordingClock(120, "synthetic demo oscillator", "confirmed", 0, "supplied", 0)
40
+ recording = Recording("demo_bands", ("low", "mid", "high", "air"),
41
+ tuple(tuple(math.sin(i / 24 + j) for j in range(4)) for i in range(512)), clock)
42
+ write_recording(directory / "recording.json", recording)
43
+ def ref(name):
44
+ path = directory / name
45
+ return dict(path=name, sha256=sha256(path))
46
+ def box(a, b, c, d):
47
+ return [round(a * width), round(b * height), round(c * width), round(d * height)]
48
+ text_size = max(8, round(min(width * .078, height * .045)))
49
+ data = dict(schema="framerudiments.project.v1", title="FrameRudiments", artist="Synthetic demonstration",
50
+ purpose="short", width=width, height=height, fps=60,
51
+ audio=dict(source=ref("tone.wav"), sample_rate=rate, start_sample=0, end_sample=rate * 4,
52
+ fade_seconds=.12),
53
+ clock=dict(bpm=120, authority="synthetic oscillator", status="synthetic", phase_status="supplied"),
54
+ font=dict(path=str(font), sha256=sha256(font)), lyrics=ref("lyrics.lrc"), cues=ref("sections.cue"),
55
+ background=dict(colour="#101b2e"),
56
+ identity=[dict(text="FrameRudiments", style=dict(box=box(.1, .13, .9, .23), font_size=text_size,
57
+ min_font_size=max(6, text_size // 2), stroke=0, line_spacing=2))],
58
+ captions=dict(box=box(.12, .28, .88, .48), font_size=text_size,
59
+ min_font_size=max(6, text_size // 2), stroke=0, line_spacing=2),
60
+ layers=[dict(id="native", asset=ref("sprite.png"), center=[width * .5, height * .65],
61
+ width=round(min(width * .33, height * .22)), bounds=box(.1, .5, .9, .82),
62
+ motion=dict(kind="named", pattern="bounce"),
63
+ mapping=dict(dy=height * .035, y_axis=0)),
64
+ dict(id="bands", type="bars", bounds=box(.15, .84, .85, .94),
65
+ motion=dict(kind="recording", file=ref("recording.json")))],
66
+ notes="Synthetic audio and motion; no actual sung lyric or creative approval implied.")
67
+ write_json(directory / "project.json", data)
68
+ return directory / "project.json"
@@ -0,0 +1,51 @@
1
+ """Reusable bounded raster operations extracted from the release compositors."""
2
+ from PIL import Image, ImageChops, ImageFilter
3
+
4
+
5
+ def alpha_scaled(image, opacity):
6
+ opacity = max(0.0, min(1.0, opacity))
7
+ result = image.copy()
8
+ result.putalpha(result.getchannel("A").point(lambda v: round(v * opacity)))
9
+ return result
10
+
11
+
12
+ def transformed_bounds(image, x, y):
13
+ bounds = image.getchannel("A").getbbox()
14
+ return None if bounds is None else (x + bounds[0], y + bounds[1], x + bounds[2], y + bounds[3])
15
+
16
+
17
+ def within(bounds, allowed):
18
+ return bounds is None or (bounds[0] >= allowed[0] and bounds[1] >= allowed[1]
19
+ and bounds[2] <= allowed[2] and bounds[3] <= allowed[3])
20
+
21
+
22
+ def directional_trail(image, *, offset=(0, 0), opacity=.2, radius=2):
23
+ """Same-pose trail avoids detached anatomy from a previous sprite pose."""
24
+ dx, dy = map(int, offset)
25
+ margin = int(radius * 3 + 2)
26
+ result = Image.new("RGBA", (image.width + abs(dx) + margin * 2,
27
+ image.height + abs(dy) + margin * 2))
28
+ origin = (margin + max(0, -dx), margin + max(0, -dy))
29
+ trail = Image.new("RGBA", result.size)
30
+ trail.alpha_composite(alpha_scaled(image, opacity), (origin[0] + dx, origin[1] + dy))
31
+ result.alpha_composite(trail.filter(ImageFilter.GaussianBlur(radius)))
32
+ result.alpha_composite(image, origin)
33
+ return result
34
+
35
+
36
+ def masked_overlay(image, mask, opacity=1.0):
37
+ result = alpha_scaled(image, opacity)
38
+ if mask.size != result.size:
39
+ raise ValueError("Overlay and mask dimensions must match")
40
+ result.putalpha(ImageChops.multiply(result.getchannel("A"), mask.convert("L")))
41
+ return result
42
+
43
+
44
+ def atlas_cell(image, *, columns, rows, index):
45
+ if image.width % columns or image.height % rows:
46
+ raise ValueError("Atlas cells must divide the exact sheet dimensions")
47
+ if not 0 <= index < columns * rows:
48
+ raise ValueError("Atlas index outside sheet")
49
+ width, height = image.width // columns, image.height // rows
50
+ x, y = index % columns * width, index // columns * height
51
+ return image.crop((x, y, x + width, y + height))
@@ -0,0 +1,111 @@
1
+ """One FFmpeg boundary for exact audio slices, repeats, H.264/AAC and decoded QA."""
2
+ from fractions import Fraction
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+
9
+
10
+ def binaries(ffmpeg=None, ffprobe=None):
11
+ ffmpeg = ffmpeg or os.environ.get("FRAMERUDIMENTS_FFMPEG") or shutil.which("ffmpeg")
12
+ if not ffmpeg:
13
+ raise ValueError("Set FRAMERUDIMENTS_FFMPEG or pass --ffmpeg")
14
+ ffmpeg = str(Path(ffmpeg).resolve())
15
+ ffprobe = ffprobe or os.environ.get("FRAMERUDIMENTS_FFPROBE")
16
+ if not ffprobe:
17
+ sibling = Path(ffmpeg).with_name("ffprobe.exe" if os.name == "nt" else "ffprobe")
18
+ ffprobe = str(sibling) if sibling.is_file() else shutil.which("ffprobe")
19
+ if not ffprobe or not Path(ffprobe).is_file() or not Path(ffmpeg).is_file():
20
+ raise ValueError("Both FFmpeg and ffprobe must be available")
21
+ return ffmpeg, str(Path(ffprobe).resolve())
22
+
23
+
24
+ def probe(path, ffprobe, *, count=False):
25
+ args = [ffprobe, "-v", "error", "-show_streams", "-show_format", "-of", "json"]
26
+ if count:
27
+ args += ["-count_frames"]
28
+ return json.loads(subprocess.check_output([*args, str(path)], encoding="utf-8"))
29
+
30
+
31
+ def validate_audio(project, ffprobe):
32
+ info = probe(project.audio_ref["path"], ffprobe)
33
+ audio = next(s for s in info["streams"] if s["codec_type"] == "audio")
34
+ if int(audio["sample_rate"]) != project.sample_rate:
35
+ raise ValueError("Configured sample rate differs from source audio")
36
+ duration = Fraction(str(audio.get("duration", info["format"]["duration"])))
37
+ if "duration_ts" in audio:
38
+ duration = int(audio["duration_ts"]) * Fraction(audio["time_base"])
39
+ if project.end_sample > duration * project.sample_rate:
40
+ raise ValueError("Excerpt exceeds source audio extent")
41
+ return info
42
+
43
+
44
+ def command(project, output, ffmpeg, *, preset="fast", crf=20):
45
+ if preset not in {"ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"}:
46
+ raise ValueError("Unsupported encoder preset")
47
+ if not isinstance(crf, int) or not 0 <= crf <= 51:
48
+ raise ValueError("CRF must be an integer in [0,51]")
49
+ p = project
50
+ filters = [f"atrim=start_sample={p.start_sample}:end_sample={p.end_sample}", "asetpts=PTS-STARTPTS"]
51
+ if p.repeats > 1:
52
+ filters += [f"aloop=loop={p.repeats - 1}:size={p.end_sample - p.start_sample}"]
53
+ if p.fade:
54
+ filters += [f"afade=t=in:st=0:d={p.fade:.12f}:curve=qsin",
55
+ f"afade=t=out:st={float(p.duration) - p.fade:.12f}:d={p.fade:.12f}:curve=qsin"]
56
+ return [ffmpeg, "-n", "-hide_banner", "-loglevel", "warning", "-f", "rawvideo",
57
+ "-pixel_format", "rgb24", "-video_size", f"{p.width}x{p.height}",
58
+ "-framerate", str(p.fps), "-i", "pipe:0", "-i", p.audio_ref["path"],
59
+ "-map", "0:v:0", "-map", "1:a:0", "-af", ",".join(filters),
60
+ "-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-threads", "4",
61
+ "-pix_fmt", "yuv420p", "-fps_mode", "cfr", "-color_primaries", "bt709",
62
+ "-color_trc", "bt709", "-colorspace", "bt709", "-c:a", "aac", "-b:a", "256k",
63
+ "-ar", str(p.sample_rate), "-movflags", "+faststart", str(output)]
64
+
65
+
66
+ def verify(path, project, ffmpeg, ffprobe):
67
+ info = probe(path, ffprobe, count=True)
68
+ video = next(s for s in info["streams"] if s["codec_type"] == "video")
69
+ audio = next(s for s in info["streams"] if s["codec_type"] == "audio")
70
+ if (int(video["nb_read_frames"]) != project.frame_count or
71
+ Fraction(video["avg_frame_rate"]) != project.fps or
72
+ Fraction(video["r_frame_rate"]) != project.fps or video["codec_name"] != "h264" or
73
+ video["pix_fmt"] != "yuv420p" or [video["width"], video["height"]] != [project.width, project.height]):
74
+ raise ValueError("Encoded video does not match the prepared frame contract")
75
+ if audio["codec_name"] != "aac" or int(audio["sample_rate"]) != project.sample_rate:
76
+ raise ValueError("Encoded audio contract differs")
77
+ duration_error = float(audio["duration"]) - float(project.duration)
78
+ if abs(duration_error) > max(2 / project.sample_rate, .002):
79
+ raise ValueError(f"Encoded audio programme length differs by {duration_error:.6f}s")
80
+ # Inspect decoded timestamps, not only nominal container rate labels.
81
+ timestamp_command = [ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries",
82
+ "frame=best_effort_timestamp_time", "-of", "csv=p=0", str(path)]
83
+ completed = subprocess.run(timestamp_command, capture_output=True, text=True, check=True)
84
+ timestamps = [float(line.split(",")[0]) for line in completed.stdout.splitlines() if line.split(",")[0].strip()]
85
+ if len(timestamps) != project.frame_count or any(abs(t - i / project.fps) > .000002
86
+ for i, t in enumerate(timestamps)):
87
+ raise ValueError("Decoded frame timestamps are not constant-rate from zero")
88
+ subprocess.run([ffmpeg, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"],
89
+ check=True, capture_output=True)
90
+ # Verify actual MP4 atom order for fast start.
91
+ atoms = []
92
+ with Path(path).open("rb") as handle:
93
+ while header := handle.read(8):
94
+ if len(header) != 8:
95
+ raise ValueError("Truncated MP4 atom")
96
+ size, kind = int.from_bytes(header[:4], "big"), header[4:].decode("ascii")
97
+ consumed = 8
98
+ if size == 1:
99
+ size = int.from_bytes(handle.read(8), "big")
100
+ consumed = 16
101
+ atoms.append(kind)
102
+ if size == 0:
103
+ break
104
+ if size < consumed:
105
+ raise ValueError("Invalid MP4 atom size")
106
+ handle.seek(size - consumed, 1)
107
+ if "moov" not in atoms or "mdat" not in atoms or atoms.index("moov") > atoms.index("mdat"):
108
+ raise ValueError("MP4 is missing fast-start atom order")
109
+ return dict(probe=info, full_decode="passed", decoded_timestamps="CFR from zero; all frames checked",
110
+ audio_duration_error_seconds=duration_error, mp4_atoms=atoms,
111
+ listening_approved=False, platform_safe_zone_verified=False)