diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:26:45 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:26:45 +0200 |
| commit | 1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (patch) | |
| tree | 00cc3132ee05894350db12e8e5cb94f81b1d4876 /packages | |
| parent | 1fd7bbcd038cddf40540bae7083b9adf4c728912 (diff) | |
| download | meshbay-1521cc1ac0cca7ee8786056f84fa399d5cc268f9.tar.gz | |
feat(photos): show a phone's clips in their albums
Videos in a photo folder are thumbnailed by the node, counted and marked in
the album, and played in the group's video player from the lightbox; the
slideshow passes them by.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
16 files changed, 447 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index ca15345..38cadef 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -380,6 +380,11 @@ export default { one: '{n} Foto', other: '{n} Fotos', }, + 'photo.n_videos': { + one: "{n} Video", + other: "{n} Videos", + }, + 'photo.play_video': "Video abspielen", 'photo.back': 'Zurück', 'photo.prev': 'Vorherige (←)', 'photo.next': 'Nächste (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 8619bdb..9dff21d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -378,6 +378,11 @@ export default { one: '{n} photo', other: '{n} photos', }, + 'photo.n_videos': { + one: "{n} video", + other: "{n} videos", + }, + 'photo.play_video': "Play the video", 'photo.back': 'Back', 'photo.prev': 'Previous (←)', 'photo.next': 'Next (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 19db8f7..6f4252a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -378,6 +378,11 @@ export default { one: '{n} foto', other: '{n} fotos', }, + 'photo.n_videos': { + one: "{n} vídeo", + other: "{n} vídeos", + }, + 'photo.play_video': "Reproducir el vídeo", 'photo.back': 'Atrás', 'photo.prev': 'Anterior (←)', 'photo.next': 'Siguiente (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 3f3cd42..65956ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -379,6 +379,11 @@ export default { one: '{n} photo', other: '{n} photos', }, + 'photo.n_videos': { + one: "{n} vidéo", + other: "{n} vidéos", + }, + 'photo.play_video': "Lire la vidéo", 'photo.back': 'Retour', 'photo.prev': 'Précédent (←)', 'photo.next': 'Suivant (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index f238495..a37d65c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -379,6 +379,11 @@ export default { one: '{n} foto', other: '{n} foto', }, + 'photo.n_videos': { + one: "{n} video", + other: "{n} video", + }, + 'photo.play_video': "Riproduci il video", 'photo.back': 'Indietro', 'photo.prev': 'Precedente (←)', 'photo.next': 'Successiva (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 6a59d1d..4910630 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -376,6 +376,11 @@ export default { one: '{n}枚', other: '{n}枚', }, + 'photo.n_videos': { + one: "動画 {n} 本", + other: "動画 {n} 本", + }, + 'photo.play_video': "動画を再生", 'photo.back': '戻る', 'photo.prev': '前へ(←)', 'photo.next': '次へ(→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 4b68975..603f69d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -380,6 +380,11 @@ export default { one: '{n} foto', other: "{n} foto's", }, + 'photo.n_videos': { + one: "{n} video", + other: "{n} video's", + }, + 'photo.play_video': "Video afspelen", 'photo.back': 'Terug', 'photo.prev': 'Vorige (←)', 'photo.next': 'Volgende (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 8b1b5db..52b6a25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -391,6 +391,13 @@ export default { many: '{n} zdjęć', other: '{n} zdjęcia', }, + 'photo.n_videos': { + one: "{n} film", + few: "{n} filmy", + many: "{n} filmów", + other: "{n} filmu", + }, + 'photo.play_video': "Odtwórz film", 'photo.back': 'Wstecz', 'photo.prev': 'Poprzednie (←)', 'photo.next': 'Następne (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 9fc634b..0371dfe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -380,6 +380,11 @@ export default { one: '{n} foto', other: '{n} fotos', }, + 'photo.n_videos': { + one: "{n} vídeo", + other: "{n} vídeos", + }, + 'photo.play_video': "Reproduzir o vídeo", 'photo.back': 'Voltar', 'photo.prev': 'Anterior (←)', 'photo.next': 'Próxima (→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 5859934..7dd09bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -373,6 +373,11 @@ export default { one: '{n} 张照片', other: '{n} 张照片', }, + 'photo.n_videos': { + one: "{n} 个视频", + other: "{n} 个视频", + }, + 'photo.play_video': "播放视频", 'photo.back': '返回', 'photo.prev': '上一张(←)', 'photo.next': '下一张(→)', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index f18acd2..df207ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -36,7 +36,9 @@ function underAnyPhotoRoot(entry, photoRoots) { function groupPhotoAlbums(entries, photoRoots) { const byDir = new Map(); for (const e of entries) { - if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue; + // A video in a photo folder is a phone's clip, backed up beside its + // photos (§9.12): it belongs to the album as it did on the phone. + if ((e.type !== 'image' && e.type !== 'video') || !underAnyPhotoRoot(e, photoRoots)) continue; // e.path is already the file's containing directory, not the full // path+filename (files-app.js's own convention, also relied on by // zipstream.js's entriesUnder) — it must not be stripped a second time, @@ -85,6 +87,25 @@ function albumYearLabel(photos) { // ── landing grid: one card per album (directory containing images) ───────── +/** A clip's length as a phone shows it: `0:42`, `1:02:05`. */ +function clipLength(seconds) { + if (!seconds) return ''; + const total = Math.round(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const sec = String(total % 60).padStart(2, '0'); + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`; +} + +/** "12 photos", "12 photos · 3 videos", or "3 videos". */ +function albumCount(items) { + const videos = items.filter((e) => e.type === 'video').length; + const photos = items.length - videos; + if (!videos) return t('photo.n_photos', { n: photos }); + const clips = t('photo.n_videos', { n: videos }); + return photos ? `${t('photo.n_photos', { n: photos })} · ${clips}` : clips; +} + function AlbumCard({ album, transportRef, gekRef, onOpen }) { const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0]; const tRef = cover._tRef || transportRef; @@ -98,7 +119,7 @@ function AlbumCard({ album, transportRef, gekRef, onOpen }) { <div class="photo-album-info"> <div class="photo-album-title">${albumTitle(album.dir)}</div> <div class="photo-album-sub"> - ${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })} + ${year}${year ? ' · ' : ''}${albumCount(album.photos)} </div> <${SourceTag} entries=${album.photos} cls="photo-card-group" /> </div> @@ -128,7 +149,9 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) { <div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}> <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef} - emptyIcon="image" /> + emptyIcon=${entry.type === 'video' ? 'video' : 'image'} /> + ${entry.type === 'video' && html` + <span class="photo-tile-video"><${Icon} name="play" />${clipLength(entry.duration)}</span>`} </div> `; } @@ -156,8 +179,13 @@ const ZOOM_MAX = 400; // a slow node must not skip a photo nobody saw. const SLIDESHOW_MS = 5000; -function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete }) { +function Lightbox({ + photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete, onPlay, +}) { const entry = photos[index]; + // A video is shown by its thumbnail and played in the group's video + // player: never downloaded whole into this page. + const isVideo = entry.type === 'video'; const tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null); @@ -174,8 +202,9 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor const castDevice = useCastDevice(); useEffect(() => { + if (isVideo) { setBlobUrl(null); setLoading(false); return undefined; } const cached = _fullBlobCache.get(entry.id); - if (cached) { setBlobUrl(cached); setLoading(false); return; } + if (cached) { setBlobUrl(cached); setLoading(false); return undefined; } setBlobUrl(null); setLoading(true); let cancelled = false; @@ -211,6 +240,14 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor if (castDevice && blobUrl && blob) showPhoto(blob); }, [castDevice, blobUrl, entry.id]); + // A slideshow is of photos: it passes a video by rather than wait on a + // clip nobody started. + useEffect(() => { + if (!slideshow || !isVideo) return; + if (index < photos.length - 1) onNav(1); + else setSlideshow(false); + }, [slideshow, isVideo, index, photos.length, onNav]); + // Next photo once this one has been on screen for SLIDESHOW_MS; the last // photo ends the slideshow rather than wrapping round. useEffect(() => { @@ -224,6 +261,8 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor useEffect(() => { const onKey = (e) => { + // The video player is over this, and its keys are its own. + if (document.querySelector('.video-player-overlay')) return; if (e.key === 'Escape') onClose(); else if (e.key === 'ArrowLeft' && index > 0) onNav(-1); else if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(1); @@ -304,6 +343,15 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor <${Icon} name="chevron" cls="photo-nav-icon photo-nav-prev-icon" /></button> <div ref=${slotRef} class="photo-lightbox-image-slot ${zoomed ? 'zoomed' : ''}"> ${loading && html`<span class="spinner"></span>`} + ${isVideo && html` + <div class="photo-lightbox-video"> + <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} + cls="photo-lightbox-poster" transportRef=${tRef} gekRef=${gRef} emptyIcon="video" /> + ${onPlay && html` + <button class="photo-lightbox-play" onClick=${() => onPlay(entry)} + title=${t('photo.play_video')} aria-label=${t('photo.play_video')}> + <${Icon} name="play" /></button>`} + </div>`} ${blobUrl && html`<img class="photo-lightbox-image ${zoomed ? 'zoomed' : ''}" style=${imgStyle} src=${blobUrl} alt=${entry.name} onLoad=${handleImgLoad} />`} </div> @@ -313,6 +361,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor </div> <div class="photo-lightbox-info"> ${entry.width && entry.height && html`<span>${entry.width}×${entry.height}</span>`} + ${isVideo && entry.duration && html`<span>${clipLength(entry.duration)}</span>`} <span>${formatSize(entry.size)}</span> ${entry.taken_at && html`<span>${formatTakenAt(entry.taken_at)}</span>`} ${entry.camera && html`<span>${entry.camera}</span>`} @@ -323,7 +372,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor } function AlbumView({ - album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete, + album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete, onPlay, }) { const [lightboxIndex, setLightboxIndex] = useState(null); // Right-click a photo for its link, or to delete it. A tile has no dots to @@ -393,7 +442,7 @@ function AlbumView({ <${Lightbox} photos=${album.photos} index=${lightboxIndex} transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor} - onDelete=${onDelete} /> + onDelete=${onDelete} onPlay=${onPlay} /> `} ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} `; @@ -405,7 +454,7 @@ function PhotosApp({ groupId, transportRef, gekRef, status, entries, availableEntries, photoDirectories, setError, hideFilter, readOnly, linkFor, - isNodeAdmin, userId, applyIndex, + isNodeAdmin, userId, applyIndex, onPreview, }) { const [openDir, setOpenDir] = useState(null); const [filter, setFilter] = useState(''); @@ -479,7 +528,7 @@ function PhotosApp({ <${AlbumView} album=${openAlbum} entries=${entries} transportRef=${transportRef} gekRef=${gekRef} setError=${setError} onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} - onDelete=${deleteFor} /> + onDelete=${deleteFor} onPlay=${readOnly ? null : onPreview} /> `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index ea017fb..1b8dd8e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -5104,6 +5104,29 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border: 1px solid var(--border); } .photo-tile:hover { border-color: var(--accent); } +.photo-tile { position: relative; } +/* A video among the photos: a play mark and its length over the thumbnail. */ +.photo-tile-video { + position: absolute; left: 6px; bottom: 6px; + display: inline-flex; align-items: center; gap: 3px; + padding: 2px 6px; border-radius: 10px; + background: rgba(0, 0, 0, 0.6); color: #fff; + font-size: 0.75em; font-variant-numeric: tabular-nums; + pointer-events: none; +} +.photo-tile-video .icon { width: 0.9em; height: 0.9em; } +.photo-lightbox-video { + position: relative; display: flex; align-items: center; justify-content: center; + max-width: 100%; max-height: 100%; +} +.photo-lightbox-poster { max-width: 100%; max-height: 70vh; object-fit: contain; display: block; } +.photo-lightbox-play { + position: absolute; width: 64px; height: 64px; border-radius: 50%; + border: 0; background: rgba(0, 0, 0, 0.6); color: #fff; cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.photo-lightbox-play:hover { background: var(--accent); } +.photo-lightbox-play .icon { width: 28px; height: 28px; } .photo-tile-thumb { width: 100%; aspect-ratio: 1 / 1; diff --git a/packages/meshbay-hub/tests/harness/photo_video_probe.py b/packages/meshbay-hub/tests/harness/photo_video_probe.py new file mode 100755 index 0000000..02ebb90 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/photo_video_probe.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Videos among the photos of an album, in a real browser. + +Mounts the shipped `PhotosApp` on a made-up index of two photos and a phone's +clip in one folder, with an `onPreview` that records what it was asked to +play. Reads the album card, the clip's tile, the lightbox on the clip, and a +slideshow started on it. + + photo_video_probe.py + +Prints JSON: one entry per case. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8777 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<div id="photos"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale, setLocale } from '/i18n.js'; +import { PhotosApp } from '/photos-app.js'; + +const LOGS = []; +addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e))); +addEventListener('unhandledrejection', + (e) => LOGS.push('rejection: ' + (e.reason && e.reason.message || e.reason))); +const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +const ENTRIES = [ + { id: 'p1', name: 'a.jpg', path: 'Pics/2026-10', size: 4, type: 'image', added_at: 1 }, + { id: 'v1', name: 'b.mp4', path: 'Pics/2026-10', size: 9e8, type: 'video', added_at: 2, + duration: 42 }, + { id: 'p2', name: 'c.jpg', path: 'Pics/2026-10', size: 4, type: 'image', added_at: 3 }, +]; +const PLAYED = []; +const ref = { current: null }; + +(async () => { + const cases = []; + try { + setLocale('en'); + await initLocale(); + render(html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${ENTRIES} photoDirectories=${['Pics']} + setError=${() => {}} linkFor=${() => null} + onPreview=${(e) => PLAYED.push(e.id)} />`, document.getElementById('photos')); + await frame(); + const card = document.querySelector('.photo-album-card'); + cases.push({ case: 'card', text: card ? card.textContent : null }); + card.click(); await frame(); + + const tiles = [...document.querySelectorAll('.photo-tile')]; + cases.push({ case: 'tiles', count: tiles.length, + badges: tiles.map((t) => (t.querySelector('.photo-tile-video') || {}).textContent || null) }); + + tiles[1].click(); await frame(); + const title = () => (document.querySelector('.photo-lightbox .video-title') || {}).textContent; + const play = document.querySelector('.photo-lightbox-play'); + const onClip = title(); + if (play) { play.click(); await frame(); } + cases.push({ case: 'lightbox', title: onClip, play: Boolean(play), played: PLAYED.slice(), + info: (document.querySelector('.photo-lightbox-info') || {}).textContent }); + + const show = [...document.querySelectorAll('.photo-lightbox .video-close')] + .find((b) => (b.title || '').toLowerCase().includes('slideshow')); + if (show) show.click(); + await wait(300); await frame(); + cases.push({ case: 'slideshow', button: Boolean(show), now: title() }); + + parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*'); + } catch (err) { + parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*'); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><div id="frames"></div><script> +addEventListener('message', (e) => { + fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }); +}); +const f = document.createElement('iframe'); +f.src = '/case'; +f.style.cssText = 'width:1100px;height:800px;border:0;display:block'; +document.getElementById('frames').appendChild(f); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_photo_videos.py b/packages/meshbay-hub/tests/test_photo_videos.py new file mode 100644 index 0000000..2585f47 --- /dev/null +++ b/packages/meshbay-hub/tests/test_photo_videos.py @@ -0,0 +1,51 @@ +""" +A phone's clips among the photos of an album (harness/photo_video_probe.py). + +Backed up beside the photos (docs/MESHBAY_DESIGN.md §9.12), a clip belongs to +its album: counted on the card, marked and timed on its tile, and played in +the group's video player from the lightbox, never downloaded whole into it. A +slideshow is of photos and passes a clip by. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "photo_video_probe.py" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, out["error"] + assert out["logs"] == [] + return {c["case"]: c for c in out["cases"]} + + +def test_the_album_counts_photos_and_videos_apart(cases): + assert "2 photos · 1 video" in cases["card"]["text"] + + +def test_a_clip_is_in_the_album_marked_with_its_length(cases): + assert cases["tiles"]["count"] == 3 + assert cases["tiles"]["badges"] == [None, "0:42", None] + + +def test_the_lightbox_hands_a_clip_to_the_video_player(cases): + c = cases["lightbox"] + assert c["title"] == "b.mp4" and c["play"] + assert c["played"] == ["v1"] + assert "0:42" in c["info"] + + +def test_a_slideshow_passes_a_clip_by(cases): + assert cases["slideshow"]["button"] and cases["slideshow"]["now"] == "c.jpg" diff --git a/packages/meshbay-node/src/meshbay_node/enrichment.py b/packages/meshbay-node/src/meshbay_node/enrichment.py index 66b69b9..19aafba 100644 --- a/packages/meshbay-node/src/meshbay_node/enrichment.py +++ b/packages/meshbay-node/src/meshbay_node/enrichment.py @@ -217,6 +217,11 @@ class EnrichmentMixin: actually photo albums would burn CPU on files never meant to be in the Photos app at all. `_enriched_attempted` is shared with the video/audio paths — content-addressed ids never collide across them. + + A video in a photo folder (a phone's clips, backed up beside its + photos, §9.12) is probed and thumbnailed by the video enricher, which + is all an album tile and the lightbox need. Nothing there looks it up + anywhere: matching is the Videos app's, asked by its own pages. """ if not self._photo_enricher or not self._roster: return @@ -224,7 +229,9 @@ class EnrichmentMixin: if not photo_dirs: return for entry in entries: - if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted: + if entry.type not in ("image", "video") or (indexer.group_id, entry.id) in self._enriched_attempted: + continue + if entry.type == "video" and not self._enricher: continue if not _under_any_directory(entry.path, photo_dirs): continue @@ -236,7 +243,10 @@ class EnrichmentMixin: async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: await self._on_enriched(_indexer, file_id, fields) - self._photo_enricher.spawn(entry, file_path, on_done) + if entry.type == "video": + self._enricher.spawn(entry, file_path, on_done) + else: + self._photo_enricher.spawn(entry, file_path, on_done) async def _enrich_photo_roots_now(self, group_id: str) -> None: """ @@ -269,7 +279,7 @@ class EnrichmentMixin: unchanged file is redundant work, not a correctness issue. """ for entry in updates: - if entry.type != "image": + if entry.type not in ("image", "video"): continue old = previous.get_entry(entry.id) if old is None or (old.name == entry.name and old.path == entry.path): diff --git a/packages/meshbay-node/tests/test_photo_folder_videos.py b/packages/meshbay-node/tests/test_photo_folder_videos.py new file mode 100644 index 0000000..79953a1 --- /dev/null +++ b/packages/meshbay-node/tests/test_photo_folder_videos.py @@ -0,0 +1,80 @@ +""" +A video in a photo folder (a phone's clips, backed up beside its photos, +docs/MESHBAY_DESIGN.md §9.12) is probed and thumbnailed by the video enricher, +so the Photos app can show it in its album; one outside every photo folder +and every video folder is left alone, as before. +""" + +import asyncio + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.crypto import generate_gek +from meshbay_node.config import Config, GroupConfig, HubConfig, KeystoreConfig, NodeConfig +from meshbay_node.daemon import NodeDaemon +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.indexer.enrich import Enricher +from meshbay_node.indexer.enrich_photo import PhotoEnricher +from meshbay_node.media_cache import MediaCache +from meshbay_node.roster import Roster + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _free_port() -> int: + import socket + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _make_daemon(tmp_path, shared, group_id): + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), + groups=[GroupConfig( + id=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29015, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 + daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await daemon._media_cache.open() + daemon._enricher = Enricher(daemon._media_cache) + daemon._photo_enricher = PhotoEnricher(daemon._media_cache) + daemon._roster = Roster(db_path=tmp_path / "roster.db") + await daemon._roster.open() + return daemon + + +async def test_a_video_in_a_photo_folder_goes_to_the_video_enricher(tmp_path): + group_id = "b" * 32 + shared = tmp_path / "shared" + (shared / "Photos" / "bob-photos").mkdir(parents=True) + (shared / "Photos" / "bob-photos" / "clip.mp4").write_bytes(b"a clip") + (shared / "Photos" / "bob-photos" / "shot.jpg").write_bytes(b"a photo") + (shared / "elsewhere.mp4").write_bytes(b"not in any app folder") + + daemon = await _make_daemon(tmp_path, shared, group_id) + video_spawned, photo_spawned = [], [] + daemon._enricher.spawn = lambda entry, path, on_done: video_spawned.append(entry.name) + daemon._photo_enricher.spawn = lambda entry, path, on_done: photo_spawned.append(entry.name) + try: + await daemon._roster.set_app_directories(group_id, "photo", ["shared/Photos"], set_by="op") + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + assert video_spawned == ["clip.mp4"] + assert photo_spawned == ["shot.jpg"] + finally: + await daemon._media_cache.close() + await daemon._roster.close() |