davanstrien HF Staff commited on
Commit
7636773
·
verified ·
1 Parent(s): 9b1b6a4

Replace Gradio Blocks with gradio.Server: own HTML shell, own CSS, no selector rewriting

Browse files
Files changed (6) hide show
  1. README.md +18 -7
  2. app.py +123 -115
  3. index.html +17 -0
  4. style.css → static/app.css +252 -283
  5. player.js → static/app.js +100 -68
  6. wide.css +0 -48
README.md CHANGED
@@ -59,13 +59,24 @@ keyword search rather than erroring.
59
  | `MOMENTS_EMBED_MODEL` | query embedder (default `BAAI/bge-m3`, must match the dataset's) |
60
  | `HF_TOKEN` | required while the dataset is private, and for Inference Providers |
61
 
62
- ## Notes for anyone editing the CSS
63
-
64
- Gradio 6 rewrites every selector passed to `css=` to sit under
65
- `.gradio-container… .contain`, so a rule aimed at the container itself becomes a
66
- descendant of it and silently matches nothing. Shell-level rules therefore live
67
- in `wide.css`, injected through `head=`, where they are not rewritten — and need
68
- `!important`, because head styles load before Gradio's own.
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  ## Caveats
71
 
 
59
  | `MOMENTS_EMBED_MODEL` | query embedder (default `BAAI/bge-m3`, must match the dataset's) |
60
  | `HF_TOKEN` | required while the dataset is private, and for Inference Providers |
61
 
62
+ ## Why `gradio.Server` and not Blocks
63
+
64
+ The page is hand-written HTML/CSS/JS served by `gradio.Server` (a FastAPI
65
+ subclass): `index.html` for the shell, `static/app.css`, `static/app.js`, and one
66
+ `/api/search` endpoint returning rendered HTML. Spaces still runs it under the
67
+ gradio SDK.
68
+
69
+ Blocks was the original approach and cost more than it gave. Gradio 6 rewrites
70
+ every selector passed to `css=` to sit under `.gradio-container… .contain`, so
71
+ rules aimed at the shell silently match nothing; Buttons land in a Row with
72
+ `flex: 1 1 0` and divide the width regardless of content; and its component
73
+ styling reaches raw markup inside `gr.HTML`, so `<button>`/`<img>` resets had to
74
+ be fought with `!important` — and lost anyway on at least one real Android
75
+ device while passing in emulation. The UI here only ever needed a text input,
76
+ six links, two selects and a checkbox, so the framework was carrying no weight.
77
+
78
+ There is no `!important` in `app.css`. If a rule does not win, that is a bug in
79
+ the file rather than a fight with a framework.
80
 
81
  ## Caveats
82
 
app.py CHANGED
@@ -2,8 +2,11 @@
2
 
3
  The dataset is the API; this is thin client #1 over it. Search runs server-side
4
  in DuckDB against a single parquet, results are plain HTML, and playback is a
5
- `<video>` pointed straight at the public bucket (range requests, so seeking into
6
- the middle of a film costs nothing).
 
 
 
7
  """
8
 
9
  from __future__ import annotations
@@ -11,12 +14,13 @@ from __future__ import annotations
11
  import logging
12
  import os
13
  import time
 
14
  from pathlib import Path
15
 
16
  import numpy as np
17
-
18
- import gradio as gr
19
- from functools import lru_cache
20
  from huggingface_hub import InferenceClient
21
 
22
  import render
@@ -27,11 +31,6 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(mess
27
  log = logging.getLogger("moments")
28
 
29
  HERE = Path(__file__).parent
30
- CSS = (HERE / "style.css").read_text()
31
- HEAD = (
32
- f"<style>{(HERE / 'wide.css').read_text()}</style>"
33
- f"<script>{(HERE / 'player.js').read_text()}</script>"
34
- )
35
 
36
  SUGGESTIONS = [
37
  "children washing hands",
@@ -42,144 +41,153 @@ SUGGESTIONS = [
42
  "family at dinner",
43
  ]
44
 
45
- STAGE = """
46
- <div class="stage" id="stage" hidden>
47
- <div class="screen">
48
- <video id="player" controls playsinline preload="metadata"></video>
49
- <button type="button" id="stage-play" aria-label="Play">
50
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5z"/></svg>
51
- </button>
52
- </div>
53
- <p class="stage-meta" id="stage-meta"></p>
54
- <div id="stage-list"></div>
55
- </div>
56
- """
57
 
58
  try:
59
  DATA = store.load()
60
  except Exception as exc: # most likely: private dataset, no HF_TOKEN secret
61
  log.exception("could not load the dataset")
62
- with gr.Blocks(css=CSS, theme=gr.themes.Base(), title="Prelinger moments") as demo:
63
- gr.HTML(
64
- '<div class="masthead"><h1>Prelinger moments</h1>'
65
- f"<p>Could not read <code>{store.DATASET_ID}</code>. While the dataset "
66
- "is private this Space needs an <code>HF_TOKEN</code> secret with read "
67
- f"access.</p><p><code>{type(exc).__name__}: {exc}</code></p></div>"
68
- )
69
- if __name__ == "__main__":
70
- demo.launch()
71
- raise SystemExit(0)
72
 
73
- # The one external call in the whole design: everything else is DuckDB over a
74
- # single parquet. Cached because the same query gets re-run on every facet change.
75
  EMBED_MODEL = os.environ.get("MOMENTS_EMBED_MODEL", "BAAI/bge-m3")
76
  _client = InferenceClient()
77
 
78
 
79
  @lru_cache(maxsize=512)
80
- def embed_query(text: str) -> tuple[float, ...]:
81
  vec = _client.feature_extraction(text, model=EMBED_MODEL)
82
  return tuple(float(x) for x in np.asarray(vec).ravel())
83
 
84
 
85
- def _embed(text: str) -> list[float]:
86
- return list(embed_query(text))
87
 
88
 
89
- try:
90
- _embed("warm up")
91
- EMBEDDER = _embed
92
- except Exception as exc:
93
- log.warning("query embedder unavailable, keyword search only: %s", exc)
94
- EMBEDDER = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- BACKENDS = search.build(DATA, embed_query=EMBEDDER)
97
- BY_NAME = {b.blurb: b for b in BACKENDS}
98
- MODES = list(BY_NAME)
99
- BACKEND = BACKENDS[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- DECADES = ["any decade", *DATA.decades, "undated"]
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
 
104
- OPENING = render.opening(search.sample(DATA))
 
 
105
 
106
 
107
- def run(query: str, decade: str, spread: bool, mode: str = "") -> str:
108
- if not query or not query.strip():
109
- return OPENING
110
- q = search.Query(
111
- text=query.strip(),
 
 
 
 
 
 
 
 
 
112
  limit=60,
113
  decade=int(decade[:4]) if decade and decade[0].isdigit() else None,
114
  undated=decade == "undated",
115
- per_film=2 if spread else None,
116
  )
117
- backend = BY_NAME.get(mode, BACKEND)
118
  t0 = time.perf_counter()
119
  try:
120
- hits = backend.search(q)
121
  except Exception as exc: # provider hiccup — keyword search still works
122
  log.warning("%s search failed, falling back to keyword: %s", backend.name, exc)
123
  backend = BY_NAME["keyword"]
124
- hits = backend.search(q)
125
  ms = (time.perf_counter() - t0) * 1000
126
- log.info("%r -> %d hits in %.0f ms (%s)", q.text, len(hits), ms, backend.name)
127
- return render.results(hits, q.text, ms)
128
-
129
-
130
- with gr.Blocks(css=CSS, head=HEAD, theme=gr.themes.Base(), title="Prelinger moments",
131
- analytics_enabled=False) as demo:
132
- gr.HTML(
133
- '<div class="masthead"><h1>Prelinger moments</h1><p>'
134
- f"{DATA.n_moments:,} minute-long moments from {DATA.n_films:,} public-domain films in the "
135
- '<a href="https://archive.org/details/prelinger" target="_blank" rel="noopener">'
136
- "Prelinger Archives</a>, each described second by second by a video model. "
137
- "Search for what you want to <em>see</em> — results play from the moment it happens."
138
- "</p></div>"
139
- )
140
-
141
- q = gr.Textbox(
142
- label="",
143
- placeholder="children washing hands",
144
- lines=1,
145
- max_lines=1,
146
- elem_id="q",
147
- show_label=False,
148
- container=False,
149
- autofocus=True,
150
- )
151
-
152
- with gr.Row(elem_classes="suggest"):
153
- chips = [gr.Button(s, size="sm", variant="secondary") for s in SUGGESTIONS]
154
-
155
- with gr.Row(elem_id="facets"):
156
- decade = gr.Dropdown(
157
- DECADES, value="any decade", show_label=False, container=False, scale=0
158
- )
159
- spread = gr.Checkbox(
160
- value=True, label="at most two moments per film", container=False, scale=0
161
- )
162
- # Only offered when the data supports more than one way of searching.
163
- mode = gr.Dropdown(
164
- MODES,
165
- value=MODES[0],
166
- show_label=False,
167
- container=False,
168
- scale=0,
169
- visible=len(MODES) > 1,
170
- )
171
-
172
- gr.HTML(STAGE)
173
- out = gr.HTML(OPENING)
174
-
175
- inputs = [q, decade, spread, mode]
176
- q.submit(run, inputs, out)
177
- decade.change(run, inputs, out)
178
- spread.change(run, inputs, out)
179
- mode.change(run, inputs, out)
180
- for chip, text in zip(chips, SUGGESTIONS):
181
- chip.click(lambda t=text: t, None, q).then(run, inputs, out)
182
 
183
 
184
  if __name__ == "__main__":
185
- demo.launch()
 
2
 
3
  The dataset is the API; this is thin client #1 over it. Search runs server-side
4
  in DuckDB against a single parquet, results are plain HTML, and playback is a
5
+ `<video>` pointed at the public bucket.
6
+
7
+ Served by `gradio.Server` (a FastAPI subclass) rather than Gradio Blocks: the
8
+ page is hand-written HTML/CSS/JS, so nothing rewrites the stylesheet or injects
9
+ component styling over it. Spaces still runs it under the gradio SDK.
10
  """
11
 
12
  from __future__ import annotations
 
14
  import logging
15
  import os
16
  import time
17
+ from functools import lru_cache
18
  from pathlib import Path
19
 
20
  import numpy as np
21
+ from fastapi.responses import HTMLResponse, JSONResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+ from gradio import Server
24
  from huggingface_hub import InferenceClient
25
 
26
  import render
 
31
  log = logging.getLogger("moments")
32
 
33
  HERE = Path(__file__).parent
 
 
 
 
 
34
 
35
  SUGGESTIONS = [
36
  "children washing hands",
 
41
  "family at dinner",
42
  ]
43
 
44
+ app = Server()
45
+ app.mount("/static", StaticFiles(directory=HERE / "static"), name="static")
46
+
47
+
48
+ # --- data -------------------------------------------------------------------
49
+
50
+ DATA = None
51
+ BACKENDS: list = []
52
+ BY_NAME: dict = {}
53
+ LOAD_ERROR: str | None = None
 
 
54
 
55
  try:
56
  DATA = store.load()
57
  except Exception as exc: # most likely: private dataset, no HF_TOKEN secret
58
  log.exception("could not load the dataset")
59
+ LOAD_ERROR = f"{type(exc).__name__}: {exc}"
 
 
 
 
 
 
 
 
 
60
 
 
 
61
  EMBED_MODEL = os.environ.get("MOMENTS_EMBED_MODEL", "BAAI/bge-m3")
62
  _client = InferenceClient()
63
 
64
 
65
  @lru_cache(maxsize=512)
66
+ def _embed_cached(text: str) -> tuple[float, ...]:
67
  vec = _client.feature_extraction(text, model=EMBED_MODEL)
68
  return tuple(float(x) for x in np.asarray(vec).ravel())
69
 
70
 
71
+ def embed_query(text: str) -> list[float]:
72
+ return list(_embed_cached(text))
73
 
74
 
75
+ if DATA is not None:
76
+ try:
77
+ embed_query("warm up")
78
+ embedder = embed_query
79
+ except Exception as exc:
80
+ log.warning("query embedder unavailable, keyword search only: %s", exc)
81
+ embedder = None
82
+ BACKENDS = search.build(DATA, embed_query=embedder)
83
+ BY_NAME = {b.blurb: b for b in BACKENDS}
84
+ OPENING = render.opening(search.sample(DATA))
85
+ DECADES = ["any decade", *DATA.decades, "undated"]
86
+ else:
87
+ OPENING, DECADES = "", ["any decade"]
88
+
89
+
90
+ # --- page -------------------------------------------------------------------
91
+
92
+
93
+ def _options(values: list[str], selected: str) -> str:
94
+ return "".join(
95
+ f'<option value="{v}"{" selected" if v == selected else ""}>{v}</option>'
96
+ for v in values
97
+ )
98
+
99
+
100
+ def page() -> str:
101
+ shell = (HERE / "index.html").read_text()
102
+ if DATA is None:
103
+ body = (
104
+ '<header class="masthead"><h1>Prelinger moments</h1>'
105
+ f"<p>Could not read <code>{store.DATASET_ID}</code>. While the dataset "
106
+ "is private this Space needs an <code>HF_TOKEN</code> secret with read "
107
+ f"access.</p><p class='stamp'>{LOAD_ERROR}</p></header>"
108
+ )
109
+ return shell.replace("<!--BODY-->", body)
110
 
111
+ modes = list(BY_NAME)
112
+ mode_select = (
113
+ '<select id="mode" aria-label="Search mode">' + _options(modes, modes[0]) + "</select>"
114
+ if len(modes) > 1
115
+ else ""
116
+ )
117
+ chips = "".join(f'<button type="button" class="chip">{s}</button>' for s in SUGGESTIONS)
118
+ body = f"""
119
+ <header class="masthead">
120
+ <h1>Prelinger moments</h1>
121
+ <p>{DATA.n_moments:,} minute-long moments from {DATA.n_films:,} public-domain films in
122
+ the <a href="https://archive.org/details/prelinger" target="_blank" rel="noopener">Prelinger
123
+ Archives</a>, each described second by second by a video model. Search for what you want to
124
+ <em>see</em> — results play from the moment it happens.</p>
125
+ </header>
126
+
127
+ <form id="search" autocomplete="off">
128
+ <input id="q" name="q" type="search" placeholder="children washing hands"
129
+ aria-label="Search the captions" enterkeyhint="search">
130
+ <div class="suggest">{chips}</div>
131
+ <div class="facets">
132
+ <select id="decade" aria-label="Decade">{_options(DECADES, "any decade")}</select>
133
+ <label class="check"><input type="checkbox" id="spread" checked>
134
+ <span>at most two moments per film</span></label>
135
+ {mode_select}
136
+ </div>
137
+ </form>
138
 
139
+ <div class="stage" id="stage" hidden>
140
+ <div class="screen">
141
+ <video id="player" controls playsinline preload="metadata"></video>
142
+ <button type="button" id="stage-play" aria-label="Play">
143
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5z"/></svg>
144
+ </button>
145
+ </div>
146
+ <p class="stage-meta" id="stage-meta"></p>
147
+ <div id="stage-list"></div>
148
+ </div>
149
+
150
+ <main id="results" aria-live="polite">{OPENING}</main>
151
+ """
152
+ return shell.replace("<!--BODY-->", body)
153
 
154
 
155
+ @app.get("/", response_class=HTMLResponse)
156
+ async def index():
157
+ return page()
158
 
159
 
160
+ @app.get("/api/search")
161
+ async def api_search(
162
+ q: str = "",
163
+ decade: str = "any decade",
164
+ spread: str = "1",
165
+ mode: str = "",
166
+ ):
167
+ if DATA is None:
168
+ return JSONResponse({"html": "", "ms": 0})
169
+ if not q.strip():
170
+ return JSONResponse({"html": OPENING, "ms": 0})
171
+
172
+ query = search.Query(
173
+ text=q.strip(),
174
  limit=60,
175
  decade=int(decade[:4]) if decade and decade[0].isdigit() else None,
176
  undated=decade == "undated",
177
+ per_film=2 if spread in ("1", "true", "on") else None,
178
  )
179
+ backend = BY_NAME.get(mode) or BACKENDS[0]
180
  t0 = time.perf_counter()
181
  try:
182
+ hits = backend.search(query)
183
  except Exception as exc: # provider hiccup — keyword search still works
184
  log.warning("%s search failed, falling back to keyword: %s", backend.name, exc)
185
  backend = BY_NAME["keyword"]
186
+ hits = backend.search(query)
187
  ms = (time.perf_counter() - t0) * 1000
188
+ log.info("%r -> %d hits in %.0f ms (%s)", query.text, len(hits), ms, backend.name)
189
+ return JSONResponse({"html": render.results(hits, query.text, ms), "ms": ms})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
 
192
  if __name__ == "__main__":
193
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <!-- Ours, not a framework's: no text-autosizing surprises, no forced scaling. -->
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
7
+ <meta name="color-scheme" content="light">
8
+ <title>Prelinger moments</title>
9
+ <link rel="stylesheet" href="/static/app.css">
10
+ </head>
11
+ <body>
12
+ <div class="shell">
13
+ <!--BODY-->
14
+ </div>
15
+ <script src="/static/app.js" defer></script>
16
+ </body>
17
+ </html>
style.css → static/app.css RENAMED
@@ -1,57 +1,67 @@
1
- /* Prelinger moments — paper, ink, and one rust-coloured accent that only ever
2
- means "this is where your query landed". Everything else is type and rules. */
3
-
4
- :root, .dark {
5
- /* Grounded in the editing bench rather than in stationery: the cool grey of
6
- a grey card, the near-black of film leader, and one amber that stands for
7
- light coming through the film base. Amber is reserved for a single meaning
8
- — this is the second your query landed on. */
 
 
 
9
  --paper: #e6e7e3;
10
- --panel: #dcdedA;
11
  --ink: #16181a;
12
  --muted: #6d726d;
13
  --rule: #c7cbc5;
14
  --accent: #9a5a12;
15
 
16
- /* Industrial film speaks in two voices: gothic title cards and typewritten
17
- continuity sheets. No book serif — this was never a printed page. */
18
  --display: Futura, "Century Gothic", "Avenir Next", "Segoe UI", Roboto,
19
  "Helvetica Neue", Arial, sans-serif;
20
- --sans: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
21
- --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
22
-
23
- --layout-gap: 0px;
24
- --block-padding: 0px;
25
- --body-background-fill: var(--paper);
26
- --background-fill-primary: var(--paper);
27
- --background-fill-secondary: var(--paper);
28
- --block-background-fill: var(--paper);
29
- --border-color-primary: var(--rule);
30
- --body-text-color: var(--ink);
31
- --block-label-text-color: var(--muted);
32
- }
33
-
34
- html, body, gradio-app { background: var(--paper) !important; }
35
-
36
- /* Gradio ships a themed box around every component. Take them all off; the few
37
- rules this design wants are added back by hand. */
38
- .gradio-container .block,
39
- .gradio-container .form,
40
- .gradio-container .wrap,
41
- .gradio-container .wrap-inner,
42
- .gradio-container .secondary-wrap,
43
- .gradio-container .input-container {
44
- border: none !important;
45
- box-shadow: none !important;
46
- background: transparent !important;
47
- border-radius: 0 !important;
48
- padding: 0 !important;
49
- }
50
- .gradio-container .block { overflow: visible !important; min-width: 0 !important; }
51
- .gradio-container .html-container { padding: 0 !important; }
52
- footer { display: none !important; }
53
-
54
- /* --- masthead ----------------------------------------------------------- */
 
 
 
 
 
 
 
55
 
56
  .masthead { padding: 2.6rem 0 0.9rem; }
57
  .masthead h1 {
@@ -60,204 +70,98 @@ footer { display: none !important; }
60
  font-weight: 400;
61
  letter-spacing: 0.01em;
62
  margin: 0;
63
- color: var(--ink);
64
  }
65
  .masthead p {
66
  margin: 0.45rem 0 0;
67
  color: var(--muted);
68
  font-size: 0.86rem;
69
- line-height: 1.5;
70
- max-width: 40rem;
71
- }
72
- /* Gradio pads anchors inside HTML blocks; take it back off. */
73
- .gradio-container .masthead a, .gradio-container .stamp a {
74
- padding: 0 !important;
75
- margin: 0 !important;
76
- }
77
- .masthead a { color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
78
-
79
- /* --- search ------------------------------------------------------------- */
80
-
81
- #q .input-container { border-bottom: 1px solid var(--ink) !important; }
82
- #q:focus-within .input-container { border-bottom-color: var(--accent) !important; }
83
- #q input, #q textarea {
84
- font-family: var(--display) !important;
85
- font-size: 1.35rem !important;
86
- line-height: 1.6 !important;
87
- color: var(--ink) !important;
88
- background: transparent !important;
89
- border: none !important;
90
- border-radius: 0 !important;
91
- box-shadow: none !important;
92
- padding: 0.35rem 0 0.5rem !important;
93
- resize: none !important;
94
- }
95
- #q input::placeholder, #q textarea::placeholder { color: #a4a9a3 !important; }
96
- #q input:focus, #q textarea:focus { outline: none !important; }
97
 
98
  .suggest {
99
- display: flex !important;
 
 
100
  padding: 0.7rem 0 0;
 
 
101
  font-size: 0.8rem;
102
  color: var(--muted);
103
- flex-wrap: wrap;
104
- justify-content: flex-start !important;
105
- gap: 0 1.2rem !important;
106
- }
107
- /* Gradio puts Buttons directly in the Row with `flex: 1 1 0`, so six of them
108
- divide the width equally however wide the page is — and squash on a phone
109
- instead of wrapping. These read as links: each should be the width of its own
110
- words, and the row should wrap when it runs out of room. */
111
- .suggest > button,
112
- .suggest > .block {
113
- flex: 0 0 auto !important;
114
- min-width: 0 !important;
115
- width: auto !important;
116
- }
117
- .gradio-container .suggest button {
118
- background: none !important;
119
- border: none !important;
120
- box-shadow: none !important;
121
- color: var(--muted) !important;
122
- font-family: var(--sans) !important;
123
- font-size: 0.8rem !important;
124
- padding: 0.15rem 0 !important;
125
- min-width: 0 !important;
126
- text-align: left;
127
- border-bottom: 1px solid var(--rule) !important;
128
- border-radius: 0 !important;
129
  white-space: nowrap;
130
  }
131
- .gradio-container .suggest button:hover { color: var(--accent) !important; border-bottom-color: var(--accent) !important; }
132
 
133
- #facets {
134
- margin-top: 0.9rem;
135
- gap: 0 1.6rem !important;
136
- align-items: center;
137
  flex-wrap: wrap;
 
 
 
 
 
138
  }
139
- .gradio-container #facets > .block {
140
- flex: 0 0 auto !important;
141
- min-width: 0 !important;
142
- width: auto !important;
143
- margin: 0 !important;
144
- }
145
- #facets label { font-size: 0.78rem !important; color: var(--muted) !important; gap: 0.4rem; }
146
- #facets input[type="checkbox"] {
147
- accent-color: var(--accent);
148
- width: 0.85rem; height: 0.85rem;
149
- border-radius: 0 !important;
150
- }
151
- #facets input[role="combobox"], #facets select {
152
- font-family: var(--mono) !important;
153
- font-size: 0.78rem !important;
154
- color: var(--muted) !important;
155
- border-bottom: 1px solid var(--rule) !important;
156
- padding: 0.1rem 1.1rem 0.1rem 0 !important;
157
- cursor: pointer;
158
- width: 8.5rem;
159
- }
160
- #facets .icon-wrap { width: 0.6rem; color: var(--muted); }
161
- #facets .options, .gradio-container .options {
162
- background: var(--paper) !important;
163
- border: 1px solid var(--rule) !important;
164
- border-radius: 0 !important;
165
- font-family: var(--mono) !important;
166
- font-size: 0.78rem !important;
167
- }
168
- .gradio-container .options .item.selected,
169
- .gradio-container .options .item:hover { background: var(--panel) !important; color: var(--ink) !important; }
170
-
171
- /* --- stage -------------------------------------------------------------- */
172
-
173
- /* A fixed panel, not a sticky banner: results are a long list, and playing the
174
- fortieth one must not move the page. Sticky can't do this — the stage's
175
- containing block is its own Gradio node, so it scrolls away rather than
176
- pinning. Fixed also means you can keep scanning while a clip runs. */
177
- .stage {
178
- position: fixed;
179
- right: 1.5rem;
180
- bottom: 1.5rem;
181
- z-index: 40;
182
- width: 22rem;
183
- max-width: calc(100vw - 3rem);
184
- background: var(--paper);
185
- border: 1px solid var(--ink);
186
- padding: 0.55rem 0.6rem 0.5rem;
187
- }
188
- .stage[hidden] { display: none; }
189
- /* Only the shot list scrolls. Scrolling the whole panel pushed the video out of
190
- view while the audio kept playing — you could hear the film but not see it. */
191
- .stage { display: flex; flex-direction: column; max-height: calc(100vh - 3rem); }
192
- .stage .screen, .stage .stage-meta { flex: 0 0 auto; }
193
- #stage-list { flex: 1 1 auto; overflow-y: auto; min-height: 0; }
194
- /* The source derivatives are 320 px wide; the panel is sized close to native
195
- so playback is not just magnified grain. */
196
- .stage video {
197
- display: block;
198
- width: 100%;
199
- max-height: 40vh;
200
- background: #12100e;
201
- }
202
- .stage-meta {
203
  font-family: var(--mono);
204
- font-size: 0.7rem;
205
  color: var(--muted);
206
- margin: 0.45rem 0 0;
207
- display: flex;
208
- gap: 0.6rem;
209
- align-items: baseline;
210
- flex-wrap: wrap;
211
- }
212
- /* Keep the last results clear of the panel while it is open. */
213
- body.has-stage .gradio-container { padding-bottom: 20rem; }
214
-
215
- @media (max-width: 640px) {
216
- .stage {
217
- right: 0; bottom: 0;
218
- width: 100%; max-width: 100%;
219
- border-width: 1px 0 0;
220
- max-height: 76vh;
221
- }
222
- .stage video { max-height: 34vh; }
223
- /* Comfortable tap targets, and timestamps that read as tappable. */
224
- .events .at { padding: 0.35rem 0 !important; text-decoration: underline; text-underline-offset: 3px; }
225
- .events li { padding: 0.3rem 0; }
226
- body.has-stage .gradio-container { padding-bottom: 16rem; }
227
- }
228
- .stage-meta b {
229
- font-family: var(--display);
230
- font-weight: 400;
231
- font-size: 0.85rem;
232
- color: var(--ink);
233
- flex: 1 1 100%;
234
- line-height: 1.3;
235
- }
236
- .stage-meta .cut { color: var(--accent); }
237
- .stage-meta button {
238
- margin-left: auto;
239
  background: none;
240
- border: 1px solid var(--rule);
241
- padding: 0.3rem 0.7rem;
242
- font-family: var(--mono);
243
- font-size: 0.72rem;
244
- color: var(--ink);
245
- cursor: pointer;
246
- }
247
- .stage-meta button:hover { border-color: var(--accent); color: var(--accent); }
 
 
 
 
 
 
248
 
