Spaces:
Sleeping
Sleeping
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>
- app.py +13 -2
- finale.py +2 -2
- render.py +40 -14
- styles.css +22 -1
- tests/test_app.py +4 -0
- 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "...",
|
| 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.",
|
| 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
|
| 140 |
-
# selves
|
| 141 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
| 152 |
-
|
| 153 |
-
|
| 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 (
|
| 158 |
-
#
|
| 159 |
-
#
|
|
|
|
| 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 |
-
|
| 170 |
-
|
| 171 |
-
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 279 |
-
|
|
|
|
| 280 |
|
| 281 |
-
def
|
| 282 |
html = render_entity(80, "convulse_loop", seq=2)
|
| 283 |
assert "entity-frenzy-wrap" in html # reuses the hard convulse
|
| 284 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|