diff --git a/.woodpecker.env b/.woodpecker.env index 93382bdda0d..fdaba07cee4 100644 --- a/.woodpecker.env +++ b/.woodpecker.env @@ -1,3 +1,3 @@ # The version of OpenCloud to use in pipelines -OPENCLOUD_COMMITID=f293031e404d51a7da4a5c06d88b449dc226b9cb +OPENCLOUD_COMMITID=4dec19089de0ab4b2d6e9d77bb3e11bb1dcc26f7 OPENCLOUD_BRANCH=main diff --git a/.woodpecker.star b/.woodpecker.star index b559db32ba9..ae24bf8f3fb 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -13,7 +13,7 @@ COLLABORA_CODE = "collabora/code:26.04.4.2.1" KEYCLOAK = "quay.io/keycloak/keycloak:26.7.3" MINIO_MC = "quay.io/opencloudeu/minio-mc-ci:1.0" OC_CI_BAZEL_BUILDIFIER = "quay.io/opencloudeu/bazel-buildifier-ci:latest" -OC_CI_GOLANG = "quay.io/opencloudeu/golang-ci:1.25" +OC_CI_GOLANG = "quay.io/opencloudeu/golang-ci:1.26" OC_CI_NODEJS = "quay.io/opencloudeu/nodejs-ci:24" OC_CI_NODEJS_ALPINE = "quay.io/opencloudeu/nodejs-alpine-ci:24" OC_CI_WAIT_FOR = "quay.io/opencloudeu/wait-for-ci:latest" diff --git a/packages/web-app-preview/src/App.vue b/packages/web-app-preview/src/App.vue index cd3cf17e26e..846e782283e 100644 --- a/packages/web-app-preview/src/App.vue +++ b/packages/web-app-preview/src/App.vue @@ -41,7 +41,7 @@