249
- /* --- results ------------------------------------------------------------ */
250
 
251
  .count {
 
 
252
  font-family: var(--mono);
253
  font-size: 0.74rem;
254
  color: var(--muted);
255
- margin: 1.4rem 0 0.2rem;
256
- display: flex;
257
- gap: 0.7rem;
258
  }
259
  .count .ms { margin-left: auto; color: #a4a9a3; }
260
- .empty { color: var(--muted); font-size: 0.92rem; line-height: 1.65; margin: 2rem 0; max-width: 34rem; }
 
 
 
 
 
 
261
  .empty em { font-style: italic; color: var(--ink); }
262
 
263
  .moment {
@@ -271,49 +175,34 @@ body.has-stage .gradio-container { padding-bottom: 20rem; }
271
 
272
  .frame {
273
  position: relative;
274
- display: block !important;
275
- margin: 0 !important;
276
- padding: 0 !important;
277
- border: none !important;
278
- background: var(--panel) !important;
279
- box-shadow: none !important;
280
- cursor: pointer;
281
- line-height: 0;
282
- width: 100% !important;
283
  aspect-ratio: 4 / 3;
 
284
  overflow: hidden;
285
- border-radius: 0 !important;
286
- -webkit-appearance: none;
287
- appearance: none;
288
- }
289
- .frame img {
290
- width: 100% !important;
291
- height: 100% !important;
292
- max-width: none !important;
293
- object-fit: cover;
294
- display: block !important;
295
- border-radius: 0 !important;
296
  }
 
297
  .frame .cue {
298
  position: absolute;
299
- left: 0; bottom: 0;
 
300
  font-family: var(--mono);
301
  font-size: 0.66rem;
302
  color: var(--paper);
303
- background: rgba(20, 18, 15, 0.72);
304
  padding: 0.12rem 0.35rem;
305
  line-height: 1.4;
306
  }
307
  .frame:hover .cue, .moment.playing .frame .cue { background: var(--accent); }
308
- .moment.playing .frame { outline: 2px solid var(--accent); outline-offset: 0; }
309
 
310
  .meta h2 {
311
  font-family: var(--display);
312
  font-size: 1.08rem;
313
- font-weight: 400 !important;
314
- margin: -0.15rem 0 0.2rem;
315
  line-height: 1.35;
316
- color: var(--ink);
317
  }
318
  .stamp {
319
  font-family: var(--mono);
@@ -330,7 +219,7 @@ body.has-stage .gradio-container { padding-bottom: 20rem; }
330
  grid-template-columns: 3.4rem 1fr;
331
  gap: 0.6rem;
332
  align-items: baseline;
333
- padding: 0.14rem 0;
334
  font-size: 0.88rem;
335
  line-height: 1.5;
336
  color: var(--muted);
@@ -340,15 +229,8 @@ body.has-stage .gradio-container { padding-bottom: 20rem; }
340
  font-family: var(--mono);
341
  font-size: 0.72rem;
342
  color: var(--muted);
343
- background: none !important;
344
- border: none !important;
345
- box-shadow: none !important;
346
- border-radius: 0 !important;
347
- padding: 0;
348
  text-align: left;
349
- cursor: pointer;
350
- -webkit-appearance: none;
351
- appearance: none;
352
  }
353
  .events li.hit .at { color: var(--accent); }
354
  .events .at:hover { text-decoration: underline; text-underline-offset: 2px; }
@@ -371,13 +253,7 @@ body.has-stage .gradio-container { padding-bottom: 20rem; }
371
  max-width: 42rem;
372
  }
373
 
374
- @media (max-width: 640px) {
375
- .moment { grid-template-columns: 1fr; gap: 0.7rem; }
376
- .frame { max-width: 18rem; }
377
- #q textarea { font-size: 1.15rem !important; }
378
- }
379
-
380
- /* --- opening: a few moments, at random ---------------------------------- */
381
 
382
  .opening {
383
  display: grid;
@@ -386,37 +262,42 @@ body.has-stage .gradio-container { padding-bottom: 20rem; }
386
  gap: 1.1rem 1rem;
387
  margin: 0.4rem 0 0;
388
  }
389
- .opening .tile { display: block; }
390
- .opening .frame { aspect-ratio: 4 / 3; }
391
  .opening h3 {
392
  font-family: var(--display);
393
  font-size: 0.82rem;
394
- font-weight: 400 !important;
395
  line-height: 1.3;
396
  margin: 0.4rem 0 0;
397
- color: var(--ink);
398
  }
399
  .opening .stamp { margin: 0.15rem 0 0; }
400
 
401
- /* --- the shot list in the rail ------------------------------------------ */
402
 
403
- /* Shown at every width: segments beneath the video is what people reach for,
404
- and on a phone the card behind the panel is covered anyway. */
405
- #stage-list { display: block; }
406
- #stage-list .events { margin-top: 0.6rem; border-top: 1px solid var(--rule); padding-top: 0.5rem; }
407
- #stage-list .events li { grid-template-columns: 3.1rem 1fr; font-size: 0.82rem; }
408
- #stage-list .scene-text {
409
- font-size: 0.78rem;
410
- line-height: 1.55;
411
- color: var(--muted);
412
- margin: 0.7rem 0 0;
413
- border-top: 1px solid var(--rule);
414
- padding-top: 0.5rem;
 
 
 
 
415
  }
