"""Frame-accurate control of one HTML5 ``<video>`` element (UI, browser-gated).
The Event-annotation task drives a single ``ui.video`` from Python: play/pause, seek to a
time or a frame, step frame-by-frame, and jump ±5 s. HTML5 video exposes only a **time**
cursor (``currentTime`` in seconds), not a frame cursor, so every frame operation converts
through the video's ``fps``:
frame -> currentTime = frame / fps + half_frame_nudge
time -> frame = round(currentTime * fps)
**Documented limitation.** Because seeking is time-based, the landed frame is
``round(currentTime * fps)``. On constant-fps media — which Annie's convert pipeline
produces (see :mod:`annie.media.convert`) — this is exact. On a variable-fps source it can
be off by one frame. That is the accepted trade-off for smooth playback *with audio*, which
a torchcodec frame-by-frame decoder cannot give; an exact confirm is a possible later
refinement, deliberately out of scope here.
This module is a thin bridge: each function returns the JavaScript that acts on the element,
run via :func:`nicegui.ui.run_javascript`. It holds no state — the task owns the model. It
is exercised in the browser, not unit-tested, like the other ``ui.video`` paths.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from nicegui import ui
if TYPE_CHECKING:
from collections.abc import Awaitable
#: A half-frame nudge (in frame units) added when seeking to a frame, so ``currentTime``
#: lands *inside* the target frame rather than exactly on the boundary between it and its
#: predecessor — where a browser may round back to the earlier frame.
_HALF_FRAME = 0.5
[docs]
def element_id(client_id: str) -> str:
"""Return the stable DOM id given to the task's video element for a client.
Args:
client_id: The NiceGUI client id (``context.client.id``).
Returns:
A DOM id unique to that client's Event-annotation video.
"""
return f"annie-event-video-{client_id}"
def _video_js(element_id_: str, body: str) -> str:
"""Wrap ``body`` so it runs only once the element exists (``v`` is the video)."""
return f"const v = document.getElementById('{element_id_}'); if (v) {{ {body} }}"
[docs]
def play(element_id_: str) -> None:
"""Start playback of the video element."""
ui.run_javascript(_video_js(element_id_, "v.play();"))
[docs]
def pause(element_id_: str) -> None:
"""Pause the video element."""
ui.run_javascript(_video_js(element_id_, "v.pause();"))
[docs]
def toggle(element_id_: str) -> None:
"""Toggle play/pause on the video element."""
ui.run_javascript(_video_js(element_id_, "if (v.paused) { v.play(); } else { v.pause(); }"))
[docs]
def seek_seconds(element_id_: str, seconds: float) -> None:
"""Seek the video to an absolute time in seconds (clamped at zero).
Args:
element_id_: The video element's DOM id.
seconds: The target time; negative values clamp to the start.
"""
target = max(0.0, seconds)
ui.run_javascript(_video_js(element_id_, f"v.currentTime = {target};"))
[docs]
def seek_frame(element_id_: str, frame: int, fps: float) -> None:
"""Seek the video to a specific frame via its time, with a half-frame nudge.
Args:
element_id_: The video element's DOM id.
frame: The target frame index (clamped at zero).
fps: Frames per second; a non-positive fps seeks to the start.
"""
if fps <= 0:
seek_seconds(element_id_, 0.0)
return
target = max(0, frame)
seconds = (target + _HALF_FRAME) / fps
ui.run_javascript(_video_js(element_id_, f"v.pause(); v.currentTime = {seconds};"))
[docs]
def step_frames(element_id_: str, delta: int, fps: float) -> None:
"""Pause and move the cursor by ``delta`` frames (positive forward, negative back).
Args:
element_id_: The video element's DOM id.
delta: Number of frames to move; sign gives the direction.
fps: Frames per second; a non-positive fps is a no-op.
"""
if fps <= 0:
return
offset = delta / fps
ui.run_javascript(
_video_js(
element_id_,
f"v.pause(); v.currentTime = Math.max(0, v.currentTime + ({offset}));",
)
)
[docs]
def nudge_seconds(element_id_: str, delta: float) -> None:
"""Jump the cursor by ``delta`` seconds (the ±5 s buttons), clamped at the start.
Args:
element_id_: The video element's DOM id.
delta: Seconds to move; sign gives the direction.
"""
ui.run_javascript(
_video_js(element_id_, f"v.currentTime = Math.max(0, v.currentTime + ({delta}));")
)
[docs]
def current_time(element_id_: str) -> Awaitable[float]:
"""Read the video's current time in seconds from the browser.
Used when ``I``/``O`` capture the playhead: the caller awaits this, converts to a frame,
and writes the event boundary. Returns ``0.0`` if the element is gone.
Args:
element_id_: The video element's DOM id.
Returns:
An awaitable resolving to the current time in seconds.
"""
js = f"const v = document.getElementById('{element_id_}'); return v ? v.currentTime : 0.0;"
return ui.run_javascript(js)
[docs]
def set_playhead_x(timeline_id: str, x_fraction: float | None) -> None:
"""Move the timeline playhead line to a horizontal fraction without a full re-render.
The playhead is a single SVG line inside the timeline element; the poll updates only its
position so playback stays cheap (no server round-trip re-draw of the event boxes).
Args:
timeline_id: DOM id of the timeline's SVG element.
x_fraction: Playhead position as a fraction ``[0, 1]`` of the visible window, or
``None`` when the playhead is outside it — the line is then hidden, never pinned
to an edge.
"""
if x_fraction is None:
action = "line.style.display = 'none';"
else:
percent = min(1.0, max(0.0, x_fraction)) * 100.0
action = (
f"line.style.display = ''; line.setAttribute('x1', '{percent:.3f}%');"
f" line.setAttribute('x2', '{percent:.3f}%');"
)
js = (
f"const t = document.getElementById('{timeline_id}');"
f" if (t) {{ const line = t.querySelector('.annie-playhead');"
f" if (line) {{ {action} }} }}"
)
ui.run_javascript(js)
[docs]
def set_pending_band(timeline_id: str, start_fraction: float, end_fraction: float) -> None:
"""Resize the amber capture band without a full re-render, while a start is armed.
Mirrors :func:`set_playhead_x`: the band is one ``.annie-pending`` rect, and the poll
updates only its ``x``/``width`` from two ``[0, 1]`` fractions so it grows with the
playhead cheaply. Fractions are ordered and clamped, so a playhead left of the start still
yields a valid (leftward) band.
Args:
timeline_id: DOM id of the timeline's SVG element.
start_fraction: The band's start edge as a fraction of the timeline width.
end_fraction: The band's end edge (the live playhead) as a fraction of the width.
"""
lo = min(1.0, max(0.0, min(start_fraction, end_fraction))) * 100.0
hi = min(1.0, max(0.0, max(start_fraction, end_fraction))) * 100.0
js = (
f"const t = document.getElementById('{timeline_id}');"
f" if (t) {{ const band = t.querySelector('.annie-pending');"
f" if (band) {{ band.setAttribute('x', '{lo:.3f}%');"
f" band.setAttribute('width', '{hi - lo:.3f}%'); }} }}"
)
ui.run_javascript(js)