Spaces:
Running on Zero
Running on Zero
Replace Gradio Blocks with gradio.Server: own HTML shell, own CSS, no selector rewriting
Browse files- README.md +18 -7
- app.py +123 -115
- index.html +17 -0
- style.css → static/app.css +252 -283
- player.js → static/app.js +100 -68
- 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 |
-
##
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
`.
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 19 |
-
from
|
| 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 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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 |
-
|
| 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
|
| 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
|
| 86 |
-
return list(
|
| 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 |
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
|
| 118 |
t0 = time.perf_counter()
|
| 119 |
try:
|
| 120 |
-
hits = backend.search(
|
| 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(
|
| 125 |
ms = (time.perf_counter() - t0) * 1000
|
| 126 |
-
log.info("%r -> %d hits in %.0f ms (%s)",
|
| 127 |
-
return render.results(hits,
|
| 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 |
-
|
|
|
|
| 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
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
--paper: #e6e7e3;
|
| 10 |
-
--panel: #
|
| 11 |
--ink: #16181a;
|
| 12 |
--muted: #6d726d;
|
| 13 |
--rule: #c7cbc5;
|
| 14 |
--accent: #9a5a12;
|
| 15 |
|
| 16 |
-
/* Industrial film
|
| 17 |
-
|
| 18 |
--display: Futura, "Century Gothic", "Avenir Next", "Segoe UI", Roboto,
|
| 19 |
"Helvetica Neue", Arial, sans-serif;
|
| 20 |
-
--sans:
|
| 21 |
-
--mono:
|
| 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 |
-
border-radius: 0
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 70 |
-
max-width:
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
.
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 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
|
|
|
|
|
|
|
| 100 |
padding: 0.7rem 0 0;
|
|
|
|
|
|
|
| 101 |
font-size: 0.8rem;
|
| 102 |
color: var(--muted);
|
| 103 |
-
|
| 104 |
-
|
| 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 |
-
.
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
gap: 0 1.6rem !important;
|
| 136 |
-
align-items: center;
|
| 137 |
flex-wrap: wrap;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
-
.
|
| 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.
|
| 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:
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
/* --- results ------------------------------------------------------------ */
|
| 250 |
|
| 251 |
.count {
|
|
|
|
|
|
|
| 252 |
font-family: var(--mono);
|
| 253 |
font-size: 0.74rem;
|
| 254 |
color: var(--muted);
|
| 255 |
-
margin: 1.
|
| 256 |
-
display: flex;
|
| 257 |
-
gap: 0.7rem;
|
| 258 |
}
|
| 259 |
.count .ms { margin-left: auto; color: #a4a9a3; }
|
| 260 |
-
.empty {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 275 |
-
|
| 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 |
-
|
| 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;
|
|
|
|
| 300 |
font-family: var(--mono);
|
| 301 |
font-size: 0.66rem;
|
| 302 |
color: var(--paper);
|
| 303 |
-
background: rgba(20, 18, 15, 0.
|
| 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);
|
| 309 |
|
| 310 |
.meta h2 {
|
| 311 |
font-family: var(--display);
|
| 312 |
font-size: 1.08rem;
|
| 313 |
-
font-weight: 400
|
| 314 |
-
margin: -0.15rem 0 0.2rem;
|
| 315 |
line-height: 1.35;
|
| 316 |
-
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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
|
| 402 |
|
| 403 |
-
/*
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
-
|
| 417 |
-
|
| 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 |
-
|
| 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
|
| 2 |
-
// data-src/data-t is a seek
|
| 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 |
-
//
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 26 |
-
|
| 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
|
| 32 |
-
//
|
| 33 |
-
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 122 |
load(v, url, t);
|
| 123 |
});
|
| 124 |
}
|
| 125 |
|
| 126 |
-
//
|
| 127 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 162 |
-
if (close) {
|
| 163 |
var s = stage(), v = player();
|
| 164 |
-
if (v)
|
| 165 |
-
if (s)
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|