From 1ae223f7003b6b5b3fb4832c9aed90351dae6137 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 3 Oct 2026 14:30:21 +0200 Subject: [PATCH 1/7] feat(web-client): add live photo dav property Request the oc:live-photo property by default and expose it as Resource.livePhoto, so listings, public links, search results and file info lookups carry the Apple Live Photo facet. --- .../src/helpers/resource/functions.ts | 5 +-- .../web-client/src/helpers/resource/types.ts | 2 ++ .../web-client/src/webdav/constants/dav.ts | 7 +++- .../unit/helpers/resource/functions.spec.ts | 36 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/web-client/src/helpers/resource/functions.ts b/packages/web-client/src/helpers/resource/functions.ts index 0095c5e666d..4864a67694a 100644 --- a/packages/web-client/src/helpers/resource/functions.ts +++ b/packages/web-client/src/helpers/resource/functions.ts @@ -72,7 +72,7 @@ export const isShareRoot = (resource: Resource) => { return typeof resource.isShareRoot === 'function' && resource.isShareRoot() } -const convertObjectToCamelCaseKeys = (data: Record) => { +const convertObjectToCamelCaseKeys = >(data: T): T => { if (!data) { return data } @@ -80,7 +80,7 @@ const convertObjectToCamelCaseKeys = (data: Record) => { Object.keys(data).forEach((key) => { converted[camelCase(key)] = data[key] }) - return converted + return converted as T } export function buildResource( @@ -174,6 +174,7 @@ export function buildResource( image: convertObjectToCamelCaseKeys(resource.props[DavProperty.Image]), photo: convertObjectToCamelCaseKeys(resource.props[DavProperty.Photo]), motionPhoto: convertObjectToCamelCaseKeys(resource.props[DavProperty.MotionPhoto]), + livePhoto: convertObjectToCamelCaseKeys(resource.props[DavProperty.LivePhoto]), extraProps, hasPreview: () => resource.props[DavProperty.HasPreview] === 1, canUpload: function () { diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index fbd776d4344..25f60bc2651 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -4,6 +4,7 @@ import { GeoCoordinates, Identity, Image, + LivePhoto, MotionPhoto, Photo, User @@ -55,6 +56,7 @@ export interface Resource { image?: Image photo?: Photo motionPhoto?: MotionPhoto + livePhoto?: LivePhoto path: string webDavPath?: string downloadURL?: string diff --git a/packages/web-client/src/webdav/constants/dav.ts b/packages/web-client/src/webdav/constants/dav.ts index c2b200ce03c..d053b5a24dc 100644 --- a/packages/web-client/src/webdav/constants/dav.ts +++ b/packages/web-client/src/webdav/constants/dav.ts @@ -1,4 +1,4 @@ -import { Audio, GeoCoordinates, Image, MotionPhoto, Photo } from '../../graph/generated' +import { Audio, GeoCoordinates, Image, LivePhoto, MotionPhoto, Photo } from '../../graph/generated' export abstract class DavPermission { static readonly Shared: string = 'S' @@ -81,6 +81,10 @@ const DavPropertyMapping = { value: 'motion-photo', type: null as MotionPhoto }, + LivePhoto: { + value: 'live-photo', + type: null as LivePhoto + }, ETag: defString('getetag' as const), MimeType: defString('getcontenttype' as const), ResourceType: defStringArray('resourcetype' as const), @@ -169,6 +173,7 @@ export abstract class DavProperties { DavProperty.Image, DavProperty.Photo, DavProperty.MotionPhoto, + DavProperty.LivePhoto, DavProperty.HasPreview ] diff --git a/packages/web-client/tests/unit/helpers/resource/functions.spec.ts b/packages/web-client/tests/unit/helpers/resource/functions.spec.ts index 6a94803c957..75d52bc99f5 100644 --- a/packages/web-client/tests/unit/helpers/resource/functions.spec.ts +++ b/packages/web-client/tests/unit/helpers/resource/functions.spec.ts @@ -167,6 +167,42 @@ describe('buildResource', () => { }) }) + describe('livePhoto facet', () => { + it('maps the live photo facet and camel-cases its keys', () => { + const webDavResponse = mockDeep({ + props: { + [DavProperty.LivePhoto]: { + 'content-id': 'A1B2C3D4-0000-1111-2222-333344445555', + 'still-image-time-us': 1250000, + auto: true, + 'vitality-score': 0.75, + 'vitality-scoring-version': 4 + } as never, + [DavProperty.Tags]: undefined + } + }) + const resource = buildResource(webDavResponse) + expect(resource.livePhoto).toEqual({ + contentId: 'A1B2C3D4-0000-1111-2222-333344445555', + stillImageTimeUs: 1250000, + auto: true, + vitalityScore: 0.75, + vitalityScoringVersion: 4 + }) + }) + + it('leaves livePhoto undefined when the facet is absent', () => { + const webDavResponse = mockDeep({ + props: { + [DavProperty.LivePhoto]: undefined, + [DavProperty.Tags]: undefined + } + }) + const resource = buildResource(webDavResponse) + expect(resource.livePhoto).toBeUndefined() + }) + }) + it('handles extraProps', () => { const webDavResponse = mockDeep({ props: { From 94eb8142161a86ecd2bee3cc9f2d2eb031d2569f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sat, 3 Oct 2026 14:30:21 +0200 Subject: [PATCH 2/7] feat(web-pkg): play live photos through the motion photo composables A live photo keeps its video in a separate QuickTime file that shares the content id of the still. The paired video is resolved lazily when playback is requested: first among the listed resources, then through a search by content id. Public links have no search, the lookup stops at the listed resources there. The video is downloaded by its id, so a move or rename does not break playback. The resolved video is kept per player next to its blob url and cleaned up with it. A missing video is only remembered for a short time, because the facet is indexed with a delay after an upload. isMotionPhoto and isLivePhoto each match their own kind, isMotionOrLivePhoto covers both. --- .../composables/motionPhoto/useMotionPhoto.ts | 203 ++++++++++++-- .../motionPhoto/useMotionPhotoPlayback.ts | 5 +- .../motionPhoto/useMotionPhoto.spec.ts | 184 +++++++++++- .../useMotionPhotoPlayback.spec.ts | 263 +++++++++++++++++- 4 files changed, 626 insertions(+), 29 deletions(-) diff --git a/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts b/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts index 184965fb0f7..82f87d0bf47 100644 --- a/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts +++ b/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts @@ -1,28 +1,82 @@ +import { shallowReactive } from 'vue' import { tryOnScopeDispose } from '@vueuse/core' import isEmpty from 'lodash-es/isEmpty' -import { Resource, SpaceResource } from '@opencloud-eu/web-client' +import { isPublicSpaceResource, Resource, SpaceResource } from '@opencloud-eu/web-client' import { useClientService } from '../clientService' +import { useResourcesStore } from '../piniaStores' +import { useGetMatchingSpace } from '../spaces' + +const PAIRED_VIDEO_SEARCH_LIMIT = 10 +const PAIRED_VIDEO_MISSING_TTL_MS = 30_000 + +type PairedVideo = { resource: Resource; space: SpaceResource } + +function isPairedVideoOf(still: Resource, candidate: Resource): boolean { + return ( + candidate.livePhoto?.contentId === still.livePhoto.contentId && + !!candidate.mimeType?.startsWith('video/') + ) +} + +export function isMotionPhoto(resource: Resource): boolean { + return !isEmpty(resource?.motionPhoto) +} + +export function isLivePhoto(resource: Resource): boolean { + return ( + !isMotionPhoto(resource) && + !!resource?.livePhoto?.contentId && + !!resource.mimeType?.startsWith('image/') + ) +} + +export function isMotionOrLivePhoto(resource: Resource): boolean { + return isMotionPhoto(resource) || isLivePhoto(resource) +} /** - * Handles the embedded video of a Google Motion Photo. + * Handles the video of a photo, which is either embedded or paired. * - * A Motion Photo is a still JPEG with a short MP4 clip appended to the end of - * the file. The `motionPhoto` facet exposes `videoSize` (the byte length of - * that appended clip), which lets us fetch just the video with a single HTTP - * Range request instead of downloading the whole file: `Range: bytes=-` - * where `offset = size - videoSize`. + * Embedded (Google Motion Photo): a still JPEG with a short MP4 video appended + * to the end of the file. The `motionPhoto` facet exposes `videoSize` (the byte + * length of that appended video), which lets us fetch just the video with a + * single HTTP Range request instead of downloading the whole file: + * `Range: bytes=-` where `offset = size - videoSize`. + * + * Paired (Apple Live Photo): the video is a separate QuickTime file that + * carries the same `livePhoto.contentId` as the still. It is looked up only + * when the video is requested and then downloaded as a whole. * * The composable owns the blob lifecycle: it memoizes the object URL per * resource id (so hover + click don't double-fetch) and revokes every URL it - * created when the owning scope is disposed. + * created when the owning scope is disposed. The paired video of a live photo + * is kept the same way. */ export function useMotionPhoto() { const clientService = useClientService() + const resourcesStore = useResourcesStore() + const { getMatchingSpace } = useGetMatchingSpace() const blobUrlCache = new Map() + const pairedVideoCache = new Map() + const missingPairedVideoTimers = shallowReactive(new Map>()) - const isMotionPhoto = (resource: Resource): boolean => !isEmpty(resource?.motionPhoto) + // "not found" is only held for a while: right after an upload the server may + // not have indexed the video yet, so a later attempt can still find it + function rememberMissingPairedVideo(resourceId: string): void { + const timer = setTimeout( + () => missingPairedVideoTimers.delete(resourceId), + PAIRED_VIDEO_MISSING_TTL_MS + ) + missingPairedVideoTimers.set(resourceId, timer) + } + + function forgetPairedVideo(resourceId: string): void { + clearTimeout(missingPairedVideoTimers.get(resourceId)) + missingPairedVideoTimers.delete(resourceId) + pairedVideoCache.delete(resourceId) + } - const getVideoOffset = (resource: Resource): number | null => { + function getVideoOffset(resource: Resource): number | null { if (!isMotionPhoto(resource)) { return null } @@ -38,10 +92,24 @@ export function useMotionPhoto() { return offset } - const canPlay = (resource: Resource): boolean => getVideoOffset(resource) !== null + function canPlay(resource: Resource): boolean { + if (isLivePhoto(resource)) { + return !missingPairedVideoTimers.has(resource.id) + } + return getVideoOffset(resource) !== null + } - // the facet marks an unspecified timestamp as -1 - const getStillTimestampSeconds = (resource: Resource): number | null => { + // the motion photo facet marks an unspecified timestamp as -1; a live photo + // carries the timestamp on its video half only, which is known once resolved + function getStillTimestampSeconds(resource: Resource, duration?: number): number | null { + if (isLivePhoto(resource)) { + const video = pairedVideoCache.get(resource.id)?.resource + const us = Number(video?.livePhoto?.stillImageTimeUs) + if (Number.isFinite(us) && us >= 0) { + return us / 1_000_000 + } + return Number.isFinite(duration) ? duration / 2 : null + } const us = Number(resource?.motionPhoto?.presentationTimestampUs) if (!Number.isFinite(us) || us < 0) { return null @@ -49,15 +117,91 @@ export function useMotionPhoto() { return us / 1_000_000 } - async function loadVideoUrl( + function findListedPairedVideo(space: SpaceResource, resource: Resource): PairedVideo | null { + const listed = resourcesStore.resources.find((candidate) => + isPairedVideoOf(resource, candidate) + ) + if (!listed) { + return null + } + // search results and flat lists mix resources of several spaces + const isSameSpace = listed.storageId === resource.storageId + return { resource: listed, space: isSameSpace ? space : getMatchingSpace(listed) } + } + + async function searchPairedVideo( + resource: Resource, + signal?: AbortSignal + ): Promise { + try { + const { resources } = await clientService.webdav.search( + `livePhoto.contentId:"${resource.livePhoto.contentId}"`, + { searchLimit: PAIRED_VIDEO_SEARCH_LIMIT, signal } + ) + const hit = resources.find(({ mimeType }) => mimeType?.startsWith('video/')) + if (!hit) { + return null + } + return { resource: hit, space: getMatchingSpace(hit) } + } catch (error) { + if (signal?.aborted) { + throw error + } + return null + } + } + + async function resolvePairedVideo( space: SpaceResource, resource: Resource, signal?: AbortSignal - ): Promise { - if (blobUrlCache.has(resource.id)) { - return blobUrlCache.get(resource.id) + ): Promise { + if (pairedVideoCache.has(resource.id)) { + return pairedVideoCache.get(resource.id) + } + + if (!missingPairedVideoTimers.has(resource.id)) { + const canSearch = !isPublicSpaceResource(space) + const video = + findListedPairedVideo(space, resource) ?? + (canSearch ? await searchPairedVideo(resource, signal) : null) + if (video) { + pairedVideoCache.set(resource.id, video) + return video + } + rememberMissingPairedVideo(resource.id) + } + throw new Error('paired video of the live photo not found') + } + + async function loadPairedVideo( + space: SpaceResource, + resource: Resource, + signal?: AbortSignal + ): Promise { + const video = await resolvePairedVideo(space, resource, signal) + // the id survives a move or rename of the video, public links only know paths + const { fileId, path } = video.resource + const file = isPublicSpaceResource(video.space) ? { path } : { fileId } + try { + const { body } = await clientService.webdav.getFileContents(video.space, file, { + responseType: 'blob', + signal + }) + return body + } catch (error) { + if (!signal?.aborted) { + pairedVideoCache.delete(resource.id) + } + throw error } + } + async function loadEmbeddedVideo( + space: SpaceResource, + resource: Resource, + signal?: AbortSignal + ): Promise { const offset = getVideoOffset(resource) if (offset === null) { throw new Error('resource is not a playable motion photo') @@ -70,9 +214,24 @@ export function useMotionPhoto() { ) // a 200 means the server ignored the Range header and sent the whole file - const raw: Blob = response?.status === 200 ? body.slice(offset) : body + return response?.status === 200 ? body.slice(offset) : body + } + + async function loadVideoUrl( + space: SpaceResource, + resource: Resource, + signal?: AbortSignal + ): Promise { + if (blobUrlCache.has(resource.id)) { + return blobUrlCache.get(resource.id) + } + + const raw: Blob = isLivePhoto(resource) + ? await loadPairedVideo(space, resource, signal) + : await loadEmbeddedVideo(space, resource, signal) - // the response is typed image/jpeg (the file's type), which