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:
1 parent
7ed18f616f
commit
6b4bd88943
8 files changed
+608
-193
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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>}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user