File size: 7,820 Bytes
51a9974
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""
Scroll-unroll birth animation for the shell.

The shell is born like an ancient scroll unfurling. A radial wipe mask reveals
the colored spiral from the eye outward — the scroll unrolling — led by a glowing
gold parchment edge. As the unrolling front passes each battle figure, that
figure inks in. The breakthrough dragon and aperture land last, at the tip.

Why a wipe MASK and not stroke-drawing: the shell body is a single filled spiral
path, so it cannot "draw" like a stroke. A growing radial mask genuinely reveals
the filled body progressively, which is what reads as a scroll unrolling.

SMIL (native SVG animation) so it survives Gradio's sanitizer; wrapped in an
<iframe srcdoc>. fill="freeze" holds the final static shell. Figures default
visible (animation drives them from opacity 0), so a no-SMIL browser still shows
the complete shell.
"""

from __future__ import annotations

import re

from anim_diagnostic import inject_probe

DIAGNOSTIC = False

SCROLL_DUR = 3.6          # seconds for the scroll to fully unroll
FIG_FADE = 0.7            # seconds each figure takes to ink in
EYE_X, EYE_Y = 320.0, 335.0   # shell eye (spiral origin); body starts here
MAX_R = 330.0            # radius that covers the whole 640 canvas from the eye
KEYSPLINE = "0.3 0 0.4 1"     # ease for the unroll


def _scroll_mask_and_edge(animated_only: bool = True) -> tuple[str, str]:
    """Return (mask_def, glowing_edge) for the live animated scroll-unroll."""
    mask_circle = (
        f'<circle cx="{EYE_X}" cy="{EYE_Y}" r="0" fill="white">'
        f'<animate attributeName="r" from="0" to="{MAX_R}" dur="{SCROLL_DUR}s" '
        f'begin="0s" fill="freeze" calcMode="spline" keySplines="{KEYSPLINE}" '
        f'keyTimes="0;1"/></circle>'
    )
    mask_def = f'<mask id="scrollWipe">{mask_circle}</mask>'
    edge = (
        f'<g class="scroll-edge">'
        f'<circle cx="{EYE_X}" cy="{EYE_Y}" r="0" fill="none" stroke="#e6c870" '
        f'stroke-width="6" opacity="0.55">'
        f'<animate attributeName="r" from="0" to="{MAX_R}" dur="{SCROLL_DUR}s" '
        f'begin="0s" fill="freeze" calcMode="spline" keySplines="{KEYSPLINE}" keyTimes="0;1"/>'
        f'<animate attributeName="opacity" values="0.55;0.55;0" keyTimes="0;0.82;1" '
        f'dur="{SCROLL_DUR}s" begin="0s" fill="freeze"/></circle>'
        f'<circle cx="{EYE_X}" cy="{EYE_Y}" r="0" fill="none" stroke="#efe0b0" '
        f'stroke-width="2" opacity="0.8">'
        f'<animate attributeName="r" from="0" to="{MAX_R}" dur="{SCROLL_DUR}s" '
        f'begin="0s" fill="freeze" calcMode="spline" keySplines="{KEYSPLINE}" keyTimes="0;1"/>'
        f'<animate attributeName="opacity" values="0.8;0.8;0" keyTimes="0;0.82;1" '
        f'dur="{SCROLL_DUR}s" begin="0s" fill="freeze"/></circle>'
        f'</g>'
    )
    return mask_def, edge


def _ink_figures(svg: str) -> str:
    """Each battle-fig group inks in (fade + slight rise) when the unrolling front
    reaches its data-pos radius. begin = pos * SCROLL_DUR (the dragon, pos==1.0,
    lands at the very end)."""
    def repl(m):
        tag = m.group(0)
        pos_match = re.search(r'data-pos="([\d.]+)"', tag)
        pos = float(pos_match.group(1)) if pos_match else 0.5
        begin = round(pos * SCROLL_DUR, 2)
        anim = (
            f'<animate attributeName="opacity" from="0" to="1" '
            f'dur="{FIG_FADE}s" begin="{begin}s" fill="freeze"/>'
            f'<animateTransform attributeName="transform" type="translate" '
            f'from="0 8" to="0 0" dur="{FIG_FADE}s" begin="{begin}s" '
            f'fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1" keyTimes="0;1"/>'
        )
        # do NOT hard-set opacity 0 (keeps figures visible if SMIL fails)
        return tag + anim
    return re.sub(r'<g class="battle-fig" data-pos="[\d.]+">', repl, svg)


def animate_shell_svg(svg: str, seed=0, style: str | None = None) -> str:
    """Inject the scroll-unroll birth: a radial wipe mask reveals the shell from
    the eye outward, a gold parchment edge rides the unrolling front, and the
    battle figures ink in as the front passes them.
    """
    # 1. inject the mask def + edge after </defs>
    defs_end = svg.find("</defs>")
    if defs_end == -1:
        open_end = svg.find(">", svg.find("<svg")) + 1
        svg = svg[:open_end] + "<defs></defs>" + svg[open_end:]
        defs_end = svg.find("</defs>")
    insert_at = defs_end + len("</defs>")
    mask_def, edge = _scroll_mask_and_edge()

    # 2. wrap the shell (from the body onward) in the masked group. Background
    #    (rect, atmosphere, particles, halos) stays unmasked so only the SHELL
    #    unrolls, against a steady night sky.
    body_idx = svg.find('<path class="shell-body"')
    if body_idx == -1:
        body_idx = insert_at  # fallback: mask everything after defs
    svg_close = svg.rfind("</svg>")

    head = svg[:insert_at] + mask_def
    pre_shell = svg[insert_at:body_idx]
    shell_region = svg[body_idx:svg_close]
    tail = svg[svg_close:]

    shell_region = _ink_figures(shell_region)

    return (
        head
        + pre_shell
        + '<g mask="url(#scrollWipe)">'
        + shell_region
        + '</g>'
        + edge
        + tail
    )


# ---- iframe wrapping (survives Gradio sanitizer) + replay button -------------

REPLAY_HTML = """
<button id="replay-birth" title="watch the scroll unroll again">\u21bb watch it unroll again</button>
<style>
#replay-birth{
  position:fixed;left:50%;bottom:10px;transform:translateX(-50%);
  z-index:9999;font:12px/1.2 Georgia,serif;font-style:italic;
  padding:6px 14px;border-radius:14px;cursor:pointer;
  background:rgba(20,16,12,0.55);color:#efe0b0;border:1px solid rgba(200,162,76,0.5);
  backdrop-filter:blur(2px);transition:opacity .3s;opacity:0.78;
}
#replay-birth:hover{opacity:1;background:rgba(20,16,12,0.75);}
</style>
<script>
(function(){
  function restart(){
    var svg = document.querySelector('svg');
    if(!svg) return;
    // Rewind the whole SVG timeline. For begin="0s" animations this re-runs
    // them from the top. We pause, seek to 0, then unpause so the flipbook
    // (stage opacity keyframes) and the curl motion both restart cleanly.
    try {
      svg.pauseAnimations();
      svg.setCurrentTime(0);
      svg.unpauseAnimations();
    } catch(e){
      // fallback: plain rewind
      try { svg.setCurrentTime(0); } catch(e2){}
    }
  }
  var btn = document.getElementById('replay-birth');
  if(btn){ btn.addEventListener('click', restart); }
})();
</script>
"""


def inject_replay(iframe_inner_html: str) -> str:
    if "</body>" in iframe_inner_html:
        return iframe_inner_html.replace("</body>", REPLAY_HTML + "</body>", 1)
    return iframe_inner_html + REPLAY_HTML


def wrap_in_iframe(animated_svg: str, height: int = 660, replay: bool = True) -> str:
    """Wrap the animated SVG in an <iframe srcdoc> (survives Gradio sanitizer),
    rendered as a centered square so the whole shell shows."""
    import html as _html
    doc = (
        "<!DOCTYPE html><html><head><style>"
        "html,body{margin:0;padding:0;background:transparent;overflow:hidden;"
        "height:100%;display:flex;align-items:center;justify-content:center}"
        "svg{max-width:100%;max-height:100%;width:auto;height:auto;display:block}"
        "</style></head><body>" + animated_svg + "</body></html>"
    )
    if DIAGNOSTIC:
        doc = inject_probe(doc)
    if replay:
        doc = inject_replay(doc)
    escaped = _html.escape(doc, quote=True)
    return (
        f'<div style="display:flex;justify-content:center;width:100%;">'
        f'<iframe srcdoc="{escaped}" '
        f'style="width:{height}px;max-width:100%;height:{height}px;'
        f'border:none;background:transparent;" '
        f'sandbox="allow-scripts"></iframe>'
        f'</div>'
    )