Steps 2 and 3 land together because the model revisions in the middle changed
code from both, and splitting them now would invent intermediate states that
never built.
domain/channel value-at across framed/keyed/dense, plus a cursor
domain/node decomposed transform, composition order, time maps
domain/scene topological order, z paths, eval-frame and resolver
clock audio-clocked frame derivation, outside app-db
db/events/subs re-frame arrives; the playhead is document state
ui/player the rAF loop; reads, blits, dispatches (almost) nothing
ui/shell transport
133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and
runs at 1/4x through 4x; verified by driving a real browser over CDP rather than
by assertion.
Two evaluators, on purpose. `eval-frame` is the specification -- allocating,
order-free, obviously correct. `resolver` is what playback uses: cached topo
order and z paths, a cursor per channel, a preallocated point buffer per node.
Both run the same walk, parameterised only by how a channel is read and where
points are written, because two independent implementations of frame evaluation
would drift and the drift would read as a rendering bug rather than as two
functions disagreeing. scene-test asserts they agree frame for frame in forward,
backward and random order.
Deviations and decisions, each with a reason:
- raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat
preallocated buffer. ONE scanline fill serves the analysis stages, which speak
{:x :y}, and frame evaluation, which hands over a buffer it owns. The parity
suite still passes pixel-for-pixel, which is what makes the rewrite safe.
- The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too,
per architecture.md's "hidden flag + palette index", and that bit was a dense
[:vis] wearing a different hat -- two mechanisms for one question, which is how
a part ends up hidden by one and shown by the other.
- The palette is a parameter of evaluation, not a global. A node names a TONE;
which ramp that tone is read in belongs to the timeline it sits in.
- :over layers and a symbol :rate THROW rather than being ignored. Neither is
built and nothing can produce one, so this can only fire on data that has run
ahead of the code. A silently dropped override is a hand correction the user
made once, watched fail, and has no reason to trust again.
Three findings the model produced rather than received:
- Presence propagates asymmetrically. An absent transform drops the subtree; an
absent [:geom :pts] drops only that node, because an absent mouth outline has
nothing to draw but the head it hangs off has not moved. That asymmetry is the
reason presence is tracked per channel and not per node.
- Z paths need lexicographic compare, not `compare`, which orders vectors by
count first -- so a cel three levels under "a1" would jump in front of a bare
"a2" and the layer order would mostly work.
- A node stencilled by something that drew nothing is dropped, not drawn
unclipped: an iris floating over the cheek is worse than a missing iris.
docs/ revised alongside, and those revisions are the load-bearing part:
- A scene, a timeline and a symbol are one type. The doc had two structures with
the same fields and never said so. Two axes of nesting are now separated --
parent/child within a timeline is flat with parent pointers, instance nesting
is by reference -- which is why "nestable" and "flat" only sounded
contradictory.
- Palettes are named, live on the project, and are ENABLED on a timeline as a
channel. Absent inherits; present travels with the timeline, so a symbol
authored against :night stays night wherever it is placed. The output index
space is the concatenation of the named ramps, which keeps one buffer and one
flat table and incidentally stops two nodes in different palettes colliding on
a stencil.
- Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so
the normalise on/off/per-plate toggle is which of the three channel shapes the
:head node carries. Always measure and always store factored -- smoothing and
velocity-minimum key selection both need the split to exist in storage.
- There is no camera node and none is needed. Placement is a node transform, the
stage clips what hangs off it, and project dimensions are independent of the
footage. `makeXform` is therefore not to be ported: it bakes a cropping
decision into every stored vertex.
- Export is removed. The .take writer was for an Animator Pro render script; the
target is encoding video in the browser, and step 9 now says not to port the
old one.
demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store
handles -- the shape freeze produces at step 5, and the first thing to exercise
that path under load. It plays at 30fps, and bench-test keeps a deliberately
loose floor under it because a performance regression here does not announce
itself: the picture stays correct and merely arrives late.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
108 lines
4.7 KiB
Clojure
108 lines
4.7 KiB
Clojure
(ns arthur.clock-test
|
|
"The clock is four lines of arithmetic and the whole take's sync depends on
|
|
them, so they are asserted rather than eyeballed. A drift bug is invisible for
|
|
the first second and unmistakable by the tenth, which is the worst possible
|
|
shape for a bug to have."
|
|
(:require [cljs.test :refer [deftest is testing]]
|
|
[arthur.clock :as clock]))
|
|
|
|
(defn- fake
|
|
"Stands in for the audio element. The clock only ever reads `currentTime`,
|
|
`paused`, `ended` and `playbackRate` and writes `currentTime`, which is the
|
|
entire coupling — so the whole of it can be asserted in node."
|
|
[& {:keys [t paused? ended? rate duration]
|
|
:or {t 0 paused? true ended? false rate 1.0 duration 7.6}}]
|
|
#js {:currentTime t :paused paused? :ended ended?
|
|
:playbackRate rate :duration duration})
|
|
|
|
(deftest the-frame-is-derived-from-the-audio-not-counted
|
|
;; frame = ⌊currentTime · fps⌋. The whole point: nothing accumulates, so
|
|
;; nothing can drift.
|
|
(clock/attach! (fake :t 0.0))
|
|
(is (= 0 (clock/frame 30 229)))
|
|
(doseq [[t want] [[0.0 0] [0.033 0] [0.034 1] [1.0 30] [1.999 59] [2.0 60]]]
|
|
(clock/attach! (fake :t t))
|
|
(is (= want (clock/frame 30 229)) (str t "s at 30fps"))))
|
|
|
|
(deftest a-dropped-frame-lands-where-the-audio-already-is
|
|
;; THE property the derivation buys. A loop that stalled for a third of a
|
|
;; second resumes at the frame the audio reached, not a third of a second
|
|
;; behind it — the failure is a visible stutter rather than an invisible slide
|
|
;; out of sync, and those are very different bugs to own.
|
|
(clock/attach! (fake :t 1.0))
|
|
(let [before (clock/frame 30 229)]
|
|
(clock/attach! (fake :t 1.5)) ; fifteen frames' worth of stall
|
|
(is (= 45 (clock/frame 30 229)))
|
|
(is (= 30 before) "and nothing was counted in between")))
|
|
|
|
(deftest the-frame-is-clamped-into-the-clip
|
|
;; Audio is 7.601s and the clip is 229 frames at 30fps = 7.633s, so the last
|
|
;; fraction of a second has no audio and the end of the audio has no frame
|
|
;; past the last. Neither may produce an out-of-range index.
|
|
(clock/attach! (fake :t 99.0))
|
|
(is (= 228 (clock/frame 30 229)))
|
|
(clock/attach! (fake :t -1.0))
|
|
(is (= 0 (clock/frame 30 229))))
|
|
|
|
(deftest a-seek-round-trips
|
|
;; Seeking to the START of a frame rather than its middle is what makes this
|
|
;; idempotent: seek to f, read back f, at every f.
|
|
(let [a (fake)]
|
|
(clock/attach! a)
|
|
(doseq [f [0 1 57 114 171 228]]
|
|
(clock/seek! 30 229 f)
|
|
(is (= f (clock/frame 30 229)) (str "seek to " f)))))
|
|
|
|
(deftest a-seek-past-either-end-is-clamped-before-it-reaches-the-element
|
|
(let [a (fake)]
|
|
(clock/attach! a)
|
|
(clock/seek! 30 229 9999)
|
|
(is (= 228 (clock/frame 30 229)))
|
|
(clock/seek! 30 229 -5)
|
|
(is (= 0 (clock/frame 30 229)))
|
|
(is (>= (.-currentTime a) 0) "and currentTime is never negative")))
|
|
|
|
(deftest rate-does-not-enter-the-frame-calculation
|
|
;; ½× and ¼× are playbackRate and nothing else. If rate appeared here as well
|
|
;; it would be applied twice — the picture would have one rate and the sound
|
|
;; another, which is precisely the desync the audio clock exists to prevent.
|
|
(doseq [r [1.0 0.5 0.25]]
|
|
(clock/attach! (fake :t 2.0 :rate r))
|
|
(is (= 60 (clock/frame 30 229)) (str "at " r "x, 2.0s is still frame 60"))))
|
|
|
|
(deftest playing-follows-the-element
|
|
(clock/attach! (fake :paused? true))
|
|
(is (not (clock/playing?)))
|
|
(clock/attach! (fake :paused? false))
|
|
(is (clock/playing?))
|
|
(testing "and a finished file is not playing, whatever `paused` says"
|
|
(clock/attach! (fake :paused? false :ended? true))
|
|
(is (not (clock/playing?)))))
|
|
|
|
(deftest the-audio-length-is-reported-rather-than-assumed
|
|
;; A clip longer than its audio is a legitimate thing to be told about and not
|
|
;; a thing to silently truncate.
|
|
(clock/attach! (fake :duration 7.601))
|
|
(is (= 229 (clock/duration-frames 30)))
|
|
(is (= 92 (clock/duration-frames 12)))
|
|
(testing "and an unloaded element has no opinion"
|
|
(clock/attach! (fake :duration js/NaN))
|
|
(is (nil? (clock/duration-frames 30)))))
|
|
|
|
(deftest exposure-is-applied-to-the-derived-frame
|
|
;; The transport shows which frame the grid holds the playhead back onto, so
|
|
;; that `exposure 2` is visibly doing something rather than only inside the
|
|
;; scene.
|
|
(is (= [0 0 2 2 4 4] (mapv #(clock/exposed-frame % 2) (range 6))))
|
|
(is (= [0 1 2 3 4 5] (mapv #(clock/exposed-frame % 1) (range 6)))))
|
|
|
|
(deftest with-no-element-attached-nothing-explodes
|
|
;; The loop starts before the :ref has fired, so every reader has to be safe
|
|
;; on a clock that has not been handed its element yet.
|
|
(clock/attach! nil)
|
|
(is (= 0 (clock/frame 30 229)))
|
|
(is (not (clock/playing?)))
|
|
(is (= 1.0 (clock/rate)))
|
|
(is (nil? (clock/duration-frames 30)))
|
|
(is (nil? (clock/seek! 30 229 5)))
|
|
(is (nil? (clock/pause!))))
|