Skip to content

Commit 7c9dac2

Browse files
torwagerclaude
andcommitted
Fix video seeking in the segment browser + click-to-seek on the feature plot
- Add tools/serve.py: a small Range-capable static server. python -m http.server ignores HTTP Range, so <video> could not seek (play-segment and the scrub bar did nothing). serve.py returns 206 Partial Content, so seeking works. - Feature time-series plot: click anywhere to jump the video to that time. - Docs updated to use tools/serve.py instead of python -m http.server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d416bc4 commit 7c9dac2

7 files changed

Lines changed: 151 additions & 15 deletions

File tree

analysis/web/README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,31 @@ computational annotations and play the matching moment.
55

66
## Launch
77

8-
Serve from the **project root** (so the app and the media files share an origin),
9-
then open the page:
8+
Serve from the **project root** with the bundled server, then open the page:
109

1110
```bash
1211
cd <project-root>
13-
python3 -m http.server 8000
12+
python3 tools/serve.py # http://localhost:8000 (pass a port to change it)
1413
# then open: http://localhost:8000/analysis/web/index.html
1514
```
1615

17-
(Serving from the root is what lets the in-browser player load videos from
18-
`/data/movies/...`; opening `index.html` directly via `file://` shows rankings but
19-
cannot play clips.)
16+
Use `tools/serve.py`, **not** `python -m http.server`: the built-in server ignores HTTP
17+
`Range` requests, so `<video>` cannot **seek** — the scrub bar and "play segment" won't
18+
jump. `tools/serve.py` adds byte-range support so seeking works. Serving from the root is
19+
also what lets the player load videos from `/data/movies/...`; opening `index.html` via
20+
`file://` shows rankings but cannot play clips.
2021

2122
## Use
2223

2324
- Filter/scroll the feature list (grouped by class); tick features to search on.
2425
- Toggle each picked feature **High** / **Low**.
2526
- Optionally filter by source and choose how many results to show.
26-
- Segments are ranked by the mean z-score of the selected features; click **▶ Play**
27-
to watch that segment (auto-seeks to its start and stops at its end).
27+
- Segments are ranked by the mean z-score of the selected features. Each result has
28+
**▶ Segment** (seeks to its start, auto-stops at its end) and **▶ Clip** (plays the
29+
whole stimulus); the player repeats these for the loaded segment.
30+
- Selected features are plotted as a time series over the whole clip, with a marker that
31+
tracks playback. **Click the plot** to jump the video to that time; the video scrub bar
32+
works too.
2833

2934
## Rebuild the index
3035

analysis/web/index.html

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,13 +334,27 @@ <h1>Narrative Feature Segment Search</h1>
334334
svg += `</svg>`;
335335

