4e6f7321b8
Serialize updateProject and preview finalize, drain before pack/close, buffer zip entries, and abort finalize cleanly when leaving the project to avoid ENOENT on multi-image drops. Co-authored-by: Cursor <cursoragent@cursor.com>
91 lines
2.6 KiB
TypeScript
91 lines
2.6 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).
|
|
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
|
|
*/
|
|
export async function generateScenePreviewThumbnailBytes(
|
|
source: string | Buffer,
|
|
kind: 'image' | 'video',
|
|
): Promise<Buffer | null> {
|
|
try {
|
|
if (kind === 'image') {
|
|
return await sharp(source)
|
|
.rotate()
|
|
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
|
|
fit: 'inside',
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality: 82 })
|
|
.toBuffer();
|
|
}
|
|
|
|
const sourceAbsPath = typeof source === 'string' ? source : null;
|
|
if (!sourceAbsPath) return null;
|
|
|
|
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;
|
|
}
|
|
}
|