416
-
417
- /* --- play affordance ----------------------------------------------------- */
418
 
419
  .screen { position: relative; line-height: 0; }
 
 
 
 
420
  #stage-play {
421
  position: absolute;
422
  inset: 0;
@@ -426,14 +307,102 @@ body.has-stage .gradio-container { padding-bottom: 20rem; }
426
  display: none;
427
  align-items: center;
428
  justify-content: center;
429
- padding: 0;
430
- border: none;
431
  border-radius: 50%;
432
  background: rgba(22, 24, 26, 0.78);
433
- border: 2px solid var(--paper);
434
- cursor: pointer;
435
  }
436
  /* Shown only while the video is actually paused — never decoration. */
437
  .stage.paused #stage-play { display: flex; }
438
  #stage-play svg { width: 2.2rem; height: 2.2rem; fill: var(--paper); }
439
  #stage-play:hover { background: var(--accent); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Prelinger moments.
2
+
3
+ Grounded in the editing bench rather than in stationery: the cool grey of a
4
+ grey card, the near-black of film leader, and one amber that stands for light
5
+ through the film base. Amber is reserved for a single meaning — this is the
6
+ second your query landed on. Everything else is type and hairlines.
7
+
8
+ No framework underneath this, so no !important anywhere. If a rule here
9
+ doesn't win, it's a bug in this file. */
10
+
11
+ :root {
12
  --paper: #e6e7e3;
13
+ --panel: #dcdeda;
14
  --ink: #16181a;
15
  --muted: #6d726d;
16
  --rule: #c7cbc5;
17
  --accent: #9a5a12;
18
 
19
+ /* Industrial film spoke in gothic title cards and typewritten continuity
20
+ sheets, never in a book serif. Falls to Roboto on Android by design. */
21
  --display: Futura, "Century Gothic", "Avenir Next", "Segoe UI", Roboto,
22
  "Helvetica Neue", Arial, sans-serif;
23
+ --sans: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
24
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
25
+ }
26
+
27
+ *, *::before, *::after { box-sizing: border-box; }
28
+
29
+ html { -webkit-text-size-adjust: 100%; }
30
+
31
+ body {
32
+ margin: 0;
33
+ background: var(--paper);
34
+ color: var(--ink);
35
+ font-family: var(--sans);
36
+ font-size: 16px;
37
+ line-height: 1.5;
38
+ }
39
+
40
+ /* Controls inherit nothing useful by default; say it once, here. */
41
+ button, input, select {
42
+ font: inherit;
43
+ color: inherit;
44
+ margin: 0;
45
+ }
46
+ button {
47
+ background: none;
48
+ border: none;
49
+ padding: 0;
50
+ border-radius: 0;
51
+ -webkit-appearance: none;
52
+ appearance: none;
53
+ cursor: pointer;
54
+ }
55
+ img { display: block; max-width: 100%; }
56
+
57
+ .shell {
58
+ max-width: 62rem;
59
+ margin: 0 auto;
60
+ padding: 0 1.25rem 4rem;
61
+ transition: padding-right 180ms ease;
62
+ }
63
+
64
+ /* --- masthead ------------------------------------------------------------ */
65
 
