music indexer: generate video poster thumbnails
Each indexed video gets a compressed poster (a frame grab ~10% in, capped at
30s, scaled ≤600px q5 like covers), written to cache/<rel>/posters/<file>.jpg
and recorded as `poster` on the meta.videos entry. The posters dir is wiped and
regenerated on each rebuild so orphans (removed videos) don't linger. New
`postersSaved` status counter.
Served by a new sidecar route GET /api/music/poster?path=<rel>&file=<video>
(image/jpeg, ETag=<v>, 304, 404 when none) — path-safe via basename.
Verified end-to-end on a real .mp4: video-only album → manifest {tracks:0,
videos:1}, meta.poster set, 14 KB poster on disk. MUSIC_API.md documents the
poster field + endpoint + postersSaved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ export type IndexVideo = {
|
||||
durationSec?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
poster?: string; // relative to <rel>: "posters/<file>.jpg" — a compressed frame grab, when generated
|
||||
};
|
||||
export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[]; videos?: IndexVideo[] };
|
||||
|
||||
@@ -103,6 +104,7 @@ export type IndexStatus = {
|
||||
tracksIndexed: number;
|
||||
videosIndexed: number;
|
||||
coversSaved: number;
|
||||
postersSaved: number;
|
||||
discographies: number; // artist discography.json files written
|
||||
currentPath: string;
|
||||
error: string | null;
|
||||
@@ -146,6 +148,7 @@ const status: IndexStatus = {
|
||||
tracksIndexed: 0,
|
||||
videosIndexed: 0,
|
||||
coversSaved: 0,
|
||||
postersSaved: 0,
|
||||
discographies: 0,
|
||||
currentPath: '',
|
||||
error: null,
|
||||
@@ -338,6 +341,35 @@ async function compressCover(srcAbs: string, destAbs: string): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
/** Grab a representative frame (~10% in, capped at 30s to skip intros) and compress it like a cover. */
|
||||
async function generateVideoPoster(srcAbs: string, destAbs: string, durationSec?: number): Promise<boolean> {
|
||||
const ts = durationSec && durationSec > 0 ? Math.min(30, Math.max(1, Math.floor(durationSec * 0.1))) : 5;
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-ss',
|
||||
String(ts),
|
||||
'-i',
|
||||
srcAbs,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
`scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`,
|
||||
'-q:v',
|
||||
'5',
|
||||
destAbs,
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'ignore' },
|
||||
);
|
||||
await proc.exited;
|
||||
return proc.exitCode === 0 && existsSync(destAbs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build ──
|
||||
|
||||
export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
@@ -352,6 +384,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
tracksIndexed: 0,
|
||||
videosIndexed: 0,
|
||||
coversSaved: 0,
|
||||
postersSaved: 0,
|
||||
discographies: 0,
|
||||
currentPath: '',
|
||||
error: null,
|
||||
@@ -502,8 +535,16 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
||||
emitProgress();
|
||||
return t;
|
||||
});
|
||||
// Regenerate posters from scratch (drop any orphaned by removed videos).
|
||||
const postersDir = join(cacheDir, 'posters');
|
||||
await rm(postersDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (video.length) await mkdir(postersDir, { recursive: true });
|
||||
const videos = await mapPool(video, TRACK_CONCURRENCY, async (name) => {
|
||||
const vmeta = await ffprobeVideo(join(dirAbs, name), name);
|
||||
if (await generateVideoPoster(join(dirAbs, name), join(postersDir, `${name}.jpg`), vmeta.durationSec)) {
|
||||
vmeta.poster = `posters/${name}.jpg`;
|
||||
status.postersSaved += 1;
|
||||
}
|
||||
status.videosIndexed += 1;
|
||||
emitProgress();
|
||||
return vmeta;
|
||||
@@ -561,3 +602,7 @@ export function coverFilePath(rel: string): string | null {
|
||||
export function discographyFilePath(rel: string): string | null {
|
||||
return resolveCachePath(rel, 'discography.json');
|
||||
}
|
||||
/** A video's poster: cache/<rel>/posters/<file>.jpg. `file` is basename'd to keep it within the folder. */
|
||||
export function posterFilePath(rel: string, file: string): string | null {
|
||||
return resolveCachePath(rel, join('posters', `${basename(file)}.jpg`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user