diff --git a/src/components/pages/Task.vue b/src/components/pages/Task.vue index 20a7171de8..d5317e34de 100644 --- a/src/components/pages/Task.vue +++ b/src/components/pages/Task.vue @@ -142,17 +142,6 @@ :title="$t('tasks.hookup_playlist')" /> - - - {{ $t('tasks.use_current_frame') }} - + + {{ $t('tasks.use_current_frame') }} + {{ $t('tasks.set_preview_error') }} diff --git a/src/components/players/players/PlaylistPlayer.vue b/src/components/players/players/PlaylistPlayer.vue index 256d38c9b4..331fecf5f1 100644 --- a/src/components/players/players/PlaylistPlayer.vue +++ b/src/components/players/players/PlaylistPlayer.vue @@ -446,8 +446,16 @@ ? currentEntity.preview_nb_frames : Math.round(2 * fps) " - :handle-in="playlist.for_entity === 'shot' ? handleIn : -1" - :handle-out="playlist.for_entity === 'shot' ? handleOut : -1" + :handle-in=" + playlist.for_entity === 'shot' && currentPreviewIndex === 0 + ? handleIn + : -1 + " + :handle-out=" + playlist.for_entity === 'shot' && currentPreviewIndex === 0 + ? handleOut + : -1 + " :preview-id="currentPreview ? currentPreview.id : ''" @start-scrub="onScrubStart" @end-scrub="onScrubEnd" @@ -2180,6 +2188,18 @@ const playEntity = (entityIndex, updateFullPlaylist = true, frame = -1) => { nextTick(() => { rawPlayer.value?.loadEntity(entityIndex, 0, true) }) + // Unlike movies, pictures have no time-update channel driving the + // playlist playhead: jump it to the entity's slot here, even while + // playing. Strip clicks/drags pass updateFullPlaylist=false and keep + // their exact clicked position. + if (updateFullPlaylist && entity) { + if (isFullMode.value && !isPlaying.value) { + fullPlaylistPlayer.value.currentTime = entity.start_duration + playlistProgress.value = entity.start_duration + } else if (!isFullMode.value) { + playlistProgress.value = entity.start_duration + } + } const ann = getAnnotation(0) if (!isPlaying.value) loadAnnotation(ann) if (wasDrawing) { @@ -2188,10 +2208,13 @@ const playEntity = (entityIndex, updateFullPlaylist = true, frame = -1) => { setAnnotationDrawingMode(true) }, 100) } + // Entities without any preview borrow the picture timer so continuous + // playback holds their slot and then moves on instead of stalling. if ( isPlaying.value && entity && - isPicturePreview(entity.preview_file_extension) + (isPicturePreview(entity.preview_file_extension) || + !entity.preview_file_id) ) { playPicture() } @@ -2628,6 +2651,7 @@ const onFrameUpdate = frame => { if (props.playlist && isPlaying.value) { const hasHandles = ['shot', 'edit', 'episode'].includes(props.playlist.for_entity) && + currentPreviewIndex.value === 0 && handleOut.value < nbFrames.value const reachedEnd = hasHandles ? frameNumber.value >= handleOut.value @@ -3429,7 +3453,10 @@ const continuePlayingPlaylist = (entityIndex, startMs) => { return } const previews = currentEntity.value?.preview_file_previews - if (previews && previews.length === currentPreviewIndex.value) { + // No previews at all (entity without preview): advance to the next + // entity, otherwise the else branch increments currentPreviewIndex + // forever and playback stalls on the empty entity. + if (!previews || previews.length === currentPreviewIndex.value) { nextTick(() => { onPlayNextEntity(true) framesSeenOfPicture.value = 1 @@ -3437,9 +3464,21 @@ const continuePlayingPlaylist = (entityIndex, startMs) => { } else { currentPreviewIndex.value++ nextTick(() => { - playingPictureTimeout = setTimeout(() => { - continuePlayingPlaylist(playingEntityIndex.value, Date.now()) - }, 100) + // A still-image sub-preview can be followed by a video sub-preview. + // Play the whole clip from its start instead of rescheduling the image + // loop (which would freeze it and then skip it). Sub-previews are not + // the entity's trimmed main preview, so ignore the entity handle-in/out. + if (isCurrentPreviewMovie.value) { + clearTimeout(playingPictureTimeout) + rawPlayer.value?.setCurrentFrame(0) + rawPlayer.value?.play() + if (isComparing.value) rawPlayerComparison.value?.play() + isPlaying.value = true + } else { + playingPictureTimeout = setTimeout(() => { + continuePlayingPlaylist(playingEntityIndex.value, Date.now()) + }, 100) + } }) } } @@ -3895,6 +3934,14 @@ const getEntityHandles = entity => { const resetHandles = entity => { if (!['shot', 'edit', 'episode'].includes(props.playlist?.for_entity)) return + // No entity argument = reset for what is displayed right now. A + // sub-preview is not the entity's trimmed main preview, so the entity + // trim handles don't apply to its timeline. + if (!entity && currentPreviewIndex.value > 0) { + handleIn.value = 0 + handleOut.value = nbFrames.value + return + } entity = entity || currentEntity.value const { handleIn: entityHandleIn, handleOut: entityHandleOut } = getEntityHandles(entity) @@ -3906,13 +3953,26 @@ const resetPlaylistFrameData = () => { let playlistDur = 0 let curFrame = 0 entityList.value.forEach((entity, index) => { + // An entity without preview still spans its edit length (like a slug + // in a conform): playback holds the slot, the strip shows it in grey. const defaultNbFrames = entity.preview_nb_frames || 2 * fps.value * frameDuration.value framesPerImage.value[index] = defaultNbFrames - const n = - Math.round((entity.preview_file_duration || 0) * fps.value) || - defaultNbFrames + // Duration only counts for movie mains: a picture revision holding a + // video sub-preview carries that video's duration, and using it here + // would stretch the entity's strip slot past the width PlaylistProgress + // computes from preview_nb_frames, leaving holes in the strip. + const n = isMoviePreview(entity.preview_file_extension) + ? Math.round((entity.preview_file_duration || 0) * fps.value) || + defaultNbFrames + : defaultNbFrames entity.start_duration = (curFrame + 1) / fps.value + // Frozen frame accounting for PlaylistProgress: the strip renders from + // these two fields only, so it tiles by construction. Recomputing widths + // from live preview fields drifts from the positions frozen here (fps is + // the *current* entity's rate, durations refresh after this pass). + entity.playlist_start_frame = curFrame + entity.playlist_nb_frames = n for (let i = 0; i < n; i++) { playlistShotPosition.value[curFrame + i] = { index, @@ -4316,6 +4376,16 @@ watch(currentPreviewIndex, () => { } }) +// Keep the compared sub-preview aligned with the main one; when the compared +// revision has fewer sub-previews, stay on its last one. +watch(currentPreviewIndex, () => { + if (!isComparing.value || currentComparisonPreviewLength.value <= 0) return + currentComparisonPreviewIndex.value = Math.min( + currentPreviewIndex.value, + currentComparisonPreviewLength.value - 1 + ) +}) + watch(playingEntityIndex, () => { endAnnotationSaving() resetUndoStacks() diff --git a/src/components/players/players/PreviewPlayer.vue b/src/components/players/players/PreviewPlayer.vue index f23b229cbd..aa3377bb4b 100644 --- a/src/components/players/players/PreviewPlayer.vue +++ b/src/components/players/players/PreviewPlayer.vue @@ -122,7 +122,23 @@ }" @panzoom-ready="onComparisonPanzoomReady" @video-loaded="onComparisonVideoLoaded" - v-show="isComparing && previewToCompare" + v-show=" + isComparing && + previewToCompare && + (isMovie || !isMovieComparison) + " + /> + + @@ -157,12 +173,14 @@ :movie-dimensions="movieDimensions" :nb-frames="nbFrames" :width="width" - :handle-in="-1" - :handle-out="-1" + :handle-in="handleIn" + :handle-out="handleOut" :preview-id="isMovie && currentPreview ? currentPreview.id : ''" @start-scrub="$refs['button-bar'].classList.add('unselectable')" @end-scrub="$refs['button-bar'].classList.remove('unselectable')" @progress-changed="onProgressChanged" + @handle-in-changed="onHandleInChanged" + @handle-out-changed="onHandleOutChanged" v-show="isMovie" /> @@ -600,6 +618,11 @@ let lastIndex = 1 let scrubbing = false let scrubStartX = 0 let containerResizeObserver = null +// A trim seek (play jump / handle-out loop) is in flight: the rVFC time +// channel keeps emitting the pre-seek position until the target frame is +// presented, and those stale ticks would flash the bar back past the +// handle-out marker. +let pendingTrimSeek = false // — Reactive @@ -612,6 +635,8 @@ const currentIndex = ref(1) const currentTime = ref('00:00:00:00') const { currentTimeRaw, isHd, isMuted, isPlaying, isRepeating, speed, volume } = usePlayerTransport() +const handleIn = ref(-1) +const handleOut = ref(-1) const is3DAnimation = ref(false) const isAnnotationsDisplayed = ref(true) const isCommentsHidden = ref(true) @@ -859,6 +884,18 @@ const isPicture = computed(() => isPicturePreview(extension.value)) const isMovie = computed(() => isMoviePreview(extension.value)) const is3DModel = computed(() => isModelPreview(extension.value)) const isSound = computed(() => isSoundPreview(extension.value)) +// When the main preview isn't a movie, the compared preview may still be a +// video. The frame-synced comparison viewer has no transport of its own in +// that case, so the compared clip is shown as a plain with native +// controls (like the playlist does). +const isMovieComparison = computed( + () => isComparing.value && isMoviePreview(comparisonPreview.value?.extension) +) +const comparisonMoviePath = computed(() => { + const preview = comparisonPreview.value + if (!preview?.id) return '' + return `/api/movies/originals/preview-files/${preview.id}.${preview.extension}` +}) const isFullScreenEnabled = computed( () => @@ -969,6 +1006,26 @@ const setVideoFrameContext = frame => { syncComparisonViewer() } if (!isPlaying.value) loadAnnotation() + if (isPlaying.value && hasTrimEnd.value && frame >= handleOut.value) { + if (isRepeating.value) { + // Loop from the frame START (see the play() jump) and repaint the + // bar at the loop target right away: on this tick the playing + // channel has already filled past the handle-out marker. + const startTime = trimStartFrame.value * frameDuration.value + pendingTrimSeek = true + previewViewer.value.setCurrentTimeRaw(startTime) + comparisonViewer.value?.setCurrentTimeRaw(startTime) + progress.value?.updateProgressBar(trimStartFrame.value - 1) + } else { + pause() + setCurrentFrame(handleOut.value) + // Align the bar fill with the handle-out marker: the fill is + // right-edge inclusive ((f + 1) * frameDuration) while the marker + // sits on the frame's LEFT edge, so filling through handleOut + // would overshoot the marker by one frame width. + progress.value?.updateProgressBar(handleOut.value - 1) + } + } } } @@ -979,6 +1036,10 @@ const setVideoFrameContext = frame => { // pausing. const onVideoTimeUpdate = time => { if (!isPlaying.value) return + if (pendingTrimSeek) { + if (time >= (trimStartFrame.value + 2) * frameDuration.value) return + pendingTrimSeek = false + } progress.value?.updateProgressBar(Math.ceil(time / frameDuration.value) + 1) } @@ -1077,9 +1138,18 @@ const play = () => { previewViewer.value.playModelAnimation(current3DAnimation.value) } else { clearCanvas() - if (currentFrame.value >= nbFrames.value - 1) { - previewViewer.value.setCurrentFrame(0) - comparisonViewer.value.setCurrentFrame(0) + const endFrame = hasTrimEnd.value ? handleOut.value : nbFrames.value - 1 + if ( + currentFrame.value >= endFrame || + currentFrame.value < trimStartFrame.value + ) { + // Seek the START of the trim frame, not the usual mid-frame + // (setCurrentFrame): starting mid-frame only shows half of the + // handle-in frame, which reads as playing one frame late. + const startTime = trimStartFrame.value * frameDuration.value + pendingTrimSeek = true + previewViewer.value.setCurrentTimeRaw(startTime) + comparisonViewer.value?.setCurrentTimeRaw(startTime) } previewViewer.value.play() if (comparisonViewer.value && isComparing.value) { @@ -1090,6 +1160,7 @@ const play = () => { } const pause = () => { + pendingTrimSeek = false if (isPlaying.value) { isPlaying.value = false if (is3DModel.value) { @@ -1227,11 +1298,80 @@ const onProgressChanged = frame => { } } +// Shot trim handles, shown on the progress bar like in PlaylistPlayer. +// Not every parent passes entity-type (the Task page doesn't): derive the +// type from the task payload too. A plain function, not a computed: the +// shotMap getter exposes a non-reactive cache, so it must be re-read at +// call time (the watcher below re-runs when the shots finish loading). +const getTrimmedShot = () => { + const entityType = + props.entityType || + props.task?.entity_type?.name || + props.task?.entity_type_name + if (entityType !== 'Shot') return null + return store.getters.shotMap?.get(props.task?.entity_id) || props.task?.entity +} + +const toFrameNumber = value => { + const frame = parseInt(value, 10) + return Number.isNaN(frame) ? -1 : frame +} + +// Handles describe the trim of the main preview: hide them on +// sub-previews, whose timelines they don't apply to. Like in +// PlaylistPlayer, unset handles fall back to the clip bounds so the +// markers always show on shot movies and dragging them creates the trim. +const resetHandles = () => { + const shot = currentIndex.value === 1 ? getTrimmedShot() : null + if (!shot) { + handleIn.value = -1 + handleOut.value = -1 + return + } + const data = shot.data || {} + const inFrame = toFrameNumber(data.handle_in) + const outFrame = toFrameNumber(data.handle_out) + handleIn.value = Math.max(inFrame, 0) + handleOut.value = outFrame > 0 ? outFrame : nbFrames.value +} + +const onHandleInChanged = ({ frameNumber, save }) => { + if (props.readOnly) return + handleIn.value = frameNumber + if (save) saveHandles() +} + +const onHandleOutChanged = ({ frameNumber, save }) => { + if (props.readOnly) return + handleOut.value = frameNumber + if (save) saveHandles() +} + +const saveHandles = () => { + const shot = getTrimmedShot() + if (!shot?.id) return + store.dispatch('editShot', { + id: shot.id, + data: { + ...shot.data, + ...(handleIn.value >= 0 && { handle_in: handleIn.value }), + ...(handleOut.value >= 0 && { handle_out: handleOut.value }) + } + }) +} + +// Playback respects the trim like PlaylistPlayer: start on handle-in, +// stop (or loop) on handle-out. Scrubbing and frame stepping stay free. +const trimStartFrame = computed(() => (handleIn.value > 1 ? handleIn.value : 0)) +const hasTrimEnd = computed( + () => handleOut.value > 0 && handleOut.value < nbFrames.value +) + const onVideoEnd = () => { isPlaying.value = false if (isRepeating.value) { - setCurrentFrame(0) - syncComparisonViewer() + // No pre-seek: play() detects the end position and re-seeks both + // viewers to the trim start's frame START itself. nextTick(() => { play() }) @@ -2000,7 +2140,7 @@ watch(current3DAnimation, () => { } }) -watch(currentPreview, () => { +watch(currentPreview, (newPreview, oldPreview) => { endAnnotationSaving() // Wipe the fabric canvas before the new preview's annotations are // re-loaded — otherwise switching between tasks (or between @@ -2011,7 +2151,13 @@ watch(currentPreview, () => { // switching would re-inject a stroke onto the wrong preview. resetUndoStacks() reloadAnnotations() - isComparing.value = false + // Only tear comparison down when the revision (preview file) actually + // changes — sub-previews of the same revision share the revision id, so + // navigating between them must leave comparison mode running. + const revisionChanged = newPreview?.revision !== oldPreview?.revision + if (revisionChanged) { + isComparing.value = false + } // Reset the frame bookkeeping synchronously. onPreviewLoaded() also // sets frame 0, but only once the media-load event fires; until then // currentFrame still holds the previous preview's playhead, so a @@ -2048,7 +2194,11 @@ watch(currentPreview, () => { clearCanvas() } }) - setDefaultComparisonTaskType() + // Re-pick the default comparison target only on a real revision change; + // doing it on every sub-preview step would reset the user's selection. + if (revisionChanged) { + setDefaultComparisonTaskType() + } isOrdering.value = props.previews.length > 1 && localPreferences.getPreference('player:ordering') !== 'false' @@ -2066,6 +2216,30 @@ watch(currentIndex, () => { lastIndex = currentIndex.value }) +// isShotsLoading: the shotMap cache is not reactive, re-read it when the +// shots finish loading. nbFrames: keeps the unset handle-out fallback on +// the clip end once the real movie duration is known. +watch( + [ + () => props.task, + currentIndex, + nbFrames, + () => store.getters.isShotsLoading + ], + () => resetHandles(), + { immediate: true } +) + +// Keep the compared sub-preview aligned with the main one; when the compared +// revision has fewer sub-previews, stay on its last one. +watch(currentIndex, () => { + if (!isComparing.value || comparisonPreviewLength.value <= 0) return + comparisonPreviewIndex.value = Math.min( + currentIndex.value - 1, + comparisonPreviewLength.value - 1 + ) +}) + watch(previewToCompare, () => { comparisonPreviewIndex.value = 0 nextTick(() => { @@ -2487,6 +2661,13 @@ defineExpose({ z-index: 2; } +.comparison-video { + min-width: 0; + max-height: 100%; + object-fit: contain; + align-self: center; +} + // Per-viewer annotation slot. The annotation canvases used to sit // directly under .preview-container, so a panned overlay in one viewer // could only be clipped at the outermost container — in side-by-side diff --git a/src/components/players/progress/PlaylistProgress.vue b/src/components/players/progress/PlaylistProgress.vue index 16e983a47c..b8e5c67531 100644 --- a/src/components/players/progress/PlaylistProgress.vue +++ b/src/components/players/progress/PlaylistProgress.vue @@ -324,27 +324,35 @@ const getFrameBackgroundStyle = frame => { } } +// The strip renders only from the frame accounting frozen by +// resetPlaylistFrameData (PlaylistPlayer), so segments tile by construction. +// Deriving widths from live preview fields (duration, nb_frames, current +// fps) drifts from the frozen positions and opens holes in the strip. +const totalFrames = computed(() => + props.entityList.reduce( + (max, entity) => + Math.max( + max, + (entity.playlist_start_frame || 0) + (entity.playlist_nb_frames || 0) + ), + 0 + ) +) + const getEntityPosition = entity => { - const ratio = - (entity.start_duration - props.frameDuration) / props.playlistDuration - return ratio * 100 + if (!totalFrames.value) return 0 + return ((entity.playlist_start_frame || 0) / totalFrames.value) * 100 } const getEntityWidth = entity => { - let ratio - if (entity.preview_file_extension === 'mp4') { - ratio = entity.preview_file_duration / props.playlistDuration - } else if (entity.preview_nb_frames) { - const duration = entity.preview_nb_frames * props.frameDuration - ratio = duration / props.playlistDuration - } else { - ratio = (2 * props.fps * props.frameDuration) / props.playlistDuration - } - return ratio * 100 + if (!totalFrames.value) return 0 + return ((entity.playlist_nb_frames || 0) / totalFrames.value) * 100 } const getEntityColor = entity => { - return entity.task_status_color + // Neutral grey for entities without a status color (no preview / no + // task): a transparent segment reads as a hole in the strip. + return entity.task_status_color || '#62656b' } const getFullEntityName = entity => { diff --git a/src/components/players/viewers/MultiVideoViewer.vue b/src/components/players/viewers/MultiVideoViewer.vue index 8fb372c1d7..24e886cfbe 100644 --- a/src/components/players/viewers/MultiVideoViewer.vue +++ b/src/components/players/viewers/MultiVideoViewer.vue @@ -723,9 +723,14 @@ const unbindLoadingHandlers = player => { watch( () => props.currentPreviewIndex, () => { - if (!isPlaying.value) { - setCurrentTimeRaw(0) - reloadCurrentEntity(true) + // Reload even mid-playback: keeping the old source makes the "sub" + // resume the previous video at its old position and stop at that + // video's end. Resume on the new source when it was playing. + const wasPlaying = isPlaying.value + setCurrentTimeRaw(0) + reloadCurrentEntity(true) + if (wasPlaying) { + currentPlayer.value?.play()?.catch(() => {}) } } ) diff --git a/src/components/players/viewers/VideoViewer.vue b/src/components/players/viewers/VideoViewer.vue index c4b71310d1..f917b8643b 100644 --- a/src/components/players/viewers/VideoViewer.vue +++ b/src/components/players/viewers/VideoViewer.vue @@ -165,6 +165,10 @@ let previousDimensions = null let renderer = null let renderLoopHandle = null let renderLoopIsRvfc = false +// Target of a raw seek issued while playing: frames already queued +// behind the seek point keep presenting for a tick or two, and painting +// them flashes content past the target (visible on handle-out loops). +let seekGuardTarget = null // mediaTime of the last frame the browser actually presented. The // source of truth for emitted times/frames (currentTime lies during // decode latency, which is the whole point of this pipeline). @@ -256,6 +260,7 @@ const setCurrentFrame = frame => { const setCurrentTimeRaw = currentTime => { if (currentTime < frameDuration.value) currentTime = 0 + if (!video.value.paused) seekGuardTarget = currentTime video.value.currentTime = currentTime } @@ -376,6 +381,17 @@ const startRenderLoop = () => { renderLoopIsRvfc = true const tick = (now, metadata) => { if (!video.value) return + // Skip presentations far from an in-flight raw-seek target: the + // canvas holds the last pre-seek frame instead of flashing the + // stale frames still coming out of the decoder. + if ( + seekGuardTarget !== null && + Math.abs(metadata.mediaTime - seekGuardTarget) > 2 * frameDuration.value + ) { + renderLoopHandle = video.value.requestVideoFrameCallback(tick) + return + } + seekGuardTarget = null hasPaintedVideoFrame = true lastMediaTime = metadata.mediaTime currentTimeRaw.value = metadata.mediaTime @@ -446,6 +462,7 @@ const play = () => { } const pause = () => { + seekGuardTarget = null video.value.pause() video.value.currentTime = frameToTime(props.currentFrame) emit('frame-update', props.currentFrame) @@ -704,6 +721,10 @@ onMounted(() => { }) video.value.addEventListener('waiting', () => { + // In-flight raw seek (trim loop, room sync): the canvas holds the + // last frame on purpose — flipping isLoading would hide the canvas + // and flash the spinner for a seek-length wait. + if (seekGuardTarget !== null) return if (props.name.indexOf('comparison') < 0) { isLoading.value = true } diff --git a/src/composables/format.js b/src/composables/format.js index 430ebbaf72..ff6731bf8d 100644 --- a/src/composables/format.js +++ b/src/composables/format.js @@ -28,13 +28,21 @@ export const formatPrioritySymbol = priority => { return '!'.repeat(clamped) } +// Number-typed TextFields emit valueAsNumber, so both sanitizers must +// accept numbers as well as user-typed strings. export const sanitizeInteger = value => { + if (typeof value === 'number') { + return Number.isFinite(value) ? Math.trunc(value) : 0 + } if (typeof value !== 'string') return 0 const digits = value.replace(/\D/g, '') return digits.length > 0 ? parseInt(digits) || 0 : 0 } export const sanitizeIntegerLight = value => { + if (typeof value === 'number') { + return Number.isFinite(value) ? Math.trunc(value) : null + } if (typeof value !== 'string') return null const digits = value.replace(/\D/g, '') return digits.length > 0 ? parseInt(digits) || null : null diff --git a/src/composables/players/annotation.js b/src/composables/players/annotation.js index 402a6237c3..bb7636f931 100644 --- a/src/composables/players/annotation.js +++ b/src/composables/players/annotation.js @@ -854,6 +854,14 @@ export const useAnnotation = ({ silentAnnotation = false } removeFromDeletions(obj) + // The object may no longer exist in the saved drawing (its deletion + // was already saved): zou drops updates for unknown ids and remote + // viewers ignore them, so the restore must be an addition. The + // update below still runs: zou applies additions first, so when the + // object still exists server-side (same-batch undo) the addition is + // ignored and the update carries the restored state. + setObjectData(obj) + addToAdditions(obj) } addToUpdates(obj) }) diff --git a/src/store/modules/assets.js b/src/store/modules/assets.js index 5c3f1345a8..3f41f06041 100644 --- a/src/store/modules/assets.js +++ b/src/store/modules/assets.js @@ -910,7 +910,7 @@ const mutations = { [CLEAR_ASSETS](state) { cache.assets = [] cache.result = [] - cache.assetMap = new Map() + cache.assetMap.clear() state.assetValidationColumns = [] cache.assetIndex = {} @@ -928,7 +928,7 @@ const mutations = { [LOAD_ASSETS_START](state) { cache.assets = [] cache.result = [] - cache.assetMap = new Map() + cache.assetMap.clear() state.isAssetsLoading = true state.isAssetsLoadingError = false state.assetValidationColumns = [] @@ -970,7 +970,7 @@ const mutations = { cache.assets = assets cache.result = assets cache.assetIndex = buildAssetIndex(assets) - cache.assetMap = new Map() + cache.assetMap.clear() assets.forEach(asset => { helpers.populateAndRegisterAsset( diff --git a/src/store/modules/edits.js b/src/store/modules/edits.js index a492a95fb6..a2de957518 100644 --- a/src/store/modules/edits.js +++ b/src/store/modules/edits.js @@ -676,7 +676,7 @@ const mutations = { cache.edits = [] cache.result = [] cache.editIndex = {} - cache.editMap = new Map() + cache.editMap.clear() state.editValidationColumns = [] state.isEditsLoading = true @@ -705,7 +705,7 @@ const mutations = { let isTime = false let isEstimation = false let isResolution = false - cache.editMap = new Map() + cache.editMap.clear() edits.forEach(edit => { const taskIds = [] const validations = new Map() diff --git a/src/store/modules/episodes.js b/src/store/modules/episodes.js index 7119c83cc1..079e42ccf2 100644 --- a/src/store/modules/episodes.js +++ b/src/store/modules/episodes.js @@ -603,7 +603,7 @@ const mutations = { let isTime = false let isEstimation = false let isResolution = false - cache.episodeMap = new Map() + cache.episodeMap.clear() episodes.forEach(episode => { const taskIds = [] const validations = new Map() @@ -773,7 +773,7 @@ const mutations = { cache.episodes = [] cache.result = [] cache.episodeIndex = {} - cache.episodeMap = new Map() + cache.episodeMap.clear() state.episodeValidationColumns = [] state.isEpisodesLoading = true @@ -792,7 +792,7 @@ const mutations = { [LOAD_EPISODES_END](state, { episodes, routeEpisodeId }) { if (state.episodes.length > 0) return if (!episodes) episodes = [] - cache.episodeMap = new Map() + cache.episodeMap.clear() episodes.forEach(episode => { if (!EPISODE_STATUS.includes(episode.status)) { episode.status = 'running' diff --git a/src/store/modules/sequences.js b/src/store/modules/sequences.js index d155d9771f..1465f4d83a 100644 --- a/src/store/modules/sequences.js +++ b/src/store/modules/sequences.js @@ -605,7 +605,7 @@ const mutations = { cache.sequences = [] state.currentSequence = null state.displayedSequences = [] - cache.sequenceMap = new Map() + cache.sequenceMap.clear() state.selectedSequences = new Map() }, @@ -617,7 +617,7 @@ const mutations = { cache.sequences = [] state.currentSequence = null state.displayedSequences = [] - cache.sequenceMap = new Map() + cache.sequenceMap.clear() state.selectedSequences = new Map() }, @@ -655,7 +655,7 @@ const mutations = { let isTime = false let isEstimation = false let isResolution = false - cache.sequenceMap = new Map() + cache.sequenceMap.clear() sequences.forEach(sequence => { const taskIds = [] const validations = new Map() @@ -841,7 +841,7 @@ const mutations = { cache.sequences = [] cache.result = [] cache.sequenceIndex = {} - cache.sequenceMap = new Map() + cache.sequenceMap.clear() state.sequenceValidationColumns = [] state.isSequencesLoading = true diff --git a/src/store/modules/shots.js b/src/store/modules/shots.js index aa12cf6c97..ae6353d4d2 100644 --- a/src/store/modules/shots.js +++ b/src/store/modules/shots.js @@ -836,7 +836,10 @@ const mutations = { cache.shots = [] cache.result = [] cache.shotIndex = {} - cache.shotMap = new Map() + // clear(), never a new Map(): the shotMap getter has no reactive + // dependency, so Vuex memoizes the reference captured at its first + // read. Reassigning would leave every consumer on a stale, empty map. + cache.shotMap.clear() state.isShotsLoading = false state.isShotsLoadingError = false @@ -856,7 +859,8 @@ const mutations = { cache.shots = [] cache.result = [] cache.shotIndex = {} - cache.shotMap = new Map() + // Same as CLEAR_SHOTS: keep the map identity, the getter is memoized. + cache.shotMap.clear() state.shotValidationColumns = [] state.isShotsLoading = true @@ -902,7 +906,6 @@ const mutations = { let isEstimation = false let isMaxRetakes = false let isResolution = false - // cache.shotMap = new Map() shots.forEach(shot => { const taskIds = [] const validations = new Map() diff --git a/tests/unit/composables/annotation.spec.js b/tests/unit/composables/annotation.spec.js index acb293f7ae..be2640c7dd 100644 --- a/tests/unit/composables/annotation.spec.js +++ b/tests/unit/composables/annotation.spec.js @@ -1225,7 +1225,8 @@ describe('composables/annotation', () => { it('undo restores and redo removes a fully erased object', () => { const canvas = createFakeCanvas() - const { api, wrapper } = mountAnnotation({ canvas }) + const { api, postAnnotationAddition, postAnnotationUpdate, wrapper } = + mountAnnotation({ canvas }) const obj = makeErasable('e1') obj.toCanvasElement = () => ({ width: 1, @@ -1243,11 +1244,21 @@ describe('composables/annotation', () => { expect(canvas._objects).toContain(obj) expect(obj.eraser).toBeUndefined() expect(api.deletions.value[0].objects).toHaveLength(0) + // The restore must sync as an addition BEFORE the update: zou and + // remote viewers drop updates for ids their drawing no longer holds. + expect(api.additions.value[0].drawing.objects[0].id).toBe('e1') + expect(postAnnotationAddition).toHaveBeenCalledTimes(1) + expect(postAnnotationUpdate).toHaveBeenCalledTimes(1) + expect(postAnnotationAddition.mock.invocationCallOrder[0]).toBeLessThan( + postAnnotationUpdate.mock.invocationCallOrder[0] + ) api.redoLastAction() expect(canvas._objects).not.toContain(obj) expect(obj.eraser.getObjects()).toHaveLength(1) expect(api.deletions.value[0].objects).toEqual(['e1']) + // Redo purges the stale update so the batch nets out to a deletion. + expect(api.updates.value[0].drawing.objects).toHaveLength(0) wrapper.unmount() }) diff --git a/tests/unit/composables/format.spec.js b/tests/unit/composables/format.spec.js new file mode 100644 index 0000000000..2b3df521b0 --- /dev/null +++ b/tests/unit/composables/format.spec.js @@ -0,0 +1,23 @@ +import { sanitizeInteger, sanitizeIntegerLight } from '@/composables/format' + +describe('composables/format', () => { + it('sanitizeInteger accepts typed strings', () => { + expect(sanitizeInteger('24')).toBe(24) + expect(sanitizeInteger('24px')).toBe(24) + expect(sanitizeInteger('')).toBe(0) + }) + + it('sanitizeInteger accepts numbers (number TextFields emit valueAsNumber)', () => { + expect(sanitizeInteger(24)).toBe(24) + expect(sanitizeInteger(24.7)).toBe(24) + expect(sanitizeInteger(NaN)).toBe(0) + expect(sanitizeInteger(null)).toBe(0) + }) + + it('sanitizeIntegerLight returns null when empty or invalid', () => { + expect(sanitizeIntegerLight('86')).toBe(86) + expect(sanitizeIntegerLight(86)).toBe(86) + expect(sanitizeIntegerLight('')).toBe(null) + expect(sanitizeIntegerLight(NaN)).toBe(null) + }) +}) diff --git a/tests/unit/players/attachmentaudioplayer.spec.js b/tests/unit/players/attachmentaudioplayer.spec.js index 966d2aced2..23ded21696 100644 --- a/tests/unit/players/attachmentaudioplayer.spec.js +++ b/tests/unit/players/attachmentaudioplayer.spec.js @@ -31,10 +31,14 @@ describe('players/AttachmentAudioPlayer', () => { expect(link.attributes('href')).toBe('/dl/note.wav') }) - it('falls back to a download link when the media errors', async () => { + it('shows an unavailable chip (no player, no link) when the media errors', async () => { const wrapper = mountPlayer() await wrapper.find('audio').trigger('error') expect(wrapper.find('.play-button').exists()).toBe(false) - expect(wrapper.find('a.attachment-fallback').exists()).toBe(true) + const chip = wrapper.find('.attachment-error') + expect(chip.exists()).toBe(true) + // The unavailable state is informational: no download link (the file is gone). + expect(wrapper.find('a').exists()).toBe(false) + expect(chip.find('.attachment-error-name').text()).toBe('note.wav') }) }) diff --git a/tests/unit/players/attachmentimage.spec.js b/tests/unit/players/attachmentimage.spec.js new file mode 100644 index 0000000000..7ebc52bea6 --- /dev/null +++ b/tests/unit/players/attachmentimage.spec.js @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils' + +import AttachmentImage from '@/components/players/viewers/AttachmentImage.vue' + +const mountImage = (props = {}) => + mount(AttachmentImage, { + props: { src: '/dl/frame.png', name: 'frame.png', ...props }, + global: { mocks: { $t: key => key } } + }) + +describe('players/AttachmentImage', () => { + it('renders the image inside a link to the source', () => { + const wrapper = mountImage() + expect(wrapper.find('img.attachment-image').attributes('src')).toBe( + '/dl/frame.png' + ) + expect(wrapper.find('a').attributes('href')).toBe('/dl/frame.png') + }) + + it('shows an unavailable chip (no image, no link) when the image errors', async () => { + const wrapper = mountImage() + await wrapper.find('img').trigger('error') + expect(wrapper.find('img').exists()).toBe(false) + const chip = wrapper.find('.attachment-error') + expect(chip.exists()).toBe(true) + // Informational only: no download link once the file is gone. + expect(wrapper.find('a').exists()).toBe(false) + expect(chip.find('.attachment-error-name').text()).toBe('frame.png') + }) +})