66
  .masthead { padding: 2.6rem 0 0.9rem; }
67
  .masthead h1 {
 
70
  font-weight: 400;
71
  letter-spacing: 0.01em;
72
  margin: 0;
 
73
  }
74
  .masthead p {
75
  margin: 0.45rem 0 0;
76
  color: var(--muted);
77
  font-size: 0.86rem;
78
+ line-height: 1.55;
79
+ max-width: 42rem;
80
+ }
81
+ .masthead a { color: var(--muted); text-underline-offset: 2px; }
82
+ .masthead em { font-style: italic; color: var(--ink); }
83
+
84
+ /* --- search -------------------------------------------------------------- */
85
+
86
+ #q {
87
+ width: 100%;
88
+ font-family: var(--display);
89
+ font-size: 1.35rem;
90
+ line-height: 1.6;
91
+ background: none;
92
+ border: none;
93
+ border-bottom: 1px solid var(--ink);
94
+ border-radius: 0;
95
+ padding: 0.35rem 0 0.5rem;
96
+ -webkit-appearance: none;
97
+ appearance: none;
98
+ }
99
+ #q::placeholder { color: #a4a9a3; }
100
+ #q:focus { outline: none; border-bottom-color: var(--accent); }
101
+ #q::-webkit-search-cancel-button { -webkit-appearance: none; }
 
 
 
 
102
 
