File size: 5,467 Bytes
0edb292
 
 
 
 
 
 
 
 
 
 
 
 
 
9822776
 
 
 
 
 
 
 
0edb292
 
 
 
 
 
 
2592c19
 
 
0edb292
2592c19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0edb292
 
 
 
 
 
 
 
 
 
2592c19
0edb292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9822776
0edb292
 
 
 
 
 
 
 
4e3fc96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3722c4
 
 
 
 
 
4e3fc96
 
 
b3722c4
 
4e3fc96
 
 
 
0edb292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""HTML for the results list.

The list is plain markup with `data-` attributes; a single delegated click
handler in the page head turns any timestamp into a seek. No per-card
JavaScript, no component churn — Gradio only ever hands back a string.
"""

from __future__ import annotations

from html import escape

from search import MAX_SHOWN_EVENTS, Hit


def _safe_url(url: str | None) -> str:
    """Only ever emit an http(s) link. The URLs come from the dataset, and a
    link labelled 'archive.org' should not be able to send a visitor to a
    `javascript:` or `data:` target if a row is ever wrong."""
    url = (url or "").strip()
    return url if url.startswith(("https://", "http://")) else "#"


def clock(seconds: float) -> str:
    seconds = max(0, int(round(seconds)))
    h, rem = divmod(seconds, 3600)
    m, s = divmod(rem, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"


MIN_EVENTS = 3


def _events_html(hit: Hit) -> str:
    """Matched events, padded with their neighbours up to a readable minimum.

    A semantic hit often matches no event text literally — the query never
    appears in the captions — so without padding those results collapse to one
    line. Padding is greyed: only literal matches get the accent, which keeps
    the colour honest about what it means.
    """
    matched = hit.matched[:MAX_SHOWN_EVENTS]
    hits = {id(e) for e in matched}
    shown = list(matched)
    for event in hit.events:
        if len(shown) >= MIN_EVENTS:
            break
        if id(event) not in hits:
            shown.append(event)
    shown.sort(key=lambda e: e["start"])
    if not shown:
        return ""
    rows = []
    for event in shown:
        rows.append(
            "<li{cls}>"
            '<button class="at" data-src="{src}" data-t="{t}" '
            'data-title="{title}" data-when="{when}">{when}</button>'
            "<span>{text}</span>"
            "</li>".format(
                cls=' class="hit"' if id(event) in hits else "",
                src=escape(hit.video_url, quote=True),
                t=f"{event['start']:.2f}",
                title=escape(hit.title, quote=True),
                when=clock(event["start"]),
                text=escape(event["text"]),
            )
        )
    return f'<ul class="events">{"".join(rows)}</ul>'


def moment(hit: Hit) -> str:
    when = clock(hit.jump)
    stamp = " · ".join(
        part
        for part in (
            str(hit.year) if hit.year else "undated",
            f"{clock(hit.chunk_start)}{clock(hit.chunk_end)}",
        )
        if part
    )
    scene = escape(hit.scene)
    scene_html = (
        f'<details class="scene"><summary>scene</summary><p>{scene}</p></details>'
        if scene
        else ""
    )
    return f"""
<article class="moment" id="m-{escape(hit.moment_id, quote=True)}">
  <button class="frame" data-src="{escape(hit.video_url, quote=True)}"
          data-t="{hit.jump:.2f}" data-title="{escape(hit.title, quote=True)}"
          data-when="{when}" aria-label="Play {escape(hit.title, quote=True)} at {when}">
    <img src="{escape(hit.thumb_url, quote=True)}" alt="" loading="lazy" decoding="async">
    <span class="cue">{when}</span>
  </button>
  <div class="meta">
    <h2>{escape(hit.title)}</h2>
    <p class="stamp">{stamp}
      <a href="{escape(_safe_url(hit.ia_url), quote=True)}" target="_blank" rel="noopener">archive.org</a>
    </p>
    {_events_html(hit)}
    {scene_html}
  </div>
</article>
"""


def opening(hits: list[Hit]) -> str:
    """What an empty search box shows: some of the collection, not a blank page.

    Every tile plays, so the first thing the page offers is the thing it does.
    """
    if not hits:
        return ""
    tiles = []
    for hit in hits:
        when = clock(hit.chunk_start)
        tiles.append(
            f"""
<div class="tile">
  <button class="frame" data-src="{escape(hit.video_url, quote=True)}"
          data-t="{hit.chunk_start:.2f}" data-title="{escape(hit.title, quote=True)}"
          data-when="{when}" aria-label="Play {escape(hit.title, quote=True)} at {when}">
    <img src="{escape(hit.thumb_url, quote=True)}" alt="" loading="lazy" decoding="async">
    <span class="cue">{when}</span>
  </button>
  <h3>{escape(hit.title)}</h3>
  <p class="stamp">{hit.year or "undated"}</p>
  <!-- Not shown in the grid, but the player panel clones it on click: browsing
       at random should show you what the model wrote, not just play the clip. -->
  <div class="detail" hidden>
    {_events_html(hit)}
    <div class="scene"><p>{escape(hit.scene)}</p></div>
  </div>
</div>"""
        )
    return (
        '<p class="count">a few moments, at random — click one to read what the '
        "model saw</p>"
        f'<div class="opening">{"".join(tiles)}</div>'
    )


def results(hits: list[Hit], query: str, elapsed_ms: float) -> str:
    if not query.strip():
        return ""
    if not hits:
        return (
            '<p class="empty">Nothing for <em>%s</em>. '
            "The captions describe what is on screen — try what you would "
            "<em>see</em>: a place, an object, an action.</p>" % escape(query)
        )
    films = len({h.identifier for h in hits})
    head = (
        f'<p class="count">{len(hits)} moments · {films} '
        f'{"film" if films == 1 else "films"}'
        f'<span class="ms">{elapsed_ms:.0f} ms</span></p>'
    )
    return head + "".join(moment(h) for h in hits)