Files
DndGamePlayer/app/main/project/scenePreviewThumbnail.ts
T
Ivan Fontosh 8f8eef53c9 feat(project): optimize image imports and converter
- Optimize imported scene preview images (smart WebP/JPEG/PNG, preserve alpha, keep pixel size)

- Update converter to re-encode existing image assets with same algorithm

- Improve import/export progress overlay and reduce presentation slide stutter

Made-with: Cursor
2026-04-23 17:59:57 +08:00

87 lines
2.4 KiB
TypeScript

import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import ffmpegStatic from 'ffmpeg-static';
import sharp from 'sharp';
const execFileAsync = promisify(execFile);
/** Longest edge; presentation uses the original asset. */
export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
/**
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
*/
export async function generateScenePreviewThumbnailBytes(
sourceAbsPath: string,
kind: 'image' | 'video',
): Promise<Buffer | null> {
try {
if (kind === 'image') {
return await sharp(sourceAbsPath)
.rotate()
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toBuffer();
}
const ffmpegPath = ffmpegStatic;
if (!ffmpegPath) return null;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-thumb-'));
const tmpPng = path.join(tmpDir, 'frame.png');
try {
const seekSeconds = ['0.5', '0.25', '0'];
let extracted = false;
for (const ss of seekSeconds) {
await fs.rm(tmpPng, { force: true }).catch(() => undefined);
try {
await execFileAsync(
ffmpegPath,
[
'-hide_banner',
'-loglevel',
'error',
'-y',
'-ss',
ss,
'-i',
sourceAbsPath,
'-frames:v',
'1',
tmpPng,
],
{ maxBuffer: 16 * 1024 * 1024 },
);
const st = await fs.stat(tmpPng).catch(() => null);
if (st !== null && st.isFile() && st.size > 0) {
extracted = true;
break;
}
} catch {
/* try next seek */
}
}
if (!extracted) return null;
return await sharp(tmpPng)
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 82 })
.toBuffer();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
} catch {
return null;
}
}