103
  .suggest {
104
+ display: flex;
105
+ flex-wrap: wrap;
106
+ gap: 0.2rem 1.2rem;
107
  padding: 0.7rem 0 0;
108
+ }
109
+ .chip {
110
  font-size: 0.8rem;
111
  color: var(--muted);
112
+ padding: 0.3rem 0;
113
+ border-bottom: 1px solid var(--rule);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  white-space: nowrap;
115
  }
116
+ .chip:hover, .chip:focus-visible { color: var(--accent); border-bottom-color: var(--accent); }
117
 
118
+ .facets {
119
+ display: flex;
 
 
120
  flex-wrap: wrap;
121
+ align-items: center;
122
+ gap: 0.6rem 1.6rem;
123
+ margin-top: 0.9rem;
124
+ font-size: 0.78rem;
125
+ color: var(--muted);
126
  }
127
+ .facets select {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  font-family: var(--mono);
129
+ font-size: 0.78rem;
130
  color: var(--muted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  background: none;
132
+ border: none;
133
+ border-bottom: 1px solid var(--rule);
134
+ border-radius: 0;
135
+ padding: 0.25rem 1.2rem 0.25rem 0;
136
+ -webkit-appearance: none;
137
+ appearance: none;
138
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
139
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%);
140
+ background-position: right 0.35rem center, right 0.05rem center;
141
+ background-size: 0.3rem 0.3rem, 0.3rem 0.3rem;
142
+ background-repeat: no-repeat;
143
+ }
144
+ .check { display: flex; align-items: center; gap: 0.45rem; cursor: pointer; }
145
+ .check input { accent-color: var(--accent); width: 0.9rem; height: 0.9rem; }
146
 
147
+ /* --- results ------------------------------------------------------------- */
148
 
149
  .count {
150
+ display: flex;
151
+ gap: 0.7rem;
152
  font-family: var(--mono);
153
  font-size: 0.74rem;
154
  color: var(--muted);
155
+ margin: 1.6rem 0 0.2rem;
 
 
156
  }
