diff --git a/pop/loner/bin/pastiche/build.py b/pop/loner/bin/pastiche/build.py new file mode 100644 index 0000000000..103d6a5c17 --- /dev/null +++ b/pop/loner/bin/pastiche/build.py @@ -0,0 +1,224 @@ +"""Pastiche: the loner takes, crossfaded onto lonerclub v4pid. + +Two vocal passes of the record (63.79s — the second door's click rush +leads straight back into "sitting", so the reel loops musically). +Fast cuts on the 122 BPM bar grid: Camille curled in the grass opens +wordless, the verse trades takes every two bars, pass two cuts every +bar then strobes in half-bar stabs, and the closer returns to her in +the grass over the door. No take with words in frame (the title card, +the captioned fan of notebooks, and the LONER-labeled paper sit out). +Each take carries a cursor so its drawing grows fuller as it runs. + +Renders segments to $WORK/segs-, chains xfades; run.sh +grades and hands to chrome-reel.mjs. +""" +import hashlib +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) +LONER = os.path.dirname(os.path.dirname(HERE)) +WORK = os.environ.get("PASTICHE_WORK") or os.path.expanduser( + "~/.cache/ac/pastiche" +) + +BAR = 60.0 / 122.0 * 4 +B0 = 0.838 # first "sitting" — bar zero of the vocal grid +END = B0 + 32 * BAR # 63.79 — two passes, cut on the second door +W, H = 720, 1280 + +GRASS = "6988619239657622790" # title card, then Camille in the grass +PAD = "6955972523087416582" # CAMILLE glitter pad +FERAL = "7108062006980201771" # the Feral File spine +OUTDOOR = "6994920700746206470" # LONER label on sunlit concrete +REDPEN = "7021262898479549702" # the 13.8M take, red pen and beads +SMUDGE = "7173130377798716714" # pencil on gray smudge, shallow focus +LACE = "7168939549962308906" # brown paper over lace +CHARCOAL = "7168612922757877035" # ruled paper, charcoal shading +NB1 = "7230893600219942186" # twin notebooks on slate, most motion +NB2 = "7226527805008268586" # fanned notebooks, "time to pass" +NB3 = "7226114462145695018" # notebooks, green + black ink +PENS3A = "7226226683349798190" # three pens at once +PENS3B = "7233886426910330158" # three pens, other angle +BLUE = "7076361738786213166" # big blue marker, split frame + +DUR = {} +for _s in [GRASS, PAD, FERAL, OUTDOOR, REDPEN, SMUDGE, LACE, CHARCOAL, + NB1, NB2, NB3, PENS3A, PENS3B, BLUE]: + DUR[_s] = float(subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", f"{LONER}/source/{_s}.mp4"], + capture_output=True, text=True, check=True).stdout) + +cursor = {s: 1.5 for s in DUR} +cursor[GRASS] = 0.5 + + +def draw(src, need): + """Advance a take's cursor so its drawing grows across the piece.""" + t = cursor[src] + if t + need > DUR[src] - 0.1: + t = max(0.5, DUR[src] - need - 0.3) + cursor[src] = t + cursor[src] = t + need * 0.55 + return t + + +# assembled as (end_bar | None=END, src, src_t, speed, fade_out, opts) +# Camille alone in the grass is wordless only ~3.55-4.15s (title card +# before, hands after) — the bookends slow that breath to 0.25x and +# clone-hold her last frame under the drifting zoom. +PLAN = [] +PLAN.append((2.0, GRASS, 3.55, 0.25, 0.8, "hold:0.40")) + +VERSE = [PAD, FERAL, REDPEN, PENS3B, SMUDGE, PAD, FERAL] +for i, b in enumerate([4, 6, 8, 10, 12, 14, 15]): + src = VERSE[i] + seg = 2 * BAR * 0.85 + 1.2 + PLAN.append((float(b), src, draw(src, seg), 0.85, 0.6, None)) + +PLAN.append((16.0, SMUDGE, draw(SMUDGE, 2.5), 0.6, 0.5, "blur")) + +PASS2 = [NB1, NB3, PENS3A, LACE, CHARCOAL, PENS3B, + NB1, NB3, PENS3A, CHARCOAL] +for i, src in enumerate(PASS2): + b = 17.0 + i + layered = b >= 23 and i % 3 == 2 # from the bells, every third + opts = f"layer:{SMUDGE if src != SMUDGE else LACE}" if layered else None + if opts: + opts += f":{draw(SMUDGE, 2.5):.2f}" + PLAN.append((b, src, draw(src, 2.6), 1.0, 0.35, opts)) + +STROBE = [NB1, PENS3A, BLUE, PENS3B, NB3, LACE, NB1, PENS3A] +for i, src in enumerate(STROBE): + b = 26.5 + i * 0.5 + opts = "cropx:170" if src == BLUE else None + if i in (5, 7): + layer_src = PENS3A if src != PENS3A else SMUDGE + opts = f"layer:{layer_src}:{draw(layer_src, 1.6):.2f}" + PLAN.append((b, src, draw(src, 1.6), 1.1, 0.15, opts)) + +PLAN.append((None, GRASS, 3.55, 0.25, 0.0, "hold:0.40")) + +SEGS = f"{WORK}/segs" +os.makedirs(SEGS, exist_ok=True) +print(f"plan: {len(PLAN)} segments") + + +def bounds(): + ts = [0.0] + for row in PLAN: + ts.append(END if row[0] is None else B0 + row[0] * BAR) + return ts + + +def chain(speed, dur, cropx, zoom_in, hold=False): + steps = [ + f"fps=30,scale={W}:{H}:force_original_aspect_ratio=increase", + f"crop={W}:{H}" + (f":{cropx}:0" if cropx else ""), + f"setpts=PTS/{speed}", + ] + if speed < 0.9: # optical flow only where slow motion needs it + steps.append("minterpolate=fps=30:mi_mode=mci:mc_mode=aobmc:vsbmc=1") + else: + steps.append("fps=30") + if hold: # freeze the last real frame under the zoom drift + steps.append(f"tpad=stop_mode=clone:stop_duration={dur:.2f}") + frames = int(dur * 30) + 1 + if zoom_in: + z = f"min(1+0.06*on/{frames},1.06)" + else: + z = f"max(1.06-0.06*on/{frames},1.0)" + steps.append( + f"zoompan=z='{z}':x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2'" + f":d=1:s={W}x{H}:fps=30" + ) + return ",".join(steps) + + +ts = bounds() +segfiles = [] +for i, (end_bar, src, src_t, speed, fade, opts) in enumerate(PLAN): + t0, t1 = ts[i], ts[i + 1] + fade_in = PLAN[i - 1][4] if i else 0.0 + dur = (t1 - t0) + fade_in / 2 + fade / 2 + key = hashlib.sha1( + repr((PLAN[i], PLAN[i - 1][4] if i else 0.0, t1 - t0)) + .encode()).hexdigest()[:12] + seg = f"{SEGS}/{key}.mp4" + segfiles.append((seg, dur, fade)) + if os.path.exists(seg): + continue + cropx = None + layer = None + blur = False + hold = False + if opts: + for o in opts.split(";"): + if o == "blur": + blur = True + elif o.startswith("hold"): + # hold: — how much source to read before the + # clone-freeze; must stop short of the take's own cut + hold = float(o.split(":")[1]) if ":" in o else 0.58 + elif o.startswith("cropx:"): + cropx = int(o.split(":")[1]) + elif o.startswith("layer:"): + _, lsrc, lt = o.split(":") + layer = (lsrc, float(lt)) + src_need = hold if hold else dur * speed + 0.5 + assert src_t + src_need <= DUR[src] + 0.1, ( + f"seg {i}: {src} needs {src_t:.2f}+{src_need:.2f}s " + f"but take is {DUR[src]:.2f}s" + ) + cmd = ["ffmpeg", "-y", "-v", "error", + "-ss", str(src_t), "-t", str(src_need), + "-i", f"{LONER}/source/{src}.mp4"] + main = chain(speed, dur, cropx, zoom_in=(i % 2 == 0), hold=hold) + if layer: + cmd += ["-ss", str(layer[1]), "-t", str(src_need), + "-i", f"{LONER}/source/{layer[0]}.mp4"] + over = chain(speed, dur, None, zoom_in=(i % 2 == 1)) + graph = ( + f"[0:v]{main}[a];[1:v]{over}[b];" + f"[a][b]blend=all_mode=average,eq=contrast=1.12[v]" + ) + else: + graph = f"[0:v]{main}[v]" + if blur: + graph = graph.replace("[v]", ",gblur=sigma=7[v]") + cmd += ["-filter_complex", graph, "-map", "[v]", + "-t", f"{dur:.3f}", "-r", "30", + "-c:v", "libx264", "-preset", "fast", "-crf", "16", + "-pix_fmt", "yuv420p", seg] + subprocess.run(cmd, check=True) + print(f"seg {i:02d} {t0:6.2f}-{t1:6.2f} {src[:6]}… " + f"{'layered ' if layer else ''}{'blur' if blur else ''}") + +# chain the crossfades: each fade straddles its boundary +inputs = [] +for seg, _, _ in segfiles: + inputs += ["-i", seg] +graph = [] +label = "[0:v]" +clock = segfiles[0][1] # running end-time of the assembled chain +for i in range(1, len(segfiles)): + fade = segfiles[i - 1][2] + off = clock - fade + out = f"[x{i}]" if i < len(segfiles) - 1 else "[v]" + graph.append( + f"{label}[{i}:v]xfade=transition=fade:" + f"duration={fade:.3f}:offset={off:.3f}{out}" + ) + label = out + clock = off + segfiles[i][1] +print(f"assembled length {clock:.2f}s (target {END})") +subprocess.run( + ["ffmpeg", "-y", "-v", "error", "-stats"] + inputs + + ["-filter_complex", ";".join(graph), "-map", "[v]", + "-r", "30", "-t", str(END), + "-c:v", "libx264", "-preset", "fast", "-crf", "16", + "-pix_fmt", "yuv420p", f"{WORK}/collage.mp4"], + check=True, +) +print(f"pastiche collage -> {WORK}/collage.mp4") diff --git a/pop/loner/bin/pastiche/chrome-reel.mjs b/pop/loner/bin/pastiche/chrome-reel.mjs new file mode 100644 index 0000000000..2b1c987a42 --- /dev/null +++ b/pop/loner/bin/pastiche/chrome-reel.mjs @@ -0,0 +1,378 @@ +#!/usr/bin/env node +// loner/bin/pastiche/chrome-reel.mjs — the pastiche reel's chrome: +// Camille's lyric arrives word by word as she sings it (timings from +// viz/wordclock.json — the same stroke-per-word map the score used), +// and a RADIAL TRACK OVERLAY turns beneath a fixed twelve-o'clock +// needle (the circular-score mechanic from nullabye, miniaturized): +// the record's waveform wrapped into a ring, played arc warming to +// red, with the title + artist beneath. YWFT throughout, shared +// helpers from pop/lib/preview-shared.mjs. +// +// Runs as a post pass over run.sh's graded collage: +// node pop/loner/bin/pastiche/chrome-reel.mjs +// Reads $PASTICHE_WORK/graded.mp4 + $PASTICHE_WORK/audio-reel.wav +// Writes pop/loner/out/lonerclub-v4pid-reel.mp4 + +import { spawn } from "node:child_process"; +import { readFileSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { once } from "node:events"; +import { homedir } from "node:os"; +import { createCanvas } from "canvas"; +import { + magickRenderText, decodeAudioMono, computeRmsEnvelope, spawnFFmpegEncode, +} from "../../../lib/preview-shared.mjs"; +import * as progress from "../../../lib/render-progress.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LONER = resolve(HERE, "..", ".."); +const WORK = process.env.PASTICHE_WORK || `${homedir()}/.cache/ac/pastiche`; +const BASE = `${WORK}/graded.mp4`; +const AUDIO = `${WORK}/audio-reel.wav`; +const OUT = `${LONER}/out/lonerclub-v4pid-reel.mp4`; +const assetsDir = `${WORK}/chrome-assets`; +mkdirSync(assetsDir, { recursive: true }); + +const W = 1080, H = 1920, FPS = 30; +const { audio, sr } = decodeAudioMono(AUDIO); +const TOTAL = audio.length / sr; +const FRAMES = Math.round(TOTAL * FPS); +const env = computeRmsEnvelope(audio, sr, FPS, TOTAL); +const envAt = (t) => + env[Math.max(0, Math.min(env.length - 1, Math.floor(t * FPS)))] ?? 0; + +const INK = "rgba(32,28,34,"; +const CREAM = "rgba(247,242,235,"; +const ROSE = "rgba(179,64,72,"; + +// ── lyrics: the wordclock, syllables gathered back into their word ───── +const clock = JSON.parse(readFileSync(`${LONER}/viz/wordclock.json`, "utf8")) + .filter((e) => e.t0 < TOTAL - 0.5); +const GATHER = { pa: "pa", tient: "patient", ly: "patiently" }; +const words = clock.map((e, i) => ({ + text: GATHER[e.word] ?? e.word, + t0: e.t0, + t1: i + 1 < clock.length && clock[i + 1].t0 - e.t0 < 3.0 + ? clock[i + 1].t0 + : e.t1 + 0.35, +})); +const wordImgs = {}; +for (const w of words) { + if (wordImgs[w.text]) continue; + wordImgs[w.text] = await magickRenderText(w.text, { + ptSize: 108, fill: "#f7f2eb", + stroke: "#201c22", strokeWidth: 3, + shadow: "#201c22", shadowSpec: "100x0+6+7", + outPath: `${assetsDir}/word-${w.text}.png`, + }); +} + +// ── the virtual whistlegraph: named vector strokes, drawn by a pen ─ +// viz/wg-strokes.json (trace-strokes.py) holds the drawing as named +// curves — skeleton geometry from the finished ink, each branch +// matched to its wordclock syllable and oriented the way the pen +// moved. The score draws itself per pass, stroke by stroke under the +// words, and a virtual nib rides the ink tip. +import { readFileSync as rf } from "node:fs"; +const wg = JSON.parse(rf(`${LONER}/viz/wg-strokes.json`, "utf8")); +const WG_W = 470, WG_H = Math.round(WG_W * wg.h / wg.w); +const WG_X = (W - WG_W) / 2, WG_Y = H * 0.185; +const WG_S = WG_W / wg.w; +const STROKES = wg.strokes.map((s) => { + const subs = s.sub.map((pts) => + pts.map(([x, y]) => [WG_X + x * WG_S, WG_Y + y * WG_S])); + const lens = subs.map((P) => { + let L = 0; + for (let i = 1; i < P.length; i++) { + L += Math.hypot(P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]); + } + return Math.max(L, 3); + }); + return { v0: s.v0, v1: s.v1, subs, lens, + total: lens.reduce((a, b) => a + b, 0) }; +}); + +// reel time -> drawing time, from the wordclock's passes +const passes = [[clock[0]]]; +for (let i = 1; i < clock.length; i++) { + if (clock[i].t0 - clock[i - 1].t1 > 2.0) passes.push([]); + passes.at(-1).push(clock[i]); +} +const vAnchors = []; +for (const p of passes) { + vAnchors.push([p[0].t0 - 0.35, 0]); // blank on the pickup + for (const e of p) vAnchors.push([e.t0, e.v0]); + vAnchors.push([p.at(-1).t1, p.at(-1).v1]); +} +vAnchors.push([TOTAL - 0.9, vAnchors.at(-1)[1]]); // hold, then un-draw +vAnchors.push([TOTAL - 0.05, 0]); +function drawTimeAt(now) { + if (now <= vAnchors[0][0]) return 0; + for (let i = 1; i < vAnchors.length; i++) { + const [t1, v1] = vAnchors[i]; + if (now <= t1) { + const [t0, v0] = vAnchors[i - 1]; + return v0 + (v1 - v0) * (now - t0) / Math.max(1e-6, t1 - t0); + } + } + return 0; +} + +let vPrev = 0, penX = W / 2, penY = WG_Y + WG_H / 2, penA = 0; + +function pathPartial(ctx, P, budget) { + // stroke the first `budget` px of polyline P; return the tip + ctx.beginPath(); + ctx.moveTo(P[0][0], P[0][1]); + let used = 0; + for (let i = 1; i < P.length; i++) { + const seg = Math.hypot(P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]); + if (used + seg >= budget) { + const f = (budget - used) / Math.max(1e-6, seg); + const tx = P[i - 1][0] + (P[i][0] - P[i - 1][0]) * f; + const ty = P[i - 1][1] + (P[i][1] - P[i - 1][1]) * f; + ctx.lineTo(tx, ty); + ctx.stroke(); + return [tx, ty]; + } + ctx.lineTo(P[i][0], P[i][1]); + used += seg; + } + ctx.stroke(); + return P.at(-1); +} + +function drawPen(ctx, now, moving, tip) { + penA += ((moving && tip ? 1 : 0) - penA) * 0.18; + if (tip) { + penX += (tip[0] - penX) * 0.42; + penY += (tip[1] - penY) * 0.42; + } + if (penA < 0.03) return; + const bob = 1.6 * Math.sin(now * 9.1); + ctx.save(); + ctx.globalAlpha = penA; + ctx.translate(penX, penY + bob); + ctx.rotate(-0.62 + 0.04 * Math.sin(now * 5.3)); + ctx.shadowColor = "rgba(32,28,40,0.35)"; + ctx.shadowBlur = 6; + ctx.fillStyle = "#c9cdd4"; // steel nib + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(13, -34); + ctx.lineTo(27, -30); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = "rgba(60,58,66,0.85)"; // nib slit + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(17, -30); + ctx.stroke(); + ctx.fillStyle = "#a9705a"; // holder + ctx.beginPath(); + ctx.roundRect(11, -96, 18, 66, 8); + ctx.fill(); + ctx.fillStyle = "rgba(255,255,255,0.35)"; // glint + ctx.beginPath(); + ctx.roundRect(14, -92, 4, 52, 2); + ctx.fill(); + ctx.restore(); +} + +function drawScore(ctx, now) { + const v = drawTimeAt(now); + const moving = Math.abs(v - vPrev) > 1e-4; + vPrev = v; + ctx.save(); + ctx.lineWidth = 4.6; + ctx.lineCap = ctx.lineJoin = "round"; + ctx.strokeStyle = "rgba(34,28,40,0.92)"; + ctx.fillStyle = "rgba(34,28,40,0.92)"; + ctx.shadowColor = "rgba(247,242,235,0.9)"; // cream halo + ctx.shadowBlur = 8; + let tip = null; + let lastEnd = null, nextStart = null, prevS = null; + for (const s of STROKES) { + const p = Math.max(0, Math.min(1, (v - s.v0) / (s.v1 - s.v0))); + if (p <= 0) { if (!nextStart) nextStart = { s }; continue; } + let budget = p * s.total; + for (let i = 0; i < s.subs.length && budget > 0; i++) { + const P = s.subs[i], L = s.lens[i]; + if (P.length < 2) { + ctx.beginPath(); + ctx.arc(P[0][0], P[0][1], 2.6, 0, Math.PI * 2); + ctx.fill(); + budget -= L; + continue; + } + if (budget >= L) { + pathPartial(ctx, P, Infinity); + budget -= L; + if (p < 1) tip = P.at(-1); + } else { + tip = pathPartial(ctx, P, budget); + budget = 0; + } + } + if (p >= 1) prevS = s; + } + ctx.restore(); + // pen travel between strokes: glide from the last mark's end to the + // coming mark's start while nothing is actively inking + if (!tip && prevS && v < STROKES.at(-1).v1) { + const nxt = STROKES.find((s) => v < s.v0); + if (nxt) { + const a = prevS.subs.at(-1).at(-1); + const b = nxt.subs[0][0]; + const g = Math.max(0, Math.min(1, + (v - prevS.v1) / Math.max(1e-6, nxt.v0 - prevS.v1))); + tip = [a[0] + (b[0] - a[0]) * g, a[1] + (b[1] - a[1]) * g]; + } + } + drawPen(ctx, now, moving, tip); +} + +// ── track info + timecode glyphs ─────────────────────────────────────── +const titleImg = await magickRenderText("lonerclub v4pid", { + ptSize: 56, fill: "#f7f2eb", stroke: "#201c22", strokeWidth: 2, + shadow: "#201c22", shadowSpec: "100x0+4+5", + outPath: `${assetsDir}/title.png`, +}); +const artistImg = await magickRenderText( + "whistlegraph dot org feat. aesthetic dot computer", { + ptSize: 26, fill: "#f7f2eb", stroke: "#201c22", strokeWidth: 1, + shadow: "#201c22", shadowSpec: "100x0+3+4", + outPath: `${assetsDir}/artist.png`, + }); +const fmt = (t) => + `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; +const tcImgs = {}; +for (let s = 0; s <= Math.ceil(TOTAL); s++) { + const label = fmt(s); + if (tcImgs[label]) continue; + tcImgs[label] = await magickRenderText(label, { + ptSize: 34, fill: "#f7f2eb", stroke: "#201c22", strokeWidth: 1, + shadow: "#201c22", shadowSpec: "100x0+3+3", + outPath: `${assetsDir}/tc-${label.replace(":", "-")}.png`, + }); +} + +// ── radial ring: the waveform wrapped into SPOKES time-bins ──────────── +const SPOKES = 180; +const spokeEnv = []; +for (let k = 0; k < SPOKES; k++) { + const a = Math.floor((k / SPOKES) * env.length); + const b = Math.max(a + 1, Math.floor(((k + 1) / SPOKES) * env.length)); + let m = 0; + for (let i = a; i < b; i++) m = Math.max(m, env[i]); + spokeEnv.push(m); +} +const CX = W / 2, CY = H * 0.795, R = 118; +const TAU = Math.PI * 2; + +function drawRing(ctx, now) { + const pulse = 1 + 0.05 * envAt(now); + ctx.save(); + ctx.translate(CX, CY); + ctx.fillStyle = `${INK}0.16)`; + ctx.beginPath(); + ctx.arc(0, 0, (R + 34) * pulse, 0, TAU); + ctx.fill(); + for (let k = 0; k < SPOKES; k++) { + const tk = (k / SPOKES) * TOTAL; + // the ring turns; the spoke holding "now" always meets the needle + const ang = -Math.PI / 2 + ((tk - now) / TOTAL) * TAU; + const played = tk <= now; + const len = (5 + spokeEnv[k] * 30) * pulse; + const r0 = R - len / 2, r1 = R + len / 2; + ctx.strokeStyle = played ? `${ROSE}0.92)` : `${CREAM}0.55)`; + ctx.lineWidth = played ? 3.4 : 2.4; + ctx.beginPath(); + ctx.moveTo(Math.cos(ang) * r0, Math.sin(ang) * r0); + ctx.lineTo(Math.cos(ang) * r1, Math.sin(ang) * r1); + ctx.stroke(); + } + // fixed needle at twelve o'clock + ctx.fillStyle = `${CREAM}0.95)`; + ctx.beginPath(); + ctx.moveTo(0, -R - 26); + ctx.lineTo(-7, -R - 40); + ctx.lineTo(7, -R - 40); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + // elapsed time in the ring's center + const tc = tcImgs[fmt(now)]; + if (tc) ctx.drawImage(tc, CX - tc.width / 2, CY - tc.height / 2); + // title + artist beneath the ring + const tw = titleImg.width, th = titleImg.height; + const bounce = 2.5 * Math.sin(now * 2.2) * (0.4 + envAt(now)); + ctx.drawImage(titleImg, CX - tw / 2, CY + R + 52 + bounce, tw, th); + const aw = artistImg.width; + ctx.drawImage(artistImg, CX - aw / 2, CY + R + 52 + th + 10 + bounce); +} + +function drawLyric(ctx, now) { + for (const w of words) { + if (now < w.t0 || now > w.t1 + 0.18) continue; + const img = wordImgs[w.text]; + const inK = Math.min(1, (now - w.t0) / 0.12); // pop in + const outK = Math.min(1, Math.max(0, (w.t1 + 0.18 - now) / 0.18)); + const scale = (0.86 + 0.14 * inK) * Math.min(1, W * 0.82 / img.width); + const alpha = inK * outK; + const lift = 3.5 * Math.sin(now * 3.0) * (0.3 + envAt(now)); + const dw = img.width * scale, dh = img.height * scale; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.translate(CX, H * 0.145 + lift); + ctx.rotate(0.015 * Math.sin(w.t0 * 7)); // deterministic tilt per word + ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); + ctx.restore(); + } +} + +// ── frame pump: base → canvas → chrome → encoder ─────────────────────── +console.log(`▸ loner reel chrome · ${FRAMES} frames · lyrics + radial track`); +progress.begin({ type: "video", label: `loner reel chrome · ${FRAMES} frames` }); +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); +const dec = spawn("ffmpeg", ["-loglevel", "error", "-i", BASE, + "-f", "rawvideo", "-pix_fmt", "rgba", "-"], + { stdio: ["ignore", "pipe", "inherit"] }); +const enc = spawnFFmpegEncode({ audioPath: AUDIO, w: W, h: H, fps: FPS, + outPath: OUT, crf: 18 }); +const FRAME_BYTES = W * H * 4; +const fbuf = Buffer.alloc(FRAME_BYTES); +const img = ctx.createImageData(W, H); +let off = 0, fi = 0; +for await (const chunk of dec.stdout) { + let cOff = 0; + while (cOff < chunk.length) { + const n = Math.min(FRAME_BYTES - off, chunk.length - cOff); + chunk.copy(fbuf, off, cOff, cOff + n); + off += n; cOff += n; + if (off === FRAME_BYTES) { + off = 0; + if (fi >= FRAMES) break; + img.data.set(fbuf); + ctx.putImageData(img, 0, 0); + const now = fi / FPS; + drawScore(ctx, now); + drawRing(ctx, now); + drawLyric(ctx, now); + if (!enc.stdin.write(canvas.toBuffer("raw"))) { + await once(enc.stdin, "drain"); + } + fi++; + progress.update((fi / FRAMES) * 100, { done: fi, total: FRAMES }); + } + } +} +enc.stdin.end(); +await new Promise((res, rej) => { + enc.on("close", (c) => (c === 0 ? res() : rej(new Error(`ffmpeg ${c}`)))); +}); +progress.end(); +console.log(`✓ ${OUT} (${fi} frames chromed)`); diff --git a/pop/loner/bin/pastiche/run.sh b/pop/loner/bin/pastiche/run.sh new file mode 100755 index 0000000000..88590158aa --- /dev/null +++ b/pop/loner/bin/pastiche/run.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Pastiche reel: build.py cuts + crossfades the two-pass collage, +# this grades it ethereal (ghost trail, soft bloom, lifted blacks) and +# upscales to 1080x1920, then chrome-reel.mjs lays the lyric words and +# the radial track overlay and muxes the two-pass audio. +set -euo pipefail +cd "$(dirname "$0")" +LONER="$(cd ../.. && pwd)" +WORK="${PASTICHE_WORK:-$HOME/.cache/ac/pastiche}" +END=63.788 # two vocal passes, cut on the second door + +python3 build.py + +ffmpeg -y -v error -stats \ + -i "$WORK/collage.mp4" \ + -filter_complex "\ +[0:v]tmix=frames=3:weights='6 3 2',split[base][glow];\ +[glow]gblur=sigma=24[soft];\ +[base][soft]blend=all_mode=screen:all_opacity=0.20,\ +curves=all='0/0.04 0.5/0.52 1/0.97',\ +eq=contrast=1.03:saturation=1.06,\ +scale=1080:1920:flags=lanczos,unsharp=5:5:0.3:5:5:0.0,format=yuv420p[v]" \ + -map "[v]" -an -r 30 -c:v libx264 -preset fast -crf 17 \ + "$WORK/graded.mp4" + +ffmpeg -y -v error -i "$LONER/out/lonerclub-v4pid.wav" \ + -t $END -af "afade=t=out:st=63.0:d=0.788" "$WORK/audio-reel.wav" + +python3 trace-strokes.py +node chrome-reel.mjs + +echo "reel -> $LONER/out/lonerclub-v4pid-reel.mp4" diff --git a/pop/loner/bin/pastiche/trace-strokes.py b/pop/loner/bin/pastiche/trace-strokes.py new file mode 100644 index 0000000000..d96f0b01a5 --- /dev/null +++ b/pop/loner/bin/pastiche/trace-strokes.py @@ -0,0 +1,149 @@ +"""Trace the loner drawing into named vector strokes. + +Geometry comes from SPACE, timing from TIME: Zhang-Suen thinning of +the finished ink mask (viz/wg-appear.npz final_mask) yields the +drawing's skeleton; the skeleton graph splits into branch segments at +junctions and endpoints; each segment inherits the appear-times of its +ink (12 fps stamps of when each pixel was drawn in the source take), +which names it — median time picks its wordclock mark/syllable — and +orients it (time increases the way the pen moved). Spurs shorter than +7px are pruned; segments smooth and resample to ~5px spacing. + +Writes viz/wg-strokes.json: + {w, h, strokes:[{name, word, v0, v1, sub:[[[x,y],...],...]}]} +sub-paths sit in drawing order within each mark. +""" +import json +import os + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +LONER = os.path.dirname(os.path.dirname(HERE)) + +z = np.load(f"{LONER}/viz/wg-appear.npz") +appear = z["appear"] +mask = z["final_mask"].astype(bool) +H, W = mask.shape +clock = json.load(open(f"{LONER}/viz/wordclock.json"))[:20] # pass one + + +def thin(m): + """Zhang-Suen thinning, vectorized over shifted views.""" + m = m.copy() + while True: + changed = False + for phase in (0, 1): + p = np.pad(m, 1) + P2 = p[:-2, 1:-1]; P3 = p[:-2, 2:]; P4 = p[1:-1, 2:] + P5 = p[2:, 2:]; P6 = p[2:, 1:-1]; P7 = p[2:, :-2] + P8 = p[1:-1, :-2]; P9 = p[:-2, :-2] + ring = [P2, P3, P4, P5, P6, P7, P8, P9] + B = sum(x.astype(int) for x in ring) + A = sum(((~ring[i]) & ring[(i + 1) % 8]).astype(int) + for i in range(8)) + if phase == 0: + cond = (~(P2 & P4 & P6)) & (~(P4 & P6 & P8)) + else: + cond = (~(P2 & P4 & P8)) & (~(P2 & P6 & P8)) + kill = m & (B >= 2) & (B <= 6) & (A == 1) & cond + if kill.any(): + m &= ~kill + changed = True + if not changed: + return m + + +skel = thin(mask) +sy, sx = np.where(skel) +print(f"skeleton: {sy.size} px from {mask.sum()} ink px") + +# 8-connected graph over skeleton pixels +index = {(int(x), int(y)): i for i, (x, y) in enumerate(zip(sx, sy))} +NBR = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] +deg = np.zeros(sy.size, int) +for (x, y), i in index.items(): + deg[i] = sum((x + dx, y + dy) in index for dx, dy in NBR) + +# walk branch segments between nodes (endpoints / junctions) +visited = set() +segments = [] +nodes = [p for p, i in index.items() if deg[i] != 2] +for start in nodes + list(index): # loops (all-deg-2) picked up second + si = index[start] + if deg[si] == 2 and any(start in s for s in ()): # placeholder no-op + pass + for dx, dy in NBR: + nxt = (start[0] + dx, start[1] + dy) + if nxt not in index: + continue + edge = frozenset((start, nxt)) + if edge in visited: + continue + path = [start, nxt] + visited.add(edge) + while deg[index[path[-1]]] == 2: + cx, cy = path[-1] + step = None + for ex, ey in NBR: + cand = (cx + ex, cy + ey) + if cand in index and cand != path[-2]: + e2 = frozenset((path[-1], cand)) + if e2 not in visited: + step = cand + visited.add(e2) + break + if step is None: + break + path.append(step) + if len(path) >= 2: + segments.append(path) + +segments = [s for s in segments if len(s) >= 4] # prune spurs +print(f"{len(segments)} branch segments") + +# name + orient each segment by the appear-times along it +bounds = np.array([e["v0"] for e in clock] + [clock[-1]["v1"]]) +named = {k: [] for k in range(len(clock))} +for path in segments: + t = np.array([appear[y, x] / 12.0 for x, y in path]) + t = np.clip(t, 0, 26) + med = float(np.median(t)) + if med <= 2.26: # first-burst ink (pre-zoom-out) = the head arc + k = 0 + else: + k = int(np.searchsorted(bounds, med, "right")) - 1 + k = max(0, min(len(clock) - 1, k)) + u = np.arange(len(t)) + if np.corrcoef(u, t)[0, 1] < 0: # pen moved the other way + path = path[::-1] + t = t[::-1] + named[k].append((float(np.median(t[: max(3, len(t) // 4)])), path)) + +strokes = [] +for k, e in enumerate(clock): + subs = [] + for _, path in sorted(named[k], key=lambda r: r[0]): + P = np.array(path, float) + if len(P) >= 3: + P[1:-1] = (P[:-2] + P[1:-1] + P[2:]) / 3 + d = np.concatenate([[0], + np.cumsum(np.hypot(*np.diff(P, axis=0).T))]) + if d[-1] > 5: + u = np.arange(0, d[-1] + 2.5, 5.0) + P = np.stack([np.interp(u, d, P[:, 0]), + np.interp(u, d, P[:, 1])], 1) + subs.append([[round(float(x), 1), round(float(y), 1)] + for x, y in P]) + if not subs: + continue + strokes.append({ + "name": e["mark"], "word": e["word"], + "v0": e["v0"], "v1": e["v1"], "sub": subs, + }) + print(f'{e["mark"]:16s} {e["word"]:10s} v {e["v0"]:5.2f}-{e["v1"]:5.2f}' + f' {len(subs)} sub, {sum(len(s) for s in subs):3d} pts') + +json.dump({"w": W, "h": H, "strokes": strokes}, + open(f"{LONER}/viz/wg-strokes.json", "w")) +print(f"-> viz/wg-strokes.json ({len(strokes)} strokes)") diff --git a/pop/loner/viz/wg-strokes.json b/pop/loner/viz/wg-strokes.json new file mode 100644 index 0000000000..1e061ad96a --- /dev/null +++ b/pop/loner/viz/wg-strokes.json @@ -0,0 +1 @@ +{"w": 452, "h": 698, "strokes": [{"name": "the head arc", "word": "sitting", "v0": 0.33, "v1": 1.74, "sub": [[[100.0, 48.0], [99.0, 48.7], [98.0, 49.0], [97.0, 49.3], [96.0, 50.0]], [[158.0, 53.0], [157.0, 52.3], [156.0, 52.0], [155.0, 52.0], [154.0, 52.0]], [[160.0, 53.0], [161.0, 53.0], [162.0, 53.0], [163.0, 53.3], [164.0, 54.0]], [[159.0, 54.0], [162.6, 57.0]], [[87.0, 56.0], [83.1, 58.9], [79.5, 62.5]], [[168.0, 57.0], [168.7, 58.0], [169.3, 59.0], [169.7, 60.0], [170.0, 61.0]], [[72.0, 73.0], [72.0, 74.0], [71.7, 75.0], [71.0, 76.0], [70.0, 77.0]], [[179.0, 73.0], [181.0, 77.5], [182.4, 81.9], [184.0, 84.0]], [[68.0, 79.0], [66.4, 83.7], [65.0, 88.4], [64.0, 93.3], [63.3, 98.1], [63.0, 103.1], [62.0, 107.9], [62.0, 112.9], [62.0, 117.9], [63.0, 122.8]], [[185.0, 85.0], [186.6, 89.7], [188.0, 94.4]], [[189.0, 98.0], [190.3, 102.8], [191.8, 107.5], [193.4, 112.2], [194.0, 117.1], [195.0, 121.9], [195.6, 126.8], [196.9, 131.6], [197.0, 136.6], [198.0, 141.4]], [[64.0, 126.0], [65.0, 130.8], [66.5, 135.6], [68.0, 139.0]], [[252.0, 128.0], [247.2, 127.0], [242.2, 127.0], [237.2, 127.3]], [[257.0, 130.0], [256.0, 129.3], [255.0, 129.0], [254.0, 129.0]], [[231.0, 130.0], [226.6, 132.3], [222.3, 134.8]], [[259.0, 131.0], [263.2, 133.5], [266.9, 136.9]], [[70.0, 143.0], [71.8, 147.7], [73.8, 152.2], [76.4, 156.4]], [[276.0, 145.0], [279.5, 148.5]], [[207.0, 148.0], [202.4, 149.9]], [[201.0, 151.0], [201.0, 152.0], [201.0, 153.0], [201.0, 154.0], [201.0, 155.0], [201.0, 156.0]], [[294.0, 173.0], [292.0, 168.5], [290.2, 163.8], [287.4, 159.7], [286.0, 157.0]], [[100.0, 177.0], [96.0, 174.0], [92.0, 171.0], [90.0, 169.0]], [[102.0, 178.0], [103.0, 178.7], [104.0, 179.3], [105.0, 179.7], [106.0, 180.0]], [[296.0, 178.0], [296.9, 182.8]], [[108.0, 181.0], [111.0, 185.0]], [[133.0, 187.0], [128.7, 185.0], [123.7, 185.0], [118.7, 185.0], [113.7, 185.0]], [[298.0, 185.0], [298.3, 186.0], [298.7, 187.0], [299.0, 188.0], [299.0, 189.0]], [[300.0, 191.0], [301.4, 195.6], [302.8, 200.4], [304.0, 205.0]], [[307.0, 221.0], [306.7, 216.1], [305.1, 211.3], [305.0, 207.0]], [[308.0, 223.0], [308.9, 227.8], [309.9, 232.7], [310.8, 237.5], [312.0, 242.4], [312.4, 247.3], [313.7, 252.1], [314.0, 257.0], [315.0, 261.9], [316.0, 266.7], [316.9, 271.6], [317.8, 276.4], [318.0, 281.4], [318.0, 285.0]], [[113.0, 46.0], [117.6, 44.1], [122.6, 44.0], [127.4, 43.0], [132.4, 43.1], [137.3, 44.0], [141.8, 45.9], [145.8, 48.8]]]}, {"name": "back curve", "word": "curled", "v0": 1.74, "v1": 3.47, "sub": [[[320.0, 517.0], [321.0, 521.8], [321.0, 526.8], [321.0, 531.8], [321.0, 536.8], [321.0, 541.8], [321.0, 545.0]], [[319.0, 547.0], [317.0, 551.5], [315.9, 556.3], [314.0, 560.9]], [[313.0, 564.0], [310.6, 568.3], [308.0, 572.6], [306.0, 575.0]], [[246.0, 599.0], [250.3, 601.4], [255.1, 603.0], [259.9, 604.0], [264.9, 604.0], [269.9, 604.0], [274.8, 603.4], [278.0, 603.0]], [[231.0, 577.0], [228.3, 572.8], [226.8, 568.1], [226.0, 563.3]], [[237.0, 588.0], [237.3, 589.0], [237.7, 590.0], [238.0, 591.0]], [[240.0, 594.0], [241.0, 595.0], [242.0, 596.0], [243.0, 597.0]], [[286.0, 595.0], [281.4, 596.9]], [[280.0, 598.0], [280.0, 599.0], [280.0, 600.0], [280.0, 601.0]], [[208.0, 347.0], [211.4, 350.6], [213.0, 355.2], [213.7, 360.1], [214.0, 365.1], [215.0, 369.9], [215.0, 374.9], [216.0, 379.8], [216.9, 384.6], [217.0, 389.6], [217.0, 394.6], [217.0, 399.6], [217.0, 404.6], [218.0, 409.4], [219.0, 414.3], [220.0, 419.1], [220.0, 424.1], [220.0, 429.1], [220.4, 434.1], [221.0, 438.9], [221.0, 443.9], [222.0, 448.8], [222.0, 453.8], [222.0, 458.8], [223.0, 463.6], [223.0, 468.6], [224.0, 473.5], [224.0, 478.5], [224.0, 483.5], [224.0, 488.5], [224.1, 493.4], [225.0, 498.3], [225.0, 503.3], [225.0, 508.3], [225.0, 513.3], [225.0, 518.3], [225.0, 523.3], [225.0, 528.3], [226.0, 533.1], [226.0, 538.1], [227.0, 543.0], [227.0, 548.0], [227.0, 553.0], [227.0, 558.0], [227.0, 561.0]], [[295.0, 587.0], [294.0, 588.0], [293.0, 589.0], [292.0, 590.0]]]}, {"name": "spine", "word": "up", "v0": 3.47, "v1": 3.72, "sub": [[[203.0, 307.0], [203.9, 302.2], [205.0, 298.0]], [[203.0, 309.0], [205.0, 313.5], [205.0, 318.5], [205.0, 323.5], [206.0, 328.3], [206.0, 333.3]]]}, {"name": "seat", "word": "in", "v0": 3.72, "v1": 4.22, "sub": [[[208.0, 551.0], [207.0, 555.7], [206.1, 560.6], [205.0, 565.0]]]}, {"name": "base line", "word": "myself", "v0": 4.22, "v1": 5.76, "sub": [[[204.0, 567.0], [202.7, 571.8], [199.2, 575.2], [197.0, 577.0]], [[190.0, 582.0], [194.0, 579.0]], [[101.0, 590.0], [105.6, 591.9], [110.2, 593.7], [115.2, 594.1], [120.0, 595.0], [125.0, 595.0], [130.0, 595.0], [135.0, 595.0], [140.0, 595.0], [145.0, 595.0], [150.0, 595.0], [155.0, 595.0], [160.0, 595.0], [164.8, 593.7]], [[91.0, 584.0], [88.3, 579.9], [87.1, 575.2]], [[86.0, 573.0], [84.0, 568.7], [84.0, 563.7], [84.7, 558.8], [85.7, 554.0], [88.0, 551.0]], [[98.0, 546.0], [93.3, 547.6]], [[100.0, 545.0], [104.9, 544.4], [109.0, 543.0]], [[111.0, 542.0], [116.0, 542.0], [120.8, 541.0], [125.7, 540.4], [130.7, 540.0], [135.6, 539.8], [140.5, 539.0]], [[143.0, 538.0], [148.0, 538.0], [153.0, 538.0], [158.0, 538.0], [162.0, 538.0]], [[189.0, 583.0], [184.7, 585.4], [180.2, 587.6], [175.8, 589.7], [172.0, 591.0]]]}, {"name": "leg", "word": "i", "v0": 5.76, "v1": 5.96, "sub": [[[163.0, 537.0], [163.0, 532.3], [163.0, 527.3], [162.5, 522.4], [161.5, 517.6], [161.0, 512.6], [160.0, 507.8], [159.3, 502.9], [159.0, 498.0], [158.4, 493.1], [158.0, 488.1], [157.0, 483.3], [157.0, 478.3], [157.0, 473.3], [157.0, 468.3], [157.0, 463.3], [156.0, 458.4], [155.5, 453.5], [155.0, 448.6], [155.0, 443.6], [155.0, 438.6], [155.0, 433.6], [155.0, 428.6], [155.0, 423.6], [155.0, 418.6], [155.0, 413.6], [155.0, 408.6], [155.0, 403.6], [155.0, 398.6], [154.0, 393.8], [154.4, 388.8], [155.0, 383.9], [155.0, 378.9], [155.0, 373.9], [155.0, 368.9], [156.0, 364.1], [156.0, 359.1], [156.0, 354.1], [156.0, 349.1], [157.0, 344.3], [156.0, 339.4]]]}, {"name": "legs", "word": "think", "v0": 5.96, "v1": 7.46, "sub": [[[181.0, 261.0], [185.3, 258.6], [189.7, 256.4]], [[200.0, 253.0], [204.8, 253.9], [208.0, 256.0]], [[198.0, 254.0], [193.2, 255.0]], [[279.0, 492.0], [279.7, 493.0], [280.0, 494.0], [280.0, 495.0], [280.0, 496.0]], [[207.0, 336.0], [207.3, 340.9], [208.0, 345.8]], [[323.0, 514.0], [324.0, 509.3], [325.0, 504.4], [325.0, 499.4], [325.0, 494.4], [326.0, 489.6], [326.0, 484.6], [326.0, 479.6], [327.0, 477.0]]]}, {"name": "knee", "word": "of", "v0": 7.46, "v1": 8.06, "sub": [[[278.0, 491.0], [277.7, 486.1], [276.4, 481.3], [275.0, 476.5], [275.0, 471.5], [273.3, 466.9], [273.0, 461.9], [272.7, 457.0], [271.1, 452.2], [271.0, 447.2], [271.0, 442.2], [270.8, 437.3], [270.0, 432.4], [269.2, 427.5], [268.0, 422.7], [268.0, 417.7], [268.0, 412.7], [267.0, 407.9], [266.6, 402.9], [266.8, 398.2]], [[199.0, 252.0], [198.4, 247.1], [197.7, 242.2], [196.2, 237.5]]]}, {"name": "knee loop", "word": "a", "v0": 8.06, "v1": 8.59, "sub": [[[122.0, 263.0], [123.3, 258.2], [125.1, 253.6], [126.0, 250.0]], [[209.0, 256.0], [214.0, 256.0]], [[114.0, 349.0], [114.0, 344.0], [114.0, 339.0], [114.0, 334.0], [114.0, 329.0], [114.0, 324.0], [114.0, 319.0], [114.0, 314.0], [114.0, 310.0]]]}, {"name": "foot + base", "word": "stone", "v0": 8.59, "v1": 11.29, "sub": [[[188.0, 220.0], [187.0, 219.0], [186.0, 218.0], [185.0, 217.0]], [[115.0, 493.0], [116.3, 488.2], [117.8, 483.5], [118.8, 478.6], [119.0, 473.6], [119.0, 470.0]], [[94.0, 512.0], [95.0, 511.7], [96.0, 511.0], [97.0, 510.0]], [[39.0, 527.0], [36.0, 531.0], [34.0, 533.0]], [[42.0, 524.0], [43.0, 524.0], [44.0, 523.7], [45.0, 523.0], [46.0, 522.0]], [[31.0, 538.0], [29.4, 542.7], [29.0, 547.6], [29.8, 552.4], [31.0, 557.0]], [[102.0, 507.0], [103.0, 506.0], [104.0, 505.0], [105.0, 504.0]], [[48.0, 521.0], [52.8, 519.7], [57.5, 518.0], [62.2, 516.9], [67.0, 515.3], [71.9, 515.0], [76.9, 514.7], [81.8, 514.0], [86.6, 513.1], [91.6, 513.0]], [[45.0, 570.0], [40.7, 567.5], [38.0, 565.0]], [[108.0, 502.0], [111.4, 498.4], [114.0, 495.0]], [[85.0, 574.0], [80.2, 575.0], [75.2, 575.0], [70.2, 575.0], [65.2, 574.7], [60.3, 574.0], [55.5, 573.0], [50.8, 571.9], [47.0, 571.0]], [[137.0, 230.0], [134.3, 234.1]], [[210.0, 548.0], [209.0, 543.3], [208.0, 538.4], [207.0, 533.6], [206.9, 528.6], [206.0, 523.7], [205.0, 518.9], [205.0, 513.9], [204.0, 509.1], [204.0, 504.1], [204.0, 499.1], [204.0, 494.1], [204.0, 489.1], [204.0, 484.1], [203.0, 479.2], [203.0, 474.2], [202.1, 469.4], [202.0, 464.4], [203.0, 459.6], [203.0, 454.6], [203.0, 449.6], [202.9, 444.6], [202.0, 439.7], [202.0, 434.7], [203.0, 429.9], [202.0, 425.0], [202.0, 420.0], [202.0, 415.0], [202.0, 410.0], [202.0, 405.0], [202.0, 400.0], [202.0, 395.0], [202.0, 390.0], [202.0, 385.0], [202.6, 380.1], [203.0, 375.2], [203.0, 370.2], [203.0, 365.2], [203.0, 360.2], [203.9, 355.3], [204.0, 351.0]], [[120.0, 468.0], [120.0, 463.0], [120.0, 458.0], [119.0, 453.2], [119.0, 448.2], [119.0, 443.2], [118.0, 438.3], [118.0, 433.3], [118.0, 428.3], [118.0, 423.3], [118.0, 418.3], [118.0, 413.3], [118.0, 408.3], [117.0, 403.5], [117.0, 398.5], [117.0, 393.5], [116.0, 388.6], [116.0, 383.6], [116.0, 378.6], [116.0, 373.6], [115.6, 368.7], [115.0, 363.8], [115.0, 358.8], [115.0, 353.8]], [[343.0, 492.0], [340.2, 487.8], [338.0, 484.0]]]}, {"name": "arm loop", "word": "just", "v0": 11.42, "v1": 13.01, "sub": [[[160.0, 291.0], [159.0, 295.8], [159.0, 300.8], [158.0, 305.0]], [[211.0, 549.0], [215.2, 551.6], [217.0, 555.0]], [[225.0, 562.0], [220.4, 560.1]], [[165.0, 283.0], [164.0, 284.0], [163.3, 285.0], [163.0, 286.0]], [[157.0, 307.0], [156.1, 311.8], [156.0, 316.8], [156.0, 321.8], [156.0, 326.8], [157.0, 331.0]], [[171.0, 276.0], [172.6, 271.3], [174.5, 266.8], [177.9, 263.1]]]}, {"name": "arm loops", "word": "waiting", "v0": 13.01, "v1": 14.71, "sub": [[[102.0, 47.0], [107.0, 47.0], [111.8, 46.1]], [[202.0, 308.0], [197.3, 309.6], [193.0, 311.0]], [[192.0, 228.0], [193.9, 232.6], [195.0, 235.0]], [[115.0, 305.0], [114.7, 306.0], [114.3, 307.0], [114.0, 308.0]], [[78.0, 217.0], [81.0, 213.0]], [[66.0, 239.0], [64.4, 243.7], [62.9, 248.4], [61.0, 253.0], [60.0, 257.8]], [[107.0, 191.0], [106.0, 191.7], [105.0, 192.3], [104.0, 193.0]], [[67.0, 237.0], [69.2, 232.5], [71.8, 228.3]], [[58.0, 304.0], [57.1, 299.2], [57.0, 294.2], [57.0, 289.2], [57.0, 284.2], [57.0, 279.2], [57.3, 274.2], [58.0, 269.3], [58.8, 264.5], [59.0, 260.0]], [[59.0, 306.0], [60.6, 310.7], [62.8, 315.2], [64.6, 319.8], [66.0, 322.0]], [[67.0, 324.0], [69.3, 328.4], [71.7, 332.7]], [[86.0, 345.0], [90.6, 346.9]], [[95.0, 349.0], [99.3, 351.0], [104.3, 351.0], [109.3, 351.0], [113.0, 351.0]], [[143.0, 342.0], [138.4, 343.9], [133.7, 345.4], [129.0, 347.0]], [[123.0, 349.0], [118.0, 349.0], [115.0, 350.0]], [[167.0, 329.0], [162.2, 330.0], [158.0, 331.0]], [[157.0, 332.0], [156.0, 336.7]], [[155.0, 338.0], [150.2, 338.9], [145.6, 340.6]], [[215.0, 288.0], [216.0, 287.7], [217.0, 287.3], [218.0, 287.0], [219.0, 287.0]], [[206.0, 296.0], [207.0, 295.0], [208.0, 294.0], [209.0, 293.0]], [[173.0, 326.0], [174.0, 325.0], [175.0, 324.3], [176.0, 324.0]], [[226.0, 269.0], [227.0, 269.0], [228.0, 269.0], [229.0, 269.0], [230.0, 269.0], [231.0, 269.0]], [[329.0, 474.0], [328.4, 469.1], [328.0, 464.2], [328.0, 459.2], [327.4, 454.3], [327.0, 449.3], [326.0, 445.0]], [[268.0, 253.0], [263.0, 253.0], [258.0, 253.0], [254.0, 254.0]], [[283.0, 261.0], [279.5, 257.5], [275.2, 255.1], [270.3, 254.0], [268.0, 253.0]]]}, {"name": "knee wrap", "word": "very", "v0": 14.71, "v1": 16.29, "sub": [[[219.0, 254.0], [221.1, 258.3], [222.0, 262.0]], [[261.0, 284.0], [264.0, 288.0], [267.0, 291.0]], [[81.0, 328.0], [77.0, 331.0]], [[78.0, 339.0], [74.0, 336.0]], [[63.0, 345.0], [66.5, 341.5], [70.0, 338.0]], [[62.0, 346.0], [61.0, 347.0], [60.0, 348.0], [59.0, 349.0]], [[57.0, 351.0], [56.3, 352.0], [56.0, 353.0], [55.7, 354.0], [55.0, 355.0]], [[98.0, 313.0], [97.3, 314.0], [96.7, 315.0], [96.0, 316.0]], [[51.0, 362.0], [49.7, 366.8], [48.5, 371.6], [48.0, 376.5], [48.0, 381.5], [48.0, 386.5], [48.5, 391.4], [49.0, 396.4], [50.9, 400.9], [52.0, 405.6]], [[58.0, 414.0], [54.6, 410.4], [53.0, 408.0]], [[92.0, 409.0], [92.3, 408.0], [93.0, 407.0], [94.0, 406.0]], [[86.0, 416.0], [81.9, 418.7], [77.1, 420.0], [72.1, 420.3], [67.2, 420.0], [63.0, 418.0]], [[127.0, 301.0], [122.2, 302.0], [117.3, 303.0]]]}, {"name": "hair line one", "word": "pa", "v0": 16.29, "v1": 17.99, "sub": [[[319.0, 287.0], [319.0, 292.0], [320.0, 296.8], [321.0, 301.7], [321.5, 306.6], [322.2, 311.5], [323.0, 316.4], [323.0, 321.4], [323.1, 326.3], [324.0, 331.2], [324.4, 336.1], [325.0, 341.0], [325.0, 346.0], [326.0, 350.9], [326.0, 355.9], [326.0, 360.9], [326.6, 365.8], [327.0, 370.7], [327.0, 375.7], [327.0, 380.7], [327.9, 385.6], [328.0, 390.5], [328.0, 395.5], [328.0, 400.5], [328.5, 405.5], [329.0, 410.4], [329.0, 415.4], [329.0, 420.4], [329.0, 425.4], [329.0, 430.4], [329.0, 435.4], [329.0, 440.4]], [[153.0, 214.0], [156.8, 210.8], [161.6, 209.5], [166.5, 209.0], [171.5, 209.2], [176.3, 210.1], [180.6, 212.6]], [[142.0, 224.0], [145.5, 220.5], [149.1, 216.9]], [[172.0, 277.0], [176.8, 276.0], [181.5, 274.5], [186.1, 272.6], [190.7, 270.8]], [[284.0, 262.0], [286.8, 266.2], [288.0, 270.9], [289.0, 275.7], [290.0, 280.6], [289.0, 285.4], [289.0, 288.0]], [[113.0, 309.0], [108.3, 310.2], [103.4, 311.0], [99.0, 312.0]], [[246.0, 215.0], [247.0, 210.2], [248.2, 205.4], [249.8, 200.6], [250.0, 195.6], [249.9, 190.7], [249.0, 187.0]], [[89.0, 372.0], [91.0, 376.5]], [[112.0, 216.0], [108.5, 219.5], [106.1, 223.9]], [[102.0, 232.0], [102.3, 231.0], [102.7, 230.0], [103.0, 229.0]], [[101.0, 234.0], [99.1, 238.6], [98.0, 243.4], [96.9, 248.2], [96.0, 253.1], [96.0, 256.0]], [[224.0, 267.0], [223.7, 266.0], [223.3, 265.0], [223.0, 264.0]], [[98.0, 312.0], [96.1, 307.4]], [[245.0, 217.0], [243.7, 221.8], [241.9, 226.4], [241.0, 229.0]], [[95.0, 305.0], [94.4, 300.1], [94.0, 295.2], [94.0, 290.2], [94.0, 285.2], [94.0, 280.2], [93.0, 275.3], [93.5, 270.5], [94.0, 265.7], [94.0, 261.0]], [[288.0, 290.0], [285.8, 294.2]], [[270.0, 293.0], [274.2, 295.7], [279.0, 297.0], [282.0, 297.0]], [[197.0, 268.0], [196.0, 268.0], [195.0, 268.3], [194.0, 269.0]], [[255.0, 310.0], [255.3, 314.9], [256.3, 319.8], [257.0, 323.0]], [[258.0, 334.0], [258.9, 338.8], [259.0, 343.8], [260.5, 348.6], [261.2, 353.5], [262.0, 358.4], [263.0, 363.2], [264.0, 368.0], [264.6, 372.9], [265.3, 377.8], [266.5, 382.6]], [[252.0, 302.0], [250.4, 297.3], [248.3, 292.9], [246.4, 288.3]], [[254.0, 308.0], [253.7, 307.0], [253.3, 306.0], [253.0, 305.0], [253.0, 304.0]]]}, {"name": "hair line two", "word": "tient", "v0": 17.99, "v1": 19.7, "sub": [[[280.0, 602.0], [281.6, 606.7], [282.2, 611.6], [283.0, 615.0]], [[299.0, 655.0], [297.1, 650.4]], [[225.0, 270.0], [224.0, 274.7], [224.0, 279.0]], [[268.0, 253.0], [269.0, 248.3], [269.6, 243.3], [270.2, 238.4], [271.0, 233.6]], [[96.0, 402.0], [96.9, 397.2], [96.0, 392.3], [95.2, 387.5]], [[94.0, 318.0], [93.0, 319.0], [92.0, 320.0], [91.0, 321.0]], [[256.0, 324.0], [251.5, 321.8], [249.0, 320.0]], [[221.0, 283.0], [220.3, 284.0], [220.0, 285.0], [220.0, 286.0]], [[147.0, 51.0], [148.0, 51.7], [149.0, 52.3], [150.0, 53.0]], [[272.0, 231.0], [272.6, 226.1], [273.0, 221.2], [273.0, 216.2], [273.3, 211.2], [274.0, 206.3], [275.0, 201.5], [275.0, 196.5], [275.0, 191.5], [275.0, 186.5]], [[222.0, 292.0], [221.1, 287.2]], [[223.0, 294.0], [224.0, 298.8], [224.3, 303.8], [225.5, 308.6], [226.0, 313.5]], [[227.0, 316.0], [227.6, 320.9], [228.0, 325.8], [229.0, 330.7], [230.0, 335.5], [231.0, 340.4], [231.0, 345.4], [231.0, 349.0]], [[279.0, 597.0], [277.1, 592.4], [276.0, 587.6], [274.3, 582.9], [272.8, 578.3], [272.0, 574.0]], [[271.0, 572.0], [269.4, 567.3], [268.2, 562.5], [267.0, 557.6]], [[300.0, 657.0], [302.7, 661.1], [305.0, 665.5], [308.7, 668.8]], [[234.0, 363.0], [234.0, 368.0], [235.0, 372.8], [235.9, 377.7], [236.0, 382.7]], [[288.0, 629.0], [286.0, 624.7], [284.7, 620.0], [284.0, 617.0]], [[293.0, 640.0], [290.8, 635.5], [289.0, 631.0]], [[218.0, 253.0], [215.6, 248.7], [214.6, 243.9], [213.4, 239.1], [211.1, 234.7], [209.7, 230.0], [207.5, 225.5], [206.2, 220.8], [204.1, 216.3], [203.0, 211.4], [201.2, 206.8], [199.4, 202.2], [197.5, 197.6], [196.0, 192.9]], [[184.0, 150.0], [184.0, 155.0], [185.0, 159.8], [185.0, 164.0]], [[149.0, 55.0], [149.3, 59.9], [151.8, 64.3], [152.0, 67.0]], [[154.0, 69.0], [154.7, 70.0], [155.0, 71.0], [155.3, 72.0], [156.0, 73.0]], [[158.0, 78.0], [158.7, 79.0], [159.3, 80.0], [160.0, 81.0]], [[195.0, 190.0], [194.7, 189.0], [194.3, 188.0], [194.0, 187.0]], [[236.0, 294.0], [238.0, 298.5], [240.0, 302.0]], [[241.0, 304.0], [242.9, 308.6]], [[281.0, 415.0], [282.9, 419.6]], [[284.0, 423.0], [284.7, 424.0], [285.0, 425.0], [285.0, 426.0], [285.0, 427.0]], [[286.0, 429.0], [286.3, 430.0], [286.7, 431.0], [287.0, 432.0]], [[288.0, 434.0], [289.0, 438.8], [290.5, 443.6], [292.0, 447.0]], [[266.0, 555.0], [265.1, 550.2], [263.8, 545.4], [262.5, 540.6], [262.0, 535.6], [260.6, 530.9], [260.0, 526.0], [259.0, 521.1], [259.0, 517.0]], [[322.0, 546.0], [325.8, 549.2], [327.0, 552.0]], [[335.0, 573.0], [333.1, 568.4], [332.0, 566.0]], [[344.0, 600.0], [342.4, 595.3], [341.5, 590.5], [339.3, 586.0], [337.8, 581.3]], [[350.0, 614.0], [348.7, 609.2]], [[347.0, 606.0], [347.0, 605.0], [346.7, 604.0], [346.0, 603.0], [345.0, 602.0]], [[365.0, 640.0], [365.7, 641.0], [366.3, 642.0], [366.7, 643.0], [367.0, 644.0]], [[237.0, 385.0], [237.0, 390.0], [238.0, 394.8], [238.0, 398.0]], [[330.0, 562.0], [329.1, 557.2]], [[182.0, 146.0], [180.4, 141.3], [178.6, 136.7], [177.3, 131.9], [176.0, 128.0]], [[268.0, 387.0], [268.0, 392.0], [269.0, 395.0]]]}, {"name": "hair line three", "word": "ly", "v0": 19.7, "v1": 21.4, "sub": [[[232.0, 269.0], [235.6, 272.4], [238.0, 274.0]], [[131.0, 299.0], [135.5, 297.0], [140.1, 295.0]], [[260.0, 332.0], [264.3, 334.4], [267.0, 336.0]], [[172.0, 113.0], [172.7, 108.2], [171.0, 104.0]], [[239.0, 400.0], [239.6, 404.9], [240.0, 409.8]], [[130.0, 241.0], [129.7, 242.0], [129.3, 243.0], [129.0, 244.0]], [[143.0, 294.0], [144.0, 293.3], [145.0, 293.0], [146.0, 293.0], [147.0, 293.0]], [[256.0, 504.0], [257.0, 508.8], [257.6, 513.7]], [[306.0, 484.0], [304.7, 479.2], [302.9, 474.6], [301.3, 469.9], [300.0, 466.0]], [[110.0, 39.0], [111.6, 43.7]], [[147.0, 108.0], [147.0, 107.0], [147.0, 106.0], [147.0, 105.0], [147.0, 104.0]], [[187.0, 171.0], [185.7, 166.2]], [[274.0, 347.0], [276.4, 351.3], [278.6, 355.7]], [[283.0, 364.0], [283.7, 365.0], [284.0, 366.0], [284.0, 367.0]], [[325.0, 444.0], [324.0, 443.3], [323.0, 442.7], [322.0, 442.3], [321.0, 442.0]], [[299.0, 399.0], [299.3, 400.0], [299.7, 401.0], [300.3, 402.0], [301.0, 403.0]], [[302.0, 405.0], [302.3, 406.0], [302.7, 407.0], [303.3, 408.0], [304.0, 409.0]], [[305.0, 411.0], [305.7, 412.0], [306.3, 413.0], [306.7, 414.0], [307.0, 415.0]], [[312.0, 425.0], [314.4, 429.3], [316.0, 433.0]], [[184.0, 164.0], [179.5, 162.0], [176.0, 160.0]], [[351.0, 509.0], [349.0, 504.5], [347.0, 501.0]], [[166.0, 141.0], [168.0, 145.5], [169.0, 148.0]], [[270.0, 396.0], [274.0, 399.0]], [[330.0, 475.0], [331.0, 475.0], [332.0, 475.3], [333.0, 476.0], [334.0, 477.0]], [[337.0, 482.0], [336.7, 481.0], [336.3, 480.0], [336.0, 479.0]], [[319.0, 516.0], [315.3, 512.8], [314.0, 509.0]], [[361.0, 530.0], [360.7, 529.0], [360.3, 528.0], [359.7, 527.0], [359.0, 526.0]], [[377.0, 558.0], [377.3, 559.0], [378.0, 560.0], [379.0, 561.0]], [[383.0, 568.0], [384.0, 569.0], [384.7, 570.0], [385.0, 571.0]], [[171.0, 152.0], [173.4, 156.3], [175.0, 159.0]], [[237.0, 235.0], [236.3, 236.0], [235.7, 237.0], [235.0, 238.0]], [[357.0, 522.0], [357.0, 521.0], [356.7, 520.0], [356.0, 519.0]], [[397.0, 586.0], [400.2, 589.8], [403.0, 593.0]], [[407.0, 597.0], [409.6, 601.2]], [[159.0, 290.0], [154.2, 291.0], [149.3, 291.9]], [[255.0, 502.0], [255.0, 497.0], [254.0, 492.2], [252.8, 487.4], [252.0, 482.5], [251.2, 477.6], [250.6, 472.7], [249.6, 467.9], [249.0, 463.0], [249.0, 458.0], [247.4, 453.3], [246.8, 448.4], [246.0, 443.6], [245.0, 438.7]], [[307.0, 486.0], [308.0, 490.8], [309.8, 495.4], [311.1, 500.2], [312.7, 505.0]], [[233.0, 361.0], [233.0, 356.0]], [[244.0, 435.0], [243.1, 430.2], [242.0, 425.3], [241.2, 420.5], [241.0, 415.5]]]}, {"name": "cross strand", "word": "for", "v0": 21.4, "v1": 22.3, "sub": [[[234.0, 291.0], [231.6, 286.7], [228.2, 283.1], [225.0, 280.0]], [[92.0, 123.0], [90.4, 127.7]], [[93.0, 121.0], [94.0, 116.3], [94.0, 113.0]], [[294.0, 451.0], [296.0, 455.5], [297.0, 459.0]]]}, {"name": "cross strands", "word": "time", "v0": 22.3, "v1": 23.4, "sub": [[[297.0, 395.0], [295.0, 390.5], [293.2, 385.8]], [[164.0, 111.0], [168.3, 113.5]], [[273.0, 345.0], [270.6, 340.7], [268.0, 337.0]], [[291.0, 382.0], [288.8, 377.5]], [[118.0, 123.0], [118.0, 127.9], [118.9, 132.7]], [[280.0, 413.0], [278.7, 408.2], [277.0, 403.5], [275.0, 400.0]], [[163.0, 136.0], [163.7, 137.0], [164.3, 138.0], [165.0, 139.0]]]}, {"name": "the eyes", "word": "pass", "v0": 24.2, "v1": 24.99, "sub": [[[156.0, 123.0], [155.7, 122.0], [155.0, 121.0], [154.0, 120.0]], [[121.0, 265.0], [120.4, 269.9], [119.4, 274.7], [118.0, 279.5], [117.5, 284.4], [117.0, 289.4], [116.0, 294.2], [116.0, 299.2], [116.0, 302.0]]]}]} \ No newline at end of file