8f8eef53c9
- 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
87 lines
2.4 KiB
TypeScript
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;
|
|
}
|
|
}
|