From d156635c49d773d4b088a2c9d8b3947cd2994b01 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sat, 17 Oct 2026 16:20:08 +0900 Subject: [PATCH 1/4] =?UTF-8?q?PHO-65/#121=20T13:=20LumosSequence=20as=20s?= =?UTF-8?q?erialisable=20data=20=E2=80=94=20steps=20of=20look=20/=20form?= =?UTF-8?q?=20/=20atmosphere=20/=20glyph,=20a=20shared=20clock=20with=20co?= =?UTF-8?q?ntrol(),=20captured=20by=20Oscilloscope=20and=20played=20by=20S?= =?UTF-8?q?ignalGenerator=20from=20one=20definition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format (:phosphor-lumos): @Serializable LumosStep / LumosTransition / LumosSequence with fromJson / toJson, a sealed LumosSequenceError that names the step, and a pure sample(seconds). One segment per step, named by the step; a step naming what the orb already shows applies nothing; names are unique. Clock: LumosSequencePlayer ticks while connected, uncontrolled and playing, dt capped at 0.1 s; control() hands time to an external driver. LiveFrameSource.follow applies steps; OrbStepper advances a player; LumosOrb(source, player) connects it while the orb runs. Capture and live: SignalGenerator.play(sequence | player); CaptureConfig.sequence and CaptureConfig.forSequence derive segments by stepping the same player at 1 / fps. A sequence capture is frame-identical to the cue capture it restates (FormSeamByteIdentityTest, StateMachineDemoSequenceTest). StateMachineDemo loads demo-sequence.json. docs/LUMOS_SEQUENCE.md documents the format. Co-Authored-By: Claude Fable 5.1 --- docs/LUMOS_SEQUENCE.md | 133 +++++ .../choreography/AtmosphereChoreographer.kt | 21 +- .../phosphor/timeline/TimelineBuilder.kt | 7 + phosphor-lumos-cli/build.gradle.kts | 2 + .../lumos/cli/demo/StateMachineDemo.kt | 127 ++--- .../resources/lumos/demo-sequence.json | 19 + .../cli/demo/StateMachineDemoSequenceTest.kt | 128 +++++ .../phosphor/lumos/compose/LumosDriver.kt | 4 +- .../socket/phosphor/lumos/compose/LumosOrb.kt | 62 ++- .../lumos/compose/LumosOrbSequenceTest.kt | 78 +++ .../phosphor/lumos/dom/DomLumosDriver.kt | 2 +- phosphor-lumos/build.gradle.kts | 3 +- .../socket/phosphor/lumos/LiveFrameSource.kt | 135 ++++- .../link/socket/phosphor/lumos/LumosLook.kt | 6 + .../socket/phosphor/lumos/LumosSequence.kt | 462 ++++++++++++++++++ .../phosphor/lumos/LumosSequencePlayer.kt | 126 +++++ .../link/socket/phosphor/lumos/OrbStepper.kt | 8 + .../lumos/LiveFrameSourceSequenceTest.kt | 195 ++++++++ .../phosphor/lumos/LumosSequencePlayerTest.kt | 183 +++++++ .../phosphor/lumos/LumosSequenceTest.kt | 338 +++++++++++++ .../socket/phosphor/lumos/OrbStepperTest.kt | 22 + .../socket/phosphor/trace/CaptureConfig.kt | 73 ++- .../socket/phosphor/trace/Oscilloscope.kt | 58 ++- .../phosphor/trace/SequenceFrameLayout.kt | 58 +++ .../socket/phosphor/trace/SignalGenerator.kt | 44 ++ .../phosphor/trace/SequenceCaptureTest.kt | 202 ++++++++ .../trace/FormSeamByteIdentityTest.kt | 47 ++ 27 files changed, 2432 insertions(+), 111 deletions(-) create mode 100644 docs/LUMOS_SEQUENCE.md create mode 100644 phosphor-lumos-cli/src/jvmMain/resources/lumos/demo-sequence.json create mode 100644 phosphor-lumos-cli/src/jvmTest/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemoSequenceTest.kt create mode 100644 phosphor-lumos-compose/src/jvmTest/kotlin/link/socket/phosphor/lumos/compose/LumosOrbSequenceTest.kt create mode 100644 phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt create mode 100644 phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt create mode 100644 phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LiveFrameSourceSequenceTest.kt create mode 100644 phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequencePlayerTest.kt create mode 100644 phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequenceTest.kt create mode 100644 phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SequenceFrameLayout.kt create mode 100644 phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt diff --git a/docs/LUMOS_SEQUENCE.md b/docs/LUMOS_SEQUENCE.md new file mode 100644 index 0000000..ba94b8c --- /dev/null +++ b/docs/LUMOS_SEQUENCE.md @@ -0,0 +1,133 @@ +# LumosSequence + +A `LumosSequence` is an orb's choreography as data: steps of look, form, +atmosphere and glyph with durations and transitions. One definition is captured +by `Oscilloscope` into a `.vxt` with named segments and played live by +`SignalGenerator`, frame for frame, so a consumer authors an orb's "then this" +once and ships it as JSON. + +It lives in `:phosphor-lumos`, is `@Serializable`, and needs no framework and no +coroutines. `LumosSequencePlayer` is its clock. + +## Example + +```json +{ + "version": 1, + "loop": true, + "steps": [ + { "name": "idle", "durationSeconds": 2.0, "atmospherePreset": "idle", "loop": true }, + { "name": "idle→thinking", "durationSeconds": 0.8, "atmospherePreset": "thinking" }, + { "name": "thinking", "durationSeconds": 2.0, "atmospherePreset": "thinking", "loop": true, + "look": { "look": "WORKING" } }, + { "name": "thinking→idle", "durationSeconds": 1.0, "atmospherePreset": "idle", + "transition": { "durationSeconds": 1.0, "easingName": "settled" } }, + { "name": "done", "durationSeconds": 1.5, "glyph": "CHECK" }, + { "name": "idle→fibonacci", "durationSeconds": 2.5, "form": "fibonacci", + "transition": { "durationSeconds": 2.4, "choreography": "SCATTER" } }, + { "name": "fibonacci→idle", "durationSeconds": 2.5, "form": "sphere" } + ] +} +``` + +```kotlin +val sequence = LumosSequence.fromJson(text).getOrThrow() + +// Live, the generator being the clock: +val player = generator.play(sequence) +player.pause(); player.seek(3f); player.play() + +// Live, under an orb's clock (Compose): +val source = remember { LiveFrameSource(AtmospherePresets.IDLE).also { it.follow(player) } } +LumosOrb(source, player, modifier = Modifier.size(160.dp)) + +// Captured, one TraceSegment per step: +val trace = Oscilloscope.capture(CaptureConfig.forSequence(sequence, fps = 24)).getOrThrow() +TraceStateMachine(trace, initialState = "idle").requestState("thinking") +``` + +## The step + +| Field | Meaning | +|---|---| +| `name` | Non-blank, unique in the sequence. Becomes the `TraceSegment` name, so follow its convention: a resting beat `"idle"`, a transition `"idle→thinking"`, a morph `"sphere→fibonacci"`. | +| `durationSeconds` | The step's length including its arriving transition, `0.05..3600`. | +| `look` | `{ "look": "WORKING", "variant": "gyro" }`; variant optional. Blends over `VoxelFrameBuilder.LOOK_BLEND_SECONDS` unless `transition` says. | +| `form` | A catalogue name (`fibonacci`, `cube`) or `sphere`, the lattice at the atmosphere's resolution. Morphs over `VoxelFrameBuilder.DEFAULT_MORPH_SECONDS` unless `transition` says. | +| `atmospherePreset` | A preset name, case-insensitive. Transitions on the `AtmosphereChoreographer` table unless `transition` says. | +| `glyph` | A `LumosGlyph` name. Fires on entering the step, every time, for 1.5 s unless `transition.durationSeconds` says. | +| `transition` | `{ durationSeconds, choreography = FLOW, easingName = "easeInOut" }`. No longer than the step. `choreography` is read by forms only. | +| `loop` | Carried onto the captured segment. Does not change how the live sequence advances. | + +A step names exactly one of `look`, `form`, `atmospherePreset`, `glyph`, or a +`look` together with an `atmospherePreset`. + +**A step that names what the orb already shows applies nothing.** The orb rests +in the state the previous one-shot landed, so `"thinking"` after +`"idle→thinking"` names `atmospherePreset: "thinking"` again without restarting +the transition, and `sample` reports no arrival for it. Glyphs are the exception: +they are one-shots and fire on every entry, including after a wrap. + +**A `loop` step should start where its arrival has landed**, as a cue-captured +loop does. A loop step that carries its own transition pops when the recording +wraps. Write the transition as its own step, named with the arrow. + +## Time + +`sequence.sample(seconds)` is a pure seek: which step, how far in, whether its +arrival is still in flight, how many times the sequence has wrapped. Step +boundaries are compared with a 0.1 ms tolerance, so a clock accumulated as +`Float` lands the frame the author expects; write durations with enough digits +to land on whole frames at your capture rate (`20 / 24` is `0.83333`, not `0.8`). + +`LumosSequencePlayer` ticks only while **connected** (`connect()` returned a +handle nobody released), **uncontrolled** (no `control()` handle outstanding) +and **playing**. Each `advance(dt)` is capped at 0.1 s, the same cap `OrbStepper` +puts on a frame gap. `control()` is how a scroll position or any external driver +owns time: while it holds, `advance` moves nothing and `seek` is the clock, and +the renderer keeps drawing. `connect()` lets several consumers share one clock. + +Who advances the player: + +| Driver | Clock | +|---|---| +| `SignalGenerator.play(sequence)` | the generator, by `1 / fps` after each `nextFrame`; frame `k` is the sequence at `k / fps` | +| `SignalGenerator.play(player)` | someone else; the generator only follows | +| `LumosOrb(source, player)` | the orb, by each frame's `dt`, connected only while the orb is on screen, started and not paused | +| `Oscilloscope.capture` with `CaptureConfig.sequence` | the fixed step, through `SignalGenerator.play(sequence)` | + +## Capture + +`CaptureConfig.forSequence(sequence, fps, …)` sizes a clip to one pass of the +sequence. Each step becomes one `TraceSegment(name, start, end, loop)`; the +layout is found by stepping a player at `1 / fps`, so it cannot disagree with +the frames. A step shorter than a frame, a rate under 10 fps, or a sequence given +alongside `segments`, `cues`, `formCues` or `phaseCues` is a +`CaptureError.InvalidConfig`. The trace's `paramSnapshot` carries the sequence's +JSON under `"sequence"`, so a clip can be traced back to its script. + +Cue-driven capture produces the same frames: a sequence step naming a preset is +the `AtmosphereCue` at its first frame, a form step the `FormCue`. +`FormSeamByteIdentityTest` and `StateMachineDemoSequenceTest` hold the two +paths to the same bytes. + +## Validation + +`LumosSequence.fromJson` returns a `Result` whose failure is a sealed +`LumosSequenceError`: + +| Error | When | +|---|---| +| `Malformed` | not a sequence document, or a value no type accepts (a `Look` variant its look does not offer) | +| `UnsupportedVersion` | `version` is not `1` | +| `StepCount` | fewer than 1 or more than 128 steps | +| `Step(index, name, reason)` | blank or duplicate name; duration out of range; not exactly one primary field; transition longer than the step or negative; unknown form, preset or easing | + +The constructor throws the same errors, so a sequence that exists is valid. + +## Not here + +The `timeline { }` DSL in `phosphor-core` is code, not data, and is untouched. +Scroll-driven progress is enabled by `control()` but no scroll code lives in +Phosphor. Steps are discrete; interpolating arbitrary numeric channels between +them is not in the format. diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt index 5330320..d33a8ce 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt @@ -349,12 +349,25 @@ class AtmosphereChoreographer( private fun resolveSpec( fromName: String?, toName: String?, - ): AtmosphereTransitionSpec { - if (fromName == null || toName == null) return DefaultSpec - return DefaultTransitionTable[fromName to toName] ?: DefaultSpec - } + ): AtmosphereTransitionSpec = transitionSpec(fromName, toName) companion object { + /** + * The duration and easing the table gives a transition from the preset + * named [fromName] to the one named [toName], or [DefaultSpec] when + * either is null or the pair is not tabled, matched exactly against the + * lower-case names in `AtmospherePresets.ALL`. What [setAtmosphere] uses + * when no spec is passed, exposed so a `LumosSequence` can say how long + * a step's arrival lasts without replaying it. + */ + fun transitionSpec( + fromName: String?, + toName: String?, + ): AtmosphereTransitionSpec { + if (fromName == null || toName == null) return DefaultSpec + return DefaultTransitionTable[fromName to toName] ?: DefaultSpec + } + /** * Fallback transition spec used when the lookup against * [DefaultTransitionTable] does not match. 1.1 seconds, easeInOut. diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/TimelineBuilder.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/TimelineBuilder.kt index 65cd8f8..4d3c583 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/TimelineBuilder.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/timeline/TimelineBuilder.kt @@ -166,6 +166,13 @@ class TimelineBuilder { /** * Create a timeline using the DSL. * + * This is code: phases carry lambdas, so a timeline cannot be written as data or + * shipped in a file. The Lumos orb's choreography — steps of look, form, + * atmosphere and glyph with durations — is `LumosSequence` in `:phosphor-lumos`, + * which is `@Serializable`, captured by `Oscilloscope` and played live by + * `SignalGenerator` from one definition. Reach for that when the "then this" is + * an orb's; reach for this when it is arbitrary code. + * * Example: * ```kotlin * val timeline = timeline { diff --git a/phosphor-lumos-cli/build.gradle.kts b/phosphor-lumos-cli/build.gradle.kts index 9731cfc..7862b7c 100644 --- a/phosphor-lumos-cli/build.gradle.kts +++ b/phosphor-lumos-cli/build.gradle.kts @@ -131,6 +131,8 @@ tasks.register("runStateMachineDemo") { val jvmMainCompilation = kotlin.targets.getByName("jvm").compilations.getByName("main") dependsOn(jvmMainCompilation.compileTaskProvider) classpath = files(jvmMainCompilation.output.allOutputs, jvmMainCompilation.runtimeDependencyFiles) + // The demo's script is a resource, demo-sequence.json, so the resources must be processed too. + dependsOn("jvmProcessResources") mainClass.set("link.socket.phosphor.lumos.cli.demo.StateMachineDemoKt") // Keypresses drive the state changes, so the task must own stdin as well as stdout. standardInput = System.`in` diff --git a/phosphor-lumos-cli/src/jvmMain/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemo.kt b/phosphor-lumos-cli/src/jvmMain/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemo.kt index 875d79f..c01a307 100644 --- a/phosphor-lumos-cli/src/jvmMain/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemo.kt +++ b/phosphor-lumos-cli/src/jvmMain/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemo.kt @@ -6,17 +6,13 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull -import link.socket.phosphor.lumos.Forms -import link.socket.phosphor.lumos.LumosForm +import link.socket.phosphor.lumos.LumosSequence import link.socket.phosphor.lumos.cli.projection.CliLattice import link.socket.phosphor.lumos.cli.renderer.CliOrb import link.socket.phosphor.lumos.cli.renderer.DefaultTerminalSize import link.socket.phosphor.palette.AtmospherePresets -import link.socket.phosphor.signal.AtmosphereState -import link.socket.phosphor.trace.AtmosphereCue import link.socket.phosphor.trace.CaptureConfig import link.socket.phosphor.trace.CaptureParams -import link.socket.phosphor.trace.FormCue import link.socket.phosphor.trace.Oscilloscope import link.socket.phosphor.trace.TraceSegment import link.socket.phosphor.trace.TraceStateMachine @@ -45,14 +41,15 @@ import link.socket.phosphor.trace.seamErrors * falls through to the crossfade, which drags cells and is the pop the morph * exists to avoid. * - * The clip is captured fresh on every run, so the segment layout in the status - * header is the layout the state machine is actually reading. It is captured - * with a baked seam blend (PHO-43), so a loop left alone — idle, most of the - * orb's life — iterates without popping too; the header prints each loop's - * measured seam. Each loop follows its transition directly: the blend fades - * toward a lead-in synthesized from the loop's own steady state (PHO-49), so no - * one-shot carries settle padding and `state` flips as soon as the authored - * motion lands. + * The clip is captured fresh on every run from [DEMO_SEQUENCE_RESOURCE], a + * `LumosSequence` written as JSON (PHO-65): thirteen steps, each the segment the + * state machine reads, so the layout in the status header is the one the data + * says. It is the first clip authored in the format. The capture bakes a seam + * blend (PHO-43), so a loop left alone — idle, most of the orb's life — iterates + * without popping too; the header prints each loop's measured seam. Each loop + * follows its transition directly: the blend fades toward a lead-in synthesized + * from the loop's own steady state (PHO-49), so no one-shot carries settle + * padding and `state` flips as soon as the authored motion lands. * * Not a test. Nothing here is asserted; it is meant to be watched. * @@ -81,58 +78,22 @@ private const val UNCERTAIN = "uncertain" private const val FIBONACCI = "fibonacci" private const val CUBE = "cube" -/** The morph's own 2.4 s at 24 fps, rounded up, so the loop that follows starts settled. */ -private const val MORPH_FRAMES = 58 - -/** The lattice every preset renders on, to morph back to. */ -private val SPHERE: LumosForm = Forms.Lattice(AtmospherePresets.IDLE.resolution) - -/** - * One stretch of the captured clip. - * - * @property name Segment name written into the trace, arrow convention included. - * @property frames How many captured frames the stretch covers. - * @property loop Whether a player should loop the stretch or play it once. - * @property cue Atmosphere to fire at the stretch's first frame, or null to carry on. - * @property form Form to morph to at the stretch's first frame, or null to keep the current one. - */ -private data class Beat( - val name: String, - val frames: Int, - val loop: Boolean, - val cue: AtmosphereState? = null, - val form: LumosForm? = null, -) - /** - * The capture script. + * The capture script, as data: the bundled `LumosSequence`. * - * Three resting loops and four of the six directed transitions between them. - * Transition lengths are the authored durations in `AtmosphereChoreographer`'s - * table — 0.8 s, 1.0 s, 1.4 s, 1.15 s at 24 fps — rounded up to whole frames, - * so each one has landed by the time the loop that follows starts on the very - * next frame. Nothing is padded: the loop's tail fades toward a lead-in - * synthesized from its own steady state, not toward these frames (PHO-49). The - * two `thinking↔uncertain` pairs are left out on purpose: they are what the - * crossfade covers. + * Three resting loops and four of the six directed transitions between them, + * then the lattice reshaping into each catalogue form and back (PHO-57). + * Transition steps last the authored durations in `AtmosphereChoreographer`'s + * table — 0.8 s, 1.0 s, 1.4 s, 1.15 s — and the morph's 2.4 s, each rounded up to + * whole frames at 24 fps and written with enough digits to land on them, so each + * one has landed by the time the loop that follows starts on the very next + * frame. Nothing is padded: a loop's tail fades toward a lead-in synthesized + * from its own steady state, not toward these frames (PHO-49). The two + * `thinking↔uncertain` pairs are left out on purpose: they are what the + * crossfade covers. Every loop step names the state the one-shot before it + * landed, which applies nothing — the format's rule for resting beats. */ -private val SCRIPT = - listOf( - Beat(IDLE, frames = 48, loop = true), - Beat("$IDLE→$THINKING", frames = 20, loop = false, cue = AtmospherePresets.THINKING), - Beat(THINKING, frames = 48, loop = true), - Beat("$THINKING→$IDLE", frames = 24, loop = false, cue = AtmospherePresets.IDLE), - Beat("$IDLE→$UNCERTAIN", frames = 34, loop = false, cue = AtmospherePresets.UNCERTAIN), - Beat(UNCERTAIN, frames = 48, loop = true), - Beat("$UNCERTAIN→$IDLE", frames = 28, loop = false, cue = AtmospherePresets.IDLE), - // Form morphs, idle only: the lattice reshaping into each catalogue form and back (PHO-57). - Beat("$IDLE→$FIBONACCI", frames = MORPH_FRAMES, loop = false, form = Forms.Fibonacci), - Beat(FIBONACCI, frames = 48, loop = true), - Beat("$FIBONACCI→$IDLE", frames = MORPH_FRAMES, loop = false, form = SPHERE), - Beat("$IDLE→$CUBE", frames = MORPH_FRAMES, loop = false, form = Forms.Cube), - Beat(CUBE, frames = 48, loop = true), - Beat("$CUBE→$IDLE", frames = MORPH_FRAMES, loop = false, form = SPHERE), - ) +internal const val DEMO_SEQUENCE_RESOURCE = "/lumos/demo-sequence.json" fun main(args: Array) { val runSeconds = args.getOrNull(0)?.toIntOrNull()?.coerceAtLeast(1) ?: DEFAULT_SECONDS @@ -147,43 +108,35 @@ fun main(args: Array) { withRawKeys { play(trace, runSeconds) } } +/** Read the bundled sequence; a malformed resource is a build error, so this throws. */ +internal fun demoSequence(): LumosSequence { + val text = + checkNotNull(object {}.javaClass.getResourceAsStream(DEMO_SEQUENCE_RESOURCE)) { + "$DEMO_SEQUENCE_RESOURCE is not on the classpath" + }.use { stream -> stream.readBytes().decodeToString() } + return LumosSequence.fromJson(text).getOrThrow() +} + /** - * Capture [SCRIPT] into a trace. [seamBlendFrames] is a parameter so a test can - * capture the same script unblended and measure what the blend changed. + * Capture the bundled sequence into a trace. [seamBlendFrames] is a parameter so + * a test can capture the same script unblended and measure what the blend changed. */ -internal fun captureDemoTrace(seamBlendFrames: Int = SEAM_BLEND_FRAMES): Result { - val totalFrames = SCRIPT.sumOf(Beat::frames) - val segments = ArrayList(SCRIPT.size) - val cues = ArrayList() - val formCues = ArrayList() - var cursor = 0 - - SCRIPT.forEach { beat -> - segments += TraceSegment(beat.name, cursor, cursor + beat.frames - 1, beat.loop) - beat.cue?.let { cues += AtmosphereCue(atFrame = cursor, atmosphere = it) } - beat.form?.let { formCues += FormCue(atFrame = cursor, form = it) } - cursor += beat.frames - } - - return Oscilloscope.capture( - CaptureConfig( +internal fun captureDemoTrace(seamBlendFrames: Int = SEAM_BLEND_FRAMES): Result = + Oscilloscope.capture( + CaptureConfig.forSequence( + sequence = demoSequence(), + fps = CAPTURE_FPS, params = CaptureParams(atmosphere = AtmospherePresets.IDLE), seed = 0x10_5E55L, - durationMs = (totalFrames * 1_000L + CAPTURE_FPS - 1) / CAPTURE_FPS, - fps = CAPTURE_FPS, - segments = segments, - cues = cues, seamBlendFrames = seamBlendFrames, - formCues = formCues, ), ) -} private fun describe(trace: VoxelTrace) { val loops = trace.segments.filter(TraceSegment::loop).map(TraceSegment::name) val recorded = trace.segments.filterNot(TraceSegment::loop).map(TraceSegment::name) - println("Lumos state-driven playback - PHO-39") + println("Lumos state-driven playback - PHO-39, scripted by $DEMO_SEQUENCE_RESOURCE (PHO-65)") println( "frames=${trace.frameCount} fps=${trace.fps} resolution=${trace.staticLattice.resolution} " + "cells/frame=${trace.cellCounts.maxOrNull() ?: 0}", diff --git a/phosphor-lumos-cli/src/jvmMain/resources/lumos/demo-sequence.json b/phosphor-lumos-cli/src/jvmMain/resources/lumos/demo-sequence.json new file mode 100644 index 0000000..f982194 --- /dev/null +++ b/phosphor-lumos-cli/src/jvmMain/resources/lumos/demo-sequence.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "loop": true, + "steps": [ + { "name": "idle", "durationSeconds": 2.0, "atmospherePreset": "idle", "loop": true }, + { "name": "idle→thinking", "durationSeconds": 0.83333, "atmospherePreset": "thinking" }, + { "name": "thinking", "durationSeconds": 2.0, "atmospherePreset": "thinking", "loop": true }, + { "name": "thinking→idle", "durationSeconds": 1.0, "atmospherePreset": "idle" }, + { "name": "idle→uncertain", "durationSeconds": 1.41667, "atmospherePreset": "uncertain" }, + { "name": "uncertain", "durationSeconds": 2.0, "atmospherePreset": "uncertain", "loop": true }, + { "name": "uncertain→idle", "durationSeconds": 1.16667, "atmospherePreset": "idle" }, + { "name": "idle→fibonacci", "durationSeconds": 2.41667, "form": "fibonacci" }, + { "name": "fibonacci", "durationSeconds": 2.0, "form": "fibonacci", "loop": true }, + { "name": "fibonacci→idle", "durationSeconds": 2.41667, "form": "sphere" }, + { "name": "idle→cube", "durationSeconds": 2.41667, "form": "cube" }, + { "name": "cube", "durationSeconds": 2.0, "form": "cube", "loop": true }, + { "name": "cube→idle", "durationSeconds": 2.41667, "form": "sphere" } + ] +} diff --git a/phosphor-lumos-cli/src/jvmTest/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemoSequenceTest.kt b/phosphor-lumos-cli/src/jvmTest/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemoSequenceTest.kt new file mode 100644 index 0000000..1b25523 --- /dev/null +++ b/phosphor-lumos-cli/src/jvmTest/kotlin/link/socket/phosphor/lumos/cli/demo/StateMachineDemoSequenceTest.kt @@ -0,0 +1,128 @@ +package link.socket.phosphor.lumos.cli.demo + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import link.socket.phosphor.lumos.Forms +import link.socket.phosphor.lumos.LumosForm +import link.socket.phosphor.lumos.LumosStep +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.signal.AtmosphereState +import link.socket.phosphor.trace.AtmosphereCue +import link.socket.phosphor.trace.CaptureConfig +import link.socket.phosphor.trace.CaptureParams +import link.socket.phosphor.trace.FormCue +import link.socket.phosphor.trace.Oscilloscope +import link.socket.phosphor.trace.TraceSegment +import link.socket.phosphor.trace.TraceStateMachine +import link.socket.phosphor.trace.VoxelTrace +import link.socket.phosphor.trace.frameLayout + +/** + * PHO-65 Task D: `demo-sequence.json` is the demo's script, as data. It lays out to the frames + * the hand-written `Beat` list laid out to before it, captures to the same frames the cue path + * captured, and every step name resolves through [TraceStateMachine]. + */ +class StateMachineDemoSequenceTest { + @Test + fun `the bundled sequence reads and lays out to the frames the Beat script had`() { + val sequence = demoSequence() + val layout = sequence.frameLayout(FPS) + + assertEquals(BEATS.map(Beat::name), sequence.steps.map(LumosStep::name)) + assertEquals(BEATS.map(Beat::frames), layout.segments.map { it!!.frameCount }) + assertEquals(BEATS.map(Beat::loop), sequence.steps.map(LumosStep::loop)) + assertEquals(BEATS.sumOf(Beat::frames), layout.frameCount) + } + + @Test + fun `the sequence captures to the frames the cue script captured`() { + val fromCues = captureBeats().getOrThrow() + + assertEquals(fromCues.segments, raw.segments) + // Columns rather than frames: 580 frames of 4,801 cells as objects is more heap than the test runner has. + assertContentEquals(fromCues.ticks, raw.ticks) + assertContentEquals(fromCues.cellCounts, raw.cellCounts) + assertEquals(fromCues.ambient, raw.ambient) + assertEquals(fromCues.glyphs, raw.glyphs) + assertContentEquals(fromCues.cellX, raw.cellX) + assertContentEquals(fromCues.cellY, raw.cellY) + assertContentEquals(fromCues.cellZ, raw.cellZ) + assertContentEquals(fromCues.cellScale, raw.cellScale) + assertContentEquals(fromCues.cellRed, raw.cellRed) + assertContentEquals(fromCues.cellGreen, raw.cellGreen) + assertContentEquals(fromCues.cellBlue, raw.cellBlue) + assertContentEquals(fromCues.cellAlpha, raw.cellAlpha) + } + + @Test + fun `every step name resolves through the state machine`() { + val machine = TraceStateMachine(raw, initialState = "idle") + + assertEquals(demoSequence().steps.map(LumosStep::name), machine.stateNames) + raw.segments.filter(TraceSegment::loop).forEach { loop -> + assertTrue(machine.requestState(loop.name).isSuccess, "${loop.name} should resolve") + } + } + + /** The old script, kept here as the reference the data is held to. */ + private data class Beat( + val name: String, + val frames: Int, + val loop: Boolean, + val cue: AtmosphereState? = null, + val form: LumosForm? = null, + ) + + private fun captureBeats(): Result { + val totalFrames = BEATS.sumOf(Beat::frames) + val segments = ArrayList(BEATS.size) + val cues = ArrayList() + val formCues = ArrayList() + var cursor = 0 + BEATS.forEach { beat -> + segments += TraceSegment(beat.name, cursor, cursor + beat.frames - 1, beat.loop) + beat.cue?.let { cues += AtmosphereCue(atFrame = cursor, atmosphere = it) } + beat.form?.let { formCues += FormCue(atFrame = cursor, form = it) } + cursor += beat.frames + } + return Oscilloscope.capture( + CaptureConfig( + params = CaptureParams(atmosphere = AtmospherePresets.IDLE), + seed = 0x10_5E55L, + durationMs = (totalFrames * 1_000L + FPS - 1) / FPS, + fps = FPS, + segments = segments, + cues = cues, + formCues = formCues, + ), + ) + } + + private companion object { + const val FPS = 24 + const val MORPH_FRAMES = 58 + val SPHERE: LumosForm = Forms.Lattice(AtmospherePresets.IDLE.resolution) + + val BEATS = + listOf( + Beat("idle", frames = 48, loop = true), + Beat("idle→thinking", frames = 20, loop = false, cue = AtmospherePresets.THINKING), + Beat("thinking", frames = 48, loop = true), + Beat("thinking→idle", frames = 24, loop = false, cue = AtmospherePresets.IDLE), + Beat("idle→uncertain", frames = 34, loop = false, cue = AtmospherePresets.UNCERTAIN), + Beat("uncertain", frames = 48, loop = true), + Beat("uncertain→idle", frames = 28, loop = false, cue = AtmospherePresets.IDLE), + Beat("idle→fibonacci", frames = MORPH_FRAMES, loop = false, form = Forms.Fibonacci), + Beat("fibonacci", frames = 48, loop = true), + Beat("fibonacci→idle", frames = MORPH_FRAMES, loop = false, form = SPHERE), + Beat("idle→cube", frames = MORPH_FRAMES, loop = false, form = Forms.Cube), + Beat("cube", frames = 48, loop = true), + Beat("cube→idle", frames = MORPH_FRAMES, loop = false, form = SPHERE), + ) + + // Unblended, so the comparison is of what the two scripts made the simulation do. + val raw: VoxelTrace by lazy { captureDemoTrace(seamBlendFrames = 0).getOrThrow() } + } +} diff --git a/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosDriver.kt b/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosDriver.kt index 768aed9..d5702bb 100644 --- a/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosDriver.kt +++ b/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosDriver.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue import link.socket.phosphor.lumos.LookClock import link.socket.phosphor.lumos.LumosConfig import link.socket.phosphor.lumos.LumosFrameSource +import link.socket.phosphor.lumos.LumosSequencePlayer import link.socket.phosphor.lumos.OrbStepper import link.socket.phosphor.lumos.PlatformMotionPreference import link.socket.phosphor.lumos.frame.LumosCanvasFrame @@ -24,9 +25,10 @@ import link.socket.phosphor.signal.LumosInput internal class LumosDriver( source: LumosFrameSource, clock: LookClock, + player: LumosSequencePlayer? = null, platformReducesMotion: () -> Boolean = { PlatformMotionPreference.reduceMotion }, ) { - private val stepper = OrbStepper(source, clock, platformReducesMotion) + private val stepper = OrbStepper(source, clock, player, platformReducesMotion) /** What the canvas draws; null until a frame has been projected. Compose state, so the canvas follows it. */ var canvasFrame: LumosCanvasFrame? by mutableStateOf(null) diff --git a/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosOrb.kt b/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosOrb.kt index 9b20784..806e881 100644 --- a/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosOrb.kt +++ b/phosphor-lumos-compose/src/commonMain/kotlin/link/socket/phosphor/lumos/compose/LumosOrb.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.delay import link.socket.phosphor.lumos.LookClock import link.socket.phosphor.lumos.LumosConfig import link.socket.phosphor.lumos.LumosFrameSource +import link.socket.phosphor.lumos.LumosSequencePlayer import link.socket.phosphor.lumos.Material import link.socket.phosphor.lumos.PlatformMotionPreference import link.socket.phosphor.lumos.SurfaceStyle @@ -103,9 +104,50 @@ fun LumosOrb( showHalo: Boolean = true, showGlyphOverlay: Boolean = true, probe: FrameProbe = FrameProbe.Disabled, +) { + DrivenOrb(source, null, config, modifier, backgroundColor, showHalo, showGlyphOverlay, probe) +} + +/** + * A [LumosOrb] whose clock also drives a [LumosSequencePlayer]: the orb connects to [player] + * while it is running — on screen, started, not paused — and advances it by every step it hands + * [source], so a source following the player ([link.socket.phosphor.lumos.LiveFrameSource.follow], + * `SignalGenerator.play(player)`) changes step in time with the frames. Off screen or paused, the + * orb releases its connection and the sequence holds, as the orb does. + * + * The player is a shared clock: several orbs may connect to one, and an external driver may take + * it over with [LumosSequencePlayer.control] while the orb keeps drawing. + * + * @param source Where frames come from; should follow [player], or the sequence shows nothing. + * @param player The sequence clock this orb advances. Remember it across recompositions. + */ +@Composable +fun LumosOrb( + source: LumosFrameSource, + player: LumosSequencePlayer, + config: LumosConfig = LumosConfig(), + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + showHalo: Boolean = true, + showGlyphOverlay: Boolean = true, + probe: FrameProbe = FrameProbe.Disabled, +) { + DrivenOrb(source, player, config, modifier, backgroundColor, showHalo, showGlyphOverlay, probe) +} + +@Composable +private fun DrivenOrb( + source: LumosFrameSource, + player: LumosSequencePlayer?, + config: LumosConfig, + modifier: Modifier, + backgroundColor: Color, + showHalo: Boolean, + showGlyphOverlay: Boolean, + probe: FrameProbe, ) { val clock = LocalLookClock.current - val driver = remember(source, clock) { LumosDriver(source, clock) } + val driver = remember(source, clock, player) { LumosDriver(source, clock, player) } SideEffect { driver.update(config, probe) } var onScreen by remember { mutableStateOf(false) } @@ -115,12 +157,18 @@ fun LumosOrb( LaunchedEffect(driver, running) { if (!running) return@LaunchedEffect - driver.rejoin() - while (true) { - withFrameNanos(driver.onFrame) - // A held pose only changes at a state or glyph boundary; a slower beat spares a - // device whose owner asked it for less motion. - if (driver.reducedMotion) delay(REDUCED_MOTION_INTERVAL) + // Connected only while running, so the sequence clock stops with the orb's. + val release = player?.connect() + try { + driver.rejoin() + while (true) { + withFrameNanos(driver.onFrame) + // A held pose only changes at a state or glyph boundary; a slower beat spares a + // device whose owner asked it for less motion. + if (driver.reducedMotion) delay(REDUCED_MOTION_INTERVAL) + } + } finally { + release?.invoke() } } diff --git a/phosphor-lumos-compose/src/jvmTest/kotlin/link/socket/phosphor/lumos/compose/LumosOrbSequenceTest.kt b/phosphor-lumos-compose/src/jvmTest/kotlin/link/socket/phosphor/lumos/compose/LumosOrbSequenceTest.kt new file mode 100644 index 0000000..3f6ed75 --- /dev/null +++ b/phosphor-lumos-compose/src/jvmTest/kotlin/link/socket/phosphor/lumos/compose/LumosOrbSequenceTest.kt @@ -0,0 +1,78 @@ +package link.socket.phosphor.lumos.compose + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import link.socket.phosphor.lumos.LiveFrameSource +import link.socket.phosphor.lumos.LumosConfig +import link.socket.phosphor.lumos.LumosSequence +import link.socket.phosphor.lumos.LumosSequencePlayer +import link.socket.phosphor.lumos.LumosStep +import link.socket.phosphor.palette.AtmospherePresets + +/** + * PHO-65 Task D: the orb that takes a [LumosSequencePlayer] connects to it while it runs, + * advances it with every frame it steps, and releases it when it stops — so the sequence clock + * is the orb's, and holds when the orb does. + * + * The orb's frame loop never ends, so the test clock is advanced by hand rather than until idle. + */ +@OptIn(ExperimentalTestApi::class) +class LumosOrbSequenceTest { + @Test + fun `a running orb connects to the player and advances it`() = + runComposeUiTest { + mainClock.autoAdvance = false + val player = LumosSequencePlayer(sequence()) + val source = LiveFrameSource(AtmospherePresets.IDLE).also { it.follow(player) } + + setContent { LumosOrb(source, player, LumosConfig(), Modifier.size(64.dp)) } + repeat(4) { mainClock.advanceTimeByFrame() } + + assertEquals(1, player.connections) + assertTrue( + player.timeSeconds > 0f, + "the orb's frames should have moved the sequence, was ${player.timeSeconds}", + ) + } + + @Test + fun `a paused orb releases the player and the sequence holds`() = + runComposeUiTest { + mainClock.autoAdvance = false + val player = LumosSequencePlayer(sequence()) + val source = LiveFrameSource(AtmospherePresets.IDLE).also { it.follow(player) } + var paused by mutableStateOf(false) + + setContent { LumosOrb(source, player, LumosConfig(paused = paused), Modifier.size(64.dp)) } + repeat(4) { mainClock.advanceTimeByFrame() } + assertTrue(player.timeSeconds > 0f) + + paused = true + // The frame already in flight may land; after it the clock holds. + mainClock.advanceTimeByFrame() + val held = player.timeSeconds + repeat(3) { mainClock.advanceTimeByFrame() } + + assertEquals(0, player.connections) + assertEquals(held, player.timeSeconds) + } + + private fun sequence(): LumosSequence = + LumosSequence( + steps = + listOf( + LumosStep("idle", 1f, atmospherePreset = "idle", loop = true), + LumosStep("idle→thinking", 0.8f, atmospherePreset = "thinking"), + LumosStep("thinking", 1f, atmospherePreset = "thinking", loop = true), + ), + ) +} diff --git a/phosphor-lumos-dom/src/jsMain/kotlin/link/socket/phosphor/lumos/dom/DomLumosDriver.kt b/phosphor-lumos-dom/src/jsMain/kotlin/link/socket/phosphor/lumos/dom/DomLumosDriver.kt index a58e9b5..3b53253 100644 --- a/phosphor-lumos-dom/src/jsMain/kotlin/link/socket/phosphor/lumos/dom/DomLumosDriver.kt +++ b/phosphor-lumos-dom/src/jsMain/kotlin/link/socket/phosphor/lumos/dom/DomLumosDriver.kt @@ -38,7 +38,7 @@ internal class DomLumosDriver( private val maxFps: Double, clock: LookClock = PageLookClock, ) { - private val stepper = OrbStepper(source, clock, host::reducesMotion) + private val stepper = OrbStepper(source, clock, platformReducesMotion = host::reducesMotion) private val minFrameMs = if (maxFps > 0.0) 1000.0 / maxFps else 0.0 private var started = false diff --git a/phosphor-lumos/build.gradle.kts b/phosphor-lumos/build.gradle.kts index c95b3da..20e68fd 100644 --- a/phosphor-lumos/build.gradle.kts +++ b/phosphor-lumos/build.gradle.kts @@ -94,12 +94,13 @@ kotlin { dependencies { implementation(project(":phosphor-core")) implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3") + // LumosSequence.fromJson / toJson: the one wire format this module reads and writes. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") } } val commonTest by getting { dependencies { implementation(kotlin("test")) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") } } } diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt index 870212a..4b41503 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt @@ -36,6 +36,18 @@ import link.socket.phosphor.signal.LumosInput * atmosphere change alone do not, and leave every phase where it is. * - [setInput] hands the pointer to [runtime], whose next snapshot carries it to [builder]. * + * ### Following a sequence + * + * [follow] puts the source on a [LumosSequencePlayer]: before every frame it reads the player's + * snapshot and, on entering a step, applies what the step names — the preset through + * [CognitiveSceneRuntime.setAtmospherePreset], the form through [VoxelFrameBuilder.morphTo], the + * look through [VoxelFrameBuilder.lookTo], the glyph through [VoxelFrameBuilder.queueGlyph]. A + * step that names the preset, form or look the orb already shows applies nothing, so a loop step + * can follow the one-shot that landed its state. Who advances the player is the caller's choice: + * `clock = true` has this source advance it by each frame's `dt` *after* building the frame, so + * frame `k` of a fixed-step stream is the sequence at `k / fps` — what `Oscilloscope` captures — + * while `clock = false` leaves the clock to a driver such as `OrbStepper`. + * * Stateful and single-threaded, like the pair it wraps. * * @property runtime The scene. Its atmosphere subsystem must be enabled; it is the only one a @@ -106,15 +118,129 @@ class LiveFrameSource( private var seenAtmosphere: AtmosphereState? = null private var changes: Long = 0L - override fun next(dt: Float): VoxelFrame = builder.build(runtime.update(dt), dt) + /** The sequence player this source follows, or null; see [follow]. */ + var sequence: LumosSequencePlayer? = null + private set + + private var clocked: Boolean = false + private var releaseConnection: (() -> Unit)? = null + private var appliedCycle: Int = -1 + private var appliedStep: Int = -1 + private var appliedPreset: String? = null + private var appliedForm: String? = null + private var appliedLook: Look? = null + + /** + * Follow [player], or stop following with null. + * + * The current step is applied at once, against what the orb shows now: the preset the scene + * is in, the form and look the builder wears. A step naming those applies nothing. + * + * @param player The clock to read, or null to leave the sequence. + * @param clock Whether this source advances [player] by each frame's `dt`, holding a + * connection on it while it follows. False leaves time to the driver. + */ + fun follow( + player: LumosSequencePlayer?, + clock: Boolean = false, + ) { + releaseConnection?.invoke() + releaseConnection = null + sequence = player + clocked = clock && player != null + appliedCycle = -1 + appliedStep = -1 + if (player == null) return + appliedPreset = presetNameOf(choreographer.activeTransition?.to ?: choreographer.currentState) + appliedForm = formNameOf(builder.form) + appliedLook = builder.look + if (clock) releaseConnection = player.connect() + applyStep(player.snapshot) + } + + override fun next(dt: Float): VoxelFrame { + sequence?.let { applyStep(it.snapshot) } + val frame = builder.build(runtime.update(dt), dt) + if (clocked) sequence?.advance(dt) + return frame + } override fun still(dt: Float): VoxelFrame { + sequence?.let { applyStep(it.snapshot) } choreographer.activeTransition?.let { transition -> choreographer.setAtmosphere(transition.to, transition.toPresetName, AtmosphereTransitionSpec.Immediate) } - return builder.buildStill(runtime.update(dt), dt) + val frame = builder.buildStill(runtime.update(dt), dt) + if (clocked) sequence?.advance(dt) + return frame + } + + // Apply the snapshot's step if it is not the one applied last; a wrapped sequence re-enters + // its steps, so the cycle counts too. + private fun applyStep(snapshot: LumosSequenceSnapshot) { + if (snapshot.cycle == appliedCycle && snapshot.stepIndex == appliedStep) return + appliedCycle = snapshot.cycle + appliedStep = snapshot.stepIndex + val step = snapshot.step + val transition = step.transition + var applied = false + + step.atmospherePreset?.lowercase()?.let { preset -> + if (preset != appliedPreset) { + appliedPreset = preset + applied = true + runtime.setAtmospherePreset( + preset, + transition?.let { AtmosphereTransitionSpec(it.durationSeconds, it.easingName) }, + ) + } + } + step.form?.lowercase()?.let { formName -> + if (formName != appliedForm) { + appliedForm = formName + applied = true + val form = + if (formName == LumosStep.LATTICE_FORM_NAME) { + Forms.Lattice((choreographer.activeTransition?.to ?: choreographer.currentState).resolution) + } else { + requireNotNull(Forms.byName(formName)) { "Unknown form: '$formName'" } + } + if (transition == null) { + builder.morphTo(form) + } else { + builder.morphTo( + form = form, + choreography = transition.choreography, + durationSeconds = transition.durationSeconds, + easingName = transition.easingName, + ) + } + } + } + step.look?.let { look -> + if (look != appliedLook) { + appliedLook = look + applied = true + builder.lookTo(look, transition?.durationSeconds ?: VoxelFrameBuilder.LOOK_BLEND_SECONDS) + } + } + step.glyph?.let { glyph -> + val window = transition?.durationSeconds?.takeIf { it > 0f } ?: DEFAULT_GLYPH_SECONDS + builder.queueGlyph(glyph, window) + } + if (applied) changes++ } + private fun presetNameOf(state: AtmosphereState): String? = + AtmospherePresets.ALL.firstOrNull { (_, preset) -> preset == state }?.first + + private fun formNameOf(form: LumosForm): String? = + if (form is Forms.Lattice) { + LumosStep.LATTICE_FORM_NAME + } else { + Forms.All.firstOrNull { (_, catalogued) -> catalogued === form }?.first + } + // The choreographer allocates a transition each time an atmosphere is set, and replaces it only // when the runtime steps, so while a driver is paused identity alone says whether one was set. override val revision: Long @@ -151,4 +277,9 @@ class LiveFrameSource( } override fun setInput(input: LumosInput) = runtime.setInput(input) + + companion object { + /** How long a sequence step's glyph burns when the step says nothing: the builder's own default. */ + const val DEFAULT_GLYPH_SECONDS: Float = 1.5f + } } diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosLook.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosLook.kt index 7052a4b..254d74a 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosLook.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosLook.kt @@ -1,5 +1,7 @@ package link.socket.phosphor.lumos +import kotlinx.serialization.Serializable + /** * The idiom the orb is wearing: the way its cells move for a cognitive moment, * as distinct from the atmosphere that colours them. @@ -47,10 +49,14 @@ enum class LumosLook(val variants: List) { /** * A [LumosLook] with one of its variants: what a [VoxelFrameBuilder] wears. * + * Serialises as `{"look":"WORKING","variant":"gyro"}`, the variant left out when it is + * the default, so a `LumosSequence` step can name one. + * * @property look The idiom. * @property variant One of [LumosLook.variants]; `"default"` unless asked otherwise. * @throws IllegalArgumentException when [variant] is not one the look offers. */ +@Serializable data class Look( val look: LumosLook, val variant: String = "default", diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt new file mode 100644 index 0000000..5d35e7d --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt @@ -0,0 +1,462 @@ +package link.socket.phosphor.lumos + +import kotlin.math.max +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import link.socket.phosphor.choreography.AtmosphereChoreographer +import link.socket.phosphor.field.Choreography +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.timeline.Easing + +/** + * One beat of a [LumosSequence]: what the orb shows, for how long, and how it gets there. + * + * A step names exactly one of [look], [form], [atmospherePreset] or [glyph], or a [look] + * together with an [atmospherePreset]. When the sequence reaches the step, whatever it names is + * applied — the atmosphere transitions, the form morphs, the look blends, the glyph fires — and + * the orb then rests there for the remainder of [durationSeconds]. A step that names what the + * orb already shows applies nothing, so a loop step may follow the one-shot that landed its + * state and name the same preset without restarting the transition. A glyph is a one-shot and + * fires every time its step is entered. + * + * Captured by `Oscilloscope`, a step is one `TraceSegment`: [name] is the segment's name, so the + * `TraceStateMachine` convention applies — a resting beat is `"idle"`, a transition between two + * beats is `"idle→thinking"`, a morph is `"sphere→fibonacci"` — and [loop] is carried. A + * `loop` step should start where its arrival has landed, as a cue-captured loop does: a loop that + * captures its own transition pops when it wraps. + * + * @property name What the step is called; non-blank and unique within its sequence. Becomes the + * `TraceSegment` name when captured. + * @property durationSeconds The step's length including its arriving transition, in + * [MIN_DURATION_SECONDS]..[MAX_DURATION_SECONDS]. + * @property look The [Look] to blend to, or null to leave the look alone. + * @property form The form to morph to, by catalogue name (`Forms.byName`: `"fibonacci"`, + * `"cube"`) or [LATTICE_FORM_NAME], the lattice at the atmosphere's resolution. Null leaves the + * form alone. + * @property atmospherePreset The atmosphere preset to transition to, by name + * (`AtmospherePresets.byName`, case-insensitive). Null leaves the atmosphere alone. + * @property glyph The glyph to fire on entering the step, or null. + * @property transition How the step arrives; null takes the default for what it names — the + * `AtmosphereChoreographer` table for a preset, `VoxelFrameBuilder.DEFAULT_MORPH_SECONDS` for a + * form, `VoxelFrameBuilder.LOOK_BLEND_SECONDS` for a look, the generator's default window for a + * glyph. + * @property loop Whether a player of the captured clip should loop this step rather than play it + * once. Does not change how the live sequence advances. + */ +@Serializable +data class LumosStep( + val name: String, + val durationSeconds: Float, + val look: Look? = null, + val form: String? = null, + val atmospherePreset: String? = null, + val glyph: LumosGlyph? = null, + val transition: LumosTransition? = null, + val loop: Boolean = false, +) { + /** Whether this step changes the atmosphere, form or look — anything but a glyph alone. */ + val changesState: Boolean get() = look != null || form != null || atmospherePreset != null + + companion object { + /** The shortest step, in seconds. */ + const val MIN_DURATION_SECONDS: Float = 0.05f + + /** The longest step, in seconds: an hour. */ + const val MAX_DURATION_SECONDS: Float = 3600f + + /** The [form] name for the lattice every preset renders on, as `TraceSegment` names it. */ + const val LATTICE_FORM_NAME: String = "sphere" + } +} + +/** + * How a [LumosStep] arrives. + * + * @property durationSeconds How long the arrival takes; `>= 0`, and no longer than the step. `0` + * snaps. + * @property choreography The arc cells travel on when the step morphs a form; atmosphere, look + * and glyph steps ignore it. + * @property easingName An `Easing.byName` identifier for the atmosphere's curve or the morph's + * arc envelope. + */ +@Serializable +data class LumosTransition( + val durationSeconds: Float, + val choreography: Choreography = Choreography.FLOW, + val easingName: String = DEFAULT_EASING, +) { + companion object { + /** The easing when none is given: the choreographer's own default. */ + const val DEFAULT_EASING: String = "easeInOut" + } +} + +/** + * Where a [LumosSequence] is at one moment: a pure function of sequence time, which is what lets + * a capture and a live player agree. + * + * @property stepIndex Index of the current step in [LumosSequence.steps]. + * @property step The current step. + * @property elapsedSeconds Time into the step, `0 <= elapsed < step.durationSeconds` except at + * the end of a sequence that does not loop, where it equals the duration. + * @property progress [elapsedSeconds] over the step's duration, in `0..1`. + * @property transitioning Whether the step's arrival is still in flight: [elapsedSeconds] is + * shorter than its arriving transition. + * @property cycle How many times the sequence has wrapped; always 0 for one that does not loop. + * @property timeSeconds The time the snapshot was sampled at, as given. + * @property ended Whether a sequence that does not loop has run out. + */ +data class LumosSequenceSnapshot( + val stepIndex: Int, + val step: LumosStep, + val elapsedSeconds: Float, + val progress: Float, + val transitioning: Boolean, + val cycle: Int, + val timeSeconds: Float, + val ended: Boolean, +) + +/** + * Why a [LumosSequence] is not one. Thrown by the constructor, so a sequence that exists is + * valid, and returned inside [LumosSequence.fromJson]'s failure. + */ +sealed class LumosSequenceError( + message: String, + cause: Throwable? = null, +) : IllegalArgumentException(message, cause) { + /** The JSON could not be read as a sequence at all. */ + class Malformed( + message: String, + cause: Throwable? = null, + ) : LumosSequenceError(message, cause) + + /** The document's [version] is not one this library reads. */ + class UnsupportedVersion( + val version: Int, + ) : LumosSequenceError( + "version $version is not supported; this library reads version ${LumosSequence.VERSION}", + ) + + /** Fewer than [LumosSequence.MIN_STEPS] or more than [LumosSequence.MAX_STEPS] steps. */ + class StepCount( + val count: Int, + ) : LumosSequenceError( + "a sequence has ${LumosSequence.MIN_STEPS}..${LumosSequence.MAX_STEPS} steps, this one has $count", + ) + + /** One step is wrong; [index] and [name] say which. */ + class Step( + val index: Int, + val name: String, + val reason: String, + ) : LumosSequenceError("step $index \"$name\": $reason") +} + +/** + * An orb's choreography as data: steps of look, form, atmosphere and glyph with durations and + * transitions, that `Oscilloscope` captures into named segments and `SignalGenerator` plays + * live, from one definition. [fromJson] is what a consumer bundles; [LumosSequencePlayer] is the + * clock. + * + * ```json + * { + * "version": 1, + * "loop": true, + * "steps": [ + * { "name": "idle", "durationSeconds": 2.0, "atmospherePreset": "idle", "loop": true }, + * { "name": "idle→thinking", "durationSeconds": 0.8, "atmospherePreset": "thinking" }, + * { "name": "thinking", "durationSeconds": 2.0, "atmospherePreset": "thinking", "loop": true }, + * { "name": "thinking→idle", "durationSeconds": 1.0, "atmospherePreset": "idle" } + * ] + * } + * ``` + * + * [sample] is a pure seek: the same time always gives the same snapshot, so a capture that steps + * the sequence at a fixed `1 / fps` and a player that steps it by a measured frame gap are + * looking at one function. Step boundaries are compared with a [BOUNDARY_TOLERANCE_SECONDS] + * tolerance, so a clock accumulated as `Float` lands the frame the author expects. + * + * The constructor validates and throws a [LumosSequenceError] — 1..128 steps, each with a + * non-blank unique name, a duration in range, exactly one primary field (or a look with a + * preset), a transition no longer than the step, and form, preset and easing names that resolve. + * + * @property version Format version; [VERSION] is the only one read. + * @property steps The beats, in order. + * @property loop Whether the sequence wraps to its first step when it runs out, or ends. + * @property defaults How a surface should drive and show an orb playing this sequence; a hint + * for the consumer, not applied by the player. + */ +@Serializable +data class LumosSequence( + val version: Int = VERSION, + val steps: List, + val loop: Boolean = true, + val defaults: LumosConfig = LumosConfig(), +) { + init { + validate()?.let { throw it } + } + + /** Where each step starts, in seconds from the sequence's start; one entry per step. */ + @Transient + val stepStarts: List = + buildList(steps.size) { + var cursor = 0f + steps.forEach { step -> + add(cursor) + cursor += step.durationSeconds + } + } + + /** The sum of every step's duration, in seconds: one pass of the sequence. */ + @Transient + val durationSeconds: Float = stepStarts.last() + steps.last().durationSeconds + + /** + * How long each step's arrival lasts, in seconds: its explicit transition, else the default + * for what it names — the choreographer's table for a preset (looked up from the preset the + * step before it names, or the sequence's last preset when it loops), the morph default for a + * form, the look blend for a look, and `0` for a glyph alone, which has no arrival. A step + * that names only what the step before it already named arrives in `0`: a loop step resting + * where its one-shot landed applies nothing, and says so. + */ + @Transient + val transitionSeconds: List = + List(steps.size) { index -> + val step = steps[index] + step.transition?.durationSeconds ?: defaultTransitionSeconds(index) + } + + /** + * Where the sequence is at [seconds] from its start. + * + * Past the end, a looping sequence wraps and a finite one holds its last step with + * [LumosSequenceSnapshot.ended] set. Negative time is the start. + */ + fun sample(seconds: Float): LumosSequenceSnapshot { + val clamped = if (seconds.isNaN()) 0f else seconds.coerceAtLeast(0f) + val cycle: Int + val local: Float + val ended: Boolean + if (clamped >= durationSeconds - BOUNDARY_TOLERANCE_SECONDS) { + if (loop) { + val passes = (clamped / durationSeconds).toInt() + val remainder = clamped - passes * durationSeconds + // Float residue can leave the remainder a hair short of a full pass. + if (remainder >= durationSeconds - BOUNDARY_TOLERANCE_SECONDS) { + cycle = passes + 1 + local = 0f + } else { + cycle = passes + local = remainder + } + ended = false + } else { + val last = steps.lastIndex + return LumosSequenceSnapshot( + stepIndex = last, + step = steps[last], + elapsedSeconds = steps[last].durationSeconds, + progress = 1f, + transitioning = false, + cycle = 0, + timeSeconds = seconds, + ended = true, + ) + } + } else { + cycle = 0 + local = clamped + ended = false + } + var index = 0 + while (index + 1 < steps.size && local >= stepStarts[index + 1] - BOUNDARY_TOLERANCE_SECONDS) { + index++ + } + val step = steps[index] + val elapsed = (local - stepStarts[index]).coerceIn(0f, step.durationSeconds) + return LumosSequenceSnapshot( + stepIndex = index, + step = step, + elapsedSeconds = elapsed, + progress = (elapsed / step.durationSeconds).coerceIn(0f, 1f), + transitioning = elapsed < transitionSeconds[index], + cycle = cycle, + timeSeconds = seconds, + ended = ended, + ) + } + + /** This sequence as JSON, as [fromJson] reads it. Defaults are left out. */ + fun toJson(): String = json.encodeToString(serializer(), this) + + /** The index of the step called [name], or -1. */ + fun indexOf(name: String): Int = steps.indexOfFirst { it.name == name } + + private fun defaultTransitionSeconds(index: Int): Float { + val step = steps[index] + var arrival = 0f + step.atmospherePreset?.lowercase()?.let { preset -> + val from = before(index) { it.atmospherePreset?.lowercase() } + if (preset != from) { + arrival = max(arrival, AtmosphereChoreographer.transitionSpec(from, preset).durationSeconds) + } + } + step.form?.lowercase()?.let { form -> + if (form != before(index) { it.form?.lowercase() }) { + arrival = max(arrival, VoxelFrameBuilder.DEFAULT_MORPH_SECONDS) + } + } + step.look?.let { look -> + if (look != before(index) { it.look }) { + arrival = max(arrival, VoxelFrameBuilder.LOOK_BLEND_SECONDS) + } + } + return arrival + } + + // What the orb shows when step `index` arrives, as [field] reads it: the nearest earlier + // step's value, wrapping to the end when the sequence loops. Null when no step before it + // names one. + private fun before( + index: Int, + field: (LumosStep) -> T?, + ): T? { + for (back in 1 until steps.size) { + val at = index - back + val resolved = + when { + at >= 0 -> at + loop -> at + steps.size + else -> return null + } + field(steps[resolved])?.let { return it } + } + return null + } + + private fun validate(): LumosSequenceError? { + if (version != VERSION) return LumosSequenceError.UnsupportedVersion(version) + if (steps.size !in MIN_STEPS..MAX_STEPS) return LumosSequenceError.StepCount(steps.size) + val names = HashSet() + steps.forEachIndexed { index, step -> + fun fail(reason: String) = LumosSequenceError.Step(index, step.name, reason) + if (step.name.isBlank()) return fail("name must not be blank") + if (!names.add(step.name)) return fail("name is used by an earlier step; names are unique") + if (step.durationSeconds.isNaN() || + step.durationSeconds < LumosStep.MIN_DURATION_SECONDS || + step.durationSeconds > LumosStep.MAX_DURATION_SECONDS + ) { + return fail( + "durationSeconds must be in " + + "${LumosStep.MIN_DURATION_SECONDS}..${LumosStep.MAX_DURATION_SECONDS}, " + + "was ${step.durationSeconds}", + ) + } + val primaries = + listOfNotNull( + step.look?.let { "look" }, + step.form?.let { "form" }, + step.atmospherePreset?.let { "atmospherePreset" }, + step.glyph?.let { "glyph" }, + ) + val lookWithPreset = primaries.size == 2 && "look" in primaries && "atmospherePreset" in primaries + if (primaries.isEmpty()) { + return fail("a step names one of look, form, atmospherePreset or glyph") + } + if (primaries.size > 1 && !lookWithPreset) { + return fail( + "a step names exactly one of look, form, atmospherePreset or glyph, or a look with an " + + "atmospherePreset; this one names ${primaries.joinToString(" + ")}", + ) + } + step.form?.let { form -> + val lattice = form.equals(LumosStep.LATTICE_FORM_NAME, ignoreCase = true) + if (!lattice && Forms.byName(form) == null) { + return fail( + "unknown form \"$form\"; the catalogue has ${Forms.All.map { it.first }} and " + + "\"${LumosStep.LATTICE_FORM_NAME}\"", + ) + } + } + step.atmospherePreset?.let { preset -> + if (AtmospherePresets.byName(preset) == null) { + return fail( + "unknown atmosphere preset \"$preset\"; the presets are " + + "${AtmospherePresets.ALL.map { it.first }}", + ) + } + } + step.transition?.let { transition -> + if (transition.durationSeconds.isNaN() || transition.durationSeconds < 0f) { + return fail("transition.durationSeconds must be >= 0, was ${transition.durationSeconds}") + } + if (transition.durationSeconds > step.durationSeconds) { + return fail( + "transition.durationSeconds ${transition.durationSeconds} is longer than the step's " + + "${step.durationSeconds}", + ) + } + if (Easing.byName(transition.easingName) == null) { + return fail("unknown easing \"${transition.easingName}\"; see Easing.byName") + } + } + } + return null + } + + companion object { + /** The format version this library reads and writes. */ + const val VERSION: Int = 1 + + /** The fewest steps a sequence has. */ + const val MIN_STEPS: Int = 1 + + /** The most steps a sequence has. */ + const val MAX_STEPS: Int = 128 + + /** + * How close to a step boundary counts as past it, in seconds: a tenth of a millisecond, + * well under any frame and over the residue of summing `Float` steps. + */ + const val BOUNDARY_TOLERANCE_SECONDS: Float = 1e-4f + + private val json: Json = + Json { + ignoreUnknownKeys = true + prettyPrint = false + } + + /** + * Read a sequence from JSON. + * + * @return The sequence, or a failure carrying a [LumosSequenceError]: + * [LumosSequenceError.Malformed] when the text is not a sequence document, + * otherwise the validation error the constructor would throw, which names the step. + */ + fun fromJson(text: String): Result = + try { + Result.success(json.decodeFromString(serializer(), text)) + } catch (error: LumosSequenceError) { + Result.failure(error) + } catch (error: SerializationException) { + Result.failure(unwrap(error) ?: LumosSequenceError.Malformed(error.message ?: "not a sequence", error)) + } catch (error: IllegalArgumentException) { + // A Look with a variant its look does not offer, or a LumosConfig out of range. + Result.failure(unwrap(error) ?: LumosSequenceError.Malformed(error.message ?: "not a sequence", error)) + } + + // A LumosSequenceError raised inside decoding may come back wrapped; find it. + private fun unwrap(error: Throwable): LumosSequenceError? { + var cause: Throwable? = error + while (cause != null) { + if (cause is LumosSequenceError) return cause + cause = cause.cause + } + return null + } + } +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt new file mode 100644 index 0000000..8d3626f --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt @@ -0,0 +1,126 @@ +package link.socket.phosphor.lumos + +import kotlin.math.min + +/** + * The clock of a [LumosSequence], with no framework in it: whoever owns the loop advances it, and + * everyone showing the sequence reads [snapshot]. + * + * Time moves only in [advance], and only while the player is *ticking*: at least one consumer is + * connected ([connect]), nobody has taken control ([control]), and it is [playing]. A driver — + * `OrbStepper`, `SignalGenerator.frames`, `Oscilloscope`'s fixed step — calls [advance] with the + * step it hands the orb; each call is capped at [MAX_STEP_SECONDS], the same cap `OrbStepper` + * puts on a frame gap, so a return from the background carries on rather than jumping. + * + * [control] is for a scroll position or any external driver that wants to own time: while a + * controller holds, [advance] moves nothing and [seek] is how time moves, and the renderer keeps + * drawing whatever time it is set to. [connect] lets several consumers share one clock without + * any of them owning it; the last one to release stops the clock. + * + * Stateful and single-threaded, like the sources that read it. + * + * @property sequence What is played. + * @param autoplay Whether the player starts [playing]; true by default. + */ +class LumosSequencePlayer( + val sequence: LumosSequence, + autoplay: Boolean = true, +) { + /** Sequence time, in seconds from the start of the first pass. */ + var timeSeconds: Float = 0f + private set + + /** Whether time moves when the gate allows; see [play] and [pause]. */ + var playing: Boolean = autoplay + private set + + /** How many consumers hold a [connect] handle. */ + var connections: Int = 0 + private set + + /** How many external drivers hold a [control] handle. */ + var controllers: Int = 0 + private set + + /** Whether [advance] moves time right now: connected, uncontrolled and playing. */ + val ticking: Boolean get() = connections > 0 && controllers == 0 && playing + + /** Where the sequence is at [timeSeconds]; `sequence.sample(timeSeconds)`. */ + val snapshot: LumosSequenceSnapshot get() = sequence.sample(timeSeconds) + + /** Let time move. A finite sequence that has ended restarts; see [restart]. */ + fun play() { + if (!sequence.loop && snapshot.ended) timeSeconds = 0f + playing = true + } + + /** Hold time where it is. */ + fun pause() { + playing = false + } + + /** + * Move to [seconds] from the start. Negative is the start; past the end a looping sequence + * wraps and a finite one ends. Allowed whoever owns the clock: this is how a controller + * drives it. + */ + fun seek(seconds: Float) { + timeSeconds = if (seconds.isNaN()) 0f else seconds.coerceAtLeast(0f) + } + + /** Back to the start, playing. */ + fun restart() { + timeSeconds = 0f + playing = true + } + + /** + * Take ownership of time. Until the returned release is called, [advance] moves nothing and + * [seek] is the clock. Releasing twice does nothing. + */ + fun control(): () -> Unit { + controllers++ + return once { controllers-- } + } + + /** + * Join the consumers sharing this clock. Time moves only while at least one holds a handle. + * Releasing twice does nothing. + */ + fun connect(): () -> Unit { + connections++ + return once { connections-- } + } + + /** + * Move time forward by [dt] seconds, capped at [MAX_STEP_SECONDS], if [ticking]. A finite + * sequence that reaches its end pauses there. + * + * @return The snapshot after the step, whether or not time moved. + */ + fun advance(dt: Float): LumosSequenceSnapshot { + if (ticking && dt > 0f && !dt.isNaN()) { + timeSeconds += min(dt, MAX_STEP_SECONDS) + if (!sequence.loop && timeSeconds >= sequence.durationSeconds) { + timeSeconds = sequence.durationSeconds + playing = false + } + } + return snapshot + } + + private inline fun once(crossinline release: () -> Unit): () -> Unit { + var released = false + return { + if (!released) { + released = true + release() + } + } + } + + companion object { + /** The most one [advance] moves time, in seconds: `LookClock.MAX_GAP_SECONDS`. */ + const val MAX_STEP_SECONDS: Float = 0.1f + } +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt index a680a40..74bbc07 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt @@ -41,14 +41,21 @@ import link.socket.phosphor.signal.LumosInput * * Main-thread only, like the source it steps. * + * With a [player], every step advances the sequence clock by the same `dt` it hands the source, + * before the source is stepped, so a source following that player ([LiveFrameSource.follow]) + * sees the step the frame falls in. Connecting to the player is the surface's job: the stepper + * does not know when the orb is on screen. + * * @param source What the orb draws. * @param clock The look clocks this orb shares with its peers. + * @param player A sequence clock to advance in step with the source, or null. * @param platformReducesMotion Whether the platform asks for reduced motion; read once a second * while stepping. Defaults to [PlatformMotionPreference.reduceMotion]. */ class OrbStepper( private val source: LumosFrameSource, private val clock: LookClock, + private val player: LumosSequencePlayer? = null, private val platformReducesMotion: () -> Boolean = { PlatformMotionPreference.reduceMotion }, ) { /** The frame drawn last; null until a frame has been projected. */ @@ -172,6 +179,7 @@ class OrbStepper( steppedLook = look rejoining = false + player?.advance(dt) return show(if (reducedMotion) source.still(dt) else source.next(dt)) } diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LiveFrameSourceSequenceTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LiveFrameSourceSequenceTest.kt new file mode 100644 index 0000000..f661a4b --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LiveFrameSourceSequenceTest.kt @@ -0,0 +1,195 @@ +package link.socket.phosphor.lumos + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import link.socket.phosphor.palette.AtmospherePresets + +/** + * PHO-65 Task B: a [LiveFrameSource] following a [LumosSequencePlayer] applies each step as the + * sequence enters it — preset, form, look, glyph — and nothing a step names that the orb already + * shows. With `clock = true` it advances the player after building, so frame `k` of a fixed-step + * stream is the sequence at `k / fps`. + */ +class LiveFrameSourceSequenceTest { + @Test + fun `following applies the current step at once`() { + val source = LiveFrameSource(AtmospherePresets.IDLE) + val player = LumosSequencePlayer(presets("thinking", "idle")) + + source.follow(player) + + assertEquals("thinking", source.lookKey) + assertEquals("thinking", source.runtime.atmosphereChoreographer?.activeTransition?.toPresetName) + } + + @Test + fun `a step naming the preset the scene is in applies nothing`() { + val source = LiveFrameSource(AtmospherePresets.IDLE) + val player = LumosSequencePlayer(presets("idle", "thinking")) + val before = source.revision + + source.follow(player) + + assertNull(source.runtime.atmosphereChoreographer?.activeTransition) + assertEquals(before, source.revision) + } + + @Test + fun `a state step bumps the revision so a paused orb redraws`() { + val source = LiveFrameSource(AtmospherePresets.IDLE) + val before = source.revision + + source.follow(LumosSequencePlayer(presets("thinking", "idle"))) + + assertTrue(source.revision > before) + } + + @Test + fun `the clocked source advances the player after building each frame`() { + val source = LiveFrameSource(AtmospherePresets.IDLE.copy(resolution = 2)) + val player = LumosSequencePlayer(presets("thinking", "idle", seconds = 0.5f)) + + source.follow(player, clock = true) + assertEquals(1, player.connections) + repeat(10) { source.next(0.05f) } + + assertEquals(0.5f, player.timeSeconds, 1e-5f) + // Frame 10 is the sequence at 0.5 s: the second step, applied before it is built. + source.next(0.05f) + assertEquals("idle", source.runtime.atmosphereChoreographer?.activeTransition?.toPresetName) + } + + @Test + fun `an unclocked source leaves time to the driver`() { + val source = LiveFrameSource(AtmospherePresets.IDLE.copy(resolution = 2)) + val player = LumosSequencePlayer(presets("thinking", "idle", seconds = 0.5f)) + player.connect() + + source.follow(player) + repeat(10) { source.next(0.05f) } + + assertEquals(0f, player.timeSeconds) + assertEquals(1, player.connections) + } + + @Test + fun `leaving a sequence releases the clocked connection`() { + val source = LiveFrameSource(AtmospherePresets.IDLE.copy(resolution = 2)) + val player = LumosSequencePlayer(presets("thinking", "idle")) + source.follow(player, clock = true) + + source.follow(null) + + assertEquals(0, player.connections) + assertNull(source.sequence) + } + + @Test + fun `a form step morphs and the same form named again does not`() { + val source = LiveFrameSource(AtmospherePresets.IDLE.copy(resolution = 2)) + val player = + LumosSequencePlayer( + LumosSequence( + steps = + listOf( + LumosStep("sphere", 0.1f, form = "sphere"), + LumosStep("sphere→fibonacci", 0.1f, form = "fibonacci"), + LumosStep("fibonacci", 0.1f, form = "fibonacci"), + ), + loop = false, + ), + ) + player.connect() + + // Already the lattice: naming it is not a morph. + source.follow(player) + source.next(0.05f) + assertFalse(source.builder.isMorphing) + + val lattice = source.builder.form + player.seek(0.1f) + source.next(0.05f) + assertTrue(source.builder.isMorphing) + val form = source.builder.form + assertNotEquals(lattice, form) + + // Still morphing when the loop step names the form again: no new trip is started. + player.seek(0.2f) + source.next(0.05f) + assertSame(form, source.builder.form) + assertEquals(2, source.sequence?.snapshot?.stepIndex) + } + + @Test + fun `a look step blends to the look`() { + val source = LiveFrameSource(AtmospherePresets.IDLE.copy(resolution = 2)) + val working = LumosSequence(steps = listOf(LumosStep("working", 1f, look = Look(LumosLook.WORKING)))) + val player = LumosSequencePlayer(working) + + source.follow(player) + source.next(0.05f) + + assertEquals(Look(LumosLook.WORKING), source.builder.look) + assertTrue(source.builder.isLookBlending) + } + + @Test + fun `a glyph fires on every entry including after a wrap`() { + val source = LiveFrameSource(AtmospherePresets.IDLE.copy(resolution = 2)) + val player = + LumosSequencePlayer( + LumosSequence( + steps = + listOf( + LumosStep("done", 0.2f, glyph = LumosGlyph.CHECK, transition = LumosTransition(0.1f)), + ), + ), + ) + player.connect() + source.follow(player) + + assertNotNull(source.next(0.05f).glyph) + player.seek(0.15f) + assertNull(source.next(0.05f).glyph, "the 0.1 s window has closed") + player.seek(0.2f) + assertEquals("CHECK", source.next(0.05f).glyph?.glyphName) + } + + @Test + fun `an explicit transition overrides the table`() { + val source = LiveFrameSource(AtmospherePresets.IDLE) + val player = + LumosSequencePlayer( + LumosSequence( + steps = + listOf( + LumosStep( + "idle→thinking", + 1f, + atmospherePreset = "thinking", + transition = LumosTransition(0.25f, easingName = "eager"), + ), + ), + ), + ) + + source.follow(player) + + val transition = assertNotNull(source.runtime.atmosphereChoreographer?.activeTransition) + assertEquals("eager", transition.easingName) + } + + private fun presets( + vararg names: String, + seconds: Float = 1f, + ): LumosSequence = + LumosSequence( + steps = names.map { LumosStep(it, seconds, atmospherePreset = it, loop = true) }, + ) +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequencePlayerTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequencePlayerTest.kt new file mode 100644 index 0000000..63ffbbc --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequencePlayerTest.kt @@ -0,0 +1,183 @@ +package link.socket.phosphor.lumos + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * PHO-65 Task B: the clock. Time moves only while connected, uncontrolled and playing; a step is + * capped at a tenth of a second; a controller owns time through `seek`; a finite sequence ends. + */ +class LumosSequencePlayerTest { + @Test + fun `nothing moves until a consumer connects`() { + val player = LumosSequencePlayer(sequence()) + + player.advance(0.05f) + + assertEquals(0f, player.timeSeconds) + assertFalse(player.ticking) + } + + @Test + fun `a connected playing player advances by dt`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + + player.advance(0.05f) + player.advance(0.05f) + + assertEquals(0.1f, player.timeSeconds, 1e-6f) + assertTrue(player.ticking) + } + + @Test + fun `a step is capped at a tenth of a second`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + + player.advance(5f) + + assertEquals(LumosSequencePlayer.MAX_STEP_SECONDS, player.timeSeconds) + } + + @Test + fun `pause holds and play resumes`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + player.advance(0.05f) + + player.pause() + player.advance(0.05f) + assertEquals(0.05f, player.timeSeconds, 1e-6f) + assertFalse(player.playing) + + player.play() + player.advance(0.05f) + assertEquals(0.1f, player.timeSeconds, 1e-6f) + } + + @Test + fun `autoplay false starts paused`() { + val player = LumosSequencePlayer(sequence(), autoplay = false) + player.connect() + + player.advance(0.05f) + + assertEquals(0f, player.timeSeconds) + } + + @Test + fun `a controller owns time and seek is its clock`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + val release = player.control() + + player.advance(0.05f) + assertEquals(0f, player.timeSeconds) + assertEquals(1, player.controllers) + assertFalse(player.ticking) + + player.seek(1.5f) + assertEquals(1.5f, player.timeSeconds) + assertEquals(1, player.snapshot.stepIndex) + + release() + release() + assertEquals(0, player.controllers) + player.advance(0.05f) + assertEquals(1.55f, player.timeSeconds, 1e-6f) + } + + @Test + fun `the clock stops when the last consumer releases`() { + val player = LumosSequencePlayer(sequence()) + val first = player.connect() + val second = player.connect() + assertEquals(2, player.connections) + + first() + first() + player.advance(0.05f) + assertEquals(0.05f, player.timeSeconds, 1e-6f) + + second() + player.advance(0.05f) + assertEquals(0.05f, player.timeSeconds, 1e-6f) + assertEquals(0, player.connections) + } + + @Test + fun `a finite sequence ends and pauses at its end`() { + val player = LumosSequencePlayer(sequence().copy(loop = false)) + player.connect() + + repeat(25) { player.advance(0.1f) } + + assertEquals(2f, player.timeSeconds) + assertFalse(player.playing) + assertTrue(player.snapshot.ended) + + player.play() + assertEquals(0f, player.timeSeconds) + assertTrue(player.playing) + } + + @Test + fun `a looping sequence wraps`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + + repeat(25) { player.advance(0.1f) } + + assertEquals(2.5f, player.timeSeconds, 1e-5f) + assertEquals(1, player.snapshot.cycle) + assertEquals(0, player.snapshot.stepIndex) + assertTrue(player.playing) + } + + @Test + fun `restart goes back to the start playing`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + player.advance(0.1f) + player.pause() + + player.restart() + + assertEquals(0f, player.timeSeconds) + assertTrue(player.playing) + } + + @Test + fun `seek clamps negative and NaN time to the start`() { + val player = LumosSequencePlayer(sequence()) + + player.seek(-1f) + assertEquals(0f, player.timeSeconds) + player.seek(Float.NaN) + assertEquals(0f, player.timeSeconds) + } + + @Test + fun `a non-positive or NaN step moves nothing`() { + val player = LumosSequencePlayer(sequence()) + player.connect() + + player.advance(0f) + player.advance(-1f) + player.advance(Float.NaN) + + assertEquals(0f, player.timeSeconds) + } + + private fun sequence(): LumosSequence = + LumosSequence( + steps = + listOf( + LumosStep("idle", 1f, atmospherePreset = "idle", loop = true), + LumosStep("thinking", 1f, atmospherePreset = "thinking", loop = true), + ), + ) +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequenceTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequenceTest.kt new file mode 100644 index 0000000..bf64640 --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosSequenceTest.kt @@ -0,0 +1,338 @@ +package link.socket.phosphor.lumos + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import link.socket.phosphor.field.Choreography + +/** + * PHO-65 Task B: the format. Validation names the step, `sample` is a pure seek with a tolerance + * at its boundaries, arrivals default to what the step names, and JSON round-trips. + */ +class LumosSequenceTest { + @Test + fun `durations and starts add up`() { + val sequence = demo() + + assertEquals(listOf(0f, 2f, 2.8f, 4.8f), sequence.stepStarts) + assertEquals(5.8f, sequence.durationSeconds, 1e-6f) + } + + @Test + fun `sample lands each step from its start and wraps a loop`() { + val sequence = demo() + + assertEquals(0, sequence.sample(0f).stepIndex) + assertEquals(0, sequence.sample(1.999f).stepIndex) + assertEquals(1, sequence.sample(2f).stepIndex) + assertEquals(1, sequence.sample(2.799f).stepIndex) + assertEquals(2, sequence.sample(2.8f).stepIndex) + assertEquals(3, sequence.sample(5.7f).stepIndex) + + val wrapped = sequence.sample(5.8f) + assertEquals(0, wrapped.stepIndex) + assertEquals(1, wrapped.cycle) + assertEquals(0f, wrapped.elapsedSeconds) + assertFalse(wrapped.ended) + + val secondPass = sequence.sample(5.8f + 2.5f) + assertEquals(1, secondPass.stepIndex) + assertEquals(1, secondPass.cycle) + assertEquals(0.5f, secondPass.elapsedSeconds, 1e-4f) + } + + @Test + fun `a boundary is passed within a tenth of a millisecond and not before`() { + val sequence = demo() + + assertEquals(1, sequence.sample(2f - 0.5e-4f).stepIndex) + assertEquals(0, sequence.sample(2f - 1e-3f).stepIndex) + } + + @Test + fun `a finite sequence ends on its last step`() { + val sequence = demo().copy(loop = false) + + val ended = sequence.sample(10f) + + assertTrue(ended.ended) + assertEquals(3, ended.stepIndex) + assertEquals(1f, ended.progress) + assertEquals(0, ended.cycle) + assertFalse(sequence.sample(5.7f).ended) + } + + @Test + fun `negative and NaN time are the start`() { + val sequence = demo() + + assertEquals(0f, sequence.sample(-3f).elapsedSeconds) + assertEquals(0, sequence.sample(Float.NaN).stepIndex) + } + + @Test + fun `progress and elapsed follow the step`() { + val snapshot = demo().sample(2.4f) + + assertEquals(1, snapshot.stepIndex) + assertEquals(0.4f, snapshot.elapsedSeconds, 1e-6f) + assertEquals(0.5f, snapshot.progress, 1e-6f) + assertEquals(2.4f, snapshot.timeSeconds) + } + + @Test + fun `a preset step arrives on the choreographer's table`() { + val sequence = demo() + + // idle→thinking is 0.8 s on the table; thinking→idle is 1.0 s. + assertEquals(0.8f, sequence.transitionSeconds[1]) + assertEquals(1.0f, sequence.transitionSeconds[3]) + assertTrue(sequence.sample(2.5f).transitioning) + + val held = + LumosSequence( + steps = + listOf( + LumosStep("idle", 1f, atmospherePreset = "idle"), + LumosStep("idle→thinking", 2f, atmospherePreset = "thinking"), + ), + ) + assertTrue(held.sample(1.5f).transitioning) + assertFalse(held.sample(1.9f).transitioning) + } + + @Test + fun `a loop step naming the state it rests in arrives in no time`() { + // "thinking" after "idle→thinking" applies nothing, so nothing is in flight. + assertEquals(0f, demo().transitionSeconds[2]) + assertFalse(demo().sample(2.85f).transitioning) + } + + @Test + fun `the first step of a looping sequence reads what the last one left`() { + val sequence = demo() + + // idle after thinking→idle names idle again: nothing arrives. + assertEquals(0f, sequence.transitionSeconds[0]) + // With no loop nothing comes before it, and the untabled default applies. + assertEquals(1.1f, sequence.copy(loop = false).transitionSeconds[0]) + } + + @Test + fun `form look and glyph steps arrive on their own defaults`() { + val sequence = + LumosSequence( + steps = + listOf( + LumosStep("to-cube", 3f, form = "cube"), + LumosStep("working", 1f, look = Look(LumosLook.WORKING)), + LumosStep("done", 1f, glyph = LumosGlyph.CHECK), + LumosStep( + "fast", + 1f, + form = "sphere", + transition = LumosTransition(0.25f, Choreography.SCATTER), + ), + ), + ) + + assertEquals( + listOf(VoxelFrameBuilder.DEFAULT_MORPH_SECONDS, VoxelFrameBuilder.LOOK_BLEND_SECONDS, 0f, 0.25f), + sequence.transitionSeconds, + ) + } + + @Test + fun `a sequence has one to 128 steps`() { + assertIs( + assertFailsWith { LumosSequence(steps = emptyList()) }, + ) + val many = List(129) { LumosStep("step-$it", 1f, atmospherePreset = "idle") } + assertIs(assertFailsWith { LumosSequence(steps = many) }) + LumosSequence(steps = many.take(128)) + } + + @Test + fun `only version 1 is read`() { + val error = + assertFailsWith { + LumosSequence(version = 2, steps = listOf(LumosStep("idle", 1f, atmospherePreset = "idle"))) + } + assertEquals(2, error.version) + } + + @Test + fun `a step error names the step`() { + val error = + stepError( + LumosStep("idle", 1f, atmospherePreset = "idle"), + LumosStep("idle", 1f, atmospherePreset = "idle"), + ) + + assertEquals(1, error.index) + assertEquals("idle", error.name) + assertTrue("unique" in error.reason, error.reason) + } + + @Test + fun `a step needs a name a duration in range and one primary field`() { + assertTrue("blank" in stepError(LumosStep(" ", 1f, atmospherePreset = "idle")).reason) + assertTrue("durationSeconds" in stepError(LumosStep("short", 0.04f, atmospherePreset = "idle")).reason) + assertTrue("durationSeconds" in stepError(LumosStep("long", 3600.5f, atmospherePreset = "idle")).reason) + assertTrue("names one of" in stepError(LumosStep("empty", 1f)).reason) + assertTrue("exactly one" in stepError(LumosStep("two", 1f, form = "cube", glyph = LumosGlyph.STAR)).reason) + } + + @Test + fun `a look may ride with a preset and nothing else may`() { + LumosSequence( + steps = listOf(LumosStep("both", 1f, look = Look(LumosLook.WORKING), atmospherePreset = "thinking")), + ) + + val error = + stepError( + LumosStep( + "three", + 1f, + look = Look(LumosLook.WORKING), + atmospherePreset = "thinking", + glyph = LumosGlyph.CHECK, + ), + ) + assertTrue("exactly one" in error.reason, error.reason) + } + + @Test + fun `unknown names fail at the step`() { + assertTrue("unknown form" in stepError(LumosStep("x", 1f, form = "torus")).reason) + assertTrue("unknown atmosphere preset" in stepError(LumosStep("x", 1f, atmospherePreset = "serene")).reason) + val easing = + stepError(LumosStep("x", 1f, form = "cube", transition = LumosTransition(0.5f, easingName = "zoom"))) + assertTrue("unknown easing" in easing.reason) + } + + @Test + fun `names resolve case-insensitively`() { + LumosSequence( + steps = listOf(LumosStep("x", 1f, form = "Cube"), LumosStep("y", 1f, atmospherePreset = "Thinking")), + ) + } + + @Test + fun `a transition fits inside its step`() { + assertTrue( + "longer than" in stepError(LumosStep("x", 1f, form = "cube", transition = LumosTransition(1.5f))).reason, + ) + assertTrue(">= 0" in stepError(LumosStep("x", 1f, form = "cube", transition = LumosTransition(-0.1f))).reason) + LumosSequence(steps = listOf(LumosStep("x", 1f, form = "cube", transition = LumosTransition(1f)))) + } + + @Test + fun `JSON round-trips and leaves defaults out`() { + val sequence = + demo().copy( + steps = + demo().steps + + LumosStep( + "gyro", + 1f, + look = Look(LumosLook.WORKING, "gyro"), + atmospherePreset = "ready", + transition = LumosTransition(0.5f, Choreography.DIRECT, "eager"), + ) + + LumosStep("done", 1f, glyph = LumosGlyph.CHECK), + defaults = LumosConfig(speed = 1.5f, label = "demo"), + ) + + val json = sequence.toJson() + + assertEquals(sequence, LumosSequence.fromJson(json).getOrThrow()) + assertFalse("\"transition\":null" in json, json) + assertTrue("\"variant\":\"gyro\"" in json, json) + assertFalse("\"variant\":\"default\"" in json, json) + } + + @Test + fun `fromJson reads a hand-written document and ignores keys it does not know`() { + val sequence = + LumosSequence + .fromJson( + """ + { + "version": 1, + "comment": "unknown keys are fine", + "steps": [ + { "name": "idle", "durationSeconds": 2.0, "atmospherePreset": "idle", "loop": true }, + { "name": "idle→thinking", "durationSeconds": 0.8, "atmospherePreset": "thinking", + "look": { "look": "WORKING" } }, + { "name": "done", "durationSeconds": 1.5, "glyph": "CHECK", + "transition": { "durationSeconds": 1.0, "choreography": "SCATTER", "easingName": "overshoot" } } + ] + } + """.trimIndent(), + ).getOrThrow() + + assertEquals(3, sequence.steps.size) + assertTrue(sequence.loop) + assertEquals(Look(LumosLook.WORKING), sequence.steps[1].look) + assertEquals(LumosGlyph.CHECK, sequence.steps[2].glyph) + assertEquals(Choreography.SCATTER, sequence.steps[2].transition?.choreography) + assertEquals(LumosConfig(), sequence.defaults) + } + + @Test + fun `fromJson reports a malformed document`() { + val error = LumosSequence.fromJson("{ not json").exceptionOrNull() + + assertIs(error) + } + + @Test + fun `fromJson reports the offending step`() { + val error = + LumosSequence + .fromJson( + """{"steps":[{"name":"idle","durationSeconds":1.0,"atmospherePreset":"idle"},""" + + """{"name":"torus","durationSeconds":1.0,"form":"torus"}]}""", + ).exceptionOrNull() + + val step = assertIs(error) + assertEquals(1, step.index) + assertEquals("torus", step.name) + } + + @Test + fun `fromJson reports a look variant its look does not offer as malformed`() { + val error = + LumosSequence + .fromJson("""{"steps":[{"name":"x","durationSeconds":1.0,"look":{"look":"BASE","variant":"gyro"}}]}""") + .exceptionOrNull() + + assertIs(error) + } + + @Test + fun `indexOf finds a step by name`() { + assertEquals(2, demo().indexOf("thinking")) + assertEquals(-1, demo().indexOf("ready")) + } + + private fun stepError(vararg steps: LumosStep): LumosSequenceError.Step = + assertIs(assertFailsWith { LumosSequence(steps = steps.toList()) }) + + private companion object { + fun demo(): LumosSequence = + LumosSequence( + steps = + listOf( + LumosStep("idle", 2f, atmospherePreset = "idle", loop = true), + LumosStep("idle→thinking", 0.8f, atmospherePreset = "thinking"), + LumosStep("thinking", 2f, atmospherePreset = "thinking", loop = true), + LumosStep("thinking→idle", 1f, atmospherePreset = "idle"), + ), + ) + } +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/OrbStepperTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/OrbStepperTest.kt index b8bcfbf..f837f65 100644 --- a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/OrbStepperTest.kt +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/OrbStepperTest.kt @@ -53,6 +53,28 @@ class OrbStepperTest { assertEquals(0.15f, source.steps.last(), 1e-6f) } + @Test + fun `a sequence player is advanced by the step the source takes`() { + val source = RecordingSource() + val player = + LumosSequencePlayer( + LumosSequence(steps = listOf(LumosStep("idle", 1f, atmospherePreset = "idle"))), + ) + val stepper = OrbStepper(source, LookClock(), player) { false } + stepper.update(LumosConfig(speed = 2f), FrameProbe.Disabled) + + // Not connected: the stepper steps the source but the sequence clock holds. + stepper.step(secondsToNanos(1.0)) + stepper.step(secondsToNanos(1.016)) + assertEquals(0f, player.timeSeconds) + + player.connect() + stepper.step(secondsToNanos(1.032)) + + assertEquals(0.032f, player.timeSeconds, 1e-6f) + assertEquals(0.032f, source.steps.last(), 1e-6f) + } + @Test fun `an orb aligns to its look's clock on its first step and on each return`() { val source = RecordingSource() diff --git a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/CaptureConfig.kt b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/CaptureConfig.kt index ab68e1d..8b9e250 100644 --- a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/CaptureConfig.kt +++ b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/CaptureConfig.kt @@ -3,6 +3,7 @@ package link.socket.phosphor.trace import link.socket.phosphor.field.Choreography import link.socket.phosphor.lumos.LumosForm import link.socket.phosphor.lumos.LumosRenderConfig +import link.socket.phosphor.lumos.LumosSequence import link.socket.phosphor.lumos.VoxelFrameBuilder import link.socket.phosphor.palette.AtmospherePresets import link.socket.phosphor.signal.AtmosphereState @@ -29,6 +30,11 @@ data class CaptureParams( * [atFrame], so that frame is the first one to reflect the start of the * transition. Cues at the same frame are applied in list order; the last one wins. * + * Cues are the frame-indexed, capture-only way to script a clip. The preferred + * path is [CaptureConfig.sequence]: a [LumosSequence] is data, names its own + * segments, and plays live through `SignalGenerator.play` from the same + * definition. Cues remain for callers that script by frame. + * * @property atFrame Zero-based frame index at which the cue fires. Must be in `0 until frameCount`. * @property atmosphere Target atmosphere. When it equals a registered preset the * transition table in `AtmosphereChoreographer` is consulted by reverse lookup. @@ -103,9 +109,13 @@ data class PhaseCue( * @property durationMs Length of the clip in milliseconds. Must be `> 0`. * @property fps Frames per second; the simulation is stepped at exactly `1f / fps`. Must be `> 0`. * @property segments Named slices of the captured stream; each must fit inside `0 until frameCount`. - * @property cues Scripted atmosphere changes; each must fire inside `0 until frameCount`. - * @property formCues Scripted form changes; each must fire inside `0 until frameCount`. - * @property phaseCues Scripted phase changes; each must fire inside `0 until frameCount`. + * Empty when [sequence] is set: the sequence names its own. + * @property cues Scripted atmosphere changes; each must fire inside `0 until frameCount`. The + * frame-indexed, capture-only path; [sequence] is the preferred one. Empty when [sequence] is set. + * @property formCues Scripted form changes; each must fire inside `0 until frameCount`. Empty when + * [sequence] is set. + * @property phaseCues Scripted phase changes; each must fire inside `0 until frameCount`. Empty + * when [sequence] is set. * @property phosphorVersion Library version stamped into the trace header. * @property createdAtEpochMs Creation timestamp stamped into the trace header. Defaults to `0` * so captures are reproducible; callers that want a real timestamp supply one explicitly. @@ -113,6 +123,14 @@ data class PhaseCue( * loop's own steady state run backwards from its first frame, so the loop wraps continuously * even when a transition ends the frame before it starts; see [Oscilloscope]. `0`, the * default, records the simulation untouched. Must be `>= 0` and shorter than every loop segment. + * @property sequence The clip as a [LumosSequence]: the preferred way to script a capture. When + * set, the generator plays it (`SignalGenerator.play`) and one [TraceSegment] is derived per + * step, named as the step is and carrying its `loop`, so the clip replays through + * [TraceStateMachine] by step name with nothing else written. Frame `k` is the sequence at + * `k / fps`; a step shorter than a frame is an error. [frameCount] must cover one pass of the + * sequence — [forSequence] sizes it — and [segments], [cues], [formCues] and [phaseCues] must be + * empty. Needs `fps >= ` [MIN_SEQUENCE_FPS], the rate below which a fixed step would exceed the + * player's cap. */ data class CaptureConfig( val params: CaptureParams = CaptureParams(), @@ -126,6 +144,7 @@ data class CaptureConfig( val seamBlendFrames: Int = 0, val formCues: List = emptyList(), val phaseCues: List = emptyList(), + val sequence: LumosSequence? = null, ) { /** * Number of frames the capture produces: `durationMs * fps / 1000`, truncated. @@ -141,6 +160,51 @@ data class CaptureConfig( companion object { /** Placeholder version recorded when the caller does not supply one. */ const val DEFAULT_PHOSPHOR_VERSION: String = "dev" + + /** + * The slowest rate a [sequence] captures at: `1 / fps` must not exceed + * `LumosSequencePlayer.MAX_STEP_SECONDS`, or the fixed step would be cut. + */ + const val MIN_SEQUENCE_FPS: Int = 10 + + /** + * A config that captures one pass of [sequence] at [fps]: [durationMs] is the + * shortest that yields every frame of the sequence and no more, so the clip + * ends where the sequence does and nothing wraps. + * + * @param sequence The clip, as data. + * @param fps Frames per second; at least [MIN_SEQUENCE_FPS]. + * @param params Tuning parameters; the starting atmosphere should be the one the + * sequence's first step expects. + * @param seed Producer seed, as [CaptureConfig.seed]. + * @param seamBlendFrames Baked seam for every `loop` step, as [CaptureConfig.seamBlendFrames]. + * @param phosphorVersion As [CaptureConfig.phosphorVersion]. + * @param createdAtEpochMs As [CaptureConfig.createdAtEpochMs]. + */ + fun forSequence( + sequence: LumosSequence, + fps: Int, + params: CaptureParams = CaptureParams(), + seed: Long = 0L, + seamBlendFrames: Int = 0, + phosphorVersion: String = DEFAULT_PHOSPHOR_VERSION, + createdAtEpochMs: Long = 0L, + ): CaptureConfig { + require(fps > 0) { "fps must be > 0, was $fps" } + val frames = sequence.frameLayout(fps).frameCount + // The shortest duration whose truncated frame count is at least `frames`. + val durationMs = (frames * 1_000L + fps - 1) / fps + return CaptureConfig( + params = params, + seed = seed, + durationMs = durationMs, + fps = fps, + phosphorVersion = phosphorVersion, + createdAtEpochMs = createdAtEpochMs, + seamBlendFrames = seamBlendFrames, + sequence = sequence, + ) + } } } @@ -156,7 +220,8 @@ sealed class CaptureError( ) : Exception(message, cause) { /** * The [CaptureConfig] is inconsistent: non-positive fps or duration, a - * segment or cue outside the clip, or a seam blend that cannot fit or pair. + * segment or cue outside the clip, a sequence the clip cannot hold or that + * is given alongside cues, or a seam blend that cannot fit or pair. */ class InvalidConfig( message: String, diff --git a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/Oscilloscope.kt b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/Oscilloscope.kt index 5f8fbf6..2f08a00 100644 --- a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/Oscilloscope.kt +++ b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/Oscilloscope.kt @@ -19,6 +19,14 @@ import link.socket.phosphor.signal.AtmosphereState * runtime; particles, flow, waveform, emitters, and camera do not contribute to * a `VoxelFrame` and are switched off so capture stays cheap. * + * ### Sequences + * + * [CaptureConfig.sequence] is the preferred script: the generator plays the + * `LumosSequence` (`SignalGenerator.play`) exactly as a live orb would, frame + * `k` being the sequence at `k / fps`, and each step becomes the `TraceSegment` + * named after it. A clip captured this way replays through [TraceStateMachine] + * by step name. Cues and hand-written segments remain for frame-indexed scripts. + * * ### Loop seams * * By default capture does not make a `loop` segment wrap. It records whatever @@ -80,7 +88,7 @@ object Oscilloscope { createdAtEpochMs = config.createdAtEpochMs, seed = config.seed, paramSnapshot = snapshotParams(config), - segments = config.segments, + segments = segmentsOf(config), ), ) } catch (error: Throwable) { @@ -104,6 +112,7 @@ object Oscilloscope { "durationMs=${config.durationMs} at fps=${config.fps} yields no frames", ) } + validateSequence(config)?.let { return it } config.segments.forEach { segment -> if (segment.endFrame >= frameCount) { return CaptureError.InvalidConfig( @@ -144,6 +153,41 @@ object Oscilloscope { return validateSeamBlend(config) } + private fun validateSequence(config: CaptureConfig): CaptureError? { + val sequence = config.sequence ?: return null + if (config.segments.isNotEmpty() || config.cues.isNotEmpty() || + config.formCues.isNotEmpty() || config.phaseCues.isNotEmpty() + ) { + return CaptureError.InvalidConfig( + "A sequence names its own segments and changes; segments, cues, formCues and phaseCues must be empty", + ) + } + if (config.fps < CaptureConfig.MIN_SEQUENCE_FPS) { + return CaptureError.InvalidConfig( + "A sequence captures at fps >= ${CaptureConfig.MIN_SEQUENCE_FPS}, was ${config.fps}", + ) + } + val layout = sequence.frameLayout(config.fps) + layout.segments.forEachIndexed { index, segment -> + if (segment == null) { + return CaptureError.InvalidConfig( + "Sequence step $index '${sequence.steps[index].name}' is shorter than a frame at ${config.fps} fps", + ) + } + } + if (layout.frameCount > config.frameCount) { + return CaptureError.InvalidConfig( + "The sequence needs ${layout.frameCount} frames at ${config.fps} fps; " + + "the clip has ${config.frameCount}. CaptureConfig.forSequence sizes a clip to its sequence", + ) + } + return null + } + + // The segments the trace is written with: the sequence's, else the config's. + private fun segmentsOf(config: CaptureConfig): List = + config.sequence?.frameLayout(config.fps)?.segments?.map { requireNotNull(it) } ?: config.segments + private fun validateSeamBlend(config: CaptureConfig): CaptureError? { val blendFrames = config.seamBlendFrames if (blendFrames == 0) return null @@ -155,7 +199,7 @@ object Oscilloscope { "${config.params.renderConfig.omitBelowScale}", ) } - config.segments.filter(TraceSegment::loop).forEach { segment -> + segmentsOf(config).filter(TraceSegment::loop).forEach { segment -> if (blendFrames >= segment.frameCount) { return CaptureError.InvalidConfig( "seamBlendFrames $blendFrames must be shorter than loop segment '${segment.name}', " + @@ -199,8 +243,11 @@ object Oscilloscope { if (blendFrames == 0) { emptySet() } else { - config.segments.filter(TraceSegment::loop).map(TraceSegment::startFrame).toSet() + segmentsOf(config).filter(TraceSegment::loop).map(TraceSegment::startFrame).toSet() } + // The sequence plays through the same path a live orb uses: frame k is the sequence at + // k / fps, applied before the step that produces it, as a cue at frame k would be. + config.sequence?.let { sequence -> generator.play(sequence) } val frames = ArrayList(config.frameCount) val leadIns = HashMap>() @@ -234,7 +281,7 @@ object Oscilloscope { ): List { val blendFrames = config.seamBlendFrames val baked = ArrayList(captured) - config.segments.filter(TraceSegment::loop).forEach { segment -> + segmentsOf(config).filter(TraceSegment::loop).forEach { segment -> val leadIn = leadIns.getValue(segment.startFrame) for (step in 0 until blendFrames) { // The window's last frame takes weight 1 and so becomes the frame @@ -306,6 +353,9 @@ object Oscilloscope { snapshot["phaseCue.$index.atFrame"] = cue.atFrame.toString() snapshot["phaseCue.$index.phase"] = cue.phase.name } + // Only when present, so a capture without a sequence keeps its bytes. The sequence's + // own JSON, so the trace carries the script it was captured from. + config.sequence?.let { sequence -> snapshot["sequence"] = sequence.toJson() } return snapshot } diff --git a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SequenceFrameLayout.kt b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SequenceFrameLayout.kt new file mode 100644 index 0000000..c208e8d --- /dev/null +++ b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SequenceFrameLayout.kt @@ -0,0 +1,58 @@ +package link.socket.phosphor.trace + +import link.socket.phosphor.lumos.LumosSequence +import link.socket.phosphor.lumos.LumosSequencePlayer + +/** + * Where each step of a `LumosSequence` falls in a fixed-step capture. + * + * @property frameCount Frames in one pass of the sequence at the rate it was laid out for. + * @property segments One [TraceSegment] per step, named as the step is and carrying its `loop`, + * or null for a step so short no frame falls in it. + */ +class SequenceFrameLayout( + val frameCount: Int, + val segments: List, +) + +/** + * Lay this sequence out at [fps]: the frames of one pass and the segment each step becomes. + * + * Frame `k` is the sequence at `k / fps`, found the way `SignalGenerator.play` finds it — a + * [LumosSequencePlayer] advanced by `1 / fps` once per frame, so the layout and the capture + * cannot disagree about which frame a step starts on, `Float` accumulation included. The + * sequence's own `loop` is ignored: this is one pass. + * + * @throws IllegalArgumentException when [fps] is not positive or `1 / fps` exceeds the player's + * step cap ([CaptureConfig.MIN_SEQUENCE_FPS]). + */ +fun LumosSequence.frameLayout(fps: Int): SequenceFrameLayout { + require(fps >= CaptureConfig.MIN_SEQUENCE_FPS) { + "A sequence lays out at fps >= ${CaptureConfig.MIN_SEQUENCE_FPS}, was $fps" + } + val dt = 1f / fps + val onePass = if (loop) copy(loop = false) else this + val player = LumosSequencePlayer(onePass) + player.connect() + val starts = IntArray(steps.size) { -1 } + var frame = 0 + while (true) { + val snapshot = player.snapshot + if (snapshot.ended) break + if (starts[snapshot.stepIndex] < 0) starts[snapshot.stepIndex] = frame + frame++ + player.advance(dt) + } + val segments = + List(steps.size) { index -> + val start = starts[index] + if (start < 0) { + null + } else { + val next = (index + 1 until steps.size).firstOrNull { starts[it] >= 0 } + val end = if (next == null) frame - 1 else starts[next] - 1 + TraceSegment(steps[index].name, start, end, steps[index].loop) + } + } + return SequenceFrameLayout(frameCount = frame, segments = segments) +} diff --git a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SignalGenerator.kt b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SignalGenerator.kt index 4c2fa42..96e20a7 100644 --- a/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SignalGenerator.kt +++ b/phosphor-trace/src/commonMain/kotlin/link/socket/phosphor/trace/SignalGenerator.kt @@ -16,6 +16,8 @@ import link.socket.phosphor.lumos.LumosForm import link.socket.phosphor.lumos.LumosFrameSource import link.socket.phosphor.lumos.LumosGlyph import link.socket.phosphor.lumos.LumosRenderConfig +import link.socket.phosphor.lumos.LumosSequence +import link.socket.phosphor.lumos.LumosSequencePlayer import link.socket.phosphor.lumos.VoxelFrame import link.socket.phosphor.lumos.VoxelFrameBuilder import link.socket.phosphor.lumos.probe.FrameProbe @@ -60,6 +62,16 @@ import link.socket.phosphor.signal.CognitivePhase * never calls them produces the frames it always has. [setLook] picks a look * directly and needs no switch. * + * ### Sequences + * + * [play] puts the generator on a [LumosSequence]: at each step boundary it + * applies what the step names — [setAtmospherePreset], [morphTo], [setLook], + * [queueGlyph] — through the one path [LiveFrameSource.follow] implements, so + * `Oscilloscope` captures a sequence and a live orb plays it frame for frame. + * `play(sequence)` makes the generator the clock; `play(player)` follows a + * [LumosSequencePlayer] someone else advances, such as the orb's `OrbStepper`, + * which lets a scroll position or any external driver own time. + * * @param configuration Scene to simulate. Atmosphere must be enabled — it is the * only subsystem a `VoxelFrame` reads. * @param renderConfig Embedder knobs forwarded to the frame builder. @@ -215,6 +227,38 @@ class SignalGenerator( builder.lookTo(look) } + /** + * Play [sequence] from its start, this generator being the clock: every + * [nextFrame] advances it by `1 / fps` after building, so frame `k` is the + * sequence at `k / fps`. Its first step is applied at once, against what the + * scene shows now. Replaces any sequence being played. + * + * @return The player, to pause, seek or [LumosSequencePlayer.control]. + */ + fun play(sequence: LumosSequence): LumosSequencePlayer { + val player = LumosSequencePlayer(sequence) + live.follow(player, clock = true) + return player + } + + /** + * Follow [player] without advancing it: whoever drives the clock — an orb + * through [asFrameSource], a scroll position holding + * [LumosSequencePlayer.control] — decides where the sequence is, and each + * frame applies the step it falls in. Replaces any sequence being played. + */ + fun play(player: LumosSequencePlayer) { + live.follow(player, clock = false) + } + + /** Leave the sequence; the scene keeps the state its last step put it in. */ + fun stopSequence() { + live.follow(null) + } + + /** The sequence player this generator follows, or null. */ + val sequencePlayer: LumosSequencePlayer? get() = live.sequence + // The agent the setters write to, chosen on first use: the configured one, else the scene's first // by id — the one the builder reads — else a new AGENT_ID. Adds it if the scene lacks it, and // installs the switch. diff --git a/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt b/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt new file mode 100644 index 0000000..c063d6d --- /dev/null +++ b/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt @@ -0,0 +1,202 @@ +package link.socket.phosphor.trace + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import link.socket.phosphor.lumos.LumosGlyph +import link.socket.phosphor.lumos.LumosSequence +import link.socket.phosphor.lumos.LumosStep +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.runtime.SceneConfiguration + +/** + * PHO-65 Task C: one definition serves capture and live play. A `LumosSequence` on + * [CaptureConfig.sequence] lays out one segment per step, produces the frames the cue path + * produces for the same script, captures deterministically, replays through [TraceStateMachine] + * by step name, and is frame for frame what `SignalGenerator.play` emits live. + */ +class SequenceCaptureTest { + @Test + fun `the layout gives each step the frames its duration spans`() { + val layout = sequence().frameLayout(FPS) + + assertEquals(30, layout.frameCount) + assertEquals( + listOf( + TraceSegment("idle", 0, 9, loop = true), + TraceSegment("idle→thinking", 10, 19), + TraceSegment("thinking", 20, 29, loop = true), + ), + layout.segments, + ) + } + + @Test + fun `forSequence sizes the clip to one pass`() { + val config = CaptureConfig.forSequence(sequence(), FPS) + + assertEquals(30, config.frameCount) + assertEquals(1_500L, config.durationMs) + } + + @Test + fun `a sequence capture carries one segment per step and the sequence it came from`() { + val trace = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() + + assertEquals(30, trace.frameCount) + assertEquals(sequence().steps.map(LumosStep::name), trace.segments.map(TraceSegment::name)) + assertEquals(listOf(true, false, true), trace.segments.map(TraceSegment::loop)) + assertEquals(sequence(), LumosSequence.fromJson(trace.paramSnapshot.getValue("sequence")).getOrThrow()) + assertNull(trace.paramSnapshot["cue.0.atFrame"]) + } + + @Test + fun `a sequence capture produces the frames the cue capture produces`() { + val fromSequence = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() + val fromCues = + Oscilloscope + .capture( + CaptureConfig( + params = params(), + durationMs = 1_500L, + fps = FPS, + segments = + listOf( + TraceSegment("idle", 0, 9, loop = true), + TraceSegment("idle→thinking", 10, 19), + TraceSegment("thinking", 20, 29, loop = true), + ), + cues = listOf(AtmosphereCue(atFrame = 10, atmosphere = AtmospherePresets.THINKING)), + ), + ).getOrThrow() + + assertEquals(fromCues.toFrames(), fromSequence.toFrames()) + assertEquals(fromCues.segments, fromSequence.segments) + } + + @Test + fun `identical sequence configs capture to identical bytes`() { + val first = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() + val second = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() + + assertEquals(first, second) + assertContentEquals(encodeForTest(first), encodeForTest(second)) + } + + @Test + fun `the live generator plays the sequence frame for frame as the capture recorded it`() { + val trace = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() + val generator = + SignalGenerator( + configuration = + SceneConfiguration( + width = 1, + height = 1, + enableWaveform = false, + enableParticles = false, + enableFlow = false, + enableEmitters = false, + enableCamera = false, + enableAtmosphere = true, + initialAtmosphere = AtmospherePresets.IDLE, + seed = 0L, + ), + fps = FPS, + ) + + val player = generator.play(sequence()) + val live = List(trace.frameCount) { generator.nextFrame() } + + assertEquals(trace.toFrames(), live) + assertEquals(1.5f, player.timeSeconds, 1e-5f) + } + + @Test + fun `a captured sequence replays through the state machine by step name`() { + val trace = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() + + val machine = TraceStateMachine(trace, initialState = "idle") + + assertEquals(sequence().steps.map(LumosStep::name), machine.stateNames) + machine.requestState("thinking").getOrThrow() + assertTrue(machine.isTransitioning) + assertEquals("thinking", machine.requestedState.value) + // The recorded idle→thinking one-shot carries it there: the machine lands on the loop. + machine.advance(2.seconds) + assertEquals("thinking", machine.state.value) + } + + @Test + fun `a sequence does not share a clip with cues or hand-written segments`() { + val config = + CaptureConfig.forSequence(sequence(), FPS, params()).copy( + cues = listOf(AtmosphereCue(0, AtmospherePresets.READY)), + ) + + val error = Oscilloscope.capture(config).exceptionOrNull() + + assertIs(error) + } + + @Test + fun `a sequence needs at least ten frames a second`() { + val error = + Oscilloscope.capture( + CaptureConfig(durationMs = 2_000L, fps = 5, sequence = sequence()), + ).exceptionOrNull() + + assertIs(error) + } + + @Test + fun `a clip must hold the whole sequence`() { + val error = + Oscilloscope.capture( + CaptureConfig(durationMs = 1_000L, fps = FPS, sequence = sequence()), + ).exceptionOrNull() + + val invalid = assertIs(error) + assertTrue("forSequence" in (invalid.message ?: ""), invalid.message) + } + + @Test + fun `a step shorter than a frame is refused`() { + val sequence = + LumosSequence( + steps = + listOf( + // At 10 fps frames fall at 0.5 and 0.6 s; a 50 ms step starting at 0.52 s holds neither. + LumosStep("idle", 0.52f, atmospherePreset = "idle", loop = true), + LumosStep("blink", 0.05f, glyph = LumosGlyph.CHECK), + ), + ) + + val error = + Oscilloscope.capture( + CaptureConfig(durationMs = 1_000L, fps = 10, sequence = sequence), + ).exceptionOrNull() + + val invalid = assertIs(error) + assertTrue("blink" in (invalid.message ?: ""), invalid.message) + } + + private fun params(): CaptureParams = CaptureParams(atmosphere = AtmospherePresets.IDLE) + + private fun sequence(): LumosSequence = + LumosSequence( + steps = + listOf( + LumosStep("idle", 0.5f, atmospherePreset = "idle", loop = true), + LumosStep("idle→thinking", 0.5f, atmospherePreset = "thinking"), + LumosStep("thinking", 0.5f, atmospherePreset = "thinking", loop = true), + ), + ) + + private companion object { + const val FPS = 20 + } +} diff --git a/phosphor-trace/src/jvmTest/kotlin/link/socket/phosphor/trace/FormSeamByteIdentityTest.kt b/phosphor-trace/src/jvmTest/kotlin/link/socket/phosphor/trace/FormSeamByteIdentityTest.kt index 67afdf9..7840937 100644 --- a/phosphor-trace/src/jvmTest/kotlin/link/socket/phosphor/trace/FormSeamByteIdentityTest.kt +++ b/phosphor-trace/src/jvmTest/kotlin/link/socket/phosphor/trace/FormSeamByteIdentityTest.kt @@ -4,6 +4,8 @@ import java.security.MessageDigest import kotlin.test.Test import kotlin.test.assertEquals import link.socket.phosphor.lumos.LumosRenderConfig +import link.socket.phosphor.lumos.LumosSequence +import link.socket.phosphor.lumos.LumosStep import link.socket.phosphor.palette.AtmospherePresets /** @@ -23,6 +25,12 @@ import link.socket.phosphor.palette.AtmospherePresets * PHO-82: an interactive orb is captured to the same bytes. The Oscilloscope's * scene has no pointer, so nothing parts, and the render config's `interactive` * is not recorded. + * + * PHO-65 extends it: the same clip scripted as a `LumosSequence` — an `idle` + * loop step and an `idle→thinking` step, half a second each — must produce the + * same frames and segments. Only `paramSnapshot` differs, by design: it records + * the sequence's JSON instead of the cue, so the digest is taken with the cue + * capture's snapshot put in its place. */ class FormSeamByteIdentityTest { @Test @@ -42,6 +50,45 @@ class FormSeamByteIdentityTest { assertEquals(EXPECTED_PAYLOAD_SHA256, sha256Hex(payload), "an interactive orb's capture moved a cell") } + @Test + fun `the same clip scripted as a sequence encodes to the same bytes`() { + val sequence = + LumosSequence( + steps = + listOf( + LumosStep("idle", 0.5f, atmospherePreset = "idle", loop = true), + LumosStep("idle→thinking", 0.5f, atmospherePreset = "thinking"), + ), + ) + val config = + CaptureConfig.forSequence( + sequence = sequence, + fps = 20, + params = CaptureParams(atmosphere = AtmospherePresets.IDLE), + ) + + val captured = Oscilloscope.capture(config).getOrThrow() + val trace = + VoxelTrace.fromFrames( + frames = captured.toFrames(), + fps = captured.fps, + phosphorVersion = captured.phosphorVersion, + createdAtEpochMs = captured.createdAtEpochMs, + seed = captured.seed, + paramSnapshot = Oscilloscope.snapshotParams(config()), + segments = captured.segments, + ) + val payload = gzipDecompress(TraceCodec.encode(trace)) + + assertEquals(1_000L, config.durationMs) + assertEquals(config().segments, captured.segments) + assertEquals( + EXPECTED_PAYLOAD_SHA256, + sha256Hex(payload), + "a sequence capture differs from the cue capture it restates", + ) + } + private fun config(renderConfig: LumosRenderConfig = LumosRenderConfig()): CaptureConfig { val resolution = AtmospherePresets.IDLE.resolution return CaptureConfig( From 738ebe47ebce130946e292e3c59d66e44012ae51 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sat, 17 Oct 2026 18:40:04 +0900 Subject: [PATCH 2/4] PHO-65: the live-equals-capture sequence test needs binary32, so it is skipped on Kotlin/JS like its peers Co-Authored-By: Claude Fable 5.1 --- .../kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt b/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt index c063d6d..b6fa13d 100644 --- a/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt +++ b/phosphor-trace/src/commonTest/kotlin/link/socket/phosphor/trace/SequenceCaptureTest.kt @@ -87,6 +87,8 @@ class SequenceCaptureTest { assertContentEquals(encodeForTest(first), encodeForTest(second)) } + // Live frames hold Floats; the trace holds them in FloatArrays. Equal only where a Float is binary32. + @NeedsBinary32 @Test fun `the live generator plays the sequence frame for frame as the capture recorded it`() { val trace = Oscilloscope.capture(CaptureConfig.forSequence(sequence(), FPS, params())).getOrThrow() From 7221e5e467ed553972aefaa7dcc154722fe25cf0 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sat, 17 Oct 2026 18:59:19 +0900 Subject: [PATCH 3/4] PHO-65: name the reduced-motion argument where :phosphor-lumos-surface builds its OrbStepper, which now takes a sequence player before it Co-Authored-By: Claude Fable 5.1 --- .../link/socket/phosphor/lumos/surface/LumosSurfaceState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phosphor-lumos-surface/src/commonMain/kotlin/link/socket/phosphor/lumos/surface/LumosSurfaceState.kt b/phosphor-lumos-surface/src/commonMain/kotlin/link/socket/phosphor/lumos/surface/LumosSurfaceState.kt index 64e6d84..49bc4e3 100644 --- a/phosphor-lumos-surface/src/commonMain/kotlin/link/socket/phosphor/lumos/surface/LumosSurfaceState.kt +++ b/phosphor-lumos-surface/src/commonMain/kotlin/link/socket/phosphor/lumos/surface/LumosSurfaceState.kt @@ -310,7 +310,7 @@ class LumosSurfaceState( homeForm = builder.form // New each flight, so its first step is dt = 0: the slot's orb has stepped this frame. stepper = - OrbStepper(fadingSource, clock, platformReducesMotion).also { + OrbStepper(fadingSource, clock, platformReducesMotion = platformReducesMotion).also { it.update(config, FrameProbe.Disabled) it.resize(widthPx, heightPx) it.place(viewport) From 9382f89dd1fbd29d292a94145a9e5c3bcae3a0f6 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sat, 17 Oct 2026 19:25:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?PHO-65:=20keep=20the=20sequence=20out=20of?= =?UTF-8?q?=20bundles=20that=20never=20follow=20one=20=E2=80=94=20Sequence?= =?UTF-8?q?Follower=20behind=20closures=20in=20LiveFrameSource,=20Sequence?= =?UTF-8?q?Clock=20on=20OrbStepper;=20the=20DOM=20bundle=20returns=20to=20?= =?UTF-8?q?72=20KB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../socket/phosphor/lumos/LiveFrameSource.kt | 103 ++++-------------- .../socket/phosphor/lumos/LumosSequence.kt | 9 +- .../phosphor/lumos/LumosSequencePlayer.kt | 17 ++- .../link/socket/phosphor/lumos/OrbStepper.kt | 10 +- .../socket/phosphor/lumos/SequenceFollower.kt | 98 +++++++++++++++++ 5 files changed, 146 insertions(+), 91 deletions(-) create mode 100644 phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/SequenceFollower.kt diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt index 4b41503..44f09dd 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LiveFrameSource.kt @@ -122,13 +122,12 @@ class LiveFrameSource( var sequence: LumosSequencePlayer? = null private set - private var clocked: Boolean = false + // The sequence is followed through two closures set by follow(), not through members that + // name the sequence types, so a bundle that never follows one — :phosphor-lumos-dom — carries + // none of the format, the player or their serializers. + private var beforeFrame: (() -> Unit)? = null + private var afterFrame: ((Float) -> Unit)? = null private var releaseConnection: (() -> Unit)? = null - private var appliedCycle: Int = -1 - private var appliedStep: Int = -1 - private var appliedPreset: String? = null - private var appliedForm: String? = null - private var appliedLook: Look? = null /** * Follow [player], or stop following with null. @@ -147,100 +146,40 @@ class LiveFrameSource( releaseConnection?.invoke() releaseConnection = null sequence = player - clocked = clock && player != null - appliedCycle = -1 - appliedStep = -1 + beforeFrame = null + afterFrame = null if (player == null) return - appliedPreset = presetNameOf(choreographer.activeTransition?.to ?: choreographer.currentState) - appliedForm = formNameOf(builder.form) - appliedLook = builder.look - if (clock) releaseConnection = player.connect() - applyStep(player.snapshot) + val follower = SequenceFollower(this) + beforeFrame = { follower.apply(player.snapshot) } + if (clock) { + releaseConnection = player.connect() + afterFrame = { dt -> player.advance(dt) } + } + follower.apply(player.snapshot) } override fun next(dt: Float): VoxelFrame { - sequence?.let { applyStep(it.snapshot) } + beforeFrame?.invoke() val frame = builder.build(runtime.update(dt), dt) - if (clocked) sequence?.advance(dt) + afterFrame?.invoke(dt) return frame } override fun still(dt: Float): VoxelFrame { - sequence?.let { applyStep(it.snapshot) } + beforeFrame?.invoke() choreographer.activeTransition?.let { transition -> choreographer.setAtmosphere(transition.to, transition.toPresetName, AtmosphereTransitionSpec.Immediate) } val frame = builder.buildStill(runtime.update(dt), dt) - if (clocked) sequence?.advance(dt) + afterFrame?.invoke(dt) return frame } - // Apply the snapshot's step if it is not the one applied last; a wrapped sequence re-enters - // its steps, so the cycle counts too. - private fun applyStep(snapshot: LumosSequenceSnapshot) { - if (snapshot.cycle == appliedCycle && snapshot.stepIndex == appliedStep) return - appliedCycle = snapshot.cycle - appliedStep = snapshot.stepIndex - val step = snapshot.step - val transition = step.transition - var applied = false - - step.atmospherePreset?.lowercase()?.let { preset -> - if (preset != appliedPreset) { - appliedPreset = preset - applied = true - runtime.setAtmospherePreset( - preset, - transition?.let { AtmosphereTransitionSpec(it.durationSeconds, it.easingName) }, - ) - } - } - step.form?.lowercase()?.let { formName -> - if (formName != appliedForm) { - appliedForm = formName - applied = true - val form = - if (formName == LumosStep.LATTICE_FORM_NAME) { - Forms.Lattice((choreographer.activeTransition?.to ?: choreographer.currentState).resolution) - } else { - requireNotNull(Forms.byName(formName)) { "Unknown form: '$formName'" } - } - if (transition == null) { - builder.morphTo(form) - } else { - builder.morphTo( - form = form, - choreography = transition.choreography, - durationSeconds = transition.durationSeconds, - easingName = transition.easingName, - ) - } - } - } - step.look?.let { look -> - if (look != appliedLook) { - appliedLook = look - applied = true - builder.lookTo(look, transition?.durationSeconds ?: VoxelFrameBuilder.LOOK_BLEND_SECONDS) - } - } - step.glyph?.let { glyph -> - val window = transition?.durationSeconds?.takeIf { it > 0f } ?: DEFAULT_GLYPH_SECONDS - builder.queueGlyph(glyph, window) - } - if (applied) changes++ + /** Something that changes what [still] draws happened without the atmosphere moving; see [revision]. */ + internal fun noteChange() { + changes++ } - private fun presetNameOf(state: AtmosphereState): String? = - AtmospherePresets.ALL.firstOrNull { (_, preset) -> preset == state }?.first - - private fun formNameOf(form: LumosForm): String? = - if (form is Forms.Lattice) { - LumosStep.LATTICE_FORM_NAME - } else { - Forms.All.firstOrNull { (_, catalogued) -> catalogued === form }?.first - } - // The choreographer allocates a transition each time an atmosphere is set, and replaces it only // when the runtime steps, so while a driver is paused identity alone says whether one was set. override val revision: Long diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt index 5d35e7d..b468368 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequence.kt @@ -291,7 +291,7 @@ data class LumosSequence( } /** This sequence as JSON, as [fromJson] reads it. Defaults are left out. */ - fun toJson(): String = json.encodeToString(serializer(), this) + fun toJson(): String = json().encodeToString(serializer(), this) /** The index of the step called [name], or -1. */ fun indexOf(name: String): Int = steps.indexOfFirst { it.name == name } @@ -424,7 +424,10 @@ data class LumosSequence( */ const val BOUNDARY_TOLERANCE_SECONDS: Float = 1e-4f - private val json: Json = + // A function, not a property: a companion property's initialiser runs whenever the class is + // reached, and would carry kotlinx-serialization-json into every bundle that steps an orb — + // :phosphor-lumos-dom reaches LumosSequence through OrbStepper and never parses one. + private fun json(): Json = Json { ignoreUnknownKeys = true prettyPrint = false @@ -439,7 +442,7 @@ data class LumosSequence( */ fun fromJson(text: String): Result = try { - Result.success(json.decodeFromString(serializer(), text)) + Result.success(json().decodeFromString(serializer(), text)) } catch (error: LumosSequenceError) { Result.failure(error) } catch (error: SerializationException) { diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt index 8d3626f..2852678 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosSequencePlayer.kt @@ -2,6 +2,16 @@ package link.socket.phosphor.lumos import kotlin.math.min +/** + * A clock an orb's driver moves with its frames: `OrbStepper` calls [tick] with every step it + * hands the source. [LumosSequencePlayer] is one; the interface is what the stepper names, so a + * bundle that steps orbs without a sequence carries no player. + */ +fun interface SequenceClock { + /** [dt] seconds have passed for the orb. */ + fun tick(dt: Float) +} + /** * The clock of a [LumosSequence], with no framework in it: whoever owns the loop advances it, and * everyone showing the sequence reads [snapshot]. @@ -25,7 +35,7 @@ import kotlin.math.min class LumosSequencePlayer( val sequence: LumosSequence, autoplay: Boolean = true, -) { +) : SequenceClock { /** Sequence time, in seconds from the start of the first pass. */ var timeSeconds: Float = 0f private set @@ -109,6 +119,11 @@ class LumosSequencePlayer( return snapshot } + /** [advance], as a driver's [SequenceClock]. */ + override fun tick(dt: Float) { + advance(dt) + } + private inline fun once(crossinline release: () -> Unit): () -> Unit { var released = false return { diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt index 74bbc07..84f2a33 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/OrbStepper.kt @@ -41,21 +41,21 @@ import link.socket.phosphor.signal.LumosInput * * Main-thread only, like the source it steps. * - * With a [player], every step advances the sequence clock by the same `dt` it hands the source, - * before the source is stepped, so a source following that player ([LiveFrameSource.follow]) + * With a [sequence], every step ticks that clock by the same `dt` it hands the source, before the + * source is stepped, so a source following a [LumosSequencePlayer] ([LiveFrameSource.follow]) * sees the step the frame falls in. Connecting to the player is the surface's job: the stepper * does not know when the orb is on screen. * * @param source What the orb draws. * @param clock The look clocks this orb shares with its peers. - * @param player A sequence clock to advance in step with the source, or null. + * @param sequence A sequence clock to tick in step with the source, or null; a [LumosSequencePlayer]. * @param platformReducesMotion Whether the platform asks for reduced motion; read once a second * while stepping. Defaults to [PlatformMotionPreference.reduceMotion]. */ class OrbStepper( private val source: LumosFrameSource, private val clock: LookClock, - private val player: LumosSequencePlayer? = null, + private val sequence: SequenceClock? = null, private val platformReducesMotion: () -> Boolean = { PlatformMotionPreference.reduceMotion }, ) { /** The frame drawn last; null until a frame has been projected. */ @@ -179,7 +179,7 @@ class OrbStepper( steppedLook = look rejoining = false - player?.advance(dt) + sequence?.tick(dt) return show(if (reducedMotion) source.still(dt) else source.next(dt)) } diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/SequenceFollower.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/SequenceFollower.kt new file mode 100644 index 0000000..35ec9f5 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/SequenceFollower.kt @@ -0,0 +1,98 @@ +package link.socket.phosphor.lumos + +import link.socket.phosphor.choreography.AtmosphereTransitionSpec +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.signal.AtmosphereState + +/** + * Applies a [LumosSequence]'s steps to a [LiveFrameSource]'s scene and builder as the sequence + * enters them: the preset through `CognitiveSceneRuntime.setAtmospherePreset`, the form through + * [VoxelFrameBuilder.morphTo], the look through [VoxelFrameBuilder.lookTo], the glyph through + * [VoxelFrameBuilder.queueGlyph]. A step that names the preset, form or look the orb already shows + * applies nothing; a glyph fires on every entry. + * + * Built by [LiveFrameSource.follow] and reached only through it, so a bundle that never follows a + * sequence carries none of this. + */ +internal class SequenceFollower( + private val source: LiveFrameSource, +) { + private val runtime = source.runtime + private val builder = source.builder + private val choreographer = + requireNotNull(runtime.atmosphereChoreographer) { "LiveFrameSource always enables the atmosphere" } + + private var appliedCycle: Int = -1 + private var appliedStep: Int = -1 + private var appliedPreset: String? = presetNameOf(choreographer.activeTransition?.to ?: choreographer.currentState) + private var appliedForm: String? = formNameOf(builder.form) + private var appliedLook: Look? = builder.look + + /** + * Apply [snapshot]'s step if it is not the one applied last. A wrapped sequence re-enters its + * steps, so the cycle counts too. + */ + fun apply(snapshot: LumosSequenceSnapshot) { + if (snapshot.cycle == appliedCycle && snapshot.stepIndex == appliedStep) return + appliedCycle = snapshot.cycle + appliedStep = snapshot.stepIndex + val step = snapshot.step + val transition = step.transition + var applied = false + + step.atmospherePreset?.lowercase()?.let { preset -> + if (preset != appliedPreset) { + appliedPreset = preset + applied = true + runtime.setAtmospherePreset( + preset, + transition?.let { AtmosphereTransitionSpec(it.durationSeconds, it.easingName) }, + ) + } + } + step.form?.lowercase()?.let { formName -> + if (formName != appliedForm) { + appliedForm = formName + applied = true + val form = + if (formName == LumosStep.LATTICE_FORM_NAME) { + Forms.Lattice((choreographer.activeTransition?.to ?: choreographer.currentState).resolution) + } else { + requireNotNull(Forms.byName(formName)) { "Unknown form: '$formName'" } + } + if (transition == null) { + builder.morphTo(form) + } else { + builder.morphTo( + form = form, + choreography = transition.choreography, + durationSeconds = transition.durationSeconds, + easingName = transition.easingName, + ) + } + } + } + step.look?.let { look -> + if (look != appliedLook) { + appliedLook = look + applied = true + builder.lookTo(look, transition?.durationSeconds ?: VoxelFrameBuilder.LOOK_BLEND_SECONDS) + } + } + step.glyph?.let { glyph -> + val window = transition?.durationSeconds?.takeIf { it > 0f } ?: LiveFrameSource.DEFAULT_GLYPH_SECONDS + builder.queueGlyph(glyph, window) + } + if (applied) source.noteChange() + } + + private fun presetNameOf(state: AtmosphereState): String? = + AtmospherePresets.ALL.firstOrNull { (_, preset) -> preset == state }?.first + + private fun formNameOf(form: LumosForm): String? = + if (form is Forms.Lattice) { + LumosStep.LATTICE_FORM_NAME + } else { + Forms.All.firstOrNull { (_, catalogued) -> catalogued === form }?.first + } +}