all repos — weimar @ 453d1fa348ab96e179dc278cf0d13e8f9d0c7121

Unnamed repository; edit this file 'description' to name the repository.

feat: probe video dimensions and duration, show badge on thumbnails

Adds GetVideoDimensions via ffprobe, stores duration in DB (migration 9),
fixes 0x0 display for existing videos by extending rescan-innate to
backfill width/height/duration. Shows a play-icon + duration badge
on video thumbnail cards.
Maxwell Jensen maxwelljensen@posteo.net
Thu, 14 May 2026 17:31:08 +0200
commit

453d1fa348ab96e179dc278cf0d13e8f9d0c7121

parent

ab494a4c3e2c99426dd9f18f02bcb3fb7ee678a3

M cmd/weimar/image.gocmd/weimar/image.go

@@ -3,6 +3,7 @@

import ( "fmt" "strconv" + "strings" "codeberg.org/maxwelljensen/weimar/internal/config" "codeberg.org/maxwelljensen/weimar/internal/db"

@@ -66,11 +67,15 @@ }

var imageRescanInnateCmd = &cobra.Command{ Use: "rescan-innate", - Short: "Recompute innate tags for all images missing them", - Long: `Scans every image in the database and computes innate tags -(image, video, gif, lowres, highres, >10 sec, <10 sec) for any -record that has no innate tags yet. This is useful after upgrading -from an older version that did not support innate tags.`, + Short: "Recompute innate tags and backfill video metadata for all images", + Long: `Scans every image in the database and: +- Computes innate tags (image, video, gif, lowres, highres, >10 sec, <10 sec) + for any record that has no innate tags yet. +- Probes and stores width, height, and duration for any video that has 0x0 + dimensions (backfill for uploads made before video probing was added). + +This is useful after upgrading from an older version that did not support +innate tags or video dimension probing.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg := config.FromViper()

@@ -91,14 +96,31 @@ if err != nil {

return fmt.Errorf("listing images: %w", err) } - var updated int + var innateUpdated, videoUpdated int for _, img := range images { + isVideo := storage.IsVideo(img.MimeType) + + // Step 1: probe and fix video dimensions/duration if needed. + if isVideo && (img.Width == 0 || img.Height == 0 || img.Duration == 0) { + videoAbs := st.AbsPath(img.StoragePath) + w, h := storage.GetVideoDimensions(videoAbs) + dur := storage.GetVideoDuration(videoAbs) + if err := database.UpdateImageVideoMetadata(cmd.Context(), img.ID, w, h, dur); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error updating video metadata for image %d: %v\n", img.ID, err) + continue + } + img.Width, img.Height, img.Duration = w, h, dur + videoUpdated++ + fmt.Fprintf(cmd.OutOrStdout(), " video %d (%s): %dx%d, %.1fs\n", img.ID, img.Filename, w, h, dur) + } + + // Step 2: compute innate tags if missing. if len(img.InnateTags) > 0 { continue } videoPath := "" - if storage.IsVideo(img.MimeType) { + if isVideo { videoPath = st.AbsPath(img.StoragePath) } tags := storage.ComputeInnateTags(img.MimeType, img.Width, img.Height, videoPath)

@@ -107,11 +129,22 @@ if err := database.UpdateImageInnateTags(cmd.Context(), img.ID, tags); err != nil {

fmt.Fprintf(cmd.ErrOrStderr(), "error updating image %d: %v\n", img.ID, err) continue } - updated++ + innateUpdated++ fmt.Fprintf(cmd.OutOrStdout(), " image %d (%s): %v\n", img.ID, img.Filename, tags) } - fmt.Fprintf(cmd.OutOrStdout(), "done — updated %d image(s)\n", updated) + var parts []string + if videoUpdated > 0 { + parts = append(parts, fmt.Sprintf("updated %d video(s)", videoUpdated)) + } + if innateUpdated > 0 { + parts = append(parts, fmt.Sprintf("updated %d image(s) with innate tags", innateUpdated)) + } + if len(parts) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "nothing to update\n") + } else { + fmt.Fprintf(cmd.OutOrStdout(), "done — %s\n", strings.Join(parts, ", ")) + } return nil }, }
M internal/api/upload.gointernal/api/upload.go

@@ -72,9 +72,11 @@ if err != nil {

writeError(w, http.StatusInternalServerError, "failed to store file") return } + storagePathAbs := a.Storage.AbsPath(storagePath) - // Get image dimensions (images only). + // Get image dimensions and duration. var width, height int + var duration float64 if storage.IsImage(mimeType) { img, _, _ := storage.DecodeImage(processed) if img != nil {

@@ -82,6 +84,10 @@ bounds := img.Bounds()

width = bounds.Dx() height = bounds.Dy() } + } else if storage.IsVideo(mimeType) { + w, h := storage.GetVideoDimensions(storagePathAbs) + width, height = w, h + duration = storage.GetVideoDuration(storagePathAbs) } // Generate thumbnail.

@@ -96,9 +102,8 @@ }

} } else if storage.IsVideo(mimeType) && storage.FFmpegAvailable() { thumbRel := storage.ThumbnailPath(storagePath) - absPath := a.Storage.AbsPath(storagePath) thumbAbs := a.Storage.AbsPath(thumbRel) - if err := storage.GenerateVideoThumbnail(absPath, thumbAbs); err == nil { + if err := storage.GenerateVideoThumbnail(storagePathAbs, thumbAbs); err == nil { thumbnailPath = &thumbRel } }

@@ -119,7 +124,7 @@

// Compute innate tags based on media characteristics. videoPath := "" if storage.IsVideo(mimeType) { - videoPath = a.Storage.AbsPath(storagePath) + videoPath = storagePathAbs } innateTags := storage.ComputeInnateTags(mimeType, width, height, videoPath)

@@ -134,6 +139,7 @@ Width: width,

Height: height, MimeType: mimeType, InnateTags: innateTags, + Duration: duration, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to save metadata")
M internal/db/models.gointernal/db/models.go

@@ -33,6 +33,7 @@ Width int `json:"width"`

Height int `json:"height"` MimeType string `json:"mime_type"` InnateTags []string `json:"innate_tags,omitempty"` + Duration float64 `json:"duration,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Tags []string `json:"tags,omitempty"`
M internal/db/queries.gointernal/db/queries.go

@@ -204,10 +204,10 @@ innateTagsJSON := marshalStrings(img.InnateTags)

res, err := d.db.ExecContext(ctx, `INSERT INTO images (filename, storage_path, thumbnail_path, uploaded_by, - file_size, width, height, mime_type, innate_tags, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + file_size, width, height, mime_type, innate_tags, duration, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, img.Filename, img.StoragePath, img.ThumbnailPath, img.UploadedBy, - img.FileSize, img.Width, img.Height, img.MimeType, innateTagsJSON, t, t, + img.FileSize, img.Width, img.Height, img.MimeType, innateTagsJSON, img.Duration, t, t, ) if err != nil { return nil, fmt.Errorf("create image: %w", err)

@@ -224,7 +224,7 @@ func (d *DB) GetImageByID(ctx context.Context, id int64) (*Image, error) {

row := d.db.QueryRowContext(ctx, `SELECT id, filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, - innate_tags, created_at, updated_at + innate_tags, duration, created_at, updated_at FROM images WHERE id = ?`, id) img, err := scanImage(row) if err != nil {

@@ -250,7 +250,7 @@ countQuery := `SELECT COUNT(DISTINCT i.id) FROM images i`

// Data query dataQuery := `SELECT i.id, i.filename, i.storage_path, i.thumbnail_path, i.uploaded_by, i.file_size, i.width, i.height, i.mime_type, - i.innate_tags, i.created_at, i.updated_at + i.innate_tags, i.duration, i.created_at, i.updated_at FROM images i` var args []any

@@ -360,7 +360,7 @@ func (d *DB) ListAllImages(ctx context.Context) ([]Image, error) {

rows, err := d.db.QueryContext(ctx, `SELECT id, filename, storage_path, thumbnail_path, uploaded_by, file_size, width, height, mime_type, - innate_tags, created_at, updated_at + innate_tags, duration, created_at, updated_at FROM images ORDER BY id`) if err != nil { return nil, fmt.Errorf("list all images: %w", err)

@@ -386,6 +386,16 @@ marshalStrings(tags), now(), id)

return err } +// UpdateImageVideoMetadata updates the width, height, and duration columns for +// a single image. Used by CLI repair commands to backfill metadata for videos +// that were uploaded before video dimension/duration probing was added. +func (d *DB) UpdateImageVideoMetadata(ctx context.Context, id int64, width, height int, duration float64) error { + _, err := d.db.ExecContext(ctx, + `UPDATE images SET width = ?, height = ?, duration = ?, updated_at = ? WHERE id = ?`, + width, height, duration, now(), id) + return err +} + type imageScanner interface { Scan(dest ...any) error }

@@ -393,10 +403,11 @@

func scanImage(s imageScanner) (*Image, error) { var img Image var thumbnailPath, innateTags, createdAt, updatedAt sql.NullString + var duration sql.NullFloat64 if err := s.Scan( &img.ID, &img.Filename, &img.StoragePath, &thumbnailPath, &img.UploadedBy, &img.FileSize, &img.Width, &img.Height, &img.MimeType, - &innateTags, &createdAt, &updatedAt, + &innateTags, &duration, &createdAt, &updatedAt, ); err != nil { return nil, err }

@@ -405,6 +416,9 @@ img.ThumbnailPath = &thumbnailPath.String

} if innateTags.Valid { img.InnateTags = unmarshalStrings(innateTags.String) + } + if duration.Valid { + img.Duration = duration.Float64 } if createdAt.Valid { img.CreatedAt, _ = scanTime(createdAt.String)
M internal/db/sqlite.gointernal/db/sqlite.go

@@ -139,6 +139,12 @@ sql: `

ALTER TABLE images ADD COLUMN innate_tags TEXT; `, }, + { + version: 9, + sql: ` + ALTER TABLE images ADD COLUMN duration REAL DEFAULT 0; + `, + }, } for _, m := range migrations {
M internal/storage/processing.gointernal/storage/processing.go

@@ -277,6 +277,32 @@ func GetVideoDuration(videoPath string) float64 {

return getVideoDuration(videoPath) } +// GetVideoDimensions probes the width and height of a video file using ffprobe. +// Returns 0, 0 if ffprobe is unavailable or the probe fails. +func GetVideoDimensions(videoPath string) (width, height int) { + cmd := exec.Command("ffprobe", + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0", + videoPath, + ) + out, err := cmd.Output() + if err != nil { + return 0, 0 + } + parts := strings.Split(strings.TrimSpace(string(out)), ",") + if len(parts) != 2 { + return 0, 0 + } + w, errW := strconv.Atoi(strings.TrimSpace(parts[0])) + h, errH := strconv.Atoi(strings.TrimSpace(parts[1])) + if errW != nil || errH != nil { + return 0, 0 + } + return w, h +} + // ComputeInnateTags determines the set of automatically-assigned tags for a // piece of media based on its MIME type, dimensions, and (for videos) duration. // The videoPath argument is used only for video duration probing; pass an empty
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -21,6 +21,7 @@ width: number;

height: number; mime_type: string; innate_tags?: string[]; + duration?: number; created_at: string; updated_at: string; tags: string[];
M web/src/routes/browse/+page.svelteweb/src/routes/browse/+page.svelte

@@ -159,6 +159,12 @@ mainEl.style.maxWidth = 'none';

} } + function formatDuration(sec: number): string { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return m + ':' + String(s).padStart(2, '0'); + } + // Auto-scroll the active suggestion into view when keyboard-navigated. $effect(() => { if (tagSugIndex >= 0 || uploaderSugIndex >= 0) {

@@ -304,6 +310,12 @@ {#if img.thumbnail_path}

<img src={thumbnailUrl(img.id)} alt={img.filename} loading="lazy" /> {:else} <div class="placeholder">{img.mime_type}</div> + {/if} + {#if img.mime_type.startsWith('video/') && img.duration} + <div class="video-badge"> + <svg class="video-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><polygon points="6,4 20,12 6,20"/></svg> + <span class="video-duration">{formatDuration(img.duration)}</span> + </div> {/if} </div> <div class="card-overlay">

@@ -522,6 +534,26 @@ width: 100%;

height: 100%; object-fit: cover; transition: transform 300ms ease, filter 300ms ease; + } + .video-badge { + position: absolute; + bottom: 0.4rem; + right: 0.4rem; + display: flex; + align-items: center; + gap: 0.25rem; + background: rgba(0, 0, 0, 0.75); + color: #fff; + padding: 0.15rem 0.45rem 0.15rem 0.35rem; + font-family: 'Inter', sans-serif; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.4; + pointer-events: none; + } + .video-icon { + flex-shrink: 0; + opacity: 0.9; } .card:hover .card-image img { transform: scale(1.08);