{ isVideo: isFileTypeVideo(file), isImage: isFileTypeImage(file), isAudio: isFileTypeAudio(file), - isMotionPhoto: isFileTypeImage(file) && !isEmpty(file.motionPhoto), + isMotionOrLivePhoto: isMotionOrLivePhoto(file), isLoading: true, isError: false, resource: file diff --git a/packages/web-app-preview/src/components/MediaControls.vue b/packages/web-app-preview/src/components/MediaControls.vue index 89842babb36..5f68ea337b6 100644 --- a/packages/web-app-preview/src/components/MediaControls.vue +++ b/packages/web-app-preview/src/components/MediaControls.vue @@ -156,7 +156,13 @@ diff --git a/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue b/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue index b636e6cb471..7515be15cb0 100644 --- a/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue +++ b/packages/web-app-preview/src/components/Sources/MediaMotionPhoto.vue @@ -20,16 +20,16 @@ v-if="isLoading && !isPlaying" class="absolute inset-0 m-auto" size="large" - :aria-label="$gettext('Loading motion photo')" + :aria-label="loadingLabel" /> diff --git a/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue b/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue index 86e4644b4d6..132fc943b2f 100644 --- a/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue +++ b/packages/web-pkg/src/components/FilesList/MotionPhotoOverlay.vue @@ -2,7 +2,7 @@
import { computed, useTemplateRef } from 'vue' -import isEmpty from 'lodash-es/isEmpty' import { Resource, SpaceResource } from '@opencloud-eu/web-client' import MotionPhotoPlayer from './MotionPhotoPlayer.vue' +import { isMotionOrLivePhoto } from '../../composables' const { resource, @@ -34,5 +34,5 @@ const { }>() const player = useTemplateRef>('player') -const isMotionPhoto = computed(() => !isEmpty(resource?.motionPhoto)) +const showPlayer = computed(() => isMotionOrLivePhoto(resource)) diff --git a/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue b/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue index 11e0f84ee68..7e553750647 100644 --- a/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue +++ b/packages/web-pkg/src/components/FilesList/MotionPhotoPlayer.vue @@ -20,6 +20,7 @@ :loading="isLoading" :icon="isPlaying ? 'pause-circle' : 'play-circle'" :label="badgeLabel" + :loading-label="badgeLoadingLabel" @click.stop.prevent="toggle" />
@@ -30,7 +31,7 @@ import { computed, unref } from 'vue' import { useGettext } from 'vue3-gettext' import { Resource, SpaceResource } from '@opencloud-eu/web-client' import MotionPhotoBadge from './MotionPhotoBadge.vue' -import { useGetMatchingSpace, useMotionPhotoPlayback } from '../../composables' +import { isLivePhoto, useGetMatchingSpace, useMotionPhotoPlayback } from '../../composables' // Owns the playback state, so mount it only for motion photos. Pointer-transparent // apart from the badge; the parent drives hover-to-play via hoverPlay/stop. @@ -58,10 +59,20 @@ const { isPlaying, isLoading, videoUrl, canPlay, hoverPlay, stop, toggle, seekTo ) const badgeLabel = computed(() => { + if (isLivePhoto(resource)) { + if (!unref(canPlay)) { + return $gettext('Live Photo (video not available)') + } + return unref(isPlaying) ? $gettext('Pause Live Photo') : $gettext('Play Live Photo') + } if (!unref(canPlay)) { - return $gettext('Motion photo (clip not available)') + return $gettext('Motion Photo (video not available)') } - return unref(isPlaying) ? $gettext('Pause motion photo') : $gettext('Play motion photo') + return unref(isPlaying) ? $gettext('Pause Motion Photo') : $gettext('Play Motion Photo') +}) + +const badgeLoadingLabel = computed(() => { + return isLivePhoto(resource) ? $gettext('Loading Live Photo') : $gettext('Loading Motion Photo') }) defineExpose({ isPlaying, hoverPlay, stop, toggle }) diff --git a/packages/web-pkg/src/components/FilesList/ResourceTile.vue b/packages/web-pkg/src/components/FilesList/ResourceTile.vue index 89feb75b0e6..2e98e1ac964 100644 --- a/packages/web-pkg/src/components/FilesList/ResourceTile.vue +++ b/packages/web-pkg/src/components/FilesList/ResourceTile.vue @@ -85,7 +85,7 @@ resourcesStore.selectedIdsSet.has(resource.id)) // the preview is inset while selected or hovered (the whole media area, badge // included, so that hovering the badge does not pop the still back out) and the -// clip follows that inset; the badge keeps the checkbox's fixed edge distance +// video follows that inset; the badge keeps the checkbox's fixed edge distance const isMediaHovered = ref(false) const motionInset = computed(() => isResourceSelected.value || isMediaHovered.value) const motionVideoClass = computed(() => diff --git a/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts b/packages/web-pkg/src/composables/motionPhoto/useMotionPhoto.ts index 184965fb0f7..3d58778d86e 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>()) + + // "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) + } - const isMotionPhoto = (resource: Resource): boolean => !isEmpty(resource?.motionPhoto) + 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,112 @@ 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) } + } + + // FIXME: remove once the search REPORT returns the facets of its hits + // (opencloud-eu/opencloud#3652), the hit then carries the still image time + async function loadVideoFacets( space: SpaceResource, + video: Resource, + signal?: AbortSignal + ): Promise { + if (video.livePhoto) { + return video + } + try { + return await clientService.webdav.getFileInfo(space, { fileId: video.fileId }, { signal }) + } catch (error) { + if (signal?.aborted) { + throw error + } + return video + } + } + + async function searchPairedVideo( resource: Resource, signal?: AbortSignal - ): Promise { - if (blobUrlCache.has(resource.id)) { - return blobUrlCache.get(resource.id) + ): 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 + } + const space = getMatchingSpace(hit) + return { resource: await loadVideoFacets(space, hit, signal), space } + } catch (error) { + if (signal?.aborted) { + throw error + } + return null + } + } + + async function resolvePairedVideo( + space: SpaceResource, + resource: Resource, + signal?: AbortSignal + ): 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 +235,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 + } - // the response is typed image/jpeg (the file's type), which