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.
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.
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
When creating the DB record, write:
- inferenceTime: result.inferenceTime ?? null
- outputDuration: result.duration != null ? String(result.duration) : null
Return a normalized object including:
- prompt
- model
- modelName
- imageUrl / videoUrl
- storageMode
- inferenceTime
- duration
- shareUrl
Use a helper like:
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}`
}
For Express apps behind a proxy, build absolute links from:
- x-forwarded-proto
- req.get('host')
Pattern:
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')}`
}
In /api/gallery, call the normalizer with the computed origin so each asset includes shareUrl.
For generated results and gallery cards, prefer persisted URLs first:
result.videoUrl || result.imageUrl || result.storageUrl || result.previewUrl
For gallery items:
item.localUrl || item.imageUrl || item.videoUrl || item.storageUrl || item.previewUrl
Do NOT prefer previewUrl first.
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:
export function getPosterUrl(result) {
if (!result) return null
return result.imageUrl || null
}
Then only pass poster={getPosterUrl(result) || undefined}.
Display at least:
- Prompt:
- Model:
- Took: inference time
- Output: duration when present
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)
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
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
Add share buttons in both places: - newly generated result view - gallery viewer/modal
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.
shareUrlshareUrlIf 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.