2 Commits

Author SHA1 Message Date
H1K0 b8d08925a2 feat(backend): take video thumbnails from the middle frame
deploy / deploy (push) Successful in 1m1s
Video thumbnails and previews were extracted ~1s in, which lands on
shared intros, title cards or black lead-in frames. Take the frame from
the middle (duration/2) instead — the same frame used for the perceptual
hash — so the thumbnail/preview reflects what dedup compared. Fold the
midpoint logic into a shared extractVideoFrameMiddle helper reused by
both the thumbnail/preview path and VideoFrameMiddle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:14:04 +03:00
H1K0 dc40729646 fix(frontend): shift-select in gesture direction, not grid order
Shift range-select normalized the range with Math.min/Math.max and
always iterated ascending, so the selection's insertion order (which the
Set preserves and which carries through to e.g. pool add order) ignored
the gesture direction. Iterate anchor → target instead via a shared
selectRange helper, so selecting first→last and last→first yield
correspondingly ordered selections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:54:32 +03:00
2 changed files with 29 additions and 23 deletions
+15 -12
View File
@@ -173,12 +173,7 @@ func (s *DiskStorage) VideoFrameMiddle(ctx context.Context, id uuid.UUID) (image
} }
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err) return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
} }
// Fall back to a 1s offset if duration can't be probed — better a frame than none. return extractVideoFrameMiddle(ctx, srcPath)
at := 1.0
if d, err := videoDurationSeconds(ctx, srcPath); err == nil && d > 0 {
at = d / 2
}
return extractVideoFrameAt(ctx, srcPath, at)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -242,7 +237,7 @@ func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePat
var img image.Image var img image.Image
if decoded, err := decodeImageLimited(srcPath, s.maxPixels); err == nil { if decoded, err := decodeImageLimited(srcPath, s.maxPixels); err == nil {
img = imaging.Fit(decoded, maxW, maxH, imaging.Lanczos) img = imaging.Fit(decoded, maxW, maxH, imaging.Lanczos)
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil { } else if frame, err := extractVideoFrameMiddle(ctx, srcPath); err == nil {
img = imaging.Fit(frame, maxW, maxH, imaging.Lanczos) img = imaging.Fit(frame, maxW, maxH, imaging.Lanczos)
} else { } else {
img = placeholder(maxW, maxH) img = placeholder(maxW, maxH)
@@ -368,10 +363,18 @@ func (s *DiskStorage) vipsThumbnail(ctx context.Context, srcPath, cachePath stri
return f, nil return f, nil
} }
// extractVideoFrame extracts a single frame ~1 second into the video — a safe // extractVideoFrameMiddle extracts a single frame from the middle of the video
// default for thumbnails. See extractVideoFrameAt for the mechanics. // (duration/2), falling back to a 1s offset when the duration can't be probed.
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) { // The midpoint dodges shared intros, title cards and black lead-in frames, and
return extractVideoFrameAt(ctx, srcPath, 1) // matches the frame used for the perceptual hash so a video's thumbnail/preview
// shows the same representative frame dedup compared. See extractVideoFrameAt for
// the mechanics.
func extractVideoFrameMiddle(ctx context.Context, srcPath string) (image.Image, error) {
at := 1.0
if d, err := videoDurationSeconds(ctx, srcPath); err == nil && d > 0 {
at = d / 2
}
return extractVideoFrameAt(ctx, srcPath, at)
} }
// extractVideoFrameAt uses ffmpeg to extract a single frame at atSec seconds into // extractVideoFrameAt uses ffmpeg to extract a single frame at atSec seconds into
@@ -403,7 +406,7 @@ func extractVideoFrameAt(ctx context.Context, srcPath string, atSec float64) (im
} }
// videoDurationSeconds returns the container duration in seconds via ffprobe. // videoDurationSeconds returns the container duration in seconds via ffprobe.
// Used to seek to the middle of a clip for perceptual hashing. // Used to seek to the middle of a clip for perceptual hashing and thumbnails.
func videoDurationSeconds(ctx context.Context, srcPath string) (float64, error) { func videoDurationSeconds(ctx context.Context, srcPath string) (float64, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
+14 -11
View File
@@ -94,15 +94,22 @@
// Select via the keyboard: a plain press toggles the focused card and drops the // Select via the keyboard: a plain press toggles the focused card and drops the
// range anchor there; a Shift press selects everything from the anchor to the // range anchor there; a Shift press selects everything from the anchor to the
// focused card — the same model as Shift+click on the grid. // focused card — the same model as Shift+click on the grid.
// Select an inclusive index range in gesture direction (anchor → target) so the
// selection's insertion order follows how the user swept, not grid order. The
// Set preserves insertion order, so this is what later carries through to e.g.
// the order files land in a pool.
function selectRange(anchorIdx: number, targetIdx: number) {
const step = targetIdx >= anchorIdx ? 1 : -1;
for (let i = anchorIdx; i !== targetIdx + step; i += step) {
if (files[i]?.id) selectionStore.select(files[i].id!);
}
}
function selectFocused(range: boolean) { function selectFocused(range: boolean) {
const idx = focusedId ? files.findIndex((f) => f.id === focusedId) : -1; const idx = focusedId ? files.findIndex((f) => f.id === focusedId) : -1;
if (idx < 0) return; if (idx < 0) return;
if (range && lastSelectedIdx !== null) { if (range && lastSelectedIdx !== null) {
const from = Math.min(lastSelectedIdx, idx); selectRange(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
for (let i = from; i <= to; i++) {
if (files[i]?.id) selectionStore.select(files[i].id!);
}
} else if (files[idx]?.id) { } else if (files[idx]?.id) {
selectionStore.toggle(files[idx].id!); selectionStore.toggle(files[idx].id!);
} }
@@ -634,12 +641,8 @@
return; return;
} }
if (e.shiftKey && lastSelectedIdx !== null) { if (e.shiftKey && lastSelectedIdx !== null) {
// Range-select between lastSelectedIdx and idx (desktop) // Range-select from the anchor toward idx (desktop), in gesture order.
const from = Math.min(lastSelectedIdx, idx); selectRange(lastSelectedIdx, idx);
const to = Math.max(lastSelectedIdx, idx);
for (let i = from; i <= to; i++) {
if (files[i]?.id) selectionStore.select(files[i].id!);
}
lastSelectedIdx = idx; lastSelectedIdx = idx;
} else { } else {
if (file.id) selectionStore.toggle(file.id); if (file.id) selectionStore.toggle(file.id);