mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-06 08:07:22 +08:00
feat: enhance video thumbnail generation
This commit is contained in:
+140
-52
@@ -13,8 +13,12 @@ ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
|||||||
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
|
||||||
VIDEO_EXT = {"mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "mpg", "mpeg", "3gp"}
|
VIDEO_EXT = {"mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "mpg", "mpeg", "3gp"}
|
||||||
MAX_IMAGE_SOURCE_SIZE = 200 * 1024 * 1024
|
MAX_IMAGE_SOURCE_SIZE = 200 * 1024 * 1024
|
||||||
VIDEO_RANGE_LIMIT = 16 * 1024 * 1024 # 16MB
|
VIDEO_TAIL_LIMIT = 2 * 1024 * 1024 # 2MB
|
||||||
VIDEO_INITIAL_CHUNK = 4 * 1024 * 1024
|
VIDEO_TAIL_FALLBACK_LIMIT = 4 * 1024 * 1024 # 4MB
|
||||||
|
VIDEO_HEAD_LIMIT = 2 * 1024 * 1024 # 2MB
|
||||||
|
VIDEO_HEAD_FALLBACK_LIMIT = 4 * 1024 * 1024 # 4MB
|
||||||
|
VIDEO_THUMB_SEEK_SECONDS = (20, 10, 5, 3, 1, 0)
|
||||||
|
VIDEO_BLACK_FRAME_MEAN_THRESHOLD = 12.0
|
||||||
CACHE_ROOT = Path('data/.thumb_cache')
|
CACHE_ROOT = Path('data/.thumb_cache')
|
||||||
|
|
||||||
|
|
||||||
@@ -176,42 +180,54 @@ async def _read_range_slice(adapter, root: str, rel: str, start: int, end: int)
|
|||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
async def _read_video_prefix(adapter, root: str, rel: str, size: int, limit: int = VIDEO_RANGE_LIMIT) -> bytes:
|
async def _read_video_head(adapter, root: str, rel: str, size: int, limit: int = VIDEO_HEAD_LIMIT) -> bytes:
|
||||||
chunk_size = min(VIDEO_INITIAL_CHUNK, limit)
|
end = limit - 1
|
||||||
offset = 0
|
if size > 0:
|
||||||
collected = bytearray()
|
end = min(end, size - 1)
|
||||||
|
if end < 0:
|
||||||
while len(collected) < limit:
|
return b""
|
||||||
end = offset + chunk_size - 1
|
return await _read_range_slice(adapter, root, rel, 0, end)
|
||||||
data = await _read_range_slice(adapter, root, rel, offset, end)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
collected.extend(data)
|
|
||||||
if len(data) < chunk_size:
|
|
||||||
break
|
|
||||||
offset += len(data)
|
|
||||||
remaining = limit - len(collected)
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
chunk_size = min(chunk_size * 2, remaining)
|
|
||||||
|
|
||||||
if not collected and size <= limit:
|
|
||||||
read_file = getattr(adapter, "read_file", None)
|
|
||||||
if callable(read_file):
|
|
||||||
blob = await read_file(root, rel)
|
|
||||||
if blob:
|
|
||||||
return bytes(blob[:limit])
|
|
||||||
|
|
||||||
return bytes(collected[:limit])
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_ffmpeg_extract_frame(src_path: str, dst_path: str):
|
async def _read_video_tail(adapter, root: str, rel: str, size: int, limit: int) -> Tuple[bytes, int]:
|
||||||
|
if size <= 0:
|
||||||
|
return b"", 0
|
||||||
|
start = max(0, size - limit)
|
||||||
|
end = size - 1
|
||||||
|
data = await _read_range_slice(adapter, root, rel, start, end)
|
||||||
|
return data, start
|
||||||
|
|
||||||
|
|
||||||
|
def _write_video_sparse_file(rel: str, head_bytes: bytes, tail_bytes: bytes, tail_offset: int) -> str:
|
||||||
|
suffix = Path(rel).suffix or ".mp4"
|
||||||
|
src_tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||||
|
src_path = src_tmp.name
|
||||||
|
try:
|
||||||
|
if head_bytes:
|
||||||
|
src_tmp.write(head_bytes)
|
||||||
|
src_tmp.flush()
|
||||||
|
finally:
|
||||||
|
src_tmp.close()
|
||||||
|
|
||||||
|
if tail_bytes:
|
||||||
|
with open(src_path, "r+b") as f:
|
||||||
|
f.seek(max(0, int(tail_offset)))
|
||||||
|
f.write(tail_bytes)
|
||||||
|
f.flush()
|
||||||
|
return src_path
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_ffmpeg_extract_frame(src_path: str, dst_path: str, *, seek_seconds: float | None = None):
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-i", src_path,
|
"-i", src_path,
|
||||||
|
]
|
||||||
|
if seek_seconds is not None:
|
||||||
|
cmd += ["-ss", str(seek_seconds)]
|
||||||
|
cmd += [
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
dst_path,
|
dst_path,
|
||||||
]
|
]
|
||||||
@@ -230,32 +246,72 @@ async def _run_ffmpeg_extract_frame(src_path: str, dst_path: str):
|
|||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
|
||||||
async def _generate_video_thumb(video_bytes: bytes, rel: str, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
def _frame_mean_luma(im) -> float:
|
||||||
from PIL import Image
|
from PIL import ImageStat
|
||||||
|
gray = im.convert("L").resize((64, 64))
|
||||||
|
return float(ImageStat.Stat(gray).mean[0])
|
||||||
|
|
||||||
suffix = Path(rel).suffix or ".mp4"
|
|
||||||
src_tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
def _is_black_image_bytes(image_bytes: bytes) -> bool:
|
||||||
src_path = src_tmp.name
|
from PIL import Image
|
||||||
try:
|
with Image.open(io.BytesIO(image_bytes)) as im:
|
||||||
src_tmp.write(video_bytes)
|
im.load()
|
||||||
src_tmp.flush()
|
return _frame_mean_luma(im) < VIDEO_BLACK_FRAME_MEAN_THRESHOLD
|
||||||
finally:
|
|
||||||
src_tmp.close()
|
|
||||||
|
async def _generate_video_thumb_from_src_path(src_path: str, w: int, h: int, fit: str) -> Tuple[bytes, str]:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
dst_tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
dst_tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||||
dst_path = dst_tmp.name
|
dst_path = dst_tmp.name
|
||||||
dst_tmp.close()
|
dst_tmp.close()
|
||||||
|
|
||||||
|
best: tuple[float, bytes, str] | None = None
|
||||||
|
last_error: Exception | None = None
|
||||||
try:
|
try:
|
||||||
await _run_ffmpeg_extract_frame(src_path, dst_path)
|
for seek_seconds in VIDEO_THUMB_SEEK_SECONDS:
|
||||||
|
try:
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
Path(dst_path).unlink()
|
||||||
|
await _run_ffmpeg_extract_frame(src_path, dst_path, seek_seconds=seek_seconds)
|
||||||
with Image.open(dst_path) as im:
|
with Image.open(dst_path) as im:
|
||||||
im.load()
|
im.load()
|
||||||
return _image_to_webp(im, w, h, fit)
|
mean = _frame_mean_luma(im)
|
||||||
|
webp_bytes, mime = _image_to_webp(im, w, h, fit)
|
||||||
|
|
||||||
|
if best is None or mean > best[0]:
|
||||||
|
best = (mean, webp_bytes, mime)
|
||||||
|
if mean >= VIDEO_BLACK_FRAME_MEAN_THRESHOLD:
|
||||||
|
return webp_bytes, mime
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
if best is not None:
|
||||||
|
return best[1], best[2]
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise RuntimeError("ffmpeg 截帧失败")
|
||||||
|
finally:
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
Path(dst_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_video_thumb_from_segments(
|
||||||
|
head_bytes: bytes,
|
||||||
|
tail_bytes: bytes,
|
||||||
|
tail_offset: int,
|
||||||
|
rel: str,
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
fit: str,
|
||||||
|
) -> Tuple[bytes, str]:
|
||||||
|
src_path = _write_video_sparse_file(rel, head_bytes, tail_bytes, tail_offset)
|
||||||
|
try:
|
||||||
|
return await _generate_video_thumb_from_src_path(src_path, w, h, fit)
|
||||||
finally:
|
finally:
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
Path(src_path).unlink()
|
Path(src_path).unlink()
|
||||||
with suppress(FileNotFoundError):
|
|
||||||
Path(dst_path).unlink()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w: int, h: int, fit: str = 'cover'):
|
||||||
@@ -294,23 +350,55 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
|
|||||||
|
|
||||||
if not thumb_bytes:
|
if not thumb_bytes:
|
||||||
if is_video:
|
if is_video:
|
||||||
|
async def _read_head(limit: int) -> bytes:
|
||||||
try:
|
try:
|
||||||
video_bytes = await _read_video_prefix(adapter, root, rel, size)
|
return await _read_video_head(adapter, root, rel, size, limit=limit)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Video prefix read failed: {e}")
|
print(f"Video head read failed: {e}")
|
||||||
raise HTTPException(500, detail=f"Video read failed: {e}")
|
raise HTTPException(500, detail=f"Video read failed: {e}")
|
||||||
|
|
||||||
if not video_bytes:
|
async def _read_tail(limit: int) -> Tuple[bytes, int]:
|
||||||
|
try:
|
||||||
|
return await _read_video_tail(adapter, root, rel, size, limit=limit)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Video tail read failed: {e}")
|
||||||
|
raise HTTPException(500, detail=f"Video read failed: {e}")
|
||||||
|
|
||||||
|
head_bytes = await _read_head(VIDEO_HEAD_LIMIT)
|
||||||
|
tail_bytes, tail_offset = await _read_tail(VIDEO_TAIL_LIMIT)
|
||||||
|
if not head_bytes and not tail_bytes:
|
||||||
raise HTTPException(500, detail="Unable to read video data for thumbnail")
|
raise HTTPException(500, detail="Unable to read video data for thumbnail")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
thumb_bytes, mime = await _generate_video_thumb(video_bytes, rel, w, h, fit)
|
thumb_bytes, mime = await _generate_video_thumb_from_segments(
|
||||||
except Exception as e:
|
head_bytes, tail_bytes, tail_offset, rel, w, h, fit
|
||||||
print(f"Video thumbnail generation failed: {e}")
|
)
|
||||||
raise HTTPException(
|
except Exception:
|
||||||
500, detail=f"Video thumbnail generation failed: {e}")
|
try:
|
||||||
|
tail_bytes, tail_offset = await _read_tail(VIDEO_TAIL_FALLBACK_LIMIT)
|
||||||
|
thumb_bytes, mime = await _generate_video_thumb_from_segments(
|
||||||
|
head_bytes, tail_bytes, tail_offset, rel, w, h, fit
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e2:
|
||||||
|
print(f"Video thumbnail generation failed: {e2}")
|
||||||
|
raise HTTPException(500, detail=f"Video thumbnail generation failed: {e2}")
|
||||||
|
|
||||||
|
if thumb_bytes and _is_black_image_bytes(thumb_bytes):
|
||||||
|
try:
|
||||||
|
head_bytes = await _read_head(VIDEO_HEAD_FALLBACK_LIMIT)
|
||||||
|
retry_thumb, retry_mime = await _generate_video_thumb_from_segments(
|
||||||
|
head_bytes, tail_bytes, tail_offset, rel, w, h, fit
|
||||||
|
)
|
||||||
|
if retry_thumb and not _is_black_image_bytes(retry_thumb):
|
||||||
|
thumb_bytes, mime = retry_thumb, retry_mime
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
read_data = await adapter.read_file(root, rel)
|
read_data = await adapter.read_file(root, rel)
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user