Skip to content

Commit ec5040d

Browse files
committed
App: Add video thumbnails
[skip ci]
1 parent 25d8aed commit ec5040d

2 files changed

Lines changed: 72 additions & 9 deletions

File tree

‎scripts/gdrive_app.py‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424

2525
from collections import OrderedDict
2626
from functools import lru_cache
27-
import pdfutils as pdfutils
28-
import gdrive_base as gdrive_base
29-
from strutils import thumbnail_path_for_file
27+
import pdfutils
28+
import videoutils
29+
import gdrive_base
30+
from strutils import thumbnail_path_for_file, THUMBNAIL_SIZES
3031

3132

3233
class ThumbnailWorker(QRunnable):
@@ -35,6 +36,7 @@ def __init__(self, item, cancel_flag, emit_callback):
3536
self.item = item
3637
self.cancel_flag = cancel_flag
3738
self.emit_callback = emit_callback
39+
self.size = 'normal'
3840

3941
def is_cancelled(self):
4042
return self.cancel_flag[0]
@@ -55,19 +57,24 @@ def _process_item(self, item):
5557
cache_path = gcache.get_cache_path_for_file(item)
5658
if not cache_path:
5759
return
58-
thumb_path = thumbnail_path_for_file(cache_path, shared=True, size='normal')
60+
size = THUMBNAIL_SIZES[self.size]
61+
thumb_path = thumbnail_path_for_file(cache_path, shared=True, size=self.size)
5962
if thumb_path.exists():
6063
img = QImage(str(thumb_path))
6164
elif cache_path.exists():
6265
if self.is_cancelled(): return
6366
if mime == 'application/pdf':
64-
thumbnail_bytes = pdfutils.get_cached_pdf_thumbnail(cache_path, size='normal')
67+
thumbnail_bytes = pdfutils.get_cached_pdf_thumbnail(cache_path, size=self.size)
68+
img = QImage()
69+
img.loadFromData(thumbnail_bytes)
70+
elif mime.startswith('video/'):
71+
thumbnail_bytes = videoutils.get_cached_video_thumbnail(cache_path, size=self.size)
6572
img = QImage()
6673
img.loadFromData(thumbnail_bytes)
6774
else:
68-
if mime == 'application/pdf':
75+
if mime == 'application/pdf' or mime.startswith('video/'):
6976
if self.is_cancelled(): return
70-
thumbnail_bytes = gdrive_base.fetch_preview_image(file_id, size=128)
77+
thumbnail_bytes = gdrive_base.fetch_preview_image(file_id, size=size)
7178
if thumbnail_bytes:
7279
img = QImage()
7380
img.loadFromData(thumbnail_bytes)
@@ -374,7 +381,7 @@ def populate_files(self, items: List[Dict[str, Any]]):
374381
list_item.setIcon(QIcon(cached_pixmap))
375382
else:
376383
list_item.setIcon(get_mime_icon(mime))
377-
if mime == 'application/pdf':
384+
if mime == 'application/pdf' or mime.startswith('video/'):
378385
items_needing_thumbnails.append(item)
379386

380387
list_item.setData(Qt.UserRole, item)
@@ -395,7 +402,7 @@ def update_visible_thumbnails(self):
395402
mime = file_data.get('mimeType', '')
396403
file_id = file_data.get('id', '')
397404

398-
if mime == 'application/pdf' and file_id not in self.queued_thumbnails:
405+
if (mime == 'application/pdf' or mime.startswith('video/')) and file_id not in self.queued_thumbnails:
399406
if not self.thumbnail_cache.get(file_id):
400407
item_rect = self.file_view.visualItemRect(item)
401408
if expanded_rect.intersects(item_rect):

‎scripts/videoutils.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/bin/python3
2+
import subprocess
3+
from pathlib import Path
4+
from strutils import thumbnail_path_for_file, THUMBNAIL_SIZES
5+
6+
def render_video_thumbnail(video_path: Path, size=128) -> bytes:
7+
"""Uses ffmpeg to extract a frame from a video file."""
8+
try:
9+
# We try to get a frame at 1 second in.
10+
# -ss 1: seek to 1 second
11+
# -i: input file
12+
# -vframes 1: extract 1 frame
13+
# -f image2pipe: output as image pipe
14+
# -vcodec png: encode as png
15+
# -vf scale: resize maintaining aspect ratio
16+
cmd = [
17+
'ffmpeg',
18+
'-ss', '1',
19+
'-i', str(video_path),
20+
'-vframes', '1',
21+
'-f', 'image2pipe',
22+
'-vcodec', 'png',
23+
'-vf', f'scale={size}:-1',
24+
'-'
25+
]
26+
# We use a short timeout and capture output
27+
result = subprocess.run(cmd, capture_output=True, timeout=10)
28+
if result.returncode == 0:
29+
return result.stdout
30+
else:
31+
print(f"ffmpeg error for {video_path}: {result.stderr.decode(errors='replace')}")
32+
return b''
33+
except subprocess.TimeoutExpired:
34+
print(f"ffmpeg timed out for {video_path}")
35+
return b''
36+
except Exception as e:
37+
print(f"Error rendering video thumbnail for {video_path}: {e}")
38+
return b''
39+
40+
def get_cached_video_thumbnail(video_path: Path, size='normal') -> bytes:
41+
"""Returns the cached thumbnail bytes for a video file, or generates it if missing."""
42+
thumbnail_path = thumbnail_path_for_file(video_path, shared=True, size=size)
43+
if thumbnail_path.is_file():
44+
return thumbnail_path.read_bytes()
45+
46+
# Also check non-shared cache
47+
alt_path = thumbnail_path_for_file(video_path, shared=False, size=size)
48+
if alt_path.is_file():
49+
return alt_path.read_bytes()
50+
51+
tsize = THUMBNAIL_SIZES[size]
52+
thebytes = render_video_thumbnail(video_path, size=tsize)
53+
if thebytes:
54+
thumbnail_path.parent.mkdir(exist_ok=True, parents=True)
55+
thumbnail_path.write_bytes(thebytes)
56+
return thebytes

0 commit comments

Comments
 (0)