feat: video support + Memories music choices

Videos:
- Worker ingests .mp4 .mov .m4v .avi .mkv .webm .3gp via ffmpeg/ffprobe
  (frame extract → sharp resize, same thumbnail pipeline as photos)
- DB: media_type + duration columns with migration
- GET /api/videos returns all indexed videos
- GET /api/photos/:id/video streams original file with Range support
  for browser seek/scrub
- VideosView: 16:9 thumbnail grid, play-button overlay, duration badge
- Full-screen player: native <video controls>, ESC to close
- Library tab now excludes videos (dedicated Videos tab)
- Dockerfile: adds ffmpeg to runtime stage

Memories music choices:
- Three styles: ⚡ Upbeat (G-major, default), 🌙 Dreamy (A-minor),
  🎬 Cinematic (D-minor, dual-oscillator richness)
- Shared Web Audio engine (reverb, LPF, pad + melody)
- Style picker in header; active style icon shown in slideshow bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 11:01:30 -04:00
1 parent 7ed18f616f
commit 6b4bd88943
8 files changed
+608 -193

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ RUN npm run build
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM node:22-alpine
RUN apk add --no-cache python3 py3-pip make g++ && \
RUN apk add --no-cache python3 py3-pip make g++ ffmpeg && \
pip3 install icloudpd --break-system-packages
WORKDIR /app
+9
View File
@@ -10,6 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
import { lazy, Suspense } from 'react';
const MapView = lazy(() => import('./components/MapView.jsx'));
const MemoriesView = lazy(() => import('./components/MemoriesView.jsx'));
const VideosView = lazy(() => import('./components/VideosView.jsx'));
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
const SIDEBAR_W = 210;
@@ -126,6 +127,7 @@ export default function App() {
</span>
)}
</Tab>
<Tab active={view === 'videos'} onClick={() => setView('videos')}>Videos</Tab>
<Tab active={view === 'memories'} onClick={() => setView('memories')}>Memories</Tab>
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
@@ -216,6 +218,13 @@ export default function App() {
</>
)}
{/* Videos */}
{view === 'videos' && (
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
<VideosView />
</Suspense>
)}
{/* Memories */}
{view === 'memories' && (
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
+5
View File
@@ -109,6 +109,11 @@ export function triggerSync(source) {
return api(`/api/sync/${source}`, { method: 'POST' });
}
// Videos
export function fetchVideos() {
return api('/api/videos');
}
// Memories
export function fetchMemories() {
const d = new Date();
+222 -180
View File
@@ -1,99 +1,169 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchMemories } from '../api/photos.js';
// ── Music ──────────────────────────────────────────────────────────────────────
// ── Music engine ───────────────────────────────────────────────────────────────
function createAmbientMusic(ctx) {
const now = ctx.currentTime;
function makeReverb(ctx, seconds) {
const conv = ctx.createConvolver();
const sr = ctx.sampleRate;
const buf = ctx.createBuffer(2, sr * seconds, sr);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
for (let i = 0; i < d.length; i++)
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6);
}
conv.buffer = buf;
return conv;
}
function makeChain(ctx, { reverbSec, lpFreq, wet, dry }) {
const master = ctx.createGain();
master.connect(ctx.destination);
const reverb = makeReverb(ctx, reverbSec);
const wg = ctx.createGain(); wg.gain.value = wet;
const dg = ctx.createGain(); dg.gain.value = dry;
reverb.connect(wg); wg.connect(master); dg.connect(master);
const lpf = ctx.createBiquadFilter();
lpf.type = 'lowpass'; lpf.frequency.value = lpFreq; lpf.Q.value = 0.4;
lpf.connect(reverb); lpf.connect(dg);
return { master, lpf };
}
function runMelody(ctx, { scale, spacing, dur, gain, dest, delay }) {
let cancelled = false, t = ctx.currentTime + delay, idx = 0;
function next() {
if (cancelled) return;
const osc = ctx.createOscillator(), env = ctx.createGain();
osc.type = 'sine'; osc.frequency.value = scale[idx++ % scale.length];
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(gain, t + 0.05);
env.gain.setValueAtTime(gain, t + dur * 0.5);
env.gain.exponentialRampToValueAtTime(0.001, t + dur);
osc.connect(env); env.connect(dest);
osc.start(t); osc.stop(t + dur);
t += spacing;
setTimeout(next, Math.max(0, (t - ctx.currentTime - 0.6) * 1000));
}
next();
return () => { cancelled = true; };
}
function runPluck(ctx, { scale, spacing, dur, gain, dest, delay }) {
// Shorter, punchier envelope for upbeat feel
let cancelled = false, t = ctx.currentTime + delay, idx = 0;
function next() {
if (cancelled) return;
const osc = ctx.createOscillator(), env = ctx.createGain();
osc.type = 'triangle'; osc.frequency.value = scale[idx++ % scale.length];
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(gain, t + 0.02);
env.gain.exponentialRampToValueAtTime(0.001, t + dur);
osc.connect(env); env.connect(dest);
osc.start(t); osc.stop(t + dur + 0.05);
t += spacing;
setTimeout(next, Math.max(0, (t - ctx.currentTime - 0.6) * 1000));
}
next();
return () => { cancelled = true; };
}
// ── Style: Upbeat (G major, bright, bouncy) ────────────────────────────────────
function createUpbeat(ctx) {
const now = ctx.currentTime;
const { master, lpf } = makeChain(ctx, { reverbSec: 2, lpFreq: 3200, wet: 0.4, dry: 0.6 });
master.gain.setValueAtTime(0, now);
master.gain.linearRampToValueAtTime(0.45, now + 1.5);
// G major pad: G3, B3, D4, G4
[196, 246.94, 293.66, 392].forEach((freq, i) => {
const osc = ctx.createOscillator(), env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
env.gain.setValueAtTime(0, now);
env.gain.linearRampToValueAtTime(0.055 / (1 + i * 0.22), now + 0.8 + i * 0.35);
osc.connect(env); env.connect(lpf);
const lfo = ctx.createOscillator(), lfoG = ctx.createGain();
lfo.frequency.value = 0.18 + i * 0.06; lfoG.gain.value = freq * 0.0025;
lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now);
});
// G major pentatonic: G3, A3, B3, D4, E4, G4, A4, B4
const scale = [293.66, 329.63, 392, 440, 392, 329.63, 293.66, 246.94,
293.66, 392, 440, 493.88, 440, 392, 329.63, 246.94];
const cancel = runPluck(ctx, { scale, spacing: 0.85, dur: 0.65, gain: 0.11, dest: lpf, delay: 1.5 });
return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.8); } };
}
// ── Style: Dreamy (A minor, slow, nostalgic) ────────────────────────────────────
function createDreamy(ctx) {
const now = ctx.currentTime;
const { master, lpf } = makeChain(ctx, { reverbSec: 4, lpFreq: 1900, wet: 0.65, dry: 0.35 });
master.gain.setValueAtTime(0, now);
master.gain.linearRampToValueAtTime(0.38, now + 4);
master.connect(ctx.destination);
// Reverb convolver (4-second synthetic impulse)
const convolver = ctx.createConvolver();
const sr = ctx.sampleRate;
const impulse = ctx.createBuffer(2, sr * 4, sr);
for (let ch = 0; ch < 2; ch++) {
const d = impulse.getChannelData(ch);
for (let i = 0; i < d.length; i++) {
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6);
}
}
convolver.buffer = impulse;
const wet = ctx.createGain(); wet.gain.value = 0.65;
const dry = ctx.createGain(); dry.gain.value = 0.35;
convolver.connect(wet); wet.connect(master);
dry.connect(master);
// Warmth filter
const lpf = ctx.createBiquadFilter();
lpf.type = 'lowpass';
lpf.frequency.value = 1900;
lpf.Q.value = 0.4;
lpf.connect(convolver);
lpf.connect(dry);
// Pad: A minor chord — A2, E3, A3, C4, E4, G4
// A minor pad: A2, E3, A3, C4, E4, G4
[110, 164.81, 220, 261.63, 329.63, 392].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
const osc = ctx.createOscillator(), env = ctx.createGain();
osc.type = i < 2 ? 'sine' : 'triangle';
osc.frequency.value = freq + (Math.random() - 0.5) * 0.25;
env.gain.setValueAtTime(0, now);
env.gain.linearRampToValueAtTime(0.052 / (1 + i * 0.28), now + 1.5 + i * 0.65);
osc.connect(env);
env.connect(lpf);
// Slow vibrato for shimmer
const lfo = ctx.createOscillator();
const lfoG = ctx.createGain();
lfo.frequency.value = 0.07 + i * 0.02;
lfoG.gain.value = freq * 0.0018;
lfo.connect(lfoG); lfoG.connect(osc.frequency);
lfo.start(now); osc.start(now);
osc.connect(env); env.connect(lpf);
const lfo = ctx.createOscillator(), lfoG = ctx.createGain();
lfo.frequency.value = 0.07 + i * 0.02; lfoG.gain.value = freq * 0.0018;
lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now);
});
// Melody: A pentatonic minor (A3, C4, D4, E4, G4, A4)
const scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63, 392, 440, 392, 329.63, 261.63, 293.66, 329.63];
let cancelled = false;
let noteTime = now + 5.5;
let noteIdx = 0;
// A pentatonic minor melody
const scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63,
392, 440, 392, 329.63, 261.63, 293.66, 329.63];
const cancel = runMelody(ctx, { scale, spacing: 2.3, dur: 1.9, gain: 0.1, dest: lpf, delay: 5.5 });
function playNote() {
if (cancelled) return;
const freq = scale[noteIdx % scale.length];
noteIdx++;
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const dur = 1.9;
env.gain.setValueAtTime(0, noteTime);
env.gain.linearRampToValueAtTime(0.1, noteTime + 0.06);
env.gain.setValueAtTime(0.1, noteTime + dur * 0.45);
env.gain.exponentialRampToValueAtTime(0.001, noteTime + dur);
osc.connect(env); env.connect(convolver);
osc.start(noteTime); osc.stop(noteTime + dur);
noteTime += 2.3;
const msUntil = (noteTime - ctx.currentTime - 0.6) * 1000;
setTimeout(playNote, Math.max(0, msUntil));
}
playNote();
return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5); } };
}
return {
master,
stop() {
cancelled = true;
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5);
},
};
// ── Style: Cinematic (D minor, sweeping, dramatic) ─────────────────────────────
function createCinematic(ctx) {
const now = ctx.currentTime;
const { master, lpf } = makeChain(ctx, { reverbSec: 5, lpFreq: 1500, wet: 0.75, dry: 0.25 });
master.gain.setValueAtTime(0, now);
master.gain.linearRampToValueAtTime(0.42, now + 5);
// D minor pad: D3, F3, A3, C4, F4 — dual oscillators slightly detuned for richness
[[146.83, 0.07], [174.61, 0.055], [220, 0.05], [261.63, 0.04], [349.23, 0.03]].forEach(([freq, g], i) => {
[-1, 1].forEach(side => {
const osc = ctx.createOscillator(), env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq * (1 + side * 0.0015);
env.gain.setValueAtTime(0, now);
env.gain.linearRampToValueAtTime(g, now + 3 + i * 0.9);
osc.connect(env); env.connect(lpf); osc.start(now);
});
});
// D natural minor melody — sweeping and slow
const scale = [220, 261.63, 293.66, 261.63, 220, 196, 174.61,
196, 220, 293.66, 261.63, 220, 196, 174.61, 196, 220];
const cancel = runMelody(ctx, { scale, spacing: 2.8, dur: 2.4, gain: 0.09, dest: lpf, delay: 6.5 });
return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 3); } };
}
function createMusic(ctx, style) {
if (style === 'dreamy') return createDreamy(ctx);
if (style === 'cinematic') return createCinematic(ctx);
return createUpbeat(ctx);
}
// ── Helpers ────────────────────────────────────────────────────────────────────
const MUSIC_STYLES = [
{ key: 'upbeat', label: 'Upbeat', icon: '⚡' },
{ key: 'dreamy', label: 'Dreamy', icon: '🌙' },
{ key: 'cinematic', label: 'Cinematic', icon: '🎬' },
];
function groupByYear(photos) {
const map = {};
for (const p of photos) {
@@ -111,20 +181,20 @@ function yearsAgo(year) {
return n === 1 ? '1 year ago' : `${n} years ago`;
}
function localDate() {
const d = new Date();
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
function todayLabel() {
return new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
}
// ── Component ──────────────────────────────────────────────────────────────────
export default function MemoriesView() {
const [groups, setGroups] = useState(null);
const [error, setError] = useState(null);
const [slideshow, setSlideshow] = useState(false);
const [ssIndex, setSsIndex] = useState(0);
const [opacity, setOpacity] = useState(1);
const [muted, setMuted] = useState(false);
const [groups, setGroups] = useState(null);
const [error, setError] = useState(null);
const [musicStyle, setMusicStyle] = useState('upbeat');
const [slideshow, setSlideshow] = useState(false);
const [ssIndex, setSsIndex] = useState(0);
const [opacity, setOpacity] = useState(1);
const [muted, setMuted] = useState(false);
const audioCtxRef = useRef(null);
const musicRef = useRef(null);
@@ -142,18 +212,18 @@ export default function MemoriesView() {
// ── Music ──
function startMusic() {
function startMusic(style) {
if (audioCtxRef.current) return;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
audioCtxRef.current = ctx;
musicRef.current = createAmbientMusic(ctx);
musicRef.current = createMusic(ctx, style);
}
function stopMusic() {
musicRef.current?.stop();
const ctx = audioCtxRef.current;
if (ctx) {
setTimeout(() => { try { ctx.close(); } catch {} }, 3000);
setTimeout(() => { try { ctx.close(); } catch {} }, 3500);
audioCtxRef.current = null;
musicRef.current = null;
}
@@ -163,8 +233,7 @@ export default function MemoriesView() {
if (!musicRef.current || !audioCtxRef.current) return;
const next = !muted;
setMuted(next);
const g = musicRef.current.master.gain;
g.linearRampToValueAtTime(next ? 0 : 0.38, audioCtxRef.current.currentTime + 0.6);
musicRef.current.master.gain.linearRampToValueAtTime(next ? 0 : 0.42, audioCtxRef.current.currentTime + 0.5);
}
// ── Slideshow ──
@@ -174,14 +243,12 @@ export default function MemoriesView() {
setTimeout(() => {
setSsIndex(i => (i + dir + allPhotos.length) % allPhotos.length);
setOpacity(1);
}, 330);
}, 320);
}, [allPhotos.length]);
function startSlideshow(startIndex) {
setSsIndex(startIndex);
setOpacity(1);
setSlideshow(true);
startMusic();
function startSlideshow(idx) {
setSsIndex(idx); setOpacity(1); setSlideshow(true);
startMusic(musicStyle);
}
function stopSlideshow() {
@@ -191,14 +258,12 @@ export default function MemoriesView() {
setMuted(false);
}
// Auto-advance
useEffect(() => {
if (!slideshow) return;
timerRef.current = setInterval(() => advance(1), 5000);
return () => clearInterval(timerRef.current);
}, [slideshow, advance]);
// Keyboard
useEffect(() => {
if (!slideshow) return;
function onKey(e) {
@@ -215,16 +280,16 @@ export default function MemoriesView() {
if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
if (!groups) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
const noPhotos = allPhotos.length === 0;
const dateLabel = localDate();
const noPhotos = allPhotos.length === 0;
const dateLabel = todayLabel();
return (
<>
{/* ── Main view ── */}
<div style={{ padding: '28px 24px', maxWidth: 980, margin: '0 auto' }}>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 32 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 14, marginBottom: 32 }}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: '#e8e8e8', margin: 0, marginBottom: 5, letterSpacing: -0.4 }}>
{dateLabel}
@@ -235,19 +300,44 @@ export default function MemoriesView() {
: `${allPhotos.length} photo${allPhotos.length !== 1 ? 's' : ''} across ${groups.length} year${groups.length !== 1 ? 's' : ''}`}
</p>
</div>
{!noPhotos && (
<button
onClick={() => startSlideshow(0)}
style={{
background: '#4d9eff', border: 'none', borderRadius: 8,
color: '#fff', cursor: 'pointer',
padding: '9px 20px', fontSize: 14, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 8,
letterSpacing: 0.1,
}}
>
▶ Play Memories
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* Music style picker */}
<div style={{ display: 'flex', gap: 4, background: '#111', borderRadius: 8, padding: 3 }}>
{MUSIC_STYLES.map(s => (
<button
key={s.key}
onClick={() => setMusicStyle(s.key)}
title={s.label}
style={{
background: musicStyle === s.key ? '#1e2a3a' : 'transparent',
border: 'none', borderRadius: 6,
color: musicStyle === s.key ? '#7ab8f5' : '#555',
cursor: 'pointer', padding: '5px 10px',
fontSize: 13, display: 'flex', alignItems: 'center', gap: 5,
transition: 'background 0.15s, color 0.15s',
}}
>
<span>{s.icon}</span>
<span style={{ fontSize: 12 }}>{s.label}</span>
</button>
))}
</div>
{/* Play button */}
<button
onClick={() => startSlideshow(0)}
style={{
background: '#4d9eff', border: 'none', borderRadius: 8,
color: '#fff', cursor: 'pointer',
padding: '9px 18px', fontSize: 14, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 7,
}}
>
▶ Play
</button>
</div>
)}
</div>
@@ -260,7 +350,7 @@ export default function MemoriesView() {
</div>
)}
{/* Year sections */}
{/* Year groups */}
{groups.map(({ year, photos: yPhotos }) => (
<div key={year} style={{ marginBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 12 }}>
@@ -275,13 +365,8 @@ export default function MemoriesView() {
<div
key={photo.id}
onClick={() => startSlideshow(idx)}
style={{
width: 168, height: 126, borderRadius: 5,
overflow: 'hidden', cursor: 'pointer',
flexShrink: 0, background: '#111',
position: 'relative',
}}
onMouseEnter={e => { e.currentTarget.style.opacity = '0.78'; }}
style={{ width: 168, height: 126, borderRadius: 5, overflow: 'hidden', cursor: 'pointer', flexShrink: 0, background: '#111' }}
onMouseEnter={e => { e.currentTarget.style.opacity = '0.75'; }}
onMouseLeave={e => { e.currentTarget.style.opacity = '1'; }}
>
{photo.thumb_url
@@ -300,29 +385,23 @@ export default function MemoriesView() {
{slideshow && allPhotos.length > 0 && (() => {
const photo = allPhotos[ssIndex];
const year = new Date(photo.date_taken).getFullYear();
const style = MUSIC_STYLES.find(s => s.key === musicStyle);
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: '#000', display: 'flex', flexDirection: 'column' }}>
{/* Photo area */}
{/* Photo */}
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
key={photo.id}
src={photo.preview_url || photo.thumb_url}
alt=""
style={{
maxWidth: '100%', maxHeight: '100%',
objectFit: 'contain',
opacity,
transition: 'opacity 0.33s ease',
userSelect: 'none',
}}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', opacity, transition: 'opacity 0.32s ease', userSelect: 'none' }}
/>
{/* Year badge */}
<div style={{
position: 'absolute', bottom: 22, left: 22,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(10px)',
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
borderRadius: 9, padding: '10px 16px',
border: '1px solid rgba(255,255,255,0.07)',
@@ -331,61 +410,31 @@ export default function MemoriesView() {
<div style={{ fontSize: 12, color: '#aaa', marginTop: 3 }}>{yearsAgo(year)}</div>
</div>
{/* Prev */}
<button
onClick={() => advance(-1)}
style={navBtn('left')}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}
>‹</button>
{/* Next */}
<button
onClick={() => advance(1)}
style={navBtn('right')}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}
>›</button>
<button onClick={() => advance(-1)} style={navBtn('left')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>‹</button>
<button onClick={() => advance(1)} style={navBtn('right')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>›</button>
</div>
{/* Bottom bar */}
<div style={{
background: '#090909',
borderTop: '1px solid #1a1a1a',
padding: '10px 18px',
display: 'flex', alignItems: 'center', gap: 12,
}}>
{/* Progress dots */}
<div style={{ background: '#090909', borderTop: '1px solid #1a1a1a', padding: '10px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
{/* Dots */}
<div style={{ flex: 1, display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
{allPhotos.map((_, i) => (
<div
key={i}
onClick={() => { setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 330); }}
style={{
width: i === ssIndex ? 20 : 6, height: 6, borderRadius: 3,
background: i === ssIndex ? '#4d9eff' : '#2a2a2a',
cursor: 'pointer',
transition: 'width 0.25s, background 0.25s',
flexShrink: 0,
}}
onClick={() => { setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 320); }}
style={{ width: i === ssIndex ? 20 : 6, height: 6, borderRadius: 3, background: i === ssIndex ? '#4d9eff' : '#2a2a2a', cursor: 'pointer', transition: 'width 0.25s, background 0.25s', flexShrink: 0 }}
/>
))}
</div>
{/* Counter */}
<span style={{ fontSize: 12, color: '#555', flexShrink: 0, minWidth: 42, textAlign: 'right' }}>
{ssIndex + 1} / {allPhotos.length}
<span style={{ fontSize: 12, color: '#555', flexShrink: 0 }}>
{style?.icon} {ssIndex + 1} / {allPhotos.length}
</span>
{/* Mute */}
<button onClick={toggleMute} title={muted ? 'Unmute' : 'Mute'} style={ctrlBtn}>
{muted ? '🔇' : '🎵'}
</button>
{/* Exit */}
<button onClick={stopSlideshow} title="Exit slideshow (Esc)" style={ctrlBtn}>
✕
</button>
<button onClick={stopSlideshow} title="Exit (Esc)" style={ctrlBtn}>✕</button>
</div>
</div>
);
@@ -394,14 +443,10 @@ export default function MemoriesView() {
);
}
// ── Styles ─────────────────────────────────────────────────────────────────────
function navBtn(side) {
return {
position: 'absolute', top: '50%', transform: 'translateY(-50%)',
[side]: 14,
background: 'rgba(0,0,0,0.45)',
border: 'none', borderRadius: 8,
position: 'absolute', top: '50%', transform: 'translateY(-50%)', [side]: 14,
background: 'rgba(0,0,0,0.45)', border: 'none', borderRadius: 8,
color: '#fff', fontSize: 36, lineHeight: 1,
width: 44, height: 68,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -410,9 +455,6 @@ function navBtn(side) {
}
const ctrlBtn = {
background: 'transparent',
border: '1px solid #222', borderRadius: 6,
color: '#888', cursor: 'pointer',
padding: '5px 11px', fontSize: 14,
flexShrink: 0,
background: 'transparent', border: '1px solid #222', borderRadius: 6,
color: '#888', cursor: 'pointer', padding: '5px 11px', fontSize: 14, flexShrink: 0,
};
+176
View File
@@ -0,0 +1,176 @@
import { useState, useEffect, useRef } from 'react';
import { fetchVideos } from '../api/photos.js';
function fmtDuration(s) {
if (!s) return null;
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
function fmtDate(d) {
if (!d) return null;
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtSize(b) {
if (!b) return null;
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
if (b < 1073741824) return `${(b / 1048576).toFixed(1)} MB`;
return `${(b / 1073741824).toFixed(2)} GB`;
}
export default function VideosView() {
const [videos, setVideos] = useState(null);
const [playing, setPlaying] = useState(null);
const [error, setError] = useState(null);
const videoRef = useRef(null);
useEffect(() => {
fetchVideos()
.then(d => setVideos(d.videos))
.catch(e => setError(e.message));
}, []);
// Keyboard: Esc to close player
useEffect(() => {
if (!playing) return;
const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [playing]);
if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
if (!videos) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
const noVideos = videos.length === 0;
return (
<>
<div style={{ padding: '24px 20px', maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 15, fontWeight: 600, color: '#999', margin: 0 }}>
Videos
{videos.length > 0 && (
<span style={{ fontWeight: 400, color: '#555', marginLeft: 8 }}>
{videos.length.toLocaleString()}
</span>
)}
</h1>
</div>
{noVideos && (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div>
<p style={{ fontSize: 15, marginBottom: 8, color: '#777' }}>No videos indexed yet.</p>
<p style={{ fontSize: 13 }}>Add a folder containing .mp4, .mov, or .m4v files and rescan.</p>
</div>
)}
{/* Video grid */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{videos.map(v => (
<div
key={v.id}
onClick={() => setPlaying(v)}
style={{
width: 220, height: 124, // 16:9
position: 'relative', cursor: 'pointer',
borderRadius: 6, overflow: 'hidden',
background: '#111', flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.querySelector('.play-ring').style.opacity = '1'}
onMouseLeave={e => e.currentTarget.querySelector('.play-ring').style.opacity = '0.7'}
>
{/* Thumbnail */}
{v.thumb_url
? <img src={v.thumb_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <div style={{ width: '100%', height: '100%', background: '#1a1a1a' }} />
}
{/* Play button overlay */}
<div className="play-ring" style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: 0.7, transition: 'opacity 0.15s',
}}>
<div style={{
width: 38, height: 38,
background: 'rgba(0,0,0,0.65)',
borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1.5px solid rgba(255,255,255,0.4)',
}}>
<span style={{ color: '#fff', fontSize: 16, marginLeft: 3, lineHeight: 1 }}>▶</span>
</div>
</div>
{/* Duration badge */}
{v.duration != null && (
<span style={{
position: 'absolute', bottom: 6, right: 6,
background: 'rgba(0,0,0,0.72)', color: '#fff',
fontSize: 11, padding: '2px 5px', borderRadius: 3, lineHeight: 1.4,
}}>
{fmtDuration(v.duration)}
</span>
)}
</div>
))}
</div>
</div>
{/* ── Video player overlay ── */}
{playing && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.95)',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
}}
onClick={e => { if (e.target === e.currentTarget) setPlaying(null); }}
>
{/* Close */}
<button
onClick={() => setPlaying(null)}
style={{
position: 'absolute', top: 16, right: 20,
background: 'transparent', border: 'none',
color: '#777', fontSize: 22, cursor: 'pointer', lineHeight: 1,
}}
title="Close (Esc)"
>
✕
</button>
{/* Video element */}
<video
ref={videoRef}
key={playing.id}
src={`/api/photos/${playing.id}/video`}
controls
autoPlay
playsInline
style={{
maxWidth: '92vw', maxHeight: '80vh',
background: '#000', borderRadius: 6,
boxShadow: '0 8px 40px rgba(0,0,0,0.7)',
}}
/>
{/* Info bar */}
<div style={{
marginTop: 14, display: 'flex', gap: 16, alignItems: 'center',
fontSize: 13, color: '#666', flexWrap: 'wrap', justifyContent: 'center',
}}>
{fmtDate(playing.date_taken) && <span>{fmtDate(playing.date_taken)}</span>}
{playing.duration && <span>{fmtDuration(playing.duration)}</span>}
{playing.width && playing.height && <span>{playing.width} × {playing.height}</span>}
{fmtSize(playing.filesize) && <span>{fmtSize(playing.filesize)}</span>}
</div>
</div>
)}
</>
);
}
+57 -6
View File
@@ -154,9 +154,18 @@ function mapPhoto(p) {
place_country: p.place_country ?? null,
thumb_url: posterUrl(p.thumb_path),
preview_url: posterUrl(p.preview_path),
media_type: p.media_type ?? 'photo',
duration: p.duration ?? null,
};
}
const VIDEO_MIME = {
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.m4v': 'video/mp4',
'.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska',
'.webm': 'video/webm', '.3gp': 'video/3gpp',
};
function videoMime(fp) { return VIDEO_MIME[path.extname(fp).toLowerCase()] ?? 'video/octet-stream'; }
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
function notFound(res) { res.status(404).json({ error: 'not found' }); }
@@ -242,7 +251,7 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() })
const SELECT_COLS = `
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize,
p.starred, p.place_city, p.place_country
p.starred, p.place_city, p.place_country, p.media_type, p.duration
`;
app.get('/api/photos', (req, res) => {
@@ -252,6 +261,10 @@ app.get('/api/photos', (req, res) => {
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city);
// Exclude videos from the photo library — they have their own tab
const photoOnlyClause = "(p.media_type IS NULL OR p.media_type = 'photo')";
const fullWhere = whereSql ? `${whereSql} AND ${photoOnlyClause}` : `WHERE ${photoOnlyClause}`;
let cursorSql = '';
const cursorParams = [];
if (cursor) {
@@ -259,21 +272,20 @@ app.get('/api/photos', (req, res) => {
const cursorDate = cursor.slice(0, pipeAt);
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
if (cursorDate === '') {
cursorSql = whereSql ? 'AND (p.date_taken IS NULL AND p.id < ?)' : 'WHERE (p.date_taken IS NULL AND p.id < ?)';
cursorSql = `AND (p.date_taken IS NULL AND p.id < ?)`;
cursorParams.push(cursorId);
} else {
const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
cursorSql = whereSql ? `AND ${clause}` : `WHERE ${clause}`;
cursorSql = `AND (p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)`;
cursorParams.push(cursorDate, cursorDate, cursorId);
}
}
const allParams = [...params, ...cursorParams, limit + 1];
const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql}
const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${fullWhere} ${cursorSql}
ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`;
const rows = db.raw.prepare(sql).all(allParams);
const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`;
const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${fullWhere}`;
const total = db.raw.prepare(countSql).get(params).n;
const hasMore = rows.length > limit;
@@ -501,6 +513,45 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
res.sendStatus(204);
});
// ── Videos ────────────────────────────────────────────────────────────────────
app.get('/api/videos', (_req, res) => {
const rows = db.raw.prepare(`
SELECT ${SELECT_COLS} FROM photos p
WHERE p.media_type = 'video'
ORDER BY p.date_taken DESC, p.id DESC
`).all();
res.json({ videos: rows.map(mapPhoto), total: rows.length });
});
app.get('/api/photos/:id/video', (req, res) => {
const id = parseInt(req.params.id, 10);
const row = db.raw.prepare('SELECT filepath, media_type FROM photos WHERE id = ?').get(id);
if (!row || row.media_type !== 'video') return notFound(res);
if (!fs.existsSync(row.filepath)) return notFound(res);
const total = fs.statSync(row.filepath).size;
const mime = videoMime(row.filepath);
const range = req.headers.range;
if (!range) {
res.writeHead(200, { 'Content-Length': total, 'Content-Type': mime, 'Accept-Ranges': 'bytes' });
fs.createReadStream(row.filepath).pipe(res);
return;
}
const [s, e] = range.replace(/bytes=/, '').split('-');
const start = parseInt(s, 10);
const end = e ? parseInt(e, 10) : Math.min(start + 4 * 1024 * 1024 - 1, total - 1);
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${total}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mime,
});
fs.createReadStream(row.filepath, { start, end }).pipe(res);
});
// ── Memories (photos on this day) ─────────────────────────────────────────────
app.get('/api/memories/today', (req, res) => {
+7 -4
View File
@@ -113,8 +113,11 @@ export function openDb(dbPath) {
try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN place_city TEXT'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
try { db.exec("ALTER TABLE photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'photo'"); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN duration REAL'); } catch {}
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)');
// Seed default settings
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
@@ -125,11 +128,11 @@ export function openDb(dbPath) {
INSERT OR IGNORE INTO photos
(filepath, thumb_path, preview_path, date_taken, date_imported,
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
place_city, place_country)
place_city, place_country, media_type, duration)
VALUES
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
@place_city, @place_country)
@place_city, @place_country, @media_type, @duration)
`),
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
+131 -2
View File
@@ -1,21 +1,36 @@
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { execFileSync } from 'child_process';
import sharp from 'sharp';
import exifr from 'exifr';
import { computeDHash } from './hash.js';
import { reverseGeocode } from './geocode.js';
const SUPPORTED = new Set([
const PHOTO_EXTS = new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.tiff', '.tif', '.gif', '.avif',
]);
const VIDEO_EXTS = new Set([
'.mp4', '.mov', '.m4v', '.avi', '.mkv', '.webm', '.3gp',
]);
export function isSupported(filepath) {
return SUPPORTED.has(path.extname(filepath).toLowerCase());
const ext = path.extname(filepath).toLowerCase();
return PHOTO_EXTS.has(ext) || VIDEO_EXTS.has(ext);
}
export async function ingestFile(filepath, db, posterDir, settings = {}) {
if (VIDEO_EXTS.has(path.extname(filepath).toLowerCase())) {
return ingestVideo(filepath, db, posterDir, settings);
}
return ingestPhoto(filepath, db, posterDir, settings);
}
// ── Photo ingest ───────────────────────────────────────────────────────────────
async function ingestPhoto(filepath, db, posterDir, settings) {
if (db.hasPhoto(filepath)) return;
const thumbSize = settings.thumb_size ?? 400;
@@ -89,11 +104,125 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
filesize: stat.size,
place_city: placeCity,
place_country: placeCountry,
media_type: 'photo',
duration: null,
});
console.log(`[ingest] ${path.basename(filepath)}`);
}
// ── Video ingest ───────────────────────────────────────────────────────────────
async function ingestVideo(filepath, db, posterDir, settings) {
if (db.hasPhoto(filepath)) return;
const thumbSize = settings.thumb_size ?? 400;
const thumbQuality = settings.thumb_quality ?? 80;
const previewSize = settings.preview_size ?? 1600;
const previewQuality = settings.preview_quality ?? 85;
const stat = fs.statSync(filepath);
// 1. ffprobe — dimensions, duration, creation time
let width = null, height = null, duration = null, dateTaken = null;
try {
const out = execFileSync('ffprobe', [
'-v', 'quiet', '-print_format', 'json',
'-show_streams', '-show_format', filepath,
], { timeout: 15000, encoding: 'utf8' });
const probe = JSON.parse(out);
const vs = probe.streams?.find(s => s.codec_type === 'video');
width = vs?.width ?? null;
height = vs?.height ?? null;
duration = parseFloat(probe.format?.duration ?? '0') || null;
const ct = probe.format?.tags?.creation_time ?? probe.format?.tags?.['com.apple.quicktime.creationdate'];
if (ct) dateTaken = new Date(ct).toISOString();
} catch (e) {
console.error(`[ingest] ffprobe failed ${path.basename(filepath)}: ${e.message}`);
return;
}
// 2. exifr — GPS + more accurate date (best-effort)
let gpsLat = null, gpsLon = null;
try {
const exif = await exifr.parse(filepath, { gps: true, pick: ['DateTimeOriginal'] }) ?? {};
if (exif.DateTimeOriginal) dateTaken = new Date(exif.DateTimeOriginal).toISOString();
if (exif.latitude != null) { gpsLat = exif.latitude; gpsLon = exif.longitude; }
} catch {}
let placeCity = null, placeCountry = null;
if (gpsLat !== null) {
const place = await reverseGeocode(gpsLat, gpsLon);
placeCity = place?.city ?? null;
placeCountry = place?.country ?? null;
}
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
fs.mkdirSync(dir, { recursive: true });
// 3. Extract a frame with ffmpeg at 10% of duration (capped at 3 s)
const seekSec = duration ? Math.min(duration * 0.1, 3) : 0;
const tmpFrame = path.join(dir, `.vtmp_${crypto.randomBytes(4).toString('hex')}.jpg`);
try {
execFileSync('ffmpeg', [
'-ss', String(seekSec.toFixed(3)),
'-i', filepath,
'-vframes', '1', '-q:v', '2', '-y', tmpFrame,
], { timeout: 30000, stdio: 'pipe' });
} catch {
try {
// Fallback: first frame
execFileSync('ffmpeg', [
'-i', filepath, '-vframes', '1', '-q:v', '2', '-y', tmpFrame,
], { timeout: 30000, stdio: 'pipe' });
} catch (e2) {
console.error(`[ingest] video thumb failed ${path.basename(filepath)}: ${e2.message}`);
return;
}
}
// 4. Resize extracted frame with sharp (same pipeline as photos)
try {
await sharp(tmpFrame, { failOn: 'none' })
.resize(thumbSize, thumbSize, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: thumbQuality })
.toFile(thumb);
await sharp(tmpFrame, { failOn: 'none' })
.resize(previewSize, previewSize, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: previewQuality })
.toFile(preview);
} catch (err) {
console.error(`[ingest] video poster failed ${path.basename(filepath)}: ${err.message}`);
try { fs.unlinkSync(tmpFrame); } catch {}
return;
}
try { fs.unlinkSync(tmpFrame); } catch {}
db.insertPhoto({
filepath,
thumb_path: thumb,
preview_path: preview,
date_taken: dateTaken,
date_imported: new Date().toISOString(),
camera_model: null,
gps_lat: gpsLat,
gps_lon: gpsLon,
phash: null,
width,
height,
filesize: stat.size,
place_city: placeCity,
place_country: placeCountry,
media_type: 'video',
duration,
});
console.log(`[ingest] video ${path.basename(filepath)}`);
}
// ── Shared helpers ─────────────────────────────────────────────────────────────
function posterPaths(filepath, posterDir, date) {
const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
const stem = path.basename(filepath, path.extname(filepath));