157
  .count .ms { margin-left: auto; color: #a4a9a3; }
158
+ .empty {
159
+ color: var(--muted);
160
+ font-size: 0.92rem;
161
+ line-height: 1.65;
162
+ margin: 2rem 0;
163
+ max-width: 34rem;
164
+ }
165
  .empty em { font-style: italic; color: var(--ink); }
166
 
167
  .moment {
 
175
 
176
  .frame {
177
  position: relative;
178
+ display: block;
179
+ width: 100%;
 
 
 
 
 
 
 
180
  aspect-ratio: 4 / 3;
181
+ background: var(--panel);
182
  overflow: hidden;
183
+ line-height: 0;
 
 
 
 
 
 
 
 
 
 
184
  }
185
+ .frame img { width: 100%; height: 100%; object-fit: cover; }
186
  .frame .cue {
187
  position: absolute;
188
+ left: 0;
189
+ bottom: 0;
190
  font-family: var(--mono);
191
  font-size: 0.66rem;
192
  color: var(--paper);
193
+ background: rgba(20, 18, 15, 0.74);
194
  padding: 0.12rem 0.35rem;
195
  line-height: 1.4;
196
  }
197
  .frame:hover .cue, .moment.playing .frame .cue { background: var(--accent); }
198
+ .moment.playing .frame { outline: 2px solid var(--accent); }
199
 
200
  .meta h2 {
201
  font-family: var(--display);
202
  font-size: 1.08rem;
203
+ font-weight: 400;
 
204
  line-height: 1.35;
205
+ margin: -0.15rem 0 0.2rem;
206
  }
207
  .stamp {
208
  font-family: var(--mono);
 
219
  grid-template-columns: 3.4rem 1fr;
220
  gap: 0.6rem;
221
  align-items: baseline;
222
+ padding: 0.16rem 0;
223
  font-size: 0.88rem;
224
  line-height: 1.5;
225
  color: var(--muted);
 
229
  font-family: var(--mono);
230
  font-size: 0.72rem;
231
  color: var(--muted);
 
 
 
 
 
232
  text-align: left;
233
+ padding: 0.1rem 0;
 
 
234
  }
235
  .events li.hit .at { color: var(--accent); }
236
  .events .at:hover { text-decoration: underline; text-underline-offset: 2px; }
 
253
  max-width: 42rem;
254
  }
255
 
256
+ /* --- opening: a few moments, at random ----------------------------------- */
 
 
 
 
 
 
257
 
258
  .opening {
259
  display: grid;
 
262
  gap: 1.1rem 1rem;
263
  margin: 0.4rem 0 0;
264
  }
 
 
265
  .opening h3 {
266
  font-family: var(--display);
267
  font-size: 0.82rem;
268
+ font-weight: 400;
269
  line-height: 1.3;
270
  margin: 0.4rem 0 0;
 
271
  }
272
  .opening .stamp { margin: 0.15rem 0 0; }
273
 
274
+ /* --- the player ---------------------------------------------------------- */
275
 
276
+ /* A fixed panel, not a sticky banner: playing the fortieth result must not move
277
+ the page. Only the shot list scrolls — scrolling the whole panel used to push
278
+ the video out of view while the audio kept going. */
279
+ .stage {
280
+ position: fixed;
281
+ right: 1.5rem;
282
+ bottom: 1.5rem;
283
+ z-index: 40;
284
+ display: flex;
285
+ flex-direction: column;
286
+ width: 22rem;
287
+ max-width: calc(100vw - 3rem);
288
+ max-height: calc(100vh - 3rem);
289
+ background: var(--paper);
290
+ border: 1px solid var(--ink);
291
+ padding: 0.55rem 0.6rem 0.5rem;
292
  }
293
+ .stage[hidden] { display: none; }
294
+ .stage .screen, .stage .stage-meta { flex: 0 0 auto; }
295
 
296
  .screen { position: relative; line-height: 0; }
297
+ /* The source derivatives are 320 px wide; sized so playback is not just
298
+ magnified grain. */
299
+ .stage video { display: block; width: 100%; max-height: 40vh; background: #12100e; }
300
+
301
  #stage-play {
302
  position: absolute;
303
  inset: 0;
 
307
  display: none;
308
  align-items: center;
309
  justify-content: center;
310
+ border: 2px solid var(--paper);
 
311
  border-radius: 50%;
312
  background: rgba(22, 24, 26, 0.78);
 
 
313
  }
314
  /* Shown only while the video is actually paused — never decoration. */
315
  .stage.paused #stage-play { display: flex; }
316
  #stage-play svg { width: 2.2rem; height: 2.2rem; fill: var(--paper); }
317
  #stage-play:hover { background: var(--accent); }
318
+
319
+ .stage-meta {
320
+ display: flex;
321
+ flex-wrap: wrap;
322
+ align-items: baseline;
323
+ gap: 0.6rem;
324
+ font-family: var(--mono);
325
+ font-size: 0.7rem;
326
+ color: var(--muted);
327
+ margin: 0.5rem 0 0;
328
+ }
329
+ .stage-meta b {
330
+ flex: 1 1 100%;
331
+ font-family: var(--display);
332
+ font-weight: 400;
333
+ font-size: 0.85rem;
334
+ color: var(--ink);
335
+ line-height: 1.3;
336
+ }
337
+ .stage-meta .cut { color: var(--accent); }
338
+ .stage-meta button {
339
+ margin-left: auto;
340
+ border: 1px solid var(--rule);
341
+ padding: 0.3rem 0.7rem;
342
+ font-family: var(--mono);
343
+ font-size: 0.72rem;
344
+ color: var(--ink);
345
+ }
346
+ .stage-meta button:hover { border-color: var(--accent); color: var(--accent); }
347
+
348
+ #stage-list { flex: 1 1 auto; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
349
+ #stage-list .events { margin-top: 0.6rem; border-top: 1px solid var(--rule); padding-top: 0.5rem; }
350
+ #stage-list .events li { grid-template-columns: 3.1rem 1fr; font-size: 0.82rem; }
351
+ #stage-list .scene-text {
352
+ font-size: 0.78rem;
353
+ line-height: 1.55;
354
+ color: var(--muted);
355
+ margin: 0.7rem 0 0;
356
+ border-top: 1px solid var(--rule);
357
+ padding-top: 0.5rem;
358
+ }
359
+
360
+ body.has-stage .shell { padding-bottom: 20rem; }
361
+
362
+ /* --- phones -------------------------------------------------------------- */
363
+
364
+ @media (max-width: 640px) {
365
+ .shell { padding: 0 1rem 3rem; }
366
+ .masthead { padding-top: 1.8rem; }
367
+ #q { font-size: 1.15rem; }
368
+ .moment { grid-template-columns: 1fr; gap: 0.7rem; }
369
+ .frame { max-width: 18rem; }
370
+ /* Comfortable tap targets, and timestamps that read as tappable. */
371
+ .events li { padding: 0.3rem 0; }
372
+ .events .at { padding: 0.35rem 0; text-decoration: underline; text-underline-offset: 3px; }
373
+ .stage {
374
+ right: 0;
375
+ bottom: 0;
376
+ width: 100%;
377
+ max-width: 100%;
378
+ max-height: 76vh;
379
+ border-width: 1px 0 0;
380
+ padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
381
+ }
382
+ .stage video { max-height: 34vh; }
383
+ body.has-stage .shell { padding-bottom: 16rem; }
384
+ }
385
+
386
+ /* --- wide: the player gets its own column -------------------------------- */
387
+
388
+ @media (min-width: 1560px) {
389
+ .shell { max-width: 94rem; }
390
+ /* Reserved only while something is playing; an empty reservation is dead
391
+ space, and the opening grid wants the full width. */
392
+ body.has-stage .shell { padding-right: 29rem; padding-bottom: 4rem; }
393
+ .stage {
394
+ top: 2.2rem;
395
+ bottom: auto;
396
+ right: max(1.5rem, calc((100vw - 94rem) / 2));
397
+ width: 27rem;
398
+ max-height: calc(100vh - 4.4rem);
399
+ }
400
+ .moment { grid-template-columns: 17rem 1fr; }
401
+ .opening { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
402
+ }
403
+
404
+ @media (prefers-reduced-motion: reduce) {
405
+ .shell { transition: none; }
406
+ }
407
+
408
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
player.js → static/app.js RENAMED
@@ -1,16 +1,77 @@
1
- // One delegated listener for the whole page. Any element carrying
2
- // data-src/data-t is a seek: load the film if it is not already loaded, jump to
3
- // the second the caption points at, play.
4
  (function () {
 
 
 
 
5
  function stage() { return document.getElementById("stage"); }
6
  function player() { return document.getElementById("player"); }
7
 
8
- // Safari cannot play a bucket `resolve/` URL directly. The URL 302s to a
9
- // per-request signed CDN URL; Safari's follow-up range requests don't re-sign
10
- // and come back 403 (first range 206, second 403, then
11
- // MEDIA_ERR_SRC_NOT_SUPPORTED). Chrome re-follows the redirect every time and
12
- // never notices. Resolving the signed URL once and handing *that* to the
13
- // element works in both. Cached per film so repeat clicks stay synchronous.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  var resolved = Object.create(null);
15
 
16
  function resolve(src) {
@@ -22,36 +83,24 @@
22
  }).catch(function () { return src; });
23
  }
24
 
25
- function seek(video, t) {
26
- // Range requests on the CDN make this cheap even mid-film.
27
- try { video.currentTime = t; } catch (e) { /* metadata not in yet */ }
28
  }
29
 
30
  function load(v, url, t) {
31
- // An element left in error state (code 4) rejects play() before it can be
32
- // unlocked, so a second tap shows a frame that will not start. Reset it
33
- // properly before reusing it.
34
- if (v.error) {
35
- v.removeAttribute("src");
36
- v.load();
37
- }
38
  // #t= is a media fragment: the browser starts there itself, and being
39
- // client-side it never touches the URL's signature. The loadedmetadata
40
- // handler stays as a fallback for anything that ignores it.
41
  v.src = url + "#t=" + t.toFixed(2);
42
  v.addEventListener("loadedmetadata", function once() {
43
  v.removeEventListener("loadedmetadata", once);
44
  if (Math.abs(v.currentTime - t) > 1) seek(v, t);
45
  });
46
  v.load();
47
- start(v);
48
- }
49
-
50
- function start(v) {
51
- // Try to play, because a click is usually enough and one tap beats two.
52
- // Never force it: if the browser says no, the paused state shows a play
53
- // control and the viewer is one tap away. No muted fallback — silent
54
- // playback of a narrated film is worse than a still frame.
55
  v.play().catch(function () {});
56
  }
57
 
@@ -72,8 +121,7 @@
72
 
73
  var meta = document.getElementById("stage-meta");
74
  if (meta) {
75
- meta.innerHTML =
76
- "<b></b><span class='cut'></span>" +
77
  "<button type='button' id='stage-close'>close</button>";
78
  meta.querySelector("b").textContent = el.getAttribute("data-title") || "";
79
  meta.querySelector(".cut").textContent = "at " + (el.getAttribute("data-when") || "");
@@ -102,29 +150,25 @@
102
  v.setAttribute("data-src", src);
103
  v.dataset.wanted = String(t);
104
 
105
- if (same) {
106
- seek(v, t);
107
- // Synchronous, inside the click: iOS Safari only honours play() while the
108
- // user gesture is still on the stack.
109
- start(v);
110
- return;
111
- }
112
-
113
- if (resolved[src]) {
114
- load(v, resolved[src], t); // fast path, still inside the gesture
115
- return;
116
- }
117
-
118
- // Not resolved yet (pointerdown didn't get there first). The fetch pushes
119
- // play() outside the gesture, so `start` may have to fall back to muted.
120
  resolve(src).then(function (url) {
121
- if (v.getAttribute("data-src") !== src) return; // superseded by another click
122
  load(v, url, t);
123
  });
124
  }
125
 
126
- // A signed URL eventually expires. Drop it and try once more rather than
127
- // leaving the viewer looking at a dead player.
 
 
 
 
 
 
 
 
 
128
  document.addEventListener("error", function (e) {
129
  var v = player();
130
  if (!v || e.target !== v) return;
@@ -132,43 +176,31 @@
132
  if (!src || !resolved[src] || v.dataset.retried === src) return;
133
  v.dataset.retried = src;
134
  delete resolved[src];
135
- resolve(src).then(function (url) {
136
- load(v, url, parseFloat(v.dataset.wanted) || 0);
137
- });
138
  }, true);
139
 
140
  // pointerdown fires before click, so the fetch is usually already done by the
141
- // time the tap completes — which keeps the play() call inside the gesture.
142
  document.addEventListener("pointerdown", function (e) {
143
  var el = e.target.closest && e.target.closest("[data-src][data-t]");
144
  if (el) resolve(el.getAttribute("data-src"));
145
  }, true);
146
 
147
- // Autoplay rules differ per browser, per platform, and per how much the
148
- // viewer has used the site. Rather than predict them, just tell the truth:
149
- // if it is paused, show a play control.
150
- ["play", "pause", "ended", "loadeddata", "emptied"].forEach(function (evt) {
151
- document.addEventListener(evt, function (e) {
152
- var v = player(), s = stage();
153
- if (!v || !s || e.target !== v) return;
154
- s.classList.toggle("paused", v.paused);
155
- }, true);
156
- });
157
-
158
  document.addEventListener("click", function (e) {
159
  var big = e.target.closest && e.target.closest("#stage-play");
160
  if (big) { player().play().catch(function () {}); return; }
161
- var close = e.target.closest && e.target.closest("#stage-close");
162
- if (close) {
163
  var s = stage(), v = player();
164
- if (v) { v.pause(); }
165
- if (s) { s.hidden = true; }
166
  document.body.classList.remove("has-stage");
167
  document.querySelectorAll(".moment.playing").forEach(function (m) {
168
  m.classList.remove("playing");
169
  });
170
  return;
171
  }
 
172
  var el = e.target.closest && e.target.closest("[data-src][data-t]");
173
  if (el) { e.preventDefault(); play(el); }
174
  });
 
1
+ // Search and playback. One delegated click listener for the whole page: any
2
+ // element carrying data-src/data-t is a seek.
 
3
  (function () {
4
+ var form = document.getElementById("search");
5
+ var results = document.getElementById("results");
6
+ var q = document.getElementById("q");
7
+
8
  function stage() { return document.getElementById("stage"); }
9
  function player() { return document.getElementById("player"); }
10
 
11
+ // --- search -------------------------------------------------------------
12
+
13
+ var inFlight = null;
14
+
15
+ function params() {
16
+ var p = new URLSearchParams();
17
+ p.set("q", q ? q.value : "");
18
+ var d = document.getElementById("decade");
19
+ var s = document.getElementById("spread");
20
+ var m = document.getElementById("mode");
21
+ if (d) p.set("decade", d.value);
22
+ if (s) p.set("spread", s.checked ? "1" : "0");
23
+ if (m) p.set("mode", m.value);
24
+ return p;
25
+ }
26
+
27
+ function run(push) {
28
+ if (!results) return;
29
+ var p = params();
30
+ if (inFlight) inFlight.abort();
31
+ inFlight = new AbortController();
32
+ fetch("/api/search?" + p.toString(), { signal: inFlight.signal })
33
+ .then(function (r) { return r.json(); })
34
+ .then(function (data) { results.innerHTML = data.html; })
35
+ .catch(function (e) { if (e.name !== "AbortError") console.error(e); });
36
+ // Results become linkable, which a search tool should be.
37
+ if (push !== false) {
38
+ var qs = p.get("q") ? "?" + p.toString() : location.pathname;
39
+ history.replaceState(null, "", qs);
40
+ }
41
+ }
42
+
43
+ if (form) {
44
+ form.addEventListener("submit", function (e) { e.preventDefault(); run(); });
45
+ form.addEventListener("change", function (e) {
46
+ if (e.target.id === "decade" || e.target.id === "spread" || e.target.id === "mode") run();
47
+ });
48
+ }
49
+ document.addEventListener("click", function (e) {
50
+ var chip = e.target.closest && e.target.closest(".chip");
51
+ if (chip && q) { q.value = chip.textContent.trim(); run(); }
52
+ });
53
+
54
+ // Restore state from the URL so a shared link opens the same search.
55
+ (function restore() {
56
+ var p = new URLSearchParams(location.search);
57
+ if (!p.get("q")) return;
58
+ if (q) q.value = p.get("q");
59
+ var d = document.getElementById("decade");
60
+ var s = document.getElementById("spread");
61
+ var m = document.getElementById("mode");
62
+ if (d && p.get("decade")) d.value = p.get("decade");
63
+ if (s && p.get("spread")) s.checked = p.get("spread") === "1";
64
+ if (m && p.get("mode")) m.value = p.get("mode");
65
+ run(false);
66
+ })();
67
+
68
+ // --- playback -----------------------------------------------------------
69
+
70
+ // A bucket `resolve/` URL 302s to a per-request signed CDN URL. Safari's
71
+ // follow-up range requests don't re-sign and come back 403 (206, then 403,
72
+ // then MEDIA_ERR_SRC_NOT_SUPPORTED); Chrome re-follows and never notices.
73
+ // Resolving the signed URL once and handing that to the element works in
74
+ // both. Cached per film so repeat taps stay synchronous.
75
  var resolved = Object.create(null);
76
 
77
  function resolve(src) {
 
83
  }).catch(function () { return src; });
84
  }
85
 
86
+ function seek(v, t) {
87
+ try { v.currentTime = t; } catch (e) { /* metadata not in yet */ }
 
88
  }
89
 
90
  function load(v, url, t) {
91
+ // An element left in error state rejects play() before it can be unlocked,
92
+ // so a second tap would show a frame that never starts. Reset it first.
93
+ if (v.error) { v.removeAttribute("src"); v.load(); }
 
 
 
 
94
  // #t= is a media fragment: the browser starts there itself, and being
95
+ // client-side it never touches the URL's signature.
 
96
  v.src = url + "#t=" + t.toFixed(2);
97
  v.addEventListener("loadedmetadata", function once() {
98
  v.removeEventListener("loadedmetadata", once);
99
  if (Math.abs(v.currentTime - t) > 1) seek(v, t);
100
  });
101
  v.load();
102
+ // Try to play, because a tap is usually enough and one tap beats two. Never
103
+ // force it: if the browser declines, the paused state shows a play control.
 
 
 
 
 
 
104
  v.play().catch(function () {});
105
  }
106
 
 
121
 
122
  var meta = document.getElementById("stage-meta");
123
  if (meta) {
124
+ meta.innerHTML = "<b></b><span class='cut'></span>" +
 
125
  "<button type='button' id='stage-close'>close</button>";
126
  meta.querySelector("b").textContent = el.getAttribute("data-title") || "";
127
  meta.querySelector(".cut").textContent = "at " + (el.getAttribute("data-when") || "");
 
150
  v.setAttribute("data-src", src);
151
  v.dataset.wanted = String(t);
152
 
153
+ if (same) { seek(v, t); v.play().catch(function () {}); return; }
154
+ if (resolved[src]) { load(v, resolved[src], t); return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  resolve(src).then(function (url) {
156
+ if (v.getAttribute("data-src") !== src) return; // superseded by another tap
157
  load(v, url, t);
158
  });
159
  }
160
 
161
+ // Autoplay rules differ per browser, platform, and prior engagement. Rather
162
+ // than predict them, tell the truth: if it is paused, show a play control.
163
+ ["play", "pause", "ended", "loadeddata", "emptied"].forEach(function (evt) {
164
+ document.addEventListener(evt, function (e) {
165
+ var v = player(), s = stage();
166
+ if (!v || !s || e.target !== v) return;
167
+ s.classList.toggle("paused", v.paused);
168
+ }, true);
169
+ });
170
+
171
+ // A signed URL eventually expires. Drop it and try once more.
172
  document.addEventListener("error", function (e) {
173
  var v = player();
174
  if (!v || e.target !== v) return;
 
176
  if (!src || !resolved[src] || v.dataset.retried === src) return;
177
  v.dataset.retried = src;
178
  delete resolved[src];
179
+ resolve(src).then(function (url) { load(v, url, parseFloat(v.dataset.wanted) || 0); });
 
 
180
  }, true);
181
 
182
  // pointerdown fires before click, so the fetch is usually already done by the
183
+ // time the tap completes, which keeps play() inside the gesture.
184
  document.addEventListener("pointerdown", function (e) {
185
  var el = e.target.closest && e.target.closest("[data-src][data-t]");
186
  if (el) resolve(el.getAttribute("data-src"));
187
  }, true);
188
 
 
 
 
 
 
 
 
 
 
 
 
189
  document.addEventListener("click", function (e) {
190
  var big = e.target.closest && e.target.closest("#stage-play");
191
  if (big) { player().play().catch(function () {}); return; }
192
+
193
+ if (e.target.closest && e.target.closest("#stage-close")) {
194
  var s = stage(), v = player();
195
+ if (v) v.pause();
196
+ if (s) s.hidden = true;
197
  document.body.classList.remove("has-stage");
198
  document.querySelectorAll(".moment.playing").forEach(function (m) {
199
  m.classList.remove("playing");
200
  });
201
  return;
202
  }
203
+
204
  var el = e.target.closest && e.target.closest("[data-src][data-t]");
205
  if (el) { e.preventDefault(); play(el); }
206
  });
wide.css DELETED
@@ -1,48 +0,0 @@
1
- /* Loaded through `head=`, not `css=`.
2
-
3
- Gradio 6 rewrites every selector in `css=` to sit under
4
- `.gradio-container.gradio-container-6-20-0 .contain`, so a rule aimed at the
5
- container itself becomes a descendant of the container and matches nothing.
6
- Layout that has to reach the shell lives here instead. Rules here also land
7
- before Gradio's own stylesheets, hence !important on the overrides. */
8
-
9
- /* The shell itself — width, ground colour, base face. These have to live in the
10
- head for the same scoping reason. */
11
- .gradio-container {
12
- max-width: 62rem !important;
13
- background: var(--paper) !important;
14
- color: var(--ink) !important;
15
- font-family: var(--sans) !important;
16
- }
17
-
18
- /* Below this width the player floats over the results — a compromise. Above it,
19
- the reserved column means the player never covers a result, and it has room
20
- to carry the shot list of whatever is playing. */
21
- @media (min-width: 1560px) {
22
- .gradio-container {
23
- max-width: 94rem !important;
24
- transition: padding-right 180ms ease;
25
- }
26
- /* Reserved only while something is playing: an empty reservation is dead
27
- space, and the opening grid wants the full width. */
28
- body.has-stage .gradio-container { padding-right: 29rem !important; }
29
- body.has-stage .gradio-container { padding-bottom: 0 !important; }
30
-
31
- .stage {
32
- top: 2.2rem !important;
33
- bottom: auto !important;
34
- right: max(1.5rem, calc((100vw - 94rem) / 2)) !important;
35
- width: 27rem !important;
36
- max-height: calc(100vh - 4.4rem) !important;
37
- overflow-y: auto !important;
38
- }
39
- #stage-list { display: block !important; }
40
-
41
- .moment { grid-template-columns: 17rem 1fr !important; }
42
- .opening { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)) !important; }
43
- .masthead p { max-width: 46rem !important; }
44
- }
45
-
46
- @media (prefers-reduced-motion: reduce) {
47
- .gradio-container { transition: none !important; }
48
- }