diff --git a/__tests__/component/media.spec.ts b/__tests__/component/media.spec.ts index 426ce1e..4f41ff1 100644 --- a/__tests__/component/media.spec.ts +++ b/__tests__/component/media.spec.ts @@ -613,8 +613,8 @@ describe('a video that has played to the end', () => { // What a user puts beside a film to show before and after it plays: // a picture of the same name in the same folder async function mountWithPoster() { - const movie = makeFile({ basename: 'trailer.webm', mime: 'video/webm' }) - const poster = makeFile({ basename: 'trailer.jpg', mime: 'image/jpeg' }) + const movie = makeFile({ id: 10, basename: 'trailer.webm', mime: 'video/webm', attributes: { 'metadata-files-live-photo': 11 } }) + const poster = makeFile({ id: 11, basename: 'trailer.jpg', mime: 'image/jpeg', attributes: { 'metadata-files-live-photo': 10 } }) const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, poster] }) }) await flushPromises() const video = wrapper.find('video').element as HTMLVideoElement @@ -628,6 +628,49 @@ describe('a video that has played to the end', () => { expect(wrapper.find('video').attributes('poster')).toBe(poster.encodedSource) }) + // The dav metadata links the halves by file id, so a renamed half keeps + // its partner while a name that merely looks alike is not one. + it('keeps the poster of a live photo whose picture was renamed', async () => { + const movie = makeFile({ id: 20, basename: 'trailer.webm', mime: 'video/webm', attributes: { 'metadata-files-live-photo': 21 } }) + const renamed = makeFile({ id: 21, basename: 'holidays.jpg', mime: 'image/jpeg', attributes: { 'metadata-files-live-photo': 20 } }) + const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, renamed] }) }) + await flushPromises() + + expect(wrapper.find('video').attributes('poster')).toBe(renamed.encodedSource) + }) + + it('ignores a picture of the same name that is not its live photo peer', async () => { + // The metadata points at a file that is not in the list: falling back + // to the name would show a picture that is not its peer. + const movie = makeFile({ id: 30, basename: 'trailer.webm', mime: 'video/webm', attributes: { 'metadata-files-live-photo': 32 } }) + const other = makeFile({ id: 31, basename: 'trailer.jpg', mime: 'image/jpeg' }) + const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, other] }) }) + await flushPromises() + + expect(wrapper.find('video').attributes('poster')).not.toBe(other.encodedSource) + }) + + // A camera saves the poster beside the video, without live photo metadata + it('shows the picture of the same name for a video without metadata', async () => { + const movie = makeFile({ id: 50, basename: 'camera.webm', mime: 'video/webm' }) + const picture = makeFile({ id: 51, basename: 'camera.jpg', mime: 'image/jpeg' }) + const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, picture] }) }) + await flushPromises() + + expect(wrapper.find('video').attributes('poster')).toBe(picture.encodedSource) + }) + + // The picture of a live photo belongs with its own video, so it cannot + // serve as the poster of a video that merely shares its name + it('does not show a picture of the same name that is another live photo', async () => { + const movie = makeFile({ id: 60, basename: 'camera.webm', mime: 'video/webm' }) + const picture = makeFile({ id: 61, basename: 'camera.jpg', mime: 'image/jpeg', attributes: { 'metadata-files-live-photo': 99 } }) + const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, picture] }) }) + await flushPromises() + + expect(wrapper.find('video').attributes('poster')).not.toBe(picture.encodedSource) + }) + it('goes back to its poster without downloading the video again', async () => { const { wrapper, video, store, poster } = await mountWithPoster() @@ -706,9 +749,11 @@ describe('a video before it can play', () => { expect(wrapper.find('video').attributes('poster')).toContain('/core/preview') }) - it('shows the picture of the same name rather than its preview', async () => { - const picture = makeFile({ basename: 'clip.jpg', mime: 'image/jpeg' }) - const wrapper = await mountVideo({ beside: [picture] }) + it('shows the picture of its live photo rather than its preview', async () => { + const picture = makeFile({ id: 41, basename: 'clip.jpg', mime: 'image/jpeg' }) + const movie = makeFile({ id: 40, basename: 'clip.mp4', mime: 'video/mp4', attributes: { hasPreview: true, 'metadata-files-live-photo': 41 } }) + const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, picture] }) }) + await flushPromises() expect(wrapper.find('video').attributes('poster')).toBe(picture.encodedSource) }) diff --git a/__tests__/utils.spec.ts b/__tests__/utils.spec.ts index b9c4fcc..025b65b 100644 --- a/__tests__/utils.spec.ts +++ b/__tests__/utils.spec.ts @@ -342,6 +342,14 @@ describe('livePhotoUtils.findLivePhotoPeerFromName', () => { expect(findLivePhotoPeerFromName(photo, [photo, peer])).toBe(peer) }) + // The picture of a live photo is the moving video's other half, so a + // video must not borrow it as its poster just because the names match + it('does not pair a video with the picture of another live photo', () => { + const video = makeFile({ id: 1, basename: 'IMG_1234.mov', mime: 'video/quicktime' }) + const otherLivePhoto = makeFile({ id: 2, basename: 'IMG_1234.jpg', attributes: { 'metadata-files-live-photo': 99 } }) + expect(findLivePhotoPeerFromName(video, [video, otherLivePhoto])).toBeUndefined() + }) + it('reads the whole name of a file that has no extension', () => { const video = makeFile({ id: 1, basename: 'clip', mime: 'video/quicktime' }) const longer = makeFile({ id: 2, basename: 'clips.jpg' }) diff --git a/lib/components/Videos.vue b/lib/components/Videos.vue index d7eb31f..b2910b1 100644 --- a/lib/components/Videos.vue +++ b/lib/components/Videos.vue @@ -65,7 +65,7 @@ import { preloadImageSize } from '../services/mediaPreloader.ts' import { canDownload } from '../utils/canDownload.ts' import { probeFile } from '../utils/isoMedia.ts' import { t } from '../utils/l10n.ts' -import { findLivePhotoPeerFromName } from '../utils/livePhotoUtils.ts' +import { findLivePhotoPeerFromFileId, findLivePhotoPeerFromName } from '../utils/livePhotoUtils.ts' import { playerLanguage } from '../utils/playerTranslations.ts' import { getPreviewIfAny, getServerPreview } from '../utils/previewUtils.ts' @@ -98,8 +98,15 @@ const width = ref(0) watch(() => props.maxHeight, updateVideoSize) watch(() => props.maxWidth, updateVideoSize) +const metadataFilesLivePhoto = computed(() => props.file.attributes?.['metadata-files-live-photo'] as number | undefined) + const livePhotoPath = computed(() => { - const peerFile = findLivePhotoPeerFromName(props.file, props.files) + // The dav metadata links the halves of a live photo by file id. A video + // without it keeps the picture of the same name beside it as its poster, + // the way cameras save them. + const peerFile = metadataFilesLivePhoto.value !== undefined + ? findLivePhotoPeerFromFileId(metadataFilesLivePhoto.value, props.files) + : findLivePhotoPeerFromName(props.file, props.files) if (peerFile === undefined) { return undefined } diff --git a/lib/utils/livePhotoUtils.ts b/lib/utils/livePhotoUtils.ts index 1c88c71..b4f5cdf 100644 --- a/lib/utils/livePhotoUtils.ts +++ b/lib/utils/livePhotoUtils.ts @@ -38,6 +38,10 @@ function nameWithoutExtension(file: IFile): string { * exactly: `IMG_1234.mov` belongs with `IMG_1234.jpg` and not with the * `IMG_1239.jpg` next to it. * + * Only a picture without live photo metadata qualifies: a picture that is + * half of a live photo of its own belongs with its own video, so showing it + * here would put someone else's poster on the video. + * * @param referenceFile - The file whose peer live photo should be found by name * @param fileList - The list of files to search within */ @@ -47,6 +51,7 @@ export function findLivePhotoPeerFromName(referenceFile: IFile, fileList: IFile[ // if same filename and extension is allowed return comparedFile.source !== referenceFile.source && (nameWithoutExtension(comparedFile) === name && livePictureExtRegex.test(comparedFile.basename)) + && comparedFile.attributes?.['metadata-files-live-photo'] === undefined }) }