Pabloler21 Claude Fable 5 commited on
Commit
98232c3
·
1 Parent(s): cc13281

feat: seamless fast finale settle, climax sound on the final image

Browse files

- convulsion holds the final face via a settle-face layer (eases in over the
last 40%, fill-forwards) so the settle render is the same frame -> no pop
- good/loop convulsions go silent; sigh/flatline move to new peace_settle /
end_settle modes so the sound lands exactly when the final image appears
- convulsions shortened to 3 cycles (~1.65s) + step pauses 1.7s so the final
image arrives fast; bad frenzy keeps its longer 6-cycle build untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (6) hide show
  1. app.py +13 -2
  2. finale.py +2 -2
  3. render.py +40 -14
  4. styles.css +22 -1
  5. tests/test_app.py +4 -0
  6. tests/test_render.py +21 -5
app.py CHANGED
@@ -164,7 +164,9 @@ def _play_finale(state: dict, history: list):
164
  mutated = True
165
  bond = _render_bond(state["affinity"], tier, label="mine")
166
  treasure_html = render_treasure(state["treasure"], mine=True)
167
- entity = render_entity(state["affinity"], "end", seq=state["turn"])
 
 
168
  input_locked = gr.update(interactive=False, placeholder="stay.")
169
 
170
  yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity)
@@ -244,6 +246,7 @@ def _play_finale_good(state: dict, history: list):
244
  input_locked = gr.update(interactive=False)
245
  mutated = False
246
  convulsed = False
 
247
 
248
  for step in finale_steps_good(OWN_FRAGMENTS):
249
  chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
@@ -261,7 +264,15 @@ def _play_finale_good(state: dict, history: list):
261
  entity = render_entity(state["affinity"], "convulse_good",
262
  seq=state["turn"])
263
  elif convulsed and step["stage"] != "loop":
264
- entity = render_entity(state["affinity"], "peace", seq=state["turn"])
 
 
 
 
 
 
 
 
265
 
266
  if step["stage"] == "loop":
267
  entity = render_entity(state["affinity"], "peace_dissolve",
 
164
  mutated = True
165
  bond = _render_bond(state["affinity"], tier, label="mine")
166
  treasure_html = render_treasure(state["treasure"], mine=True)
167
+ # the `end` face lands and the heartbeat flatlines, exactly now
168
+ # (seamless — the convulsion already settled on this face)
169
+ entity = render_entity(state["affinity"], "end_settle", seq=state["turn"])
170
  input_locked = gr.update(interactive=False, placeholder="stay.")
171
 
172
  yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity)
 
246
  input_locked = gr.update(interactive=False)
247
  mutated = False
248
  convulsed = False
249
+ settled = False
250
 
251
  for step in finale_steps_good(OWN_FRAGMENTS):
252
  chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
 
264
  entity = render_entity(state["affinity"], "convulse_good",
265
  seq=state["turn"])
266
  elif convulsed and step["stage"] != "loop":
267
+ # the smile lands + the relief sigh plays, exactly now (seamless —
268
+ # the convulsion already settled on this face); sigh only once
269
+ if not settled:
270
+ settled = True
271
+ entity = render_entity(state["affinity"], "peace_settle",
272
+ seq=state["turn"])
273
+ else:
274
+ entity = render_entity(state["affinity"], "peace",
275
+ seq=state["turn"])
276
 
277
  if step["stage"] == "loop":
