--- name: generated-media-gallery-share-links description: Persist generated media to stable URLs, store generation metadata, and expose shareable gallery links in Vite/Express apps. version: 1.0.0 author: Hermes Agent license: MIT metadata: hermes: tags: [gallery, media, sharing, express, vite, sqlite, s3, local-storage] --- # Generated Media Gallery + Share Links Use when a web app generates images/videos, saves them to S3 or local disk, and needs a gallery that shows metadata and provides stable share/download links. ## Problem Pattern A generation succeeds, the app saves the file, but the UI still uses the provider's transient preview URL (`previewUrl`) for display or sharing. Result: users see generic load failures even though the media exists locally. ## Core Rules 1. Prefer persisted asset URLs over provider preview URLs for gallery/display/share. 2. Store generation metadata at save time, not only in transient API responses. 3. Return absolute share URLs from the backend for easy copying/sharing. 4. Support both S3-hosted and local fallback assets. ## Recommended Data Model In the `assets` table, store at least: - `model` - `prompt` - `result_kind` - `storage_key` - `storage_url` - `storage_mode` - `params_json` - `inference_time` - `output_duration` - `created_at` For SQLite migrations in an existing app: - `ALTER TABLE assets ADD COLUMN inference_time REAL` - `ALTER TABLE assets ADD COLUMN output_duration TEXT` ## Backend Pattern ### 1. Persist stable metadata when saving an asset When creating the DB record, write: - `inferenceTime: result.inferenceTime ?? null` - `outputDuration: result.duration != null ? String(result.duration) : null` ### 2. Normalize asset records for API responses Return a normalized object including: - `prompt` - `model` - `modelName` - `imageUrl` / `videoUrl` - `storageMode` - `inferenceTime` - `duration` - `shareUrl` ### 3. Absolute share URL helper Use a helper like: ```js function absolutizeUrl(url, origin) { if (!url) return null if (/^https?:\/\//i.test(url)) return url if (!origin) return url const normalizedOrigin = origin.replace(/\/$/, '') const normalizedPath = url.startsWith('/') ? url : `/${url}` return `${normalizedOrigin}${normalizedPath}` } ``` ### 4. Derive request origin behind nginx For Express apps behind a proxy, build absolute links from: - `x-forwarded-proto` - `req.get('host')` Pattern: ```js function getRequestOrigin(req) { const forwardedProto = req.headers['x-forwarded-proto'] const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto) || req.protocol || 'https' return `${proto}://${req.get('host')}` } ``` ### 5. Gallery endpoint In `/api/gallery`, call the normalizer with the computed origin so each asset includes `shareUrl`. ## Frontend Pattern ### 1. URL preference order For generated results and gallery cards, prefer persisted URLs first: ```js result.videoUrl || result.imageUrl || result.storageUrl || result.previewUrl ``` For gallery items: ```js item.localUrl || item.imageUrl || item.videoUrl || item.storageUrl || item.previewUrl ``` Do NOT prefer `previewUrl` first. ### 2. Never use a video file as a poster image If the result is a video, do NOT set `poster` to the video URL itself. Browsers may surface this as a vague client-side `Load failed` / preview failure even when the MP4 exists and returns 200. Use a helper like: ```js export function getPosterUrl(result) { if (!result) return null return result.imageUrl || null } ``` Then only pass `poster={getPosterUrl(result) || undefined}`. ### 3. Show gallery metadata Display at least: - `Prompt:` - `Model:` - `Took:` inference time - `Output:` duration when present ### 4. Share action Implement a single share helper: - use `navigator.share()` if available - else `navigator.clipboard.writeText(shareUrl)` - else fallback to `window.prompt('Copy this share link:', shareUrl)` ### 5. Replace janky lightboxes with a real viewer surface For gallery media viewing, prefer a modal surface over an old-style full-screen overlay with floating controls. Recommended structure: - blurred/dimmed backdrop - solid centered surface with rounded corners - header with title/prompt and close button - scrollable body (`overflow-y: auto`) - dedicated framed media area for image/video - footer actions for download/share This avoids: - non-scrollable overlays - controls floating on top of media - browser video controls fighting with app chrome ### 6. Delete actions need explicit confirmation UI Do not delete immediately from a tiny trash icon. Use a confirmation modal with: - clear prompt (`Delete this item?`) - model + prompt summary - explicit `Cancel` and `Delete` buttons - disabled state while delete is in flight ### 7. Lightbox and post-generation actions Add share buttons in both places: - newly generated result view - gallery viewer/modal ## Testing Pattern Use `node:test` or the project test framework to verify normalization logic. Good regression tests: 1. Local asset path (`/api/local-assets/...`) becomes an absolute `https://.../api/local-assets/...` share URL. 2. Absolute S3 URL is preserved as-is. 3. Inference time and duration are exposed by the normalizer. ## Verification Checklist - [ ] Failing tests written first for share URL normalization - [ ] Gallery API returns `shareUrl` - [ ] Newly generated items include `shareUrl` - [ ] Gallery prefers stable stored asset URLs over provider preview URLs - [ ] Local saved assets open directly via app domain - [ ] S3 assets remain shareable via direct object URL - [ ] App rebuilt and PM2 restarted - [ ] Deployed bundle timestamp verified ## Key Lesson If the provider returns a preview URL and the app also persists the file locally or to S3, the gallery/share system must treat the persisted file as the source of truth. The preview URL is only a transient convenience.