336336
host.innerHTML = `<div class="lg">${lg}</div>${svg}
337-
<div class="cap">Feature values (z-scored across the corpus) over the full clip; the marker tracks playback.</div>`;
337+
<div class="cap">Feature values (z-scored across the corpus) over the full clip; the marker tracks playback. <b>Click the plot to jump the video to that time.</b></div>`;
338338

339339
TL = { stim, tMax, series, X, Y,
340340
svg: host.querySelector("svg"),
341341
ph: host.querySelector("#tlph"),
342342
dots: [...host.querySelectorAll("[data-dot]")],
343343
lgVals: Object.fromEntries(series.map(s => [s.name, host.querySelector(`[data-lg="${CSS.escape(s.name)}"]`)])) };
344+
345+
// click anywhere on the plot -> seek the video to that time point
346+
TL.svg.style.cursor = "crosshair";
347+
TL.svg.onclick = (e) => {
348+
const rect = TL.svg.getBoundingClientRect();
349+
if(rect.width <= 0) return;
350+
const frac = (e.clientX - rect.left) / rect.width; // 0..1 across the viewBox
351+
let t = ((frac * W - pL) / (W - pL - pR)) * TL.tMax;
352+
t = Math.max(0, Math.min(t, Math.max(0, TL.tMax - 0.05)));
353+
const v = vid();
354+
curRange = { from:t, to:Infinity }; // free navigation from here
355+
const seek = () => { try { v.currentTime = t; } catch(_){} updateDot(); };
356+
if(v.readyState >= 1) seek(); else v.addEventListener("loadedmetadata", seek, { once:true });
357+
};
344358
updateDot();
345359
}
346360

docs/CONTENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Browser tool to rank segments by any combination of features and play the matchi
130130
moment. Serve from the project root, then open the page:
131131

132132
```bash
133-
python3 -m http.server 8000 # from the project root
133+
python3 tools/serve.py # from the project root (Range-enabled, so video seeking works)
134134
# open http://localhost:8000/analysis/web/index.html
135135
```
136136

@@ -177,7 +177,7 @@ sel = selectStimulusSet(C, "K", 20); % design a stimulus set; sel.table
177177

178178
```bash
179179
# 4) SEARCH segments by feature in the browser (serve from project root)
180-
python3 -m http.server 8000 # then open http://localhost:8000/analysis/web/index.html
180+
python3 tools/serve.py # then open http://localhost:8000/analysis/web/index.html
181181
```
182182

183183
See [`walkthrough.m`](walkthrough.m) for the same steps, runnable section by section.

docs/design/PHASE4_ANALYSIS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ expressions are individually searchable). Pick any combination of features,
7676
toggle High/Low, and segments are ranked by mean z-score (with a features-covered count when
7777
a segment lacks some selected channels); **▶ Play** seeks the actual clip to that segment
7878
(audio stories play as sound; text-only has no playback). Serve from the project root
79-
(`python3 -m http.server 8000``analysis/web/index.html`). Rankings validate well —
79+
(`python3 tools/serve.py``analysis/web/index.html`; the bundled server adds HTTP
80+
Range support so video seeking works). Rankings validate well —
8081
high flow surfaces action scenes, high word-rate the dialogue clips, EmoNet "Aesthetic
8182
Appreciation" the beach-sunset clips, etc. Launch +
8283
rebuild notes: [`../../analysis/web/README.md`](../../analysis/web/README.md).

docs/walkthrough.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,6 @@
8484
% PYTHONPATH=src .venv/bin/python -m nfe.run <movie> --vision --audio-hl --events \
8585
% --template schema/channel_template.json
8686
% - SEARCH segments by feature in a browser (serve from the project root):
87-
% python3 -m http.server 8000 % then open http://localhost:8000/analysis/web/index.html
87+
% python3 tools/serve.py % Range-enabled (video seeking works); open http://localhost:8000/analysis/web/index.html
8888
% - Full reference: docs/CONTENTS.md ; format: docs/design/ANNOTATION_FORMAT.md
8989
disp("Walkthrough complete. See docs/CONTENTS.md for the full guide.");

tools/build_book.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ def write_browser() -> None:
181181
> **Playback & media.** The ranking interface works anywhere. Actually *playing* a clip
182182
> needs the source media served from the same origin — so playback works when you serve
183183
> the project locally, but not on the public GitHub Pages site, where the licensed media
184-
> are not hosted. To use it fully, serve the repository root
185-
> (`python3 -m http.server 8000`) and open `analysis/web/index.html`.
184+
> are not hosted. To use it fully, serve the repository root with `python3 tools/serve.py`
185+
> (a small Range-capable server, so video **seeking** works — the built-in
186+
> `python -m http.server` cannot seek) and open `analysis/web/index.html`.
186187
{extra}""")
187188

188189

tools/serve.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""Static file server for the project WITH HTTP Range support.
3+
4+
`python -m http.server` ignores the `Range` header and returns the whole file (200),
5+
which means HTML5 <video> cannot SEEK — clicking the scrub bar or jumping to a segment
6+
does nothing. This server answers Range requests with `206 Partial Content`, so video
7+
seeking works. Use it (instead of `python -m http.server`) to run the segment browser:
8+
9+
python3 tools/serve.py # serves the project root on http://localhost:8000
10+
python3 tools/serve.py 8100 # ...on a different port
11+
# then open http://localhost:8000/analysis/web/index.html
12+
13+
Pure standard library; no third-party dependencies.
14+
"""
15+
from __future__ import annotations
16+
17+
import functools
18+
import os
19+
import re
20+
import sys
21+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
22+
23+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24+
25+
26+
class RangeHandler(SimpleHTTPRequestHandler):
27+
"""SimpleHTTPRequestHandler + byte-range (206) support for seekable media."""
28+
29+
def send_head(self):
30+
self._range = None
31+
path = self.translate_path(self.path)
32+
if os.path.isdir(path):
33+
return super().send_head() # directory listing / index.html redirect
34+
try:
35+
f = open(path, "rb")
36+
except OSError:
37+
self.send_error(404, "File not found")
38+
return None
39+
try:
40+
fs = os.fstat(f.fileno())
41+
size = fs.st_size
42+
ctype = self.guess_type(path)
43+
rng = self.headers.get("Range")
44+
if not rng:
45+
self.send_response(200)
46+
self.send_header("Content-Type", ctype)
47+
self.send_header("Content-Length", str(size))
48+
self.send_header("Accept-Ranges", "bytes")
49+
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
50+
self.end_headers()
51+
return f
52+
m = re.match(r"bytes=(\d*)-(\d*)\s*$", rng)
53+
if not m:
54+
self.send_error(400, "Invalid Range header")
55+
f.close()
56+
return None
57+
g_start, g_end = m.group(1), m.group(2)
58+
if g_start == "": # suffix range: last N bytes
59+
n = int(g_end or 0)
60+
start, end = max(0, size - n), size - 1
61+
else:
62+
start = int(g_start)
63+
end = int(g_end) if g_end else size - 1
64+
end = min(end, size - 1)
65+
if start > end or start >= size:
66+
self.send_response(416) # Range Not Satisfiable
67+
self.send_header("Content-Range", f"bytes */{size}")
68+
self.end_headers()
69+
f.close()
70+
return None
71+
self.send_response(206)
72+
self.send_header("Content-Type", ctype)
73+
self.send_header("Accept-Ranges", "bytes")
74+
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
75+
self.send_header("Content-Length", str(end - start + 1))
76+
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
77+
self.end_headers()
78+
f.seek(start)
79+
self._range = (start, end)
80+
return f
81+
except Exception:
82+
f.close()
83+
raise
84+
85+
def copyfile(self, source, outputfile):
86+
rng = getattr(self, "_range", None)
87+
if rng is None:
88+
return super().copyfile(source, outputfile)
89+
start, end = rng
90+
remaining = end - start + 1
91+
while remaining > 0:
92+
chunk = source.read(min(64 * 1024, remaining))
93+
if not chunk:
94+
break
95+
try:
96+
outputfile.write(chunk)
97+
except (BrokenPipeError, ConnectionResetError):
98+
break # client seeked away / closed — fine
99+
remaining -= len(chunk)
100+
101+
102+
def main():
103+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
104+
handler = functools.partial(RangeHandler, directory=ROOT)
105+
with ThreadingHTTPServer(("", port), handler) as httpd:
106+
print(f"Serving {ROOT}\n http://localhost:{port}/analysis/web/index.html\n"
107+
f"(Range requests enabled — video seeking works. Ctrl+C to stop.)")
108+
try:
109+
httpd.serve_forever()
110+
except KeyboardInterrupt:
111+
print("\nstopped.")
112+
113+
114+
if __name__ == "__main__":
115+
main()

0 commit comments

Comments
 (0)