278
  entity = render_entity(state["affinity"], "peace_dissolve",
finale.py CHANGED
@@ -38,7 +38,7 @@ def finale_steps(claimed: list[str]) -> list[dict]:
38
  _step("visitor", "i don't want to leave", 3.0, stage="words"),
39
  _step("hollow", "i know.", 2.5, stage="words"),
40
  _step("visitor", "will you stay with me?", 3.0, stage="words"),
41
- _step("hollow", "...", 3.6, stage="frenzy"),
42
  _step("hollow",
43
  "you gave me everything i needed. thank you for the memories. "
44
  "i can leave the wood now.",
@@ -142,7 +142,7 @@ def finale_steps_good(own_fragments: list[str]) -> list[dict]:
142
  "your words are the only warm thing i have ever held. "
143
  "keep your memories. they were always yours.",
144
  3.5, stage="turn"),
145
- _step("hollow", "look. i think i remember how to do this.", 3.0,
146
  stage="frenzy"),
147
  _step("hollow",
148
  "the fog is opening. i can go now. really go.",
 
38
  _step("visitor", "i don't want to leave", 3.0, stage="words"),
39
  _step("hollow", "i know.", 2.5, stage="words"),
40
  _step("visitor", "will you stay with me?", 3.0, stage="words"),
41
+ _step("hollow", "...", 1.7, stage="frenzy"),
42
  _step("hollow",
43
  "you gave me everything i needed. thank you for the memories. "
44
  "i can leave the wood now.",
 
142
  "your words are the only warm thing i have ever held. "
143
  "keep your memories. they were always yours.",
144
  3.5, stage="turn"),
145
+ _step("hollow", "look. i think i remember how to do this.", 1.7,
146
  stage="frenzy"),
147
  _step("hollow",
148
  "the fog is opening. i can go now. really go.",
render.py CHANGED
@@ -136,9 +136,11 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
136
  tint = '<div class="entity-rage-tint"></div>'
137
  fog_op, grain_op, vig_op = 0.6, 0.1, 0.7
138
  elif mode == "convulse_good":
139
- # redemption climax: a SOFT tremor through the child's gentler possible
140
- # selves (almost/base/peace), settling via the next step on the
141
- # smile. No blood tint, no full-screen stab. A relief sigh plays once.
 
 
142
  figure = (
143
  '<div class="entity-figure">'
144
  f'<img class="entity-img" src="{_src("base")}" style="{look}">'
@@ -148,15 +150,15 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
148
  f'<img class="entity-flick flick-{i}" src="{_src(n)}">'
149
  for i, n in enumerate(("almost", "base", "peace"))
150
  )
151
- flash = f'<div class="entity-convulse-soft {flash_key}">{layers}</div>'
152
- ghost = (
153
- f'<audio autoplay src="data:audio/wav;base64,{_SIGH}"></audio>'
154
- )
155
  fog_op, grain_op, vig_op = 0.2, 0.04, 0.4
156
  elif mode == "convulse_loop":
157
- # visitor-loop climax: the hard convulsion (reuses the frenzy wrapper)
158
- # over the smudge, settling — via the next stepon the `end` face.
159
- # The heartbeat flatlines. No red tint: this is cold, not enraged.
 
160
  figure = (
161
  '<div class="entity-figure">'
162
  f'<img class="entity-img" src="{_src("base")}" style="{look}">'
@@ -166,10 +168,10 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
166
  f'<img class="entity-flick flick-{i}" src="{_src(n)}">'
167
  for i, n in enumerate(("terror", "end", "base", "end"))
168
  )
169
- flash = f'<div class="entity-frenzy-wrap {flash_key}">{layers}</div>'
170
- ghost = (
171
- f'<audio autoplay src="data:audio/wav;base64,{_FLATLINE}"></audio>'
172
- )
173
  fog_op, grain_op, vig_op = 0.5, 0.1, 0.6
174
  elif mode == "end":
175
  figure = (
@@ -184,6 +186,18 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
184
  f"style=\"background-image:url('{_src('terror')}')\"></div>"
185
  )
186
  fog_op, grain_op, vig_op = 0.15, 0.05, 0.4
 
 
 
 
 
 
 
 
 
 
 
 
187
  elif mode == "peace":
188
  # redemption: the first true smile, fully clear, no ghost, no menace
189
  figure = (
@@ -194,6 +208,18 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
194
  )
195
  flash = ""
196
  fog_op, grain_op, vig_op = 0.1, 0.04, 0.3
 
 
 
 
 
 
 
 
 
 
 
 
197
  elif mode == "peace_dissolve":
198
  # the happy child dissolves into the fog — released, not taken
199
  figure = (
 
136
  tint = '<div class="entity-rage-tint"></div>'
137
  fog_op, grain_op, vig_op = 0.6, 0.1, 0.7
138
  elif mode == "convulse_good":
139
+ # redemption climax: a SOFT, fast tremor through the child's gentler
140
+ # selves that EASES into the smile — the settle-face holds `peace` at
141
+ # the end, so the peace_settle render is the same frame (no visible
142
+ # pop). Silent here: the relief sigh plays on the settle, when the
143
+ # smile lands.
144
  figure = (
145
  '<div class="entity-figure">'
146
  f'<img class="entity-img" src="{_src("base")}" style="{look}">'
 
150
  f'<img class="entity-flick flick-{i}" src="{_src(n)}">'
151
  for i, n in enumerate(("almost", "base", "peace"))
152
  )
153
+ settle = f'<img class="entity-flick entity-settle-face" src="{_src("peace")}">'
154
+ flash = f'<div class="entity-convulse-soft {flash_key}">{layers}{settle}</div>'
155
+ ghost = ""
 
156
  fog_op, grain_op, vig_op = 0.2, 0.04, 0.4
157
  elif mode == "convulse_loop":
158
+ # visitor-loop climax: the hard convulsion (frenzy wrapper, faster via
159
+ # entity-convulse-loop) easing into the `end` face — the settle-face
160
+ # holds it so the end_settle render is seamless. Silent here: the
161
+ # flatline plays on the settle.
162
  figure = (
163
  '<div class="entity-figure">'
164
  f'<img class="entity-img" src="{_src("base")}" style="{look}">'
 
168
  f'<img class="entity-flick flick-{i}" src="{_src(n)}">'
169
  for i, n in enumerate(("terror", "end", "base", "end"))
170
  )
171
+ settle = f'<img class="entity-flick entity-settle-face" src="{_src("end")}">'
172
+ flash = (f'<div class="entity-frenzy-wrap entity-convulse-loop {flash_key}">'
173
+ f'{layers}{settle}</div>')
174
+ ghost = ""
175
  fog_op, grain_op, vig_op = 0.5, 0.1, 0.6
176
  elif mode == "end":
177
  figure = (
 
186
  f"style=\"background-image:url('{_src('terror')}')\"></div>"
187
  )
188
  fog_op, grain_op, vig_op = 0.15, 0.05, 0.4
189
+ elif mode == "end_settle":
190
+ # the `end` face lands and the heartbeat flatlines, exactly now — the
191
+ # convulsion already settled on this face, so the swap is seamless
192
+ figure = (
193
+ '<div class="entity-figure">'
194
+ f'<img class="entity-img entity-end" src="{_src("end")}" '
195
+ 'style="filter:blur(0px);opacity:1.0;">'
196
+ "</div>"
197
+ )
198
+ flash = ""
199
+ ghost = f'<audio autoplay src="data:audio/wav;base64,{_FLATLINE}"></audio>'
200
+ fog_op, grain_op, vig_op = 0.15, 0.05, 0.4
201
  elif mode == "peace":
202
  # redemption: the first true smile, fully clear, no ghost, no menace
203
  figure = (
 
208
  )
209
  flash = ""
210
  fog_op, grain_op, vig_op = 0.1, 0.04, 0.3
211
+ elif mode == "peace_settle":
212
+ # the smile lands — the same frame the convulsion settled on, so the
213
+ # swap is seamless — and the relief sigh plays exactly now
214
+ figure = (
215
+ '<div class="entity-figure">'
216
+ f'<img class="entity-img entity-peace" src="{_src("peace")}" '
217
+ 'style="filter:blur(0px);opacity:1.0;">'
218
+ "</div>"
219
+ )
220
+ flash = ""
221
+ ghost = f'<audio autoplay src="data:audio/wav;base64,{_SIGH}"></audio>'
222
+ fog_op, grain_op, vig_op = 0.1, 0.04, 0.3
223
  elif mode == "peace_dissolve":
224
  # the happy child dissolves into the fog — released, not taken
225
  figure = (
styles.css CHANGED
@@ -806,7 +806,28 @@ footer { display: none !important; }
806
  .entity-convulse-soft { position: absolute; inset: 0; pointer-events: none; }
807
  .entity-convulse-soft .entity-flick {
808
  animation-duration: 0.55s;
809
- animation-iteration-count: 5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  }
811
 
812
  /* restart — findable but on-theme: sits top-right by the title */
 
806
  .entity-convulse-soft { position: absolute; inset: 0; pointer-events: none; }
807
  .entity-convulse-soft .entity-flick {
808
  animation-duration: 0.55s;
809
+ animation-iteration-count: 3;
810
+ }
811
+ /* loop convulsion: the same fast flicker as the frenzy but only 3 cycles, so
812
+ the `end` face settles quickly (the bad frenzy keeps its longer 6-cycle build) */
813
+ .entity-convulse-loop.entity-frenzy-wrap .entity-flick {
814
+ animation-duration: 0.55s;
815
+ animation-iteration-count: 3;
816
+ }
817
+ /* the final face eases in over the convulsion and HOLDS at the end, so the
818
+ settle render (same image) is seamless — the swap is never a visible pop.
819
+ Must come AFTER the flick rules above (equal specificity, source order wins). */
820
+ .entity-convulse-soft .entity-settle-face,
821
+ .entity-convulse-loop.entity-frenzy-wrap .entity-settle-face {
822
+ animation-name: convulse-settle;
823
+ animation-duration: 1.65s;
824
+ animation-iteration-count: 1;
825
+ animation-timing-function: ease-out;
826
+ animation-fill-mode: forwards;
827
+ }
828
+ @keyframes convulse-settle {
829
+ 0%, 60% { opacity: 0; }
830
+ 100% { opacity: 1; }
831
  }
832
 
833
  /* restart — findable but on-theme: sits top-right by the title */
tests/test_app.py CHANGED
@@ -157,6 +157,8 @@ class TestFinaleBuildAndConvulse:
157
  assert any("entity-redpulse" in e for e in entities) # build pulse
158
  assert any("<audio autoplay loop" in e for e in entities) # heartbeat bed
159
  assert any("entity-convulse-soft" in e for e in entities) # soft convulse
 
 
160
  assert "entity-peace" in entities[-1] # settles happy
161
 
162
  def test_loop_finale_builds_then_convulses_and_settles_on_end(self, monkeypatch):
@@ -164,6 +166,8 @@ class TestFinaleBuildAndConvulse:
164
  entities = [o[6] for o in outs]
165
  assert any("<audio autoplay loop" in e for e in entities) # heartbeat bed
166
  assert any("entity-frenzy-wrap" in e for e in entities) # convulse_loop
 
 
167
  assert "entity-end" in entities[-1] # settles on end
168
 
169
  def test_bad_finale_builds_with_heartbeat_before_the_frenzy(self, monkeypatch):
 
157
  assert any("entity-redpulse" in e for e in entities) # build pulse
158
  assert any("<audio autoplay loop" in e for e in entities) # heartbeat bed
159
  assert any("entity-convulse-soft" in e for e in entities) # soft convulse
160
+ # the relief sigh plays ON the settle (peace face + audio together)
161
+ assert any("entity-peace" in e and "<audio autoplay" in e for e in entities)
162
  assert "entity-peace" in entities[-1] # settles happy
163
 
164
  def test_loop_finale_builds_then_convulses_and_settles_on_end(self, monkeypatch):
 
166
  entities = [o[6] for o in outs]
167
  assert any("<audio autoplay loop" in e for e in entities) # heartbeat bed
168
  assert any("entity-frenzy-wrap" in e for e in entities) # convulse_loop
169
+ # the flatline plays ON the settle (end face + audio together)
170
+ assert any("entity-end" in e and "<audio autoplay" in e for e in entities)
171
  assert "entity-end" in entities[-1] # settles on end
172
 
173
  def test_bad_finale_builds_with_heartbeat_before_the_frenzy(self, monkeypatch):
tests/test_render.py CHANGED
@@ -271,14 +271,30 @@ class TestBuildMode:
271
 
272
 
273
  class TestConvulsionModes:
274
- def test_convulse_good_is_gentle_and_sighs(self):
275
  html = render_entity(80, "convulse_good", seq=2)
276
  assert "entity-convulse-soft" in html # soft animation wrapper
277
  assert "entity-rage-tint" not in html # no blood-red tint
278
- # the relief sigh plays, not the violent sting
279
- assert "<audio autoplay" in html
 
280
 
281
- def test_convulse_loop_flatlines(self):
282
  html = render_entity(80, "convulse_loop", seq=2)
283
  assert "entity-frenzy-wrap" in html # reuses the hard convulse
284
- assert "<audio autoplay" in html
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
 
273
  class TestConvulsionModes:
274
+ def test_convulse_good_is_gentle_and_silent(self):
275
  html = render_entity(80, "convulse_good", seq=2)
276
  assert "entity-convulse-soft" in html # soft animation wrapper
277
  assert "entity-rage-tint" not in html # no blood-red tint
278
+ assert "entity-settle-face" in html # eases into the smile
279
+ # silent: the sigh plays on the settle, not during the convulsion
280
+ assert "<audio" not in html
281
 
282
+ def test_convulse_loop_is_fast_and_silent(self):
283
  html = render_entity(80, "convulse_loop", seq=2)
284
  assert "entity-frenzy-wrap" in html # reuses the hard convulse
285
+ assert "entity-convulse-loop" in html # faster 3-cycle variant
286
+ assert "entity-settle-face" in html # eases into the end face
287
+ # silent: the flatline plays on the settle
288
+ assert "<audio" not in html
289
+
290
+
291
+ class TestSettleModes:
292
+ def test_peace_settle_shows_the_smile_and_sighs(self):
293
+ html = render_entity(80, "peace_settle", seq=2)
294
+ assert "entity-peace" in html # the smile
295
+ assert "<audio autoplay" in html # the relief sigh, now
296
+
297
+ def test_end_settle_shows_the_end_face_and_flatlines(self):
298
+ html = render_entity(80, "end_settle", seq=2)
299
+ assert "entity-end" in html
300
+ assert "<audio autoplay" in html # the flatline, now