fix(journey): let the browser read a picked clip, so it previews and gets a poster

A clip picked in the entry editor is previewed through a <video> on an object
URL, and its poster frame is grabbed the same way before anything is uploaded.
The CSP never named media-src, so the browser fell back to default-src, which
does not admit blob: at all. The preview stayed empty and the poster capture
failed silently, so every clip landed without a poster and its thumbnail route
answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in
development, where Vite serves the document and helmet never sees it.

media-src now carries 'self' and blob:. For the clips that still come up
without a poster, because the browser cannot decode them, the entry card and
the editor tile draw the play badge on a black tile instead of asking for a
thumbnail that is 404 by design, the way the gallery already did.

Closes #2341
This commit is contained in:
Maurice
2026-09-21 10:35:37 +02:00
parent 43a3a5217e
commit 141afe708d
8 changed files with 127 additions and 14 deletions
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
@@ -11,7 +11,7 @@ import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import MarkdownToolbar from './MarkdownToolbar'
import { DatePicker } from './JourneyDetailPageDatePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -527,7 +527,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-xl overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{posterlessVideo(p) ? (
// No poster to show and falling back to /original would hand
// an <img> the clip itself, so this tile stays a play badge.
<div className="w-full h-full bg-black flex items-center justify-center text-white">
<Play size={18} className="ml-0.5" fill="currentColor" />
</div>
) : (
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
)}
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)}
@@ -8,7 +8,7 @@ import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
@@ -151,9 +151,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
onClick={() => onPhotoClick(allPhotos, i)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }}
>
{photo.media_type === 'video' &&
photo.provider === 'local' &&
!photo.thumbnail_path ? (
{posterlessVideo(photo) ? (
// Poster-less local video: show a neutral tile.
<div className="w-full h-full bg-zinc-200 dark:bg-zinc-800" />
) : (
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { PhotoImg } from './JourneyDetailPagePhotoGrid'
import type { JourneyPhoto } from '../../store/journeyStore'
function buildPhoto(overrides: Partial<JourneyPhoto> = {}): JourneyPhoto {
return {
id: 1,
entry_id: 10,
photo_id: 515,
sort_order: 0,
shared: 1,
created_at: 0,
provider: 'local',
...overrides,
}
}
describe('PhotoImg', () => {
it('asks for the thumbnail of a photo', () => {
const { container } = render(<PhotoImg photo={buildPhoto()} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
})
it('shows the poster of a clip behind the play badge', () => {
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: 'journey/poster.jpg' })} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
expect(container.querySelector('svg')).not.toBeNull()
})
it('draws no image for a clip without a poster, whose thumbnail route answers 404 (#2341)', () => {
// The tile stays black with the play badge on it. An <img> here would only
// add the broken-image glyph the reporter saw.
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', thumbnail_path: null })} />)
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
expect(container.firstElementChild?.className).toContain('bg-black')
})
it('still asks the server for a provider clip, which serves its own poster', () => {
const { container } = render(<PhotoImg photo={buildPhoto({ media_type: 'video', provider: 'immich', thumbnail_path: null })} />)
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/515/thumbnail')
})
})
@@ -1,6 +1,6 @@
import { Image, Play } from 'lucide-react'
import type { JourneyPhoto } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) {
const src = photoUrl(photo, 'thumbnail')
@@ -11,12 +11,16 @@ export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; cla
className={`relative overflow-hidden ${isVideo ? 'bg-black' : ''} ${className || ''}`}
style={style}
>
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
{/* A clip without a poster keeps the black tile and the play badge; asking
for its thumbnail would only draw the broken-image glyph over them. */}
{!posterlessVideo(photo) && (
<img
src={src}
alt=""
className={`w-full h-full ${isVideo ? 'object-contain' : 'object-cover'}`}
loading="lazy"
/>
)}
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">