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
+587
-172
No files matched your search
+1
-1
@@ -13,7 +13,7 @@ RUN npm run build
|
|||||||
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
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
|
pip3 install icloudpd --break-system-packages
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
const MapView = lazy(() => import('./components/MapView.jsx'));
|
const MapView = lazy(() => import('./components/MapView.jsx'));
|
||||||
const MemoriesView = lazy(() => import('./components/MemoriesView.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 EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
||||||
const SIDEBAR_W = 210;
|
const SIDEBAR_W = 210;
|
||||||
@@ -126,6 +127,7 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab active={view === 'videos'} onClick={() => setView('videos')}>Videos</Tab>
|
||||||
<Tab active={view === 'memories'} onClick={() => setView('memories')}>Memories</Tab>
|
<Tab active={view === 'memories'} onClick={() => setView('memories')}>Memories</Tab>
|
||||||
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
|
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
|
||||||
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
|
<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 */}
|
{/* Memories */}
|
||||||
{view === 'memories' && (
|
{view === 'memories' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
<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' });
|
return api(`/api/sync/${source}`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Videos
|
||||||
|
export function fetchVideos() {
|
||||||
|
return api('/api/videos');
|
||||||
|
}
|
||||||
|
|
||||||
// Memories
|
// Memories
|
||||||
export function fetchMemories() {
|
export function fetchMemories() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
|
|||||||
@@ -1,99 +1,169 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { fetchMemories } from '../api/photos.js';
|
import { fetchMemories } from '../api/photos.js';
|
||||||
|
|
||||||
// ── Music ──────────────────────────────────────────────────────────────────────
|
// ── Music engine ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function createAmbientMusic(ctx) {
|
function makeReverb(ctx, seconds) {
|
||||||
const now = ctx.currentTime;
|
const conv = ctx.createConvolver();
|
||||||
|
|
||||||
const master = ctx.createGain();
|
|
||||||
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 sr = ctx.sampleRate;
|
||||||
const impulse = ctx.createBuffer(2, sr * 4, sr);
|
const buf = ctx.createBuffer(2, sr * seconds, sr);
|
||||||
for (let ch = 0; ch < 2; ch++) {
|
for (let ch = 0; ch < 2; ch++) {
|
||||||
const d = impulse.getChannelData(ch);
|
const d = buf.getChannelData(ch);
|
||||||
for (let i = 0; i < d.length; i++) {
|
for (let i = 0; i < d.length; i++)
|
||||||
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6);
|
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6);
|
||||||
}
|
}
|
||||||
|
conv.buffer = buf;
|
||||||
|
return conv;
|
||||||
}
|
}
|
||||||
convolver.buffer = impulse;
|
|
||||||
|
|
||||||
const wet = ctx.createGain(); wet.gain.value = 0.65;
|
function makeChain(ctx, { reverbSec, lpFreq, wet, dry }) {
|
||||||
const dry = ctx.createGain(); dry.gain.value = 0.35;
|
const master = ctx.createGain();
|
||||||
convolver.connect(wet); wet.connect(master);
|
master.connect(ctx.destination);
|
||||||
dry.connect(master);
|
const reverb = makeReverb(ctx, reverbSec);
|
||||||
|
const wg = ctx.createGain(); wg.gain.value = wet;
|
||||||
// Warmth filter
|
const dg = ctx.createGain(); dg.gain.value = dry;
|
||||||
|
reverb.connect(wg); wg.connect(master); dg.connect(master);
|
||||||
const lpf = ctx.createBiquadFilter();
|
const lpf = ctx.createBiquadFilter();
|
||||||
lpf.type = 'lowpass';
|
lpf.type = 'lowpass'; lpf.frequency.value = lpFreq; lpf.Q.value = 0.4;
|
||||||
lpf.frequency.value = 1900;
|
lpf.connect(reverb); lpf.connect(dg);
|
||||||
lpf.Q.value = 0.4;
|
return { master, lpf };
|
||||||
lpf.connect(convolver);
|
}
|
||||||
lpf.connect(dry);
|
|
||||||
|
|
||||||
// Pad: A minor chord — A2, E3, A3, C4, E4, G4
|
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);
|
||||||
|
|
||||||
|
// A minor pad: A2, E3, A3, C4, E4, G4
|
||||||
[110, 164.81, 220, 261.63, 329.63, 392].forEach((freq, i) => {
|
[110, 164.81, 220, 261.63, 329.63, 392].forEach((freq, i) => {
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator(), env = ctx.createGain();
|
||||||
const env = ctx.createGain();
|
|
||||||
osc.type = i < 2 ? 'sine' : 'triangle';
|
osc.type = i < 2 ? 'sine' : 'triangle';
|
||||||
osc.frequency.value = freq + (Math.random() - 0.5) * 0.25;
|
osc.frequency.value = freq + (Math.random() - 0.5) * 0.25;
|
||||||
env.gain.setValueAtTime(0, now);
|
env.gain.setValueAtTime(0, now);
|
||||||
env.gain.linearRampToValueAtTime(0.052 / (1 + i * 0.28), now + 1.5 + i * 0.65);
|
env.gain.linearRampToValueAtTime(0.052 / (1 + i * 0.28), now + 1.5 + i * 0.65);
|
||||||
osc.connect(env);
|
osc.connect(env); env.connect(lpf);
|
||||||
env.connect(lpf);
|
const lfo = ctx.createOscillator(), lfoG = ctx.createGain();
|
||||||
|
lfo.frequency.value = 0.07 + i * 0.02; lfoG.gain.value = freq * 0.0018;
|
||||||
// Slow vibrato for shimmer
|
lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Melody: A pentatonic minor (A3, C4, D4, E4, G4, A4)
|
// 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 scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63,
|
||||||
let cancelled = false;
|
392, 440, 392, 329.63, 261.63, 293.66, 329.63];
|
||||||
let noteTime = now + 5.5;
|
const cancel = runMelody(ctx, { scale, spacing: 2.3, dur: 1.9, gain: 0.1, dest: lpf, delay: 5.5 });
|
||||||
let noteIdx = 0;
|
|
||||||
|
|
||||||
function playNote() {
|
return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5); } };
|
||||||
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 {
|
// ── Style: Cinematic (D minor, sweeping, dramatic) ─────────────────────────────
|
||||||
master,
|
function createCinematic(ctx) {
|
||||||
stop() {
|
const now = ctx.currentTime;
|
||||||
cancelled = true;
|
const { master, lpf } = makeChain(ctx, { reverbSec: 5, lpFreq: 1500, wet: 0.75, dry: 0.25 });
|
||||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5);
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MUSIC_STYLES = [
|
||||||
|
{ key: 'upbeat', label: 'Upbeat', icon: '⚡' },
|
||||||
|
{ key: 'dreamy', label: 'Dreamy', icon: '🌙' },
|
||||||
|
{ key: 'cinematic', label: 'Cinematic', icon: '🎬' },
|
||||||
|
];
|
||||||
|
|
||||||
function groupByYear(photos) {
|
function groupByYear(photos) {
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const p of photos) {
|
for (const p of photos) {
|
||||||
@@ -111,9 +181,8 @@ function yearsAgo(year) {
|
|||||||
return n === 1 ? '1 year ago' : `${n} years ago`;
|
return n === 1 ? '1 year ago' : `${n} years ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDate() {
|
function todayLabel() {
|
||||||
const d = new Date();
|
return new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
||||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
@@ -121,6 +190,7 @@ function localDate() {
|
|||||||
export default function MemoriesView() {
|
export default function MemoriesView() {
|
||||||
const [groups, setGroups] = useState(null);
|
const [groups, setGroups] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [musicStyle, setMusicStyle] = useState('upbeat');
|
||||||
const [slideshow, setSlideshow] = useState(false);
|
const [slideshow, setSlideshow] = useState(false);
|
||||||
const [ssIndex, setSsIndex] = useState(0);
|
const [ssIndex, setSsIndex] = useState(0);
|
||||||
const [opacity, setOpacity] = useState(1);
|
const [opacity, setOpacity] = useState(1);
|
||||||
@@ -142,18 +212,18 @@ export default function MemoriesView() {
|
|||||||
|
|
||||||
// ── Music ──
|
// ── Music ──
|
||||||
|
|
||||||
function startMusic() {
|
function startMusic(style) {
|
||||||
if (audioCtxRef.current) return;
|
if (audioCtxRef.current) return;
|
||||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
audioCtxRef.current = ctx;
|
audioCtxRef.current = ctx;
|
||||||
musicRef.current = createAmbientMusic(ctx);
|
musicRef.current = createMusic(ctx, style);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopMusic() {
|
function stopMusic() {
|
||||||
musicRef.current?.stop();
|
musicRef.current?.stop();
|
||||||
const ctx = audioCtxRef.current;
|
const ctx = audioCtxRef.current;
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
setTimeout(() => { try { ctx.close(); } catch {} }, 3000);
|
setTimeout(() => { try { ctx.close(); } catch {} }, 3500);
|
||||||
audioCtxRef.current = null;
|
audioCtxRef.current = null;
|
||||||
musicRef.current = null;
|
musicRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -163,8 +233,7 @@ export default function MemoriesView() {
|
|||||||
if (!musicRef.current || !audioCtxRef.current) return;
|
if (!musicRef.current || !audioCtxRef.current) return;
|
||||||
const next = !muted;
|
const next = !muted;
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
const g = musicRef.current.master.gain;
|
musicRef.current.master.gain.linearRampToValueAtTime(next ? 0 : 0.42, audioCtxRef.current.currentTime + 0.5);
|
||||||
g.linearRampToValueAtTime(next ? 0 : 0.38, audioCtxRef.current.currentTime + 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Slideshow ──
|
// ── Slideshow ──
|
||||||
@@ -174,14 +243,12 @@ export default function MemoriesView() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSsIndex(i => (i + dir + allPhotos.length) % allPhotos.length);
|
setSsIndex(i => (i + dir + allPhotos.length) % allPhotos.length);
|
||||||
setOpacity(1);
|
setOpacity(1);
|
||||||
}, 330);
|
}, 320);
|
||||||
}, [allPhotos.length]);
|
}, [allPhotos.length]);
|
||||||
|
|
||||||
function startSlideshow(startIndex) {
|
function startSlideshow(idx) {
|
||||||
setSsIndex(startIndex);
|
setSsIndex(idx); setOpacity(1); setSlideshow(true);
|
||||||
setOpacity(1);
|
startMusic(musicStyle);
|
||||||
setSlideshow(true);
|
|
||||||
startMusic();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopSlideshow() {
|
function stopSlideshow() {
|
||||||
@@ -191,14 +258,12 @@ export default function MemoriesView() {
|
|||||||
setMuted(false);
|
setMuted(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-advance
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slideshow) return;
|
if (!slideshow) return;
|
||||||
timerRef.current = setInterval(() => advance(1), 5000);
|
timerRef.current = setInterval(() => advance(1), 5000);
|
||||||
return () => clearInterval(timerRef.current);
|
return () => clearInterval(timerRef.current);
|
||||||
}, [slideshow, advance]);
|
}, [slideshow, advance]);
|
||||||
|
|
||||||
// Keyboard
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slideshow) return;
|
if (!slideshow) return;
|
||||||
function onKey(e) {
|
function onKey(e) {
|
||||||
@@ -216,15 +281,15 @@ export default function MemoriesView() {
|
|||||||
if (!groups) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
|
if (!groups) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
|
||||||
|
|
||||||
const noPhotos = allPhotos.length === 0;
|
const noPhotos = allPhotos.length === 0;
|
||||||
const dateLabel = localDate();
|
const dateLabel = todayLabel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Main view ── */}
|
{/* ── Main view ── */}
|
||||||
<div style={{ padding: '28px 24px', maxWidth: 980, margin: '0 auto' }}>
|
<div style={{ padding: '28px 24px', maxWidth: 980, margin: '0 auto' }}>
|
||||||
|
|
||||||
{/* Header row */}
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 32 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 14, marginBottom: 32 }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, color: '#e8e8e8', margin: 0, marginBottom: 5, letterSpacing: -0.4 }}>
|
<h1 style={{ fontSize: 24, fontWeight: 700, color: '#e8e8e8', margin: 0, marginBottom: 5, letterSpacing: -0.4 }}>
|
||||||
{dateLabel}
|
{dateLabel}
|
||||||
@@ -235,19 +300,44 @@ export default function MemoriesView() {
|
|||||||
: `${allPhotos.length} photo${allPhotos.length !== 1 ? 's' : ''} across ${groups.length} year${groups.length !== 1 ? 's' : ''}`}
|
: `${allPhotos.length} photo${allPhotos.length !== 1 ? 's' : ''} across ${groups.length} year${groups.length !== 1 ? 's' : ''}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!noPhotos && (
|
{!noPhotos && (
|
||||||
|
<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
|
<button
|
||||||
onClick={() => startSlideshow(0)}
|
onClick={() => startSlideshow(0)}
|
||||||
style={{
|
style={{
|
||||||
background: '#4d9eff', border: 'none', borderRadius: 8,
|
background: '#4d9eff', border: 'none', borderRadius: 8,
|
||||||
color: '#fff', cursor: 'pointer',
|
color: '#fff', cursor: 'pointer',
|
||||||
padding: '9px 20px', fontSize: 14, fontWeight: 600,
|
padding: '9px 18px', fontSize: 14, fontWeight: 600,
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 7,
|
||||||
letterSpacing: 0.1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
▶ Play Memories
|
▶ Play
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,7 +350,7 @@ export default function MemoriesView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Year sections */}
|
{/* Year groups */}
|
||||||
{groups.map(({ year, photos: yPhotos }) => (
|
{groups.map(({ year, photos: yPhotos }) => (
|
||||||
<div key={year} style={{ marginBottom: 40 }}>
|
<div key={year} style={{ marginBottom: 40 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 12 }}>
|
||||||
@@ -275,13 +365,8 @@ export default function MemoriesView() {
|
|||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
onClick={() => startSlideshow(idx)}
|
onClick={() => startSlideshow(idx)}
|
||||||
style={{
|
style={{ width: 168, height: 126, borderRadius: 5, overflow: 'hidden', cursor: 'pointer', flexShrink: 0, background: '#111' }}
|
||||||
width: 168, height: 126, borderRadius: 5,
|
onMouseEnter={e => { e.currentTarget.style.opacity = '0.75'; }}
|
||||||
overflow: 'hidden', cursor: 'pointer',
|
|
||||||
flexShrink: 0, background: '#111',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.opacity = '0.78'; }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.opacity = '1'; }}
|
onMouseLeave={e => { e.currentTarget.style.opacity = '1'; }}
|
||||||
>
|
>
|
||||||
{photo.thumb_url
|
{photo.thumb_url
|
||||||
@@ -300,29 +385,23 @@ export default function MemoriesView() {
|
|||||||
{slideshow && allPhotos.length > 0 && (() => {
|
{slideshow && allPhotos.length > 0 && (() => {
|
||||||
const photo = allPhotos[ssIndex];
|
const photo = allPhotos[ssIndex];
|
||||||
const year = new Date(photo.date_taken).getFullYear();
|
const year = new Date(photo.date_taken).getFullYear();
|
||||||
|
const style = MUSIC_STYLES.find(s => s.key === musicStyle);
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: '#000', display: 'flex', flexDirection: 'column' }}>
|
<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' }}>
|
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<img
|
<img
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
src={photo.preview_url || photo.thumb_url}
|
src={photo.preview_url || photo.thumb_url}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', opacity, transition: 'opacity 0.32s ease', userSelect: 'none' }}
|
||||||
maxWidth: '100%', maxHeight: '100%',
|
|
||||||
objectFit: 'contain',
|
|
||||||
opacity,
|
|
||||||
transition: 'opacity 0.33s ease',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Year badge */}
|
{/* Year badge */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', bottom: 22, left: 22,
|
position: 'absolute', bottom: 22, left: 22,
|
||||||
background: 'rgba(0,0,0,0.55)',
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(10px)',
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
WebkitBackdropFilter: 'blur(10px)',
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
borderRadius: 9, padding: '10px 16px',
|
borderRadius: 9, padding: '10px 16px',
|
||||||
border: '1px solid rgba(255,255,255,0.07)',
|
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 style={{ fontSize: 12, color: '#aaa', marginTop: 3 }}>{yearsAgo(year)}</div>
|
||||||
</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>
|
||||||
<button
|
<button onClick={() => advance(1)} style={navBtn('right')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>›</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar */}
|
{/* Bottom bar */}
|
||||||
<div style={{
|
<div style={{ background: '#090909', borderTop: '1px solid #1a1a1a', padding: '10px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
background: '#090909',
|
{/* Dots */}
|
||||||
borderTop: '1px solid #1a1a1a',
|
|
||||||
padding: '10px 18px',
|
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
|
||||||
}}>
|
|
||||||
{/* Progress dots */}
|
|
||||||
<div style={{ flex: 1, display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{allPhotos.map((_, i) => (
|
{allPhotos.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => { setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 330); }}
|
onClick={() => { setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 320); }}
|
||||||
style={{
|
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 }}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Counter */}
|
<span style={{ fontSize: 12, color: '#555', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 12, color: '#555', flexShrink: 0, minWidth: 42, textAlign: 'right' }}>
|
{style?.icon} {ssIndex + 1} / {allPhotos.length}
|
||||||
{ssIndex + 1} / {allPhotos.length}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Mute */}
|
|
||||||
<button onClick={toggleMute} title={muted ? 'Unmute' : 'Mute'} style={ctrlBtn}>
|
<button onClick={toggleMute} title={muted ? 'Unmute' : 'Mute'} style={ctrlBtn}>
|
||||||
{muted ? '🔇' : '🎵'}
|
{muted ? '🔇' : '🎵'}
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={stopSlideshow} title="Exit (Esc)" style={ctrlBtn}>✕</button>
|
||||||
{/* Exit */}
|
|
||||||
<button onClick={stopSlideshow} title="Exit slideshow (Esc)" style={ctrlBtn}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -394,14 +443,10 @@ export default function MemoriesView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styles ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function navBtn(side) {
|
function navBtn(side) {
|
||||||
return {
|
return {
|
||||||
position: 'absolute', top: '50%', transform: 'translateY(-50%)',
|
position: 'absolute', top: '50%', transform: 'translateY(-50%)', [side]: 14,
|
||||||
[side]: 14,
|
background: 'rgba(0,0,0,0.45)', border: 'none', borderRadius: 8,
|
||||||
background: 'rgba(0,0,0,0.45)',
|
|
||||||
border: 'none', borderRadius: 8,
|
|
||||||
color: '#fff', fontSize: 36, lineHeight: 1,
|
color: '#fff', fontSize: 36, lineHeight: 1,
|
||||||
width: 44, height: 68,
|
width: 44, height: 68,
|
||||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -410,9 +455,6 @@ function navBtn(side) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ctrlBtn = {
|
const ctrlBtn = {
|
||||||
background: 'transparent',
|
background: 'transparent', border: '1px solid #222', borderRadius: 6,
|
||||||
border: '1px solid #222', borderRadius: 6,
|
color: '#888', cursor: 'pointer', padding: '5px 11px', fontSize: 14, flexShrink: 0,
|
||||||
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,
|
place_country: p.place_country ?? null,
|
||||||
thumb_url: posterUrl(p.thumb_path),
|
thumb_url: posterUrl(p.thumb_path),
|
||||||
preview_url: posterUrl(p.preview_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 badRequest(res, msg) { res.status(400).json({ error: msg }); }
|
||||||
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
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 = `
|
const SELECT_COLS = `
|
||||||
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
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.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) => {
|
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);
|
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 = '';
|
let cursorSql = '';
|
||||||
const cursorParams = [];
|
const cursorParams = [];
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@@ -259,21 +272,20 @@ app.get('/api/photos', (req, res) => {
|
|||||||
const cursorDate = cursor.slice(0, pipeAt);
|
const cursorDate = cursor.slice(0, pipeAt);
|
||||||
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
|
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
|
||||||
if (cursorDate === '') {
|
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);
|
cursorParams.push(cursorId);
|
||||||
} else {
|
} else {
|
||||||
const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
|
cursorSql = `AND (p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)`;
|
||||||
cursorSql = whereSql ? `AND ${clause}` : `WHERE ${clause}`;
|
|
||||||
cursorParams.push(cursorDate, cursorDate, cursorId);
|
cursorParams.push(cursorDate, cursorDate, cursorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParams = [...params, ...cursorParams, limit + 1];
|
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 ?`;
|
ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`;
|
||||||
const rows = db.raw.prepare(sql).all(allParams);
|
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 total = db.raw.prepare(countSql).get(params).n;
|
||||||
|
|
||||||
const hasMore = rows.length > limit;
|
const hasMore = rows.length > limit;
|
||||||
@@ -501,6 +513,45 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
|
|||||||
res.sendStatus(204);
|
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) ─────────────────────────────────────────────
|
// ── Memories (photos on this day) ─────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/memories/today', (req, res) => {
|
app.get('/api/memories/today', (req, res) => {
|
||||||
|
|||||||
+5
-2
@@ -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 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_city TEXT'); } catch {}
|
||||||
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
|
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
|
||||||
|
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_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_place_city ON photos(place_city)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)');
|
||||||
|
|
||||||
// Seed default settings
|
// Seed default settings
|
||||||
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
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
|
INSERT OR IGNORE INTO photos
|
||||||
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
||||||
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
|
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
|
||||||
place_city, place_country)
|
place_city, place_country, media_type, duration)
|
||||||
VALUES
|
VALUES
|
||||||
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
||||||
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
|
@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'),
|
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
|
||||||
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
||||||
|
|||||||
+131
-2
@@ -1,21 +1,36 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import { computeDHash } from './hash.js';
|
import { computeDHash } from './hash.js';
|
||||||
import { reverseGeocode } from './geocode.js';
|
import { reverseGeocode } from './geocode.js';
|
||||||
|
|
||||||
const SUPPORTED = new Set([
|
const PHOTO_EXTS = new Set([
|
||||||
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||||
'.tiff', '.tif', '.gif', '.avif',
|
'.tiff', '.tif', '.gif', '.avif',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const VIDEO_EXTS = new Set([
|
||||||
|
'.mp4', '.mov', '.m4v', '.avi', '.mkv', '.webm', '.3gp',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isSupported(filepath) {
|
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 = {}) {
|
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;
|
if (db.hasPhoto(filepath)) return;
|
||||||
|
|
||||||
const thumbSize = settings.thumb_size ?? 400;
|
const thumbSize = settings.thumb_size ?? 400;
|
||||||
@@ -89,11 +104,125 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
|||||||
filesize: stat.size,
|
filesize: stat.size,
|
||||||
place_city: placeCity,
|
place_city: placeCity,
|
||||||
place_country: placeCountry,
|
place_country: placeCountry,
|
||||||
|
media_type: 'photo',
|
||||||
|
duration: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[ingest] ${path.basename(filepath)}`);
|
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) {
|
function posterPaths(filepath, posterDir, date) {
|
||||||
const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
|
const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
|
||||||
const stem = path.basename(filepath, path.extname(filepath));
|
const stem = path.basename(filepath, path.extname(filepath));
|
||||||
|
|||||||
Reference in new issue
Block a user