diff --git a/data/locales/pages.en.yml b/data/locales/pages.en.yml index efd19eb0..a8735062 100644 --- a/data/locales/pages.en.yml +++ b/data/locales/pages.en.yml @@ -457,6 +457,25 @@ en: credits_title: Standing on open shoulders cta_chat: Join the FPV chat cta_guide: Read the build guide + flight_copy: H.264 copy + flight_credit_html: 'Flight by gilankpam on mabur · SSC338Q + RTL8812EU' + flight_error: This browser cannot play the stream + flight_failed: 'The flight did not load. Try again' + flight_frame_back: Previous frame + flight_frame_forward: Next frame + flight_fullscreen: Full screen + flight_gs: On the pilot's screen + flight_handle: Divider between the two recordings + flight_loading: Loading… + flight_onboard: On the drone + flight_open_rnd: Open in R&D Player + flight_original: Original quality + flight_pause: Pause + flight_play: Play + flight_reduced: reduced + flight_seek: Position in the flight + flight_text: 'The drone''s own recording and what reached the pilot''s screen over the link, frame by frame. Drag the divider to compare; the link''s latency is in the telemetry along the bottom.' + flight_title: 'See it on a real flight' fpv_text: 'A fully open digital FPV system: no locked ecosystem, no paired hardware, no firmware ceiling. Build the air unit from a supported camera, pick your ground station, and tune every stage of the pipeline.' fpv_title: 'FPV: from lens to goggles' hardware_text_html: Air units are built from supported cameras and AIO boards — start from the supported hardware list. RunCam and other vendors ship OpenIPC-based FPV hardware out of the box. diff --git a/data/locales/pages.ru.yml b/data/locales/pages.ru.yml index 0824d9b5..5d05556c 100644 --- a/data/locales/pages.ru.yml +++ b/data/locales/pages.ru.yml @@ -495,6 +495,25 @@ ru: credits_title: Стоим на открытых плечах cta_chat: FPV-чат cta_guide: Гайд по сборке + flight_copy: копия H.264 + flight_credit_html: 'Полёт — gilankpam на mabur · SSC338Q + RTL8812EU' + flight_error: Этот браузер не может воспроизвести поток + flight_failed: 'Полёт не загрузился. Попробуйте ещё раз' + flight_frame_back: Предыдущий кадр + flight_frame_forward: Следующий кадр + flight_fullscreen: Во весь экран + flight_gs: На экране пилота + flight_handle: Граница между двумя записями + flight_loading: Загрузка… + flight_onboard: На борту дрона + flight_open_rnd: Открыть в R&D Player + flight_original: Исходное качество + flight_pause: Пауза + flight_play: Смотреть + flight_reduced: снижено + flight_seek: Позиция в полёте + flight_text: 'Запись с борта дрона и то, что дошло по линку до экрана пилота, кадр в кадр. Двигайте границу, чтобы сравнить; задержка линка — в телеметрии внизу.' + flight_title: 'Посмотрите на настоящем полёте' fpv_text: 'Полностью открытая цифровая FPV-система: никакой закрытой экосистемы, никакого спаренного железа, никакого потолка прошивки. Соберите борт из поддерживаемой камеры, выберите наземную станцию и настройте каждый этап конвейера.' fpv_title: 'FPV: от объектива до очков' hardware_text_html: Борт собирается из поддерживаемых камер и AIO-плат — начните со списка поддерживаемого оборудования. RunCam и другие вендоры выпускают FPV-железо на OpenIPC из коробки. diff --git a/data/locales/pages.zh.yml b/data/locales/pages.zh.yml index 4735253b..8297cb5d 100644 --- a/data/locales/pages.zh.yml +++ b/data/locales/pages.zh.yml @@ -457,6 +457,25 @@ zh: credits_title: 站在开放的肩膀上 cta_chat: 加入 FPV 聊天群 cta_guide: 阅读搭建指南 + flight_copy: H.264 副本 + flight_credit_html: '飞行:gilankpam,使用 mabur · SSC338Q + RTL8812EU' + flight_error: 此浏览器无法播放该视频流 + flight_failed: '飞行视频未能加载,请重试' + flight_frame_back: 上一帧 + flight_frame_forward: 下一帧 + flight_fullscreen: 全屏 + flight_gs: 飞手屏幕上 + flight_handle: 两段录像之间的分隔线 + flight_loading: 加载中… + flight_onboard: 无人机机载 + flight_open_rnd: 在 R&D Player 中打开 + flight_original: 原始画质 + flight_pause: 暂停 + flight_play: 播放 + flight_reduced: 已降低 + flight_seek: 飞行中的位置 + flight_text: '无人机机载录像与经链路到达飞手屏幕的画面,逐帧对照。拖动分隔线进行比较;链路延迟显示在底部的遥测中。' + flight_title: '在真实飞行中看看' fpv_text: '一套完全开放的数字 FPV 系统:没有封闭生态,没有配对绑定的硬件,也没有固件上限。用受支持的摄像机搭建天空端,挑选你的地面站,并可调节管线的每一个环节。' fpv_title: 'FPV:从镜头到眼镜' hardware_text_html: 天空端由受支持的摄像机和 AIO 板搭建——请从支持硬件列表开始。RunCam 等厂商出厂即提供基于 OpenIPC 的 FPV 硬件。 diff --git a/deploy/RESTORE.md b/deploy/RESTORE.md index 26be6e4e..51b6bef2 100644 --- a/deploy/RESTORE.md +++ b/deploy/RESTORE.md @@ -310,6 +310,12 @@ Only needed on a rebuilt host: - `/srv/www/shared/wall` — the wall's images. **Not** in the backup; the Open Wall is empty until cameras re-upload, so an empty directory owned by uid 1000 is a complete restore. +- `/srv/www/shared/media` — the recorded flight the A/B player on + `/low-latency` streams. **Not** in the backup: about 2.5 GB, rebuilt from the + two source recordings by `tools/flight-ab/run.sh` and uploaded as its README + says. The sources are on this host too (`/srv/www/flight-sources/`, not in + S3), so a rebuilt host needs them from wherever else they were kept. Until then the page shows its poster and the player says it cannot + play. - **analytics**, via `deploy/install-analytics.sh` (#181). It installs GoatCounter, its account and its systemd unit, and creates the site on first run from `ANALYTICS_EMAIL` and `ANALYTICS_PASSWORD`. The SQLite database is diff --git a/deploy/nginx/check-config.sh b/deploy/nginx/check-config.sh index 48d7dbcf..84ef86c0 100755 --- a/deploy/nginx/check-config.sh +++ b/deploy/nginx/check-config.sh @@ -191,6 +191,10 @@ ln -s site-test /srv/www/static/prod/current printf 'TOKEN-OK\n' > /var/lib/dehydrated/acme-challenges/probe-token install -d -m 0755 /srv/www/shared/images printf 'PNG\n' > /srv/www/shared/images/logo_openipc.png +# A recorded flight for the A/B player on /low-latency (tools/flight-ab). +install -d -m 0755 /srv/www/shared/media/flights/test/v1/gs/original +printf '\n' > /srv/www/shared/media/flights/test/v1/gs.mpd +printf '0123456789' > /srv/www/shared/media/flights/test/v1/gs/original/1.m4s # A second loopback address stands in for a datacentre client, so the block # and CI's way through it are measured rather than read off the map. @@ -440,6 +444,30 @@ expect_cache /no-such-page-at-all "no-cache" expect /fonts/ibm-plex-sans-latin-400-normal.woff2 200 static hsts expect_cache /fonts/ibm-plex-sans-latin-400-normal.woff2 "public, max-age=31536000, immutable" expect /assets/application.css 410 - no-hsts + +# Recorded flights (tools/flight-ab): versioned directories never rewritten, so +# a year of cache; the types DASH players insist on; ranges, which a player +# may ask for; and CORS for the R&D Player's origin and nobody else's. +expect /media/flights/test/v1/gs.mpd 200 - hsts +expect_cache /media/flights/test/v1/gs.mpd "public, max-age=31536000, immutable" +media_header() { + path=$1; header=$2; want=$3; shift 3 + curl -sS -o /dev/null -D /tmp/hm -k --max-time 5 "$@" \ + --resolve "openipc.org:443:127.0.0.1" "https://openipc.org$path" >/dev/null 2>&1 + if [ "$header" = status ]; then got=$(awk 'NR==1{print $2}' /tmp/hm) + else got=$(grep -i "^$header:" /tmp/hm | tr -d '\r' | cut -d' ' -f2- | head -1); fi + if [ "$got" = "$want" ]; then + printf ' %-32s %s: %s\n' "$path" "$header" "$got" + else + printf ' %-32s MISMATCH: %s is %s (want %s)\n' "$path" "$header" "${got:-}" "$want" + fail=1 + fi +} +media_header /media/flights/test/v1/gs.mpd content-type application/dash+xml +media_header /media/flights/test/v1/gs/original/1.m4s content-type video/iso.segment +media_header /media/flights/test/v1/gs/original/1.m4s status 206 -H 'Range: bytes=2-5' +media_header /media/flights/test/v1/gs.mpd access-control-allow-origin https://openipc.github.io +expect /media/flights/test/v1/none.mpd 404 - no-hsts # The home page keeps its address when its content changes, like every other # page in the bundle, so it may be cached and must always be revalidated. expect_cache / "public, max-age=0, must-revalidate" diff --git a/deploy/nginx/sites-available/org.openipc b/deploy/nginx/sites-available/org.openipc index c33a6b32..9e5a1ab1 100644 --- a/deploy/nginx/sites-available/org.openipc +++ b/deploy/nginx/sites-available/org.openipc @@ -202,6 +202,32 @@ server { alias /srv/www/shared/images/; } + # Recorded flights, streamed as DASH by the A/B player on /low-latency + # (tools/flight-ab). Each flight is one versioned directory that is never + # rewritten -- a new cut is a new v/ -- so a year of immutable cache is + # safe. Host-only and in no backup: the tree is regenerated from the two + # source recordings by tools/flight-ab/run.sh. + # + # The CORS header is for one origin only, the R&D Player's, which the page + # offers as "open in R&D Player" and which fetches the manifests and + # segments cross-origin. Both players fetch a segment per stream at a + # time, well inside the http-level per_subnet twenty. + location ^~ /media/ { + alias /srv/www/shared/media/; + types { + application/dash+xml mpd; + video/iso.segment m4s; + video/mp4 mp4; + application/json json; + image/jpeg jpg; + } + add_header Strict-Transport-Security max-age=15768000; + add_header X-Content-Type-Options nosniff always; + add_header Access-Control-Allow-Origin https://openipc.github.io always; + # Not `always`: a 404 cached for a year would outlive the upload. + add_header Cache-Control "public, max-age=31536000, immutable"; + } + # Analytics beacon (#181). GoatCounter runs on the loopback and these are # the only two paths of it the public site exposes. # diff --git a/deploy/nginx/sites-available/org.openipc.dev b/deploy/nginx/sites-available/org.openipc.dev index 13f62f07..aadc1090 100644 --- a/deploy/nginx/sites-available/org.openipc.dev +++ b/deploy/nginx/sites-available/org.openipc.dev @@ -772,6 +772,33 @@ server { } + # Recorded flights, streamed as DASH by the A/B player on /low-latency + # (tools/flight-ab). Each flight is one versioned directory that is never + # rewritten -- a new cut is a new v/ -- so a year of immutable cache is + # safe. Host-only and in no backup: the tree is regenerated from the two + # source recordings by tools/flight-ab/run.sh. + # + # The CORS header is for one origin only, the R&D Player's, which the page + # offers as "open in R&D Player" and which fetches the manifests and + # segments cross-origin. Both players fetch a segment per stream at a + # time, well inside the http-level per_subnet twenty. + location ^~ /media/ { + alias /srv/www/shared/media/; + types { + application/dash+xml mpd; + video/iso.segment m4s; + video/mp4 mp4; + application/json json; + image/jpeg jpg; + } + add_header Strict-Transport-Security max-age=15768000; + add_header X-Content-Type-Options nosniff always; + add_header Access-Control-Allow-Origin https://openipc.github.io always; + # Not `always`: a 404 cached for a year would outlive the upload. + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Robots-Tag "noindex, nofollow, noarchive" always; + } + # The Go firmware process's image cache (#299): one version of each image, # built on first request. Reachable only through its X-Accel-Redirect. location /firmware-cache/ { diff --git a/deploy/static/reserved-paths b/deploy/static/reserved-paths index 6a42b3cd..a5cd3f75 100644 --- a/deploy/static/reserved-paths +++ b/deploy/static/reserved-paths @@ -54,6 +54,8 @@ /board-files/ # Owner reports' files, internal: reached only through the web role's X-Accel-Redirect for a published report. /report-files/ +# Recorded flights for the A/B player on /low-latency (tools/flight-ab), host-only. +/media/ # --- the expensive leaf ------------------------------------------------------ # /cameras/ is deliberately NOT reserved: the catalogue is exactly what #162 diff --git a/frontend/apps/site/package.json b/frontend/apps/site/package.json index bd716581..ab4032c3 100644 --- a/frontend/apps/site/package.json +++ b/frontend/apps/site/package.json @@ -18,7 +18,8 @@ "dependencies": { "@openipc/ui": "*", "d3-hierarchy": "^3.1.2", - "preact": "10.27.2" + "preact": "10.27.2", + "shaka-player": "^4.16.51" }, "devDependencies": { "@astrojs/check": "^0.9.4", diff --git a/frontend/apps/site/src/assets/pages/flight-mabur-2026-10.jpg b/frontend/apps/site/src/assets/pages/flight-mabur-2026-10.jpg new file mode 100644 index 00000000..3271c36d Binary files /dev/null and b/frontend/apps/site/src/assets/pages/flight-mabur-2026-10.jpg differ diff --git a/frontend/apps/site/src/components/flight/FlightCompare.tsx b/frontend/apps/site/src/components/flight/FlightCompare.tsx new file mode 100644 index 00000000..ae1d05e2 --- /dev/null +++ b/frontend/apps/site/src/components/flight/FlightCompare.tsx @@ -0,0 +1,419 @@ +/** + * A real flight, as the drone recorded it and as the pilot saw it. + * + * Two recordings of one flight, cut onto one timeline by tools/flight-ab and + * streamed as two DASH presentations from /media/. The drone's side is on top, + * clipped at the split; the ground station's is underneath. Same arrangement + * and the same way of keeping them on one frame as the R&D Player's compare + * (../../lib/flight-sync.ts), without the player around it. + * + * Nothing is fetched until the reader presses play: before that this is a + * poster, so the page weighs what it did. Shaka arrives then, as its own + * chunk. + * + * Each side adapts on its own, and each says what it is showing, because a + * comparison where one side has quietly dropped to 540p is not one. "Original + * quality" pins both to the recordings themselves. + */ +import { useEffect, useRef, useState } from 'preact/hooks'; +import type shakaNs from 'shaka-player/dist/shaka-player.dash.js'; +import { clock, originalOf, rungKind, SEEK_DRIFT, syncAction, type RungKind } from '../../lib/flight-sync'; + +type Shaka = typeof shakaNs; +type Player = InstanceType; +type Side = 'onboard' | 'gs'; + +export interface FlightLabels { + play: string; + pause: string; + loading: string; + error: string; + failed: string; + onboard: string; + gs: string; + original: string; + reduced: string; + copy: string; + fullscreen: string; + frameBack: string; + frameForward: string; + handle: string; + seek: string; + openRnd: string; +} + +interface Props { + /** The flight's directory under /media/, with a trailing slash. */ + base: string; + poster: string; + /** The common window, on the drone's timeline, from stats.json. */ + start: number; + end: number; + rndPlayer: string; + labels: FlightLabels; +} + +type Phase = 'idle' | 'loading' | 'ready' | 'failed' | 'unsupported'; +interface Badge { height: number | null; kind: RungKind } + +const FRAME = 1 / 60; + +// Drawn rather than typed: ⏮ ⏭ ⇆ are missing from enough system fonts to +// arrive as empty boxes. +const Glyph = ({ d }: { d: string }) => ( + +); +const STEP_BACK = 'M3 3h2v10H3zM13 3v10L6 8z'; +const STEP_FORWARD = 'M11 3h2v10h-2zM3 3v10l7-5z'; +const BOTH_WAYS = 'M1 8l4-4v3h6V4l4 4-4 4V9H5v3z'; + +export default function FlightCompare({ base, poster, start, end, rndPlayer, labels }: Props) { + // Never exactly on the ground station's first frame: a playhead a rounding + // error before its buffered range has no picture, and Shaka steps over that + // gap only while the video plays -- which a side held for the other never + // does, so both waited for good. Five milliseconds in is the same frame. + const begin = start + 0.005; + const box = useRef(null); + const lead = useRef(null); + const follow = useRef(null); + const players = useRef>>({}); + const held = useRef(false); + + const [phase, setPhase] = useState('idle'); + const [playing, setPlaying] = useState(false); + const [time, setTime] = useState(begin); + const [split, setSplit] = useState(50); + const [pinned, setPinned] = useState(false); + const [badges, setBadges] = useState>>({}); + + const describe = (side: Side) => { + const p = players.current[side]; + if (!p) return; + const tracks = p.getVariantTracks(); + const active = tracks.find((t) => t.active); + if (!active) return; + setBadges((b) => ({ ...b, [side]: { height: active.height, kind: rungKind(side, active, tracks) } })); + }; + + // Both players go together: one left attached beside a failed other would + // keep its buffers for nothing. + async function teardown() { + const ps = Object.values(players.current); + players.current = {}; + held.current = false; + setBadges({}); + setPlaying(false); + await Promise.all(ps.map((p) => p?.destroy().catch(() => {}))); + } + + // A browser without MSE is told so; anything else -- the media tree missing, + // a network failure, a stream that breaks mid-flight -- is a failure the + // reader can retry, and is logged rather than blamed on the browser. + async function fail(err: unknown) { + console.error('flight: the streams failed', err); + await teardown(); + setPhase('failed'); + } + + async function load() { + setPhase('loading'); + try { + const { default: shaka } = await import('shaka-player/dist/shaka-player.dash.js'); + shaka.polyfill.installAll(); + if (!shaka.Player.isBrowserSupported()) { setPhase('unsupported'); return; } + const sides: [Side, HTMLVideoElement][] = [['onboard', lead.current!], ['gs', follow.current!]]; + await Promise.all(sides.map(async ([side, video]) => { + const p = new shaka.Player(); + await p.attach(video); + p.configure({ + // HEVC first: the ground station's recording is HEVC, and its H.264 + // rungs are for browsers that cannot decode it. + preferredVideoCodecs: ['hvc1', 'hev1', 'avc1'], + // No playRangeStart: Shaka makes it the MSE append window, and the + // browser then drops every frame up to the next keyframe -- 4 s of + // the drone's side. The window is kept by the controls below instead. + streaming: { bufferingGoal: 20, rebufferingGoal: 1 }, + abr: { defaultBandwidthEstimate: 8_000_000 }, + }); + p.addEventListener('error', (e) => { + const err = (e as unknown as { detail?: { severity?: number } }).detail; + if (err?.severity === shaka.util.Error.Severity.CRITICAL) void fail(err); + }); + p.addEventListener('adaptation', () => describe(side)); + p.addEventListener('variantchanged', () => describe(side)); + players.current[side] = p; + await p.load(`${base}${side}.mpd`, begin); + describe(side); + })); + } catch (err) { + await fail(err); + return; + } + setPhase('ready'); + // Outside the try: a play() cut short by the buffering hold below rejects + // with AbortError, which is not the stream failing to load. + void lead.current!.play().catch(() => {}); + } + + // Everything the follower does, it does because the leader did it. + useEffect(() => { + const a = lead.current, b = follow.current; + if (!a || !b || phase !== 'ready') return; + const onPlay = () => { setPlaying(true); void b.play().catch(() => {}); }; + const onPause = () => { + // A pause event is dispatched after the fact: one from a hold that has + // already been released would otherwise stop the follower for good. + if (held.current || !a.paused) return; + setPlaying(false); + b.pause(); + b.currentTime = a.currentTime; + }; + const onSeek = () => { + // Before the window the ground station has no picture yet. + if (a.currentTime < begin) { a.currentTime = begin; return; } + setTime(a.currentTime); + // Not while the follower is still starting: a seek that lands before + // Shaka's first append left it seeking with nothing buffered for good + // (about one start in six). The hold below lines it up once it has a + // picture. + if (b.readyState >= 2) b.currentTime = a.currentTime; + }; + const onEnded = () => { setPlaying(false); b.pause(); }; + a.addEventListener('play', onPlay); + a.addEventListener('pause', onPause); + a.addEventListener('seeking', onSeek); + a.addEventListener('ended', onEnded); + + let raf = 0; + let shown = -1; + const tick = () => { + raf = requestAnimationFrame(tick); + // Read off the element rather than kept from events: the first play() + // runs before this effect has attached its listeners. + setPlaying(!a.paused || held.current); + // The clock under the picture, a few times a second rather than every + // frame: it reads in whole seconds. + if (Math.abs(a.currentTime - shown) >= 0.25 || a.paused) { + shown = a.currentTime; + setTime(shown); + } + // If either side is starved, both wait: a side that ran on alone would + // be compared with a picture from seconds before. Seeking and Shaka's + // own buffering count as starved -- a leader that kept playing while + // the follower sought would be further ahead when the seek landed, and + // the follower would chase it for ever. + const pa = players.current.onboard, pb = players.current.gs; + const aStarved = a.readyState < 3 || a.seeking || !!pa?.isBuffering(); + const bStarved = b.readyState < 3 || b.seeking || !!pb?.isBuffering(); + const starved = aStarved || bStarved; + if (!a.paused && starved) { + held.current = true; + a.pause(); + } + if (held.current && starved) { + // A starved follower is left playing: it cannot move without data, + // and Shaka gets a stalled stream going again only while it plays -- + // paused while still starting, it was seen to wait for ever. Only a + // follower that has data, and would run ahead, is stopped. + if (bStarved && b.paused) void b.play().catch(() => {}); + if (!bStarved && !b.paused) b.pause(); + return; + } + if (held.current && !starved) { + // Line the follower up with the stopped leader if it is really + // elsewhere, then go. Only a real gap: a follower left running while + // it waited is a few milliseconds on by the time it is seen ready, and + // re-seeking that -- a seek decodes from the keyframe before it -- had + // the two take turns being ready for good. The nudge closes the rest. + if (Math.abs(b.currentTime - a.currentTime) > SEEK_DRIFT) { b.currentTime = a.currentTime; return; } + held.current = false; + b.playbackRate = 1; + void a.play().catch(() => {}); + void b.play().catch(() => {}); + return; + } + if (a.paused || b.seeking) return; + if (b.paused) void b.play().catch(() => {}); + const act = syncAction(a.currentTime, b.currentTime); + if (act.kind === 'seek') b.currentTime = act.to; + else if (b.playbackRate !== act.rate) b.playbackRate = act.rate; + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + a.removeEventListener('play', onPlay); + a.removeEventListener('pause', onPause); + a.removeEventListener('seeking', onSeek); + a.removeEventListener('ended', onEnded); + }; + }, [phase]); + + useEffect(() => () => { + for (const p of Object.values(players.current)) void p?.destroy(); + }, []); + + async function pin(on: boolean) { + setPinned(on); + for (const side of ['onboard', 'gs'] as Side[]) { + const p = players.current[side]; + if (!p) continue; + p.configure({ abr: { enabled: !on } }); + if (on) { + const top = originalOf(side, p.getVariantTracks()); + if (top) p.selectVariantTrack(top, true); + } + describe(side); + } + } + + const toggle = () => { + const a = lead.current; + if (phase === 'idle' || phase === 'failed') { void load(); return; } + if (!a || phase !== 'ready') return; + // Held for a starved side counts as playing: Pause has to stop both, + // including a follower left running while it waits for data. + if (held.current) { held.current = false; follow.current?.pause(); return; } + if (a.paused) void a.play(); else a.pause(); + }; + + const step = (by: number) => { + const a = lead.current; + if (!a || phase !== 'ready') return; + // A step is a decision to stop here: a hold must not resume over it. + held.current = false; + a.pause(); + follow.current?.pause(); + a.currentTime = Math.min(end, Math.max(begin, a.currentTime + by)); + }; + + // The split follows the pointer while it is down anywhere on the picture, + // which is what makes it usable on a phone, where the handle is a hairline. + const dragging = useRef(false); + const place = (clientX: number) => { + const r = box.current!.getBoundingClientRect(); + setSplit(Math.min(100, Math.max(0, ((clientX - r.left) * 100) / r.width))); + }; + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { setSplit((s) => Math.max(0, s - 2)); e.preventDefault(); } + if (e.key === 'ArrowRight') { setSplit((s) => Math.min(100, s + 2)); e.preventDefault(); } + }; + + const clip = `inset(0 ${100 - split}% 0 0)`; + + const badge = (side: Side) => { + const b = badges[side]; + if (!b) return null; + const what = b.kind === 'original' ? labels.original : b.kind === 'copy' ? labels.copy : labels.reduced; + return ( + // On a phone only the warning: two full badges do not fit beside the labels. + + {b.height ? `${b.height}p · ` : ''}{what} + + ); + }; + + const showOnboardLabel = split > 0; + const showGsLabel = split < 100; + + return ( +
+
{ + if (phase !== 'ready') return; + dragging.current = true; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + place(e.clientX); + }} + onPointerMove={(e) => { if (dragging.current) place(e.clientX); }} + onPointerUp={() => { dragging.current = false; }} + onPointerCancel={() => { dragging.current = false; }} + > +
+ +
+ + + + { if (lead.current) lead.current.currentTime = Number((e.target as HTMLInputElement).value); }} + /> + {clock(time - start)} / {clock(end - start)} +
+ +
+ + + {labels.openRnd} +
+
+ ); +} diff --git a/frontend/apps/site/src/components/pages/LowLatency.astro b/frontend/apps/site/src/components/pages/LowLatency.astro index 73ddbaf9..bd65121e 100644 --- a/frontend/apps/site/src/components/pages/LowLatency.astro +++ b/frontend/apps/site/src/components/pages/LowLatency.astro @@ -24,12 +24,22 @@ * download step sends FPV chips here (`#support`) rather than to /business. * Both links name their events, which the monthly memo counts as the FPV * segment's business clicks. + * + * The flight sits under the latency chart, as the evidence for it rather than + * the page's opening: the page sells the stack first, and the reader who wants + * to see it gets one real flight recorded on the drone and on the ground + * station, cut onto one timeline by tools/flight-ab and compared frame by + * frame (../flight/FlightCompare.tsx). Nothing loads until play. */ import Icon from '../Icon.astro'; import PageHeader from '../PageHeader.astro'; import CtaBand from '../CtaBand.astro'; import { pathFor, useTranslations, type Locale } from '../../lib/i18n'; import { LATENCY_PATHS, LATENCY_SCALE_MAX, latencyBarStyle } from '../../data/latency'; +import { getImage } from 'astro:assets'; +import FlightCompare, { type FlightLabels } from '../flight/FlightCompare'; +import { FLIGHT, rndPlayerUrl } from '../../data/flight'; +import flightPoster from '../../assets/pages/flight-mabur-2026-10.jpg'; const { locale } = Astro.props as { locale: Locale }; const t = useTranslations(locale); @@ -44,6 +54,26 @@ const CHAIN: [string, string][] = [ const GUIDE = 'https://github.com/OpenIPC/wiki/blob/master/en/fpv.md'; const FPV_CHAT = 'https://t.me/+BMyMoolVOpkzNWUy'; const SUPPORT_HOURS = 'https://opencollective.com/openipc/contribute/technical-support-56715/checkout'; + +const poster = await getImage({ src: flightPoster, width: 1920, format: 'webp', quality: 80 }); +const flightLabels: FlightLabels = { + play: k('flight_play'), + pause: k('flight_pause'), + loading: k('flight_loading'), + error: k('flight_error'), + failed: k('flight_failed'), + onboard: k('flight_onboard'), + gs: k('flight_gs'), + original: k('flight_original'), + reduced: k('flight_reduced'), + copy: k('flight_copy'), + fullscreen: k('flight_fullscreen'), + frameBack: k('flight_frame_back'), + frameForward: k('flight_frame_forward'), + handle: k('flight_handle'), + seek: k('flight_seek'), + openRnd: k('flight_open_rnd'), +}; --- @@ -141,6 +171,25 @@ const SUPPORT_HOURS = 'https://opencollective.com/openipc/contribute/technical-s +
+
+ +
+
+

{k('flight_title')}

+

{k('flight_text')}

+

+

+
+

{k('hardware_title')}

diff --git a/frontend/apps/site/src/data/flight-mabur-2026-10.json b/frontend/apps/site/src/data/flight-mabur-2026-10.json new file mode 100644 index 00000000..4c74ca14 --- /dev/null +++ b/frontend/apps/site/src/data/flight-mabur-2026-10.json @@ -0,0 +1,28 @@ +{ + "offset_s": 1.0043, + "offset_spread_ms": 26.7, + "match_score_median": 0.9992, + "onboard": { + "cut_s": 0.0 + }, + "gs": { + "cut_s": 1.708867 + }, + "start_s": 0.7046, + "end_s": 360.7273, + "duration_s": 360.023, + "gs_frames_shown": 21060, + "gs_frames_expected": 21701, + "gs_frames_missing_by_run": { + "1": 524, + "2": 39, + "3": 7, + "18": 1 + }, + "gs_longest_hold_ms": 315, + "gs_longest_hold_at_s": 32.7, + "gs_holds_over_50ms": 8, + "onboard_longest_gap_ms": 250, + "onboard_longest_gap_at_s": 127.56, + "poster_at_s": 120.71 +} \ No newline at end of file diff --git a/frontend/apps/site/src/data/flight.ts b/frontend/apps/site/src/data/flight.ts new file mode 100644 index 00000000..aa957faf --- /dev/null +++ b/frontend/apps/site/src/data/flight.ts @@ -0,0 +1,35 @@ +/** + * The recorded flight on /low-latency. + * + * `flight-mabur-2026-10.json` is the stats.json tools/flight-ab wrote next to + * the published streams, copied here so the page's window onto the timeline is + * versioned with the page and cannot disagree with the streams it describes: + * the same run produced both. A new cut is a new directory under /media/ and + * a new copy of its stats. + */ +import stats from './flight-mabur-2026-10.json'; + +/** + * The drone sits still on the ground for the first seven seconds of the + * common window; the page starts the flight at the eighth. Trimmed here, in + * the player's window, not in the streams, which keep the whole recording. + */ +const LEAD_IN_S = 8; + +export const FLIGHT = { + /** Where tools/flight-ab's output was uploaded, under /srv/www/shared/media/. */ + base: '/media/flights/mabur-2026-10/v2/', + start: stats.start_s + LEAD_IN_S, + end: stats.end_s, +}; + +/** + * The R&D Player with both sides of the flight loaded into its compare. + * + * `/current/`, not the root: the root redirects there and drops the query + * string, which leaves the player with an empty address box. + */ +export function rndPlayerUrl(origin: string): string { + const mpd = (side: string) => encodeURIComponent(`${origin}${FLIGHT.base}${side}.mpd`); + return `https://openipc.github.io/rnd-player/current/?v=${mpd('onboard')}&compare=${mpd('gs')}&t=${FLIGHT.start.toFixed(2)}`; +} diff --git a/frontend/apps/site/src/i18n/en.json b/frontend/apps/site/src/i18n/en.json index bd90991b..68489c87 100644 --- a/frontend/apps/site/src/i18n/en.json +++ b/frontend/apps/site/src/i18n/en.json @@ -531,6 +531,25 @@ "credits_title": "Standing on open shoulders", "cta_chat": "Join the FPV chat", "cta_guide": "Read the build guide", + "flight_copy": "H.264 copy", + "flight_credit_html": "Flight by gilankpam on mabur · SSC338Q + RTL8812EU", + "flight_error": "This browser cannot play the stream", + "flight_failed": "The flight did not load. Try again", + "flight_frame_back": "Previous frame", + "flight_frame_forward": "Next frame", + "flight_fullscreen": "Full screen", + "flight_gs": "On the pilot's screen", + "flight_handle": "Divider between the two recordings", + "flight_loading": "Loading…", + "flight_onboard": "On the drone", + "flight_open_rnd": "Open in R&D Player", + "flight_original": "Original quality", + "flight_pause": "Pause", + "flight_play": "Play", + "flight_reduced": "reduced", + "flight_seek": "Position in the flight", + "flight_text": "The drone's own recording and what reached the pilot's screen over the link, frame by frame. Drag the divider to compare; the link's latency is in the telemetry along the bottom.", + "flight_title": "See it on a real flight", "fpv_text": "A fully open digital FPV system: no locked ecosystem, no paired hardware, no firmware ceiling. Build the air unit from a supported camera, pick your ground station, and tune every stage of the pipeline.", "fpv_title": "FPV: from lens to goggles", "hardware_text_html": "Air units are built from supported cameras and AIO boards — start from the supported hardware list. RunCam and other vendors ship OpenIPC-based FPV hardware out of the box.", diff --git a/frontend/apps/site/src/i18n/ru.json b/frontend/apps/site/src/i18n/ru.json index f22f2129..178285f1 100644 --- a/frontend/apps/site/src/i18n/ru.json +++ b/frontend/apps/site/src/i18n/ru.json @@ -537,6 +537,25 @@ "credits_title": "Стоим на открытых плечах", "cta_chat": "FPV-чат", "cta_guide": "Гайд по сборке", + "flight_copy": "копия H.264", + "flight_credit_html": "Полёт — gilankpam на mabur · SSC338Q + RTL8812EU", + "flight_error": "Этот браузер не может воспроизвести поток", + "flight_failed": "Полёт не загрузился. Попробуйте ещё раз", + "flight_frame_back": "Предыдущий кадр", + "flight_frame_forward": "Следующий кадр", + "flight_fullscreen": "Во весь экран", + "flight_gs": "На экране пилота", + "flight_handle": "Граница между двумя записями", + "flight_loading": "Загрузка…", + "flight_onboard": "На борту дрона", + "flight_open_rnd": "Открыть в R&D Player", + "flight_original": "Исходное качество", + "flight_pause": "Пауза", + "flight_play": "Смотреть", + "flight_reduced": "снижено", + "flight_seek": "Позиция в полёте", + "flight_text": "Запись с борта дрона и то, что дошло по линку до экрана пилота, кадр в кадр. Двигайте границу, чтобы сравнить; задержка линка — в телеметрии внизу.", + "flight_title": "Посмотрите на настоящем полёте", "fpv_text": "Полностью открытая цифровая FPV-система: никакой закрытой экосистемы, никакого спаренного железа, никакого потолка прошивки. Соберите борт из поддерживаемой камеры, выберите наземную станцию и настройте каждый этап конвейера.", "fpv_title": "FPV: от объектива до очков", "hardware_text_html": "Борт собирается из поддерживаемых камер и AIO-плат — начните со списка поддерживаемого оборудования. RunCam и другие вендоры выпускают FPV-железо на OpenIPC из коробки.", diff --git a/frontend/apps/site/src/i18n/zh.json b/frontend/apps/site/src/i18n/zh.json index 05c96a5e..c9a84819 100644 --- a/frontend/apps/site/src/i18n/zh.json +++ b/frontend/apps/site/src/i18n/zh.json @@ -531,6 +531,25 @@ "credits_title": "站在开放的肩膀上", "cta_chat": "加入 FPV 聊天群", "cta_guide": "阅读搭建指南", + "flight_copy": "H.264 副本", + "flight_credit_html": "飞行:gilankpam,使用 mabur · SSC338Q + RTL8812EU", + "flight_error": "此浏览器无法播放该视频流", + "flight_failed": "飞行视频未能加载,请重试", + "flight_frame_back": "上一帧", + "flight_frame_forward": "下一帧", + "flight_fullscreen": "全屏", + "flight_gs": "飞手屏幕上", + "flight_handle": "两段录像之间的分隔线", + "flight_loading": "加载中…", + "flight_onboard": "无人机机载", + "flight_open_rnd": "在 R&D Player 中打开", + "flight_original": "原始画质", + "flight_pause": "暂停", + "flight_play": "播放", + "flight_reduced": "已降低", + "flight_seek": "飞行中的位置", + "flight_text": "无人机机载录像与经链路到达飞手屏幕的画面,逐帧对照。拖动分隔线进行比较;链路延迟显示在底部的遥测中。", + "flight_title": "在真实飞行中看看", "fpv_text": "一套完全开放的数字 FPV 系统:没有封闭生态,没有配对绑定的硬件,也没有固件上限。用受支持的摄像机搭建天空端,挑选你的地面站,并可调节管线的每一个环节。", "fpv_title": "FPV:从镜头到眼镜", "hardware_text_html": "天空端由受支持的摄像机和 AIO 板搭建——请从支持硬件列表开始。RunCam 等厂商出厂即提供基于 OpenIPC 的 FPV 硬件。", diff --git a/frontend/apps/site/src/lib/flight-sync.test.ts b/frontend/apps/site/src/lib/flight-sync.test.ts new file mode 100644 index 00000000..51ee9d26 --- /dev/null +++ b/frontend/apps/site/src/lib/flight-sync.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { clock, NUDGE, originalOf, rungKind, syncAction, type Rung } from './flight-sync'; + +describe('syncAction', () => { + it('leaves two sides on the same frame alone', () => { + expect(syncAction(10, 10)).toEqual({ kind: 'rate', rate: 1 }); + expect(syncAction(10, 10.003)).toEqual({ kind: 'rate', rate: 1 }); + }); + + it('does not let the follower settle a whole or half a frame behind', () => { + expect(syncAction(10, 10 - 1 / 60)).toEqual({ kind: 'rate', rate: 1 + NUDGE }); + expect(syncAction(10, 10 - 1 / 120)).toEqual({ kind: 'rate', rate: 1 + NUDGE }); + }); + + it('slows a follower that runs ahead and hurries one that lags', () => { + expect(syncAction(10, 10.05)).toEqual({ kind: 'rate', rate: 1 - NUDGE }); + expect(syncAction(10, 9.95)).toEqual({ kind: 'rate', rate: 1 + NUDGE }); + }); + + it('seeks rather than chasing a large drift', () => { + expect(syncAction(10, 10.5)).toEqual({ kind: 'seek', to: 10 }); + expect(syncAction(42, 3)).toEqual({ kind: 'seek', to: 42 }); + }); +}); + +const onboard: Rung[] = [ + { height: 1080, bandwidth: 19_100_000, videoCodec: 'avc1.64002a' }, + { height: 1080, bandwidth: 8_000_000, videoCodec: 'avc1.64002a' }, + { height: 720, bandwidth: 4_000_000, videoCodec: 'avc1.640020' }, +]; +const gs: Rung[] = [ + { height: 1080, bandwidth: 7_800_000, videoCodec: 'hvc1.1.6.L120.90' }, + { height: 720, bandwidth: 3_000_000, videoCodec: 'hvc1.1.6.L93.90' }, +]; +const gsH264: Rung[] = [ + { height: 1080, bandwidth: 10_000_000, videoCodec: 'avc1.64002a' }, + { height: 720, bandwidth: 4_000_000, videoCodec: 'avc1.640020' }, +]; + +describe('originalOf', () => { + it("is the drone's highest bitrate", () => { + expect(originalOf('onboard', onboard)).toBe(onboard[0]); + }); + + it("is the ground station's HEVC recording, not its larger H.264 copy", () => { + expect(originalOf('gs', [...gsH264, ...gs])).toBe(gs[0]); + }); + + it('falls back to the copy where HEVC cannot be played', () => { + expect(originalOf('gs', gsH264)).toBe(gsH264[0]); + }); +}); + +describe('rungKind', () => { + it('names the recordings themselves', () => { + expect(rungKind('onboard', onboard[0], onboard)).toBe('original'); + expect(rungKind('gs', gs[0], gs)).toBe('original'); + }); + + it('says when the ground station is shown through its H.264 copy', () => { + expect(rungKind('gs', gsH264[0], gsH264)).toBe('copy'); + }); + + it('says when either side is below its recording', () => { + expect(rungKind('onboard', onboard[1], onboard)).toBe('reduced'); + expect(rungKind('gs', gs[1], gs)).toBe('reduced'); + expect(rungKind('gs', gsH264[1], gsH264)).toBe('reduced'); + }); +}); + +describe('clock', () => { + it('reads as minutes and seconds', () => { + expect(clock(0)).toBe('0:00'); + expect(clock(65.9)).toBe('1:05'); + expect(clock(360)).toBe('6:00'); + }); +}); diff --git a/frontend/apps/site/src/lib/flight-sync.ts b/frontend/apps/site/src/lib/flight-sync.ts new file mode 100644 index 00000000..c2dff56d --- /dev/null +++ b/frontend/apps/site/src/lib/flight-sync.ts @@ -0,0 +1,87 @@ +/** + * Keeping the two sides of the flight A/B on the same frame. + * + * Two