feat: add video thumbnail support and update

This commit is contained in:
ShiYu
2025-10-18 16:34:48 +08:00
parent 11799cd97c
commit 8eaa025f7e
4 changed files with 238 additions and 41 deletions
+4 -2
View File
@@ -13,7 +13,9 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y nginx git && rm -rf /var/lib/apt/lists/* RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx git ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN pip install uv RUN pip install uv
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
@@ -35,4 +37,4 @@ EXPOSE 80
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+3 -3
View File
@@ -17,7 +17,7 @@ from services.virtual_fs import (
verify_temp_link_token, verify_temp_link_token,
maybe_redirect_download, maybe_redirect_download,
) )
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename, is_video_filename
from schemas import MkdirRequest, MoveRequest from schemas import MkdirRequest, MoveRequest
from api.response import success from api.response import success
from services.config import ConfigCenter from services.config import ConfigCenter
@@ -121,8 +121,8 @@ async def get_thumb(
adapter, mount, root, rel = await resolve_adapter_and_rel(full_path) adapter, mount, root, rel = await resolve_adapter_and_rel(full_path)
if not rel or rel.endswith('/'): if not rel or rel.endswith('/'):
raise HTTPException(400, detail="Not a file") raise HTTPException(400, detail="Not a file")
if not is_image_filename(rel): if not (is_image_filename(rel) or is_video_filename(rel)):
raise HTTPException(404, detail="Not an image") raise HTTPException(404, detail="Not an image or video")
# type: ignore # type: ignore
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit) data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit)
headers = { headers = {
+226 -32
View File
@@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import inspect
import io import io
import hashlib import hashlib
import tempfile
from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Tuple from typing import Tuple
from fastapi import HTTPException from fastapi import HTTPException
@@ -8,7 +12,10 @@ from fastapi import HTTPException
ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp", ALLOWED_EXT = {"jpg", "jpeg", "png", "webp", "gif", "bmp",
"tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"} "tiff", "arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"} RAW_EXT = {"arw", "cr2", "cr3", "nef", "rw2", "orf", "pef", "dng"}
MAX_SOURCE_SIZE = 200 * 1024 * 1024 VIDEO_EXT = {"mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "mpg", "mpeg", "3gp"}
MAX_IMAGE_SOURCE_SIZE = 200 * 1024 * 1024
VIDEO_RANGE_LIMIT = 16 * 1024 * 1024 # 16MB
VIDEO_INITIAL_CHUNK = 4 * 1024 * 1024
CACHE_ROOT = Path('data/.thumb_cache') CACHE_ROOT = Path('data/.thumb_cache')
@@ -26,6 +33,13 @@ def is_raw_filename(name: str) -> bool:
return parts[1].lower() in RAW_EXT return parts[1].lower() in RAW_EXT
def is_video_filename(name: str) -> bool:
parts = name.rsplit('.', 1)
if len(parts) < 2:
return False
return parts[1].lower() in VIDEO_EXT
def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str: def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str:
raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode() raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
return hashlib.sha1(raw).hexdigest() return hashlib.sha1(raw).hexdigest()
@@ -40,6 +54,30 @@ def _ensure_cache_dir(p: Path):
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
def _image_to_webp(im, w: int, h: int, fit: str) -> Tuple[bytes, str]:
from PIL import Image
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
if fit == 'cover':
im_ratio = im.width / im.height
target_ratio = w / h
if im_ratio > target_ratio:
new_h = h
new_w = int(h * im_ratio)
else:
new_w = w
new_h = int(w / im_ratio)
im = im.resize((new_w, new_h))
left = max(0, (im.width - w)//2)
top = max(0, (im.height - h)//2)
im = im.crop((left, top, left + w, top + h))
else:
im.thumbnail((w, h))
buf = io.BytesIO()
im.save(buf, 'WEBP', quality=80)
return buf.getvalue(), 'image/webp'
def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False) -> Tuple[bytes, str]: def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False) -> Tuple[bytes, str]:
from PIL import Image from PIL import Image
if is_raw: if is_raw:
@@ -64,35 +102,172 @@ def generate_thumb(data: bytes, w: int, h: int, fit: str, is_raw: bool = False)
else: else:
im = Image.open(io.BytesIO(data)) im = Image.open(io.BytesIO(data))
if im.mode not in ("RGB", "RGBA"): return _image_to_webp(im, w, h, fit)
im = im.convert("RGBA" if im.mode in ("P", "LA") else "RGB")
if fit == 'cover':
im_ratio = im.width / im.height async def _collect_response_bytes(response, limit: int) -> bytes:
target_ratio = w / h if response is None:
if im_ratio > target_ratio: return b""
new_h = h
new_w = int(h * im_ratio) try:
else: if isinstance(response, (bytes, bytearray)):
new_w = w return bytes(response[:limit])
new_h = int(w / im_ratio)
im = im.resize((new_w, new_h)) body = getattr(response, "body", None)
left = max(0, (im.width - w)//2) if body is not None:
top = max(0, (im.height - h)//2) return bytes(body[:limit])
im = im.crop((left, top, left + w, top + h))
else: iterator = getattr(response, "body_iterator", None)
im.thumbnail((w, h)) if iterator is not None:
buf = io.BytesIO() data = bytearray()
im.save(buf, 'WEBP', quality=80) async for chunk in iterator:
return buf.getvalue(), 'image/webp' if not chunk:
continue
need = limit - len(data)
if need <= 0:
break
data.extend(chunk[:need])
if len(data) >= limit:
break
return bytes(data)
if hasattr(response, "__aiter__"):
data = bytearray()
async for chunk in response:
if not chunk:
continue
need = limit - len(data)
if need <= 0:
break
data.extend(chunk[:need])
if len(data) >= limit:
break
return bytes(data)
finally:
close_func = getattr(response, "close", None)
if callable(close_func):
result = close_func()
if inspect.isawaitable(result):
await result
return b""
async def _read_range_slice(adapter, root: str, rel: str, start: int, end: int) -> bytes:
read_range = getattr(adapter, "read_file_range", None)
if callable(read_range):
try:
return await read_range(root, rel, start, end)
except TypeError:
return await read_range(root, rel, start, end=end)
stream_impl = getattr(adapter, "stream_file", None)
if callable(stream_impl):
range_header = f"bytes={start}-{end}"
response = await stream_impl(root, rel, range_header)
expected = end - start + 1
return await _collect_response_bytes(response, expected)
read_file = getattr(adapter, "read_file", None)
if callable(read_file) and start == 0:
data = await read_file(root, rel)
slice_end = end + 1
return data[:slice_end]
return b""
async def _read_video_prefix(adapter, root: str, rel: str, size: int, limit: int = VIDEO_RANGE_LIMIT) -> bytes:
chunk_size = min(VIDEO_INITIAL_CHUNK, limit)
offset = 0
collected = bytearray()
while len(collected) < limit:
end = offset + chunk_size - 1
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):
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", src_path,
"-frames:v", "1",
dst_path,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as e:
raise RuntimeError("未找到 ffmpeg,可执行文件需要在 PATH 中") from e
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
message = stderr.decode().strip() or stdout.decode().strip() or "ffmpeg 执行失败"
raise RuntimeError(message)
async def _generate_video_thumb(video_bytes: bytes, rel: str, w: int, h: int, fit: str) -> Tuple[bytes, str]:
from PIL import Image
suffix = Path(rel).suffix or ".mp4"
src_tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
src_path = src_tmp.name
try:
src_tmp.write(video_bytes)
src_tmp.flush()
finally:
src_tmp.close()
dst_tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
dst_path = dst_tmp.name
dst_tmp.close()
try:
await _run_ffmpeg_extract_frame(src_path, dst_path)
with Image.open(dst_path) as im:
im.load()
return _image_to_webp(im, w, h, fit)
finally:
with suppress(FileNotFoundError):
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'):
stat = await adapter.stat_file(root, rel) stat = await adapter.stat_file(root, rel)
if stat['size'] > MAX_SOURCE_SIZE: size = int(stat.get('size') or 0)
is_video = is_video_filename(rel)
if not is_video and size > MAX_IMAGE_SOURCE_SIZE:
raise HTTPException(400, detail="Image too large for thumbnail") raise HTTPException(400, detail="Image too large for thumbnail")
key = _cache_key(adapter_id, rel, stat['size'], int( key = _cache_key(adapter_id, rel, size, int(
stat['mtime']), w, h, fit) stat.get('mtime', 0)), w, h, fit)
path = _cache_path(key) path = _cache_path(key)
if path.exists(): if path.exists():
return path.read_bytes(), 'image/webp', key return path.read_bytes(), 'image/webp', key
@@ -119,14 +294,33 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
thumb_bytes, mime = None, None thumb_bytes, mime = None, None
if not thumb_bytes: if not thumb_bytes:
read_data = await adapter.read_file(root, rel) if is_video:
try: try:
thumb_bytes, mime = generate_thumb( video_bytes = await _read_video_prefix(adapter, root, rel, size)
read_data, w, h, fit, is_raw=is_raw_filename(rel)) except HTTPException:
except Exception as e: raise
print(e) except Exception as e:
raise HTTPException( print(f"Video prefix read failed: {e}")
500, detail=f"Thumbnail generation failed: {e}") raise HTTPException(500, detail=f"Video read failed: {e}")
if not video_bytes:
raise HTTPException(500, detail="Unable to read video data for thumbnail")
try:
thumb_bytes, mime = await _generate_video_thumb(video_bytes, rel, w, h, fit)
except Exception as e:
print(f"Video thumbnail generation failed: {e}")
raise HTTPException(
500, detail=f"Video thumbnail generation failed: {e}")
else:
read_data = await adapter.read_file(root, rel)
try:
thumb_bytes, mime = generate_thumb(
read_data, w, h, fit, is_raw=is_raw_filename(rel))
except Exception as e:
print(e)
raise HTTPException(
500, detail=f"Thumbnail generation failed: {e}")
if thumb_bytes: if thumb_bytes:
path.write_bytes(thumb_bytes) path.write_bytes(thumb_bytes)
+5 -4
View File
@@ -15,7 +15,7 @@ import aiofiles
from models import StorageAdapter from models import StorageAdapter
from .adapters.registry import runtime_registry from .adapters.registry import runtime_registry
from api.response import page from api.response import page
from .thumbnail import is_image_filename, is_raw_filename from .thumbnail import is_image_filename, is_raw_filename, is_video_filename
from services.processors.registry import get as get_processor from services.processors.registry import get as get_processor
from services.tasks import task_service from services.tasks import task_service
from services.logging import LogService from services.logging import LogService
@@ -171,7 +171,8 @@ async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, so
def annotate_entry(entry: Dict) -> None: def annotate_entry(entry: Dict) -> None:
if not entry.get("is_dir"): if not entry.get("is_dir"):
entry["has_thumbnail"] = is_image_filename(entry.get("name", "")) name = entry.get("name", "")
entry["has_thumbnail"] = bool(is_image_filename(name) or is_video_filename(name))
else: else:
entry["has_thumbnail"] = False entry["has_thumbnail"] = False
@@ -597,8 +598,8 @@ async def stat_file(path: str):
except Exception: except Exception:
is_dir = False is_dir = False
rel_name = rel.rstrip('/').split('/')[-1] if rel else path.rstrip('/').split('/')[-1] rel_name = rel.rstrip('/').split('/')[-1] if rel else path.rstrip('/').split('/')[-1]
name_hint = info.get("name") or rel_name name_hint = str(info.get("name") or rel_name or "")
info["has_thumbnail"] = bool(not is_dir and is_image_filename(str(name_hint or ""))) info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
if not is_dir: if not is_dir:
vector_index = await _gather_vector_index(path) vector_index = await _gather_vector_index(path)
if vector_index is not None: if vector_index is not None: