mirror of
https://github.com/Afilmory/afilmory
synced 2026-06-01 19:05:42 +00:00
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { gallerySettingAtom } from '~/atoms/app'
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import { usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
import { MageLens } from '~/icons'
|
||||
|
||||
@@ -34,6 +35,18 @@ const allTags = photoLoader.getAllTags()
|
||||
const allCameras = photoLoader.getAllCameras()
|
||||
const allLenses = photoLoader.getAllLenses()
|
||||
|
||||
const PhotoThumbnailIcon = ({ photo }: { photo: ReturnType<typeof photoLoader.getPhotos>[number] }) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
|
||||
if (!thumbnailSrc) {
|
||||
return (
|
||||
<div className="bg-fill-tertiary flex h-6 w-6 items-center justify-center rounded text-xs text-white/70">📷</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <img src={thumbnailSrc} alt={photo.title || 'Photo'} className="h-6 w-6 rounded object-cover" />
|
||||
}
|
||||
|
||||
const getLocationTokens = (
|
||||
location?: { locationName?: string | null; city?: string | null; country?: string | null } | null,
|
||||
) => {
|
||||
@@ -294,7 +307,7 @@ export const CommandPalette = ({ isOpen, onClose }: CommandPaletteProps) => {
|
||||
type: 'photo',
|
||||
title: photo.title || photo.id,
|
||||
subtitle: photo.description || locationSubtitle || `${photo.exif?.Model || 'Photo'}`,
|
||||
icon: <img src={photo.thumbnailUrl} alt={photo.title || 'Photo'} className="h-6 w-6 rounded object-cover" />,
|
||||
icon: <PhotoThumbnailIcon photo={photo} />,
|
||||
action: () => {
|
||||
const allPhotos = photoLoader.getPhotos()
|
||||
const photoIndex = allPhotos.findIndex((p) => p.id === photo.id)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { m } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import type { PhotoMarker } from '~/types/map'
|
||||
|
||||
interface ClusterPhotoGridProps {
|
||||
@@ -28,52 +29,7 @@ export const ClusterPhotoGrid = ({ photos, onPhotoClick }: ClusterPhotoGridProps
|
||||
{/* 照片网格 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{displayPhotos.map((photoMarker, index) => (
|
||||
<m.div
|
||||
key={photoMarker.photo.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
...Spring.presets.smooth,
|
||||
delay: index * 0.05,
|
||||
}}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg"
|
||||
>
|
||||
<Link
|
||||
to={`/${photoMarker.photo.id}`}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPhotoClick?.(photoMarker)
|
||||
}}
|
||||
className="block h-full w-full"
|
||||
>
|
||||
<LazyImage
|
||||
src={photoMarker.photo.thumbnailUrl || photoMarker.photo.originalUrl}
|
||||
alt={photoMarker.photo.title || photoMarker.photo.id}
|
||||
thumbHash={photoMarker.photo.thumbHash}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
rootMargin="200px"
|
||||
threshold={0.1}
|
||||
/>
|
||||
|
||||
{/* 悬停遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/0 transition-colors duration-300 group-hover:bg-black/20" />
|
||||
|
||||
{/* 悬停图标 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<div className="rounded-full bg-black/50 p-2 backdrop-blur-sm">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</m.div>
|
||||
<ClusterPhotoTile key={photoMarker.photo.id} marker={photoMarker} index={index} onPhotoClick={onPhotoClick} />
|
||||
))}
|
||||
|
||||
{/* 更多照片指示器 */}
|
||||
@@ -148,3 +104,60 @@ export const ClusterPhotoGrid = ({ photos, onPhotoClick }: ClusterPhotoGridProps
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ClusterPhotoTile = ({
|
||||
marker,
|
||||
index,
|
||||
onPhotoClick,
|
||||
}: {
|
||||
marker: PhotoMarker
|
||||
index: number
|
||||
onPhotoClick?: (photo: PhotoMarker) => void
|
||||
}) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(marker.photo)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
...Spring.presets.smooth,
|
||||
delay: index * 0.05,
|
||||
}}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg"
|
||||
>
|
||||
<Link
|
||||
to={`/${marker.photo.id}`}
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPhotoClick?.(marker)
|
||||
}}
|
||||
className="block h-full w-full"
|
||||
>
|
||||
<LazyImage
|
||||
src={thumbnailSrc || marker.photo.originalUrl}
|
||||
alt={marker.photo.title || marker.photo.id}
|
||||
thumbHash={marker.photo.thumbHash}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
rootMargin="200px"
|
||||
threshold={0.1}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/0 transition-colors duration-300 group-hover:bg-black/20" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<div className="rounded-full bg-black/50 p-2 backdrop-blur-sm">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger, LazyImage } from '@afilmory/ui'
|
||||
import { m } from 'motion/react'
|
||||
import { Marker } from 'react-map-gl/maplibre'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
|
||||
import { ClusterPhotoGrid } from '../ClusterPhotoGrid'
|
||||
import type { ClusterMarkerProps } from './types'
|
||||
|
||||
@@ -66,14 +69,7 @@ export const ClusterMarker = ({
|
||||
|
||||
return (
|
||||
<div key={photoMarker.photo.id} className="absolute opacity-30" style={position}>
|
||||
<LazyImage
|
||||
src={photoMarker.photo.thumbnailUrl || photoMarker.photo.originalUrl}
|
||||
alt={photoMarker.photo.title || photoMarker.photo.id}
|
||||
thumbHash={photoMarker.photo.thumbHash}
|
||||
className="h-full w-full object-cover"
|
||||
rootMargin="100px"
|
||||
threshold={0.1}
|
||||
/>
|
||||
<ClusterMosaicImage photo={photoMarker.photo} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -117,3 +113,18 @@ export const ClusterMarker = ({
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
|
||||
const ClusterMosaicImage = ({ photo }: { photo: PhotoManifestItem }) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
|
||||
return (
|
||||
<LazyImage
|
||||
src={thumbnailSrc || photo.originalUrl}
|
||||
alt={photo.title || photo.id}
|
||||
thumbHash={photo.thumbHash}
|
||||
className="h-full w-full object-cover"
|
||||
rootMargin="100px"
|
||||
threshold={0.1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { m } from 'motion/react'
|
||||
import { Marker } from 'react-map-gl/maplibre'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
|
||||
import type { PhotoMarkerPinProps } from './types'
|
||||
|
||||
export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }: PhotoMarkerPinProps) => {
|
||||
@@ -41,10 +43,8 @@ export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }:
|
||||
|
||||
{/* Photo background preview */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-full">
|
||||
<LazyImage
|
||||
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
|
||||
alt={marker.photo.title || marker.photo.id}
|
||||
thumbHash={marker.photo.thumbHash}
|
||||
<MarkerImage
|
||||
photo={marker.photo}
|
||||
className="h-full w-full object-cover opacity-40"
|
||||
rootMargin="100px"
|
||||
threshold={0.1}
|
||||
@@ -98,10 +98,8 @@ export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }:
|
||||
|
||||
{/* Photo header */}
|
||||
<div className="relative h-32 overflow-hidden">
|
||||
<LazyImage
|
||||
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
|
||||
alt={marker.photo.title || marker.photo.id}
|
||||
thumbHash={marker.photo.thumbHash}
|
||||
<MarkerImage
|
||||
photo={marker.photo}
|
||||
className="h-full w-full object-cover"
|
||||
rootMargin="200px"
|
||||
threshold={0.1}
|
||||
@@ -180,3 +178,28 @@ export const PhotoMarkerPin = ({ marker, isSelected = false, onClick, onClose }:
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkerImage = ({
|
||||
photo,
|
||||
className,
|
||||
rootMargin,
|
||||
threshold,
|
||||
}: {
|
||||
photo: PhotoMarkerPinProps['marker']['photo']
|
||||
className?: string
|
||||
rootMargin?: string
|
||||
threshold?: number
|
||||
}) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
|
||||
return (
|
||||
<LazyImage
|
||||
src={thumbnailSrc || photo.originalUrl}
|
||||
alt={photo.title || photo.id}
|
||||
thumbHash={photo.thumbHash}
|
||||
className={className}
|
||||
rootMargin={rootMargin}
|
||||
threshold={threshold}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export const ExifPanel: FC<{
|
||||
{/* 直方图 */}
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 text-xs font-medium text-white/70">{t('exif.histogram')}</div>
|
||||
<HistogramChart thumbnailUrl={currentPhoto.thumbnailUrl} />
|
||||
<HistogramChart photo={currentPhoto} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import { nextFrame } from '~/lib/dom'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
@@ -150,31 +151,13 @@ export const GalleryThumbnail: FC<{
|
||||
{photos.slice(startIndex, endIndex + 1).map((photo, sliceIndex) => {
|
||||
const index = startIndex + sliceIndex
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<GalleryThumbnailButton
|
||||
key={photo.id}
|
||||
className={clsxm(
|
||||
'contain-intrinsic-size relative shrink-0 overflow-hidden rounded-lg border-2 transition-all',
|
||||
index === currentIndex
|
||||
? 'scale-110 border-accent shadow-[0_0_20px_color-mix(in_srgb,var(--color-accent)_20%,transparent)]'
|
||||
: 'grayscale-50 border-accent/20 hover:border-accent hover:grayscale-0',
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
width: thumbnailSize.mobile,
|
||||
height: thumbnailSize.mobile,
|
||||
}
|
||||
: {
|
||||
width: thumbnailSize.desktop,
|
||||
height: thumbnailSize.desktop,
|
||||
}
|
||||
}
|
||||
photo={photo}
|
||||
isActive={index === currentIndex}
|
||||
size={isMobile ? thumbnailSize.mobile : thumbnailSize.desktop}
|
||||
onClick={() => onIndexChange(index)}
|
||||
>
|
||||
{photo.thumbHash && <Thumbhash thumbHash={photo.thumbHash} className="size-fill absolute inset-0" />}
|
||||
<img src={photo.thumbnailUrl} alt={photo.title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
</button>
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -191,3 +174,34 @@ export const GalleryThumbnail: FC<{
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
const GalleryThumbnailButton: FC<{
|
||||
photo: PhotoManifest
|
||||
isActive: boolean
|
||||
size: number
|
||||
onClick: () => void
|
||||
}> = ({ photo, isActive, size, onClick }) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsxm(
|
||||
'contain-intrinsic-size relative shrink-0 overflow-hidden rounded-lg border-2 transition-all',
|
||||
isActive
|
||||
? 'scale-110 border-accent shadow-[0_0_20px_color-mix(in_srgb,var(--color-accent)_20%,transparent)]'
|
||||
: 'grayscale-50 border-accent/20 hover:border-accent hover:grayscale-0',
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{photo.thumbHash && <Thumbhash thumbHash={photo.thumbHash} className="size-fill absolute inset-0" />}
|
||||
{thumbnailSrc ? (
|
||||
<img src={thumbnailSrc} alt={photo.title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
interface CompressedHistogramData {
|
||||
red: number[]
|
||||
green: number[]
|
||||
@@ -178,9 +181,9 @@ const drawHistogram = (canvas: HTMLCanvasElement, histogram: CompressedHistogram
|
||||
}
|
||||
|
||||
export const HistogramChart: FC<{
|
||||
thumbnailUrl: string
|
||||
photo: PhotoManifest
|
||||
className?: string
|
||||
}> = ({ thumbnailUrl, className = '' }) => {
|
||||
}> = ({ photo, className = '' }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const previousHistogramRef = useRef<CompressedHistogramData | null>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
@@ -188,8 +191,14 @@ export const HistogramChart: FC<{
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const thumbnailUrl = usePhotoThumbnailSrc(photo)
|
||||
|
||||
useEffect(() => {
|
||||
if (!thumbnailUrl) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
|
||||
|
||||
@@ -317,7 +317,9 @@ export const PhotoViewer = ({
|
||||
loadingIndicatorRef={loadingIndicatorRef}
|
||||
isCurrentImage={isCurrentImage}
|
||||
src={photo.originalUrl}
|
||||
thumbnailSrc={photo.thumbnailUrl}
|
||||
originalObjectKey={photo.s3Key}
|
||||
thumbnailSrc={photo.thumbnailUrl ?? undefined}
|
||||
thumbnailObjectKey={photo.thumbnailKey ?? null}
|
||||
alt={photo.title}
|
||||
width={isCurrentImage ? currentPhoto.width : undefined}
|
||||
height={isCurrentImage ? currentPhoto.height : undefined}
|
||||
@@ -333,6 +335,7 @@ export const PhotoViewer = ({
|
||||
? {
|
||||
type: 'motion-photo',
|
||||
imageUrl: photo.originalUrl,
|
||||
objectKey: photo.s3Key ?? null,
|
||||
offset: photo.video.offset,
|
||||
size: photo.video.size,
|
||||
presentationTimestamp: photo.video.presentationTimestamp,
|
||||
@@ -341,6 +344,7 @@ export const PhotoViewer = ({
|
||||
? {
|
||||
type: 'live-photo',
|
||||
videoUrl: photo.video.videoUrl,
|
||||
objectKey: photo.video.s3Key ?? null,
|
||||
}
|
||||
: { type: 'none' }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { WebGLImageViewer } from '@afilmory/webgl-viewer'
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
|
||||
import { useShowContextMenu } from '~/atoms/context-menu'
|
||||
import { canUseWebGL } from '~/lib/feature'
|
||||
import type { AssetDescriptor } from '~/lib/secure-asset'
|
||||
import { getDisplayAssetUrl, isSecureAccessRequired } from '~/lib/secure-asset'
|
||||
|
||||
import { SlidingNumber } from '../number/SlidingNumber'
|
||||
import { DOMImageViewer } from './DOMImageViewer'
|
||||
@@ -27,6 +29,8 @@ import type { ProgressiveImageProps, WebGLImageViewerRef } from './types'
|
||||
export const ProgressiveImage = ({
|
||||
src,
|
||||
thumbnailSrc,
|
||||
originalObjectKey,
|
||||
thumbnailObjectKey,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
@@ -45,6 +49,7 @@ export const ProgressiveImage = ({
|
||||
loadingIndicatorRef,
|
||||
}: ProgressiveImageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const secureAccessRequired = isSecureAccessRequired()
|
||||
|
||||
// State management
|
||||
const [state, setState] = useProgressiveImageState()
|
||||
@@ -77,9 +82,56 @@ export const ProgressiveImage = ({
|
||||
return src
|
||||
}, [src])
|
||||
|
||||
const [resolvedThumbnailSrc, setResolvedThumbnailSrc] = useState<string | null>(thumbnailSrc ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (!secureAccessRequired) {
|
||||
setResolvedThumbnailSrc(thumbnailSrc ?? null)
|
||||
return
|
||||
}
|
||||
if (!thumbnailObjectKey) {
|
||||
setResolvedThumbnailSrc(thumbnailSrc ?? null)
|
||||
return
|
||||
}
|
||||
getDisplayAssetUrl({
|
||||
objectKey: thumbnailObjectKey,
|
||||
intent: 'thumbnail',
|
||||
fallbackUrl: thumbnailSrc ?? null,
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) {
|
||||
setResolvedThumbnailSrc(url)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to resolve secure thumbnail', error)
|
||||
if (!cancelled) {
|
||||
setResolvedThumbnailSrc(thumbnailSrc ?? null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [secureAccessRequired, thumbnailObjectKey, thumbnailSrc])
|
||||
|
||||
const imageSource: AssetDescriptor | string = useMemo(() => {
|
||||
if (!secureAccessRequired) {
|
||||
return resolvedSrc
|
||||
}
|
||||
if (originalObjectKey) {
|
||||
return {
|
||||
objectKey: originalObjectKey,
|
||||
intent: 'photo',
|
||||
fallbackUrl: resolvedSrc,
|
||||
}
|
||||
}
|
||||
return resolvedSrc
|
||||
}, [secureAccessRequired, originalObjectKey, resolvedSrc])
|
||||
|
||||
// Hooks
|
||||
const imageLoaderManagerRef = useImageLoader(
|
||||
resolvedSrc,
|
||||
imageSource,
|
||||
isCurrentImage,
|
||||
highResLoaded,
|
||||
error,
|
||||
@@ -123,11 +175,11 @@ export const ProgressiveImage = ({
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
>
|
||||
{/* 缩略图 - 在高分辨率图片未加载或加载失败时显示 */}
|
||||
{thumbnailSrc && (!isHighResImageRendered || error) && (
|
||||
{resolvedThumbnailSrc && (!isHighResImageRendered || error) && (
|
||||
<img
|
||||
ref={thumbnailRef}
|
||||
src={thumbnailSrc}
|
||||
key={thumbnailSrc}
|
||||
src={resolvedThumbnailSrc}
|
||||
key={resolvedThumbnailSrc}
|
||||
alt={alt}
|
||||
className={clsxm(
|
||||
'absolute inset-0 h-full w-full object-contain transition-opacity duration-300',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import type { AnimationFrameRect, PhotoViewerTransition, PhotoViewerTransitionState } from './types'
|
||||
@@ -34,6 +35,7 @@ export const usePhotoViewerTransitions = ({
|
||||
currentBlobSrc,
|
||||
isMobile,
|
||||
}: UsePhotoViewerTransitionsParams): UsePhotoViewerTransitionsResult => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(currentPhoto ?? null)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const cachedTriggerRef = useRef<HTMLElement | null>(triggerElement)
|
||||
const wasOpenRef = useRef(isOpen)
|
||||
@@ -134,7 +136,7 @@ export const usePhotoViewerTransitions = ({
|
||||
return
|
||||
}
|
||||
|
||||
const imageSrc = currentBlobSrc || currentPhoto.thumbnailUrl || currentPhoto.originalUrl || null
|
||||
const imageSrc = currentBlobSrc || thumbnailSrc || currentPhoto?.originalUrl || null
|
||||
|
||||
if (!imageSrc) {
|
||||
setIsViewerContentVisible(true)
|
||||
@@ -243,7 +245,7 @@ export const usePhotoViewerTransitions = ({
|
||||
triggerEl instanceof HTMLImageElement && triggerEl.parentElement ? triggerEl.parentElement : triggerEl,
|
||||
)
|
||||
|
||||
const imageSrc = currentPhoto.thumbnailUrl || currentBlobSrc || currentPhoto.originalUrl || null
|
||||
const imageSrc = thumbnailSrc || currentBlobSrc || currentPhoto?.originalUrl || null
|
||||
|
||||
if (!imageSrc) {
|
||||
wasOpenRef.current = false
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { LoadingState } from '@afilmory/webgl-viewer'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { MenuItemSeparator, MenuItemText } from '~/atoms/context-menu'
|
||||
import { isMobileDevice } from '~/lib/device-viewport'
|
||||
import { ImageLoaderManager } from '~/lib/image-loader-manager'
|
||||
import type { AssetDescriptor } from '~/lib/secure-asset'
|
||||
import { resolveAssetRequest } from '~/lib/secure-asset'
|
||||
|
||||
import type { LivePhotoVideoHandle } from './LivePhotoVideo'
|
||||
import type { LoadingIndicatorRef } from './LoadingIndicator'
|
||||
@@ -59,8 +61,22 @@ export const useProgressiveImageState = (): [
|
||||
]
|
||||
}
|
||||
|
||||
const serializeSourceDescriptor = (descriptor: AssetDescriptor | string | null): string | null => {
|
||||
if (!descriptor) {
|
||||
return null
|
||||
}
|
||||
if (typeof descriptor === 'string') {
|
||||
return descriptor
|
||||
}
|
||||
return JSON.stringify({
|
||||
objectKey: descriptor.objectKey ?? null,
|
||||
intent: descriptor.intent,
|
||||
fallbackUrl: descriptor.fallbackUrl ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export const useImageLoader = (
|
||||
src: string,
|
||||
source: AssetDescriptor | string | null,
|
||||
isCurrentImage: boolean,
|
||||
highResLoaded: boolean,
|
||||
error: boolean,
|
||||
@@ -72,12 +88,13 @@ export const useImageLoader = (
|
||||
setHighResLoaded?: (loaded: boolean) => void,
|
||||
setError?: (error: boolean) => void,
|
||||
setIsHighResImageRendered?: (rendered: boolean) => void,
|
||||
) => {
|
||||
): React.RefObject<ImageLoaderManager | null> => {
|
||||
const { t } = useTranslation()
|
||||
const imageLoaderManagerRef = useRef<ImageLoaderManager | null>(null)
|
||||
const sourceKey = useMemo(() => serializeSourceDescriptor(source), [source])
|
||||
|
||||
useEffect(() => {
|
||||
if (highResLoaded || error || !isCurrentImage) return
|
||||
if (highResLoaded || error || !isCurrentImage || !sourceKey || !source) return
|
||||
|
||||
// Create new image loader manager
|
||||
const imageLoaderManager = new ImageLoaderManager()
|
||||
@@ -96,7 +113,8 @@ export const useImageLoader = (
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
const result = await imageLoaderManager.loadImage(src, {
|
||||
const resolvedSource = await resolveAssetRequest(source)
|
||||
const result = await imageLoaderManager.loadImage(resolvedSource, {
|
||||
onProgress,
|
||||
onError,
|
||||
onLoadingStateUpdate: (state) => {
|
||||
@@ -121,7 +139,15 @@ export const useImageLoader = (
|
||||
}
|
||||
|
||||
cleanup()
|
||||
loadImage()
|
||||
loadImage().catch((loadError) => {
|
||||
console.error('Failed to load image:', loadError)
|
||||
setError?.(true)
|
||||
loadingIndicatorRef?.current?.updateLoadingState({
|
||||
isVisible: true,
|
||||
isError: true,
|
||||
errorMessage: t('photo.error.loading'),
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
imageLoaderManager.cleanup()
|
||||
@@ -130,7 +156,7 @@ export const useImageLoader = (
|
||||
highResLoaded,
|
||||
error,
|
||||
onProgress,
|
||||
src,
|
||||
sourceKey,
|
||||
onError,
|
||||
isCurrentImage,
|
||||
onBlobSrcChange,
|
||||
@@ -140,6 +166,7 @@ export const useImageLoader = (
|
||||
setHighResLoaded,
|
||||
setError,
|
||||
setIsHighResImageRendered,
|
||||
source,
|
||||
])
|
||||
|
||||
return imageLoaderManagerRef
|
||||
|
||||
@@ -7,13 +7,22 @@ export const SHOW_SCALE_INDICATOR_DURATION = 1000
|
||||
|
||||
// Video source 的 sum type:Live Photo 或 Motion Photo
|
||||
export type VideoSource =
|
||||
| { type: 'live-photo'; videoUrl: string }
|
||||
| { type: 'motion-photo'; imageUrl: string; offset: number; size?: number; presentationTimestamp?: number }
|
||||
| { type: 'live-photo'; videoUrl: string; objectKey?: string | null }
|
||||
| {
|
||||
type: 'motion-photo'
|
||||
imageUrl: string
|
||||
objectKey?: string | null
|
||||
offset: number
|
||||
size?: number
|
||||
presentationTimestamp?: number
|
||||
}
|
||||
| { type: 'none' }
|
||||
|
||||
export interface ProgressiveImageProps {
|
||||
src: string
|
||||
thumbnailSrc?: string
|
||||
originalObjectKey?: string | null
|
||||
thumbnailObjectKey?: string | null
|
||||
|
||||
alt: string
|
||||
width?: number
|
||||
|
||||
@@ -6,6 +6,7 @@ const defaultInjectConfig = {
|
||||
useApi: false,
|
||||
useNext: false,
|
||||
useCloud: false,
|
||||
secureAccessEnabled: false,
|
||||
}
|
||||
|
||||
export const injectConfig = merge(defaultInjectConfig, __CONFIG__)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface InjectConfig {
|
||||
useApi: boolean
|
||||
useCloud: boolean
|
||||
secureAccessEnabled?: boolean
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
import { thumbHashToDataURL } from 'thumbhash'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import {
|
||||
CarbonIsoOutline,
|
||||
MaterialSymbolsShutterSpeed,
|
||||
@@ -21,7 +22,7 @@ interface PhotoItemProps {
|
||||
export function PhotoItem({ photo, className }: PhotoItemProps) {
|
||||
// 生成 thumbhash 预览
|
||||
const thumbHashDataURL = photo.thumbHash ? thumbHashToDataURL(decompressUint8Array(photo.thumbHash)) : null
|
||||
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
const ratio = photo.aspectRatio
|
||||
|
||||
// 格式化 EXIF 数据
|
||||
@@ -91,12 +92,14 @@ export function PhotoItem({ photo, className }: PhotoItemProps) {
|
||||
{thumbHashDataURL && (
|
||||
<img src={thumbHashDataURL} alt={photo.title} className="absolute inset-0 size-full" loading="lazy" />
|
||||
)}
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={photo.title}
|
||||
className="absolute inset-0 size-full object-cover object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
{thumbnailSrc ? (
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={photo.title}
|
||||
className="absolute inset-0 size-full object-cover object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 图片信息和 EXIF 覆盖层 */}
|
||||
|
||||
44
apps/web/src/hooks/usePhotoThumbnailSrc.ts
Normal file
44
apps/web/src/hooks/usePhotoThumbnailSrc.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { getDisplayAssetUrl, isSecureAccessRequired } from '~/lib/secure-asset'
|
||||
|
||||
export const usePhotoThumbnailSrc = (photo: PhotoManifestItem | null): string | null => {
|
||||
const secureAccess = isSecureAccessRequired()
|
||||
const fallback = useMemo(() => photo?.thumbnailUrl ?? null, [photo?.thumbnailUrl])
|
||||
const [src, setSrc] = useState<string | null>(fallback)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (!photo) {
|
||||
setSrc(null)
|
||||
return
|
||||
}
|
||||
if (!secureAccess || !photo.thumbnailKey) {
|
||||
setSrc(photo.thumbnailUrl ?? null)
|
||||
return
|
||||
}
|
||||
getDisplayAssetUrl({
|
||||
objectKey: photo.thumbnailKey,
|
||||
intent: 'thumbnail',
|
||||
fallbackUrl: photo.thumbnailUrl ?? null,
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) {
|
||||
setSrc(url)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to resolve secure thumbnail', error)
|
||||
if (!cancelled) {
|
||||
setSrc(photo.thumbnailUrl ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [photo?.id, photo?.thumbnailKey, photo?.thumbnailUrl, secureAccess])
|
||||
|
||||
return src
|
||||
}
|
||||
@@ -103,7 +103,10 @@ export class ImageLoaderManager {
|
||||
}
|
||||
}
|
||||
|
||||
async loadImage(src: string, callbacks: LoadingCallbacks = {}): Promise<ImageLoadResult> {
|
||||
async loadImage(
|
||||
source: string | { url: string; headers?: Record<string, string> },
|
||||
callbacks: LoadingCallbacks = {},
|
||||
): Promise<ImageLoadResult> {
|
||||
const { onProgress, onError, onLoadingStateUpdate } = callbacks
|
||||
|
||||
// Show loading indicator
|
||||
@@ -114,7 +117,13 @@ export class ImageLoaderManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.delayTimer = setTimeout(async () => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('GET', src)
|
||||
const target = typeof source === 'string' ? { url: source, headers: {} } : source
|
||||
xhr.open('GET', target.url)
|
||||
if (target.headers) {
|
||||
for (const [key, value] of Object.entries(target.headers)) {
|
||||
xhr.setRequestHeader(key, value)
|
||||
}
|
||||
}
|
||||
xhr.responseType = 'blob'
|
||||
|
||||
xhr.onload = async () => {
|
||||
@@ -131,11 +140,7 @@ export class ImageLoaderManager {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.processImageBlob(
|
||||
blob,
|
||||
src, // 传递原始 URL
|
||||
callbacks,
|
||||
)
|
||||
const result = await this.processImageBlob(blob, target.url, callbacks)
|
||||
resolve(result)
|
||||
} catch (error) {
|
||||
onLoadingStateUpdate?.({
|
||||
|
||||
232
apps/web/src/lib/secure-asset.ts
Normal file
232
apps/web/src/lib/secure-asset.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { injectConfig } from '~/config'
|
||||
|
||||
const SIGN_ENDPOINT = '/api/storage/sign'
|
||||
const EXPIRATION_SKEW_MS = 5_000
|
||||
|
||||
export type AssetDescriptor =
|
||||
| string
|
||||
| {
|
||||
objectKey?: string | null
|
||||
intent: string
|
||||
fallbackUrl?: string | null
|
||||
}
|
||||
|
||||
export interface ResolvedAssetRequest {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type CachedDisplayEntry = {
|
||||
url: string
|
||||
expiresAt: number
|
||||
isObjectUrl: boolean
|
||||
}
|
||||
|
||||
const signedRequestCache = new Map<string, ResolvedAssetRequest>()
|
||||
const pendingSignedRequests = new Map<string, Promise<ResolvedAssetRequest>>()
|
||||
const displayUrlCache = new Map<string, CachedDisplayEntry>()
|
||||
|
||||
let inferredSecureAccess: boolean | null = null
|
||||
|
||||
export const isSecureAccessRequired = (): boolean => {
|
||||
if (typeof injectConfig.secureAccessEnabled === 'boolean') {
|
||||
return injectConfig.secureAccessEnabled
|
||||
}
|
||||
if (inferredSecureAccess !== null) {
|
||||
return inferredSecureAccess
|
||||
}
|
||||
if (typeof window !== 'undefined' && '__MANIFEST__' in window) {
|
||||
try {
|
||||
const manifest = (window as typeof window & { __MANIFEST__?: { data?: Array<{ thumbnailKey?: unknown }> } })
|
||||
.__MANIFEST__
|
||||
if (manifest?.data && Array.isArray(manifest.data)) {
|
||||
inferredSecureAccess = manifest.data.some((entry) => Boolean(entry?.thumbnailKey))
|
||||
return inferredSecureAccess
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
inferredSecureAccess = false
|
||||
return inferredSecureAccess
|
||||
}
|
||||
|
||||
const needsSecureAccess = (): boolean => isSecureAccessRequired()
|
||||
|
||||
const cacheKeyFor = (objectKey: string, intent: string, suffix = ''): string => {
|
||||
return `${objectKey}::${intent}${suffix ? `::${suffix}` : ''}`
|
||||
}
|
||||
|
||||
const parseExpiresAt = (value: string | undefined): number => {
|
||||
if (!value) {
|
||||
return Date.now() + 10 * 60 * 1000
|
||||
}
|
||||
const timestamp = Date.parse(value)
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return Date.now() + 10 * 60 * 1000
|
||||
}
|
||||
return timestamp
|
||||
}
|
||||
|
||||
const requestSignedUrl = async (objectKey: string, intent: string): Promise<ResolvedAssetRequest> => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('objectKey', objectKey)
|
||||
if (intent) {
|
||||
params.set('intent', intent)
|
||||
}
|
||||
params.set('format', 'json')
|
||||
|
||||
const response = await fetch(`${SIGN_ENDPOINT}?${params.toString()}`, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to sign storage asset (status ${response.status})`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
url: string
|
||||
expiresAt: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
return {
|
||||
url: payload.url,
|
||||
headers: payload.headers ?? {},
|
||||
expiresAt: parseExpiresAt(payload.expiresAt),
|
||||
}
|
||||
}
|
||||
|
||||
const ensureSignedRequest = async (objectKey: string, intent: string): Promise<ResolvedAssetRequest> => {
|
||||
const key = cacheKeyFor(objectKey, intent)
|
||||
const cached = signedRequestCache.get(key)
|
||||
if (cached && cached.expiresAt - EXPIRATION_SKEW_MS > Date.now()) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let pending = pendingSignedRequests.get(key)
|
||||
if (!pending) {
|
||||
pending = requestSignedUrl(objectKey, intent)
|
||||
.then((result) => {
|
||||
signedRequestCache.set(key, result)
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSignedRequests.delete(key)
|
||||
})
|
||||
pendingSignedRequests.set(key, pending)
|
||||
}
|
||||
|
||||
return await pending
|
||||
}
|
||||
|
||||
export const resolveAssetRequest = async (descriptor: AssetDescriptor): Promise<ResolvedAssetRequest> => {
|
||||
if (typeof descriptor === 'string') {
|
||||
return {
|
||||
url: descriptor,
|
||||
headers: {},
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsSecureAccess()) {
|
||||
const fallback = descriptor.fallbackUrl
|
||||
if (!fallback) {
|
||||
throw new Error('Missing fallback URL for non-secure asset descriptor')
|
||||
}
|
||||
return {
|
||||
url: fallback,
|
||||
headers: {},
|
||||
expiresAt: Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
}
|
||||
|
||||
const objectKey = descriptor.objectKey?.trim()
|
||||
if (!objectKey) {
|
||||
throw new Error('Secure access is enabled but object key is missing')
|
||||
}
|
||||
|
||||
return await ensureSignedRequest(objectKey, descriptor.intent)
|
||||
}
|
||||
|
||||
const requiresCustomHeaders = (headers: Record<string, string>): boolean => {
|
||||
return Object.keys(headers).length > 0
|
||||
}
|
||||
|
||||
export const getDisplayAssetUrl = async (descriptor: AssetDescriptor): Promise<string> => {
|
||||
if (typeof descriptor === 'string') {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
if (!needsSecureAccess()) {
|
||||
return descriptor.fallbackUrl ?? ''
|
||||
}
|
||||
|
||||
const objectKey = descriptor.objectKey?.trim()
|
||||
if (!objectKey) {
|
||||
throw new Error('Secure asset descriptor is missing object key')
|
||||
}
|
||||
|
||||
const cacheKey = cacheKeyFor(objectKey, descriptor.intent, 'display')
|
||||
const cached = displayUrlCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt - EXPIRATION_SKEW_MS > Date.now()) {
|
||||
return cached.url
|
||||
}
|
||||
|
||||
const request = await resolveAssetRequest(descriptor)
|
||||
let finalUrl = request.url
|
||||
let isObjectUrl = false
|
||||
|
||||
if (requiresCustomHeaders(request.headers)) {
|
||||
const response = await fetch(request.url, {
|
||||
headers: request.headers,
|
||||
credentials: 'omit',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch secured asset (status ${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
finalUrl = URL.createObjectURL(blob)
|
||||
isObjectUrl = true
|
||||
}
|
||||
|
||||
displayUrlCache.set(cacheKey, {
|
||||
url: finalUrl,
|
||||
expiresAt: request.expiresAt,
|
||||
isObjectUrl,
|
||||
})
|
||||
|
||||
if (isObjectUrl) {
|
||||
const ttl = Math.max(request.expiresAt - Date.now(), 0)
|
||||
setTimeout(() => {
|
||||
const entry = displayUrlCache.get(cacheKey)
|
||||
if (entry && entry.url === finalUrl) {
|
||||
displayUrlCache.delete(cacheKey)
|
||||
try {
|
||||
URL.revokeObjectURL(finalUrl)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, ttl || EXPIRATION_SKEW_MS)
|
||||
}
|
||||
|
||||
return finalUrl
|
||||
}
|
||||
|
||||
export const invalidateDisplayAsset = (objectKey: string, intent: string) => {
|
||||
const cacheKey = cacheKeyFor(objectKey, intent, 'display')
|
||||
const entry = displayUrlCache.get(cacheKey)
|
||||
if (entry?.isObjectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(entry.url)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
displayUrlCache.delete(cacheKey)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { m } from 'motion/react'
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
import {
|
||||
CarbonIsoOutline,
|
||||
@@ -29,6 +30,7 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes
|
||||
const [isConvertingVideo, setIsConvertingVideo] = useState(false)
|
||||
const [videoConvertionError, setVideoConversionError] = useState<unknown>(null)
|
||||
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(data)
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -223,10 +225,10 @@ export const MasonryPhotoItem = ({ data, width, index: _ }: { data: PhotoManifes
|
||||
{/* Blurhash 占位符 */}
|
||||
{data.thumbHash && <Thumbhash thumbHash={data.thumbHash} className="absolute inset-0" />}
|
||||
|
||||
{!imageError && (
|
||||
{!imageError && thumbnailSrc && (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={data.thumbnailUrl}
|
||||
src={thumbnailSrc}
|
||||
alt={data.title}
|
||||
loading="lazy"
|
||||
className={clsx('absolute inset-0 h-full w-full object-cover duration-300 group-hover:scale-105')}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { photoLoader } from '@afilmory/data'
|
||||
import { Button, ScrollArea } from '@afilmory/ui'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
|
||||
// JSON 语法高亮组件
|
||||
const JsonHighlight = ({ data }: { data: any }) => {
|
||||
const jsonString = JSON.stringify(data, null, 2)
|
||||
@@ -95,82 +97,86 @@ const ManifestStats = ({ data }: { data: any[] }) => {
|
||||
}
|
||||
|
||||
// 照片卡片组件
|
||||
const PhotoCard = ({ photo, index }: { photo: any; index: number }) => (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/30 backdrop-blur-sm transition-all hover:border-zinc-700 hover:bg-zinc-900/50">
|
||||
<div className="absolute inset-0 bg-linear-to-br from-zinc-800/0 via-zinc-800/5 to-zinc-800/10 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
const PhotoCard = ({ photo, index }: { photo: any; index: number }) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
|
||||
<div className="relative p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 缩略图 */}
|
||||
<div className="flex-shrink-0">
|
||||
{photo.thumbnailUrl ? (
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={photo.title}
|
||||
className="h-16 w-16 object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-zinc-800 text-zinc-600">
|
||||
<span className="text-xl">📷</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/30 backdrop-blur-sm transition-all hover:border-zinc-700 hover:bg-zinc-900/50">
|
||||
<div className="absolute inset-0 bg-linear-to-br from-zinc-800/0 via-zinc-800/5 to-zinc-800/10 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-6 w-8 items-center justify-center rounded bg-zinc-800 font-mono text-xs text-zinc-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h3 className="truncate font-medium text-zinc-100">{photo.title}</h3>
|
||||
<div className="relative p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 缩略图 */}
|
||||
<div className="flex-shrink-0">
|
||||
{thumbnailSrc ? (
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={photo.title}
|
||||
className="h-16 w-16 object-cover transition-transform group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-zinc-800 text-zinc-600">
|
||||
<span className="text-xl">📷</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元数据网格 */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm lg:grid-cols-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">📐</span>
|
||||
<span className="text-zinc-300">
|
||||
{photo.width} × {photo.height}
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-6 w-8 items-center justify-center rounded bg-zinc-800 font-mono text-xs text-zinc-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h3 className="truncate font-medium text-zinc-100">{photo.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">📦</span>
|
||||
<span className="text-zinc-300">{(photo.size / (1024 * 1024)).toFixed(1)} MB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">📷</span>
|
||||
<span className="truncate text-zinc-300">
|
||||
{photo.exif?.Make} {photo.exif?.Model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{photo.tags && photo.tags.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{photo.tags.slice(0, 3).map((tag: string, tagIndex: number) => (
|
||||
<span
|
||||
key={tagIndex}
|
||||
className="inline-flex items-center rounded-full bg-blue-500/10 px-2.5 py-1 text-xs font-medium text-blue-400 ring-1 ring-blue-500/20"
|
||||
>
|
||||
{tag}
|
||||
{/* 元数据网格 */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm lg:grid-cols-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">📐</span>
|
||||
<span className="text-zinc-300">
|
||||
{photo.width} × {photo.height}
|
||||
</span>
|
||||
))}
|
||||
{photo.tags.length > 3 && (
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-1 text-xs text-zinc-400">
|
||||
+{photo.tags.length - 3} more
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">📦</span>
|
||||
<span className="text-zinc-300">{(photo.size / (1024 * 1024)).toFixed(1)} MB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">📷</span>
|
||||
<span className="truncate text-zinc-300">
|
||||
{photo.exif?.Make} {photo.exif?.Model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{photo.tags && photo.tags.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{photo.tags.slice(0, 3).map((tag: string, tagIndex: number) => (
|
||||
<span
|
||||
key={tagIndex}
|
||||
className="inline-flex items-center rounded-full bg-blue-500/10 px-2.5 py-1 text-xs font-medium text-blue-400 ring-1 ring-blue-500/20"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{photo.tags.length > 3 && (
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-1 text-xs text-zinc-400">
|
||||
+{photo.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const Component = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { photoLoader } from '@afilmory/data'
|
||||
import { ScrollArea, Thumbhash } from '@afilmory/ui'
|
||||
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
|
||||
export const Component = () => {
|
||||
const photos = photoLoader.getPhotos()
|
||||
|
||||
@@ -8,28 +10,39 @@ export const Component = () => {
|
||||
<ScrollArea rootClassName="h-screen">
|
||||
<div className="columns-4 gap-0">
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="group relative m-2"
|
||||
style={{
|
||||
paddingBottom: `${(photo.height / photo.width) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={photo.title}
|
||||
height={photo.height}
|
||||
width={photo.width}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
{photo.thumbHash && (
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100">
|
||||
<Thumbhash thumbHash={photo.thumbHash} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DebugPhotoTile key={photo.id} photo={photo} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
const DebugPhotoTile = ({ photo }: { photo: ReturnType<typeof photoLoader.getPhotos>[number] }) => {
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(photo)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative m-2"
|
||||
style={{
|
||||
paddingBottom: `${(photo.height / photo.width) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{thumbnailSrc ? (
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={photo.title}
|
||||
height={photo.height}
|
||||
width={photo.width}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-fill-secondary absolute inset-0" />
|
||||
)}
|
||||
{photo.thumbHash && (
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100">
|
||||
<Thumbhash thumbHash={photo.thumbHash} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RemoveScroll } from 'react-remove-scroll'
|
||||
|
||||
import { NotFound } from '~/components/common/NotFound'
|
||||
import { PhotoViewer } from '~/components/ui/photo-viewer'
|
||||
import { usePhotoThumbnailSrc } from '~/hooks/usePhotoThumbnailSrc'
|
||||
import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
|
||||
import { useTitle } from '~/hooks/useTitle'
|
||||
import { deriveAccentFromSources } from '~/lib/color'
|
||||
@@ -23,18 +24,19 @@ export const Component = () => {
|
||||
useTitle(photos[photoViewer.currentIndex]?.title || 'Not Found')
|
||||
|
||||
const [accentColor, setAccentColor] = useState<string | null>(null)
|
||||
const currentPhoto = photos[photoViewer.currentIndex] ?? null
|
||||
const thumbnailSrc = usePhotoThumbnailSrc(currentPhoto)
|
||||
|
||||
useEffect(() => {
|
||||
const current = photos[photoViewer.currentIndex]
|
||||
if (!current) return
|
||||
if (!currentPhoto) return
|
||||
|
||||
let isCancelled = false
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const color = await deriveAccentFromSources({
|
||||
thumbHash: current.thumbHash,
|
||||
thumbnailUrl: current.thumbnailUrl,
|
||||
thumbHash: currentPhoto.thumbHash,
|
||||
thumbnailUrl: thumbnailSrc,
|
||||
})
|
||||
if (!isCancelled) {
|
||||
const $css = document.createElement('style')
|
||||
@@ -59,7 +61,7 @@ export const Component = () => {
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [photoViewer.currentIndex, photos])
|
||||
}, [photoViewer.currentIndex, photos, thumbnailSrc, currentPhoto])
|
||||
|
||||
if (!photos[photoViewer.currentIndex]) {
|
||||
return <NotFound />
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import type { AfilmoryManifest, CameraInfo, LensInfo, PhotoManifestItem } from '@afilmory/builder'
|
||||
import type {
|
||||
AfilmoryManifest,
|
||||
CameraInfo,
|
||||
LensInfo,
|
||||
ManagedStorageConfig,
|
||||
PhotoManifestItem,
|
||||
S3CompatibleConfig,
|
||||
StorageConfig,
|
||||
} from '@afilmory/builder'
|
||||
import { DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY } from '@afilmory/builder/plugins/thumbnail-storage/shared.js'
|
||||
import { CURRENT_PHOTO_MANIFEST_VERSION, photoAssets } from '@afilmory/db'
|
||||
import { APP_GLOBAL_PREFIX } from 'core/app.constants'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { normalizedBoolean } from 'core/helpers/normalize.helper'
|
||||
import { SettingService } from 'core/modules/configuration/setting/setting.service'
|
||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
||||
import { StorageAccessService } from 'core/modules/content/photo/access/storage-access.service'
|
||||
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
const DEFAULT_THUMBNAIL_EXTENSION = '.jpg'
|
||||
|
||||
@injectable()
|
||||
export class ManifestService {
|
||||
constructor(
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
private readonly photoStorageService: PhotoStorageService,
|
||||
private readonly storageAccessService: StorageAccessService,
|
||||
) {}
|
||||
|
||||
async getManifest(): Promise<AfilmoryManifest> {
|
||||
@@ -37,7 +47,11 @@ export class ManifestService {
|
||||
}
|
||||
}
|
||||
|
||||
const secureAccessEnabled = await this.isSecureAccessEnabled(tenant.tenant.id)
|
||||
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
|
||||
const secureAccessEnabled = await this.storageAccessService.resolveSecureAccessPreference(
|
||||
storageConfig,
|
||||
tenant.tenant.id,
|
||||
)
|
||||
const items: PhotoManifestItem[] = []
|
||||
|
||||
for (const record of records) {
|
||||
@@ -53,6 +67,9 @@ export class ManifestService {
|
||||
if (normalized.video?.type === 'live-photo' && normalized.video.s3Key) {
|
||||
normalized.video.videoUrl = this.createProxyUrl(normalized.video.s3Key, 'live-video')
|
||||
}
|
||||
const thumbnailKey = this.resolveThumbnailStorageKey(storageConfig, normalized.id)
|
||||
normalized.thumbnailKey = thumbnailKey
|
||||
normalized.thumbnailUrl = thumbnailKey ? this.createProxyUrl(thumbnailKey, 'thumbnail') : null
|
||||
}
|
||||
items.push(normalized)
|
||||
}
|
||||
@@ -140,15 +157,6 @@ export class ManifestService {
|
||||
return Array.from(lensMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
|
||||
private async isSecureAccessEnabled(tenantId: string): Promise<boolean> {
|
||||
const activeProvider = await this.settingService.get('builder.storage.activeProvider', { tenantId })
|
||||
if (activeProvider?.trim() === 'managed') {
|
||||
return await this.systemSettingService.isManagedStorageSecureAccessEnabled()
|
||||
}
|
||||
const value = await this.settingService.get('photo.storage.secureAccess', { tenantId })
|
||||
return normalizedBoolean(value ?? 'false')
|
||||
}
|
||||
|
||||
private createProxyUrl(storageKey: string, intent = 'photo'): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('objectKey', storageKey)
|
||||
@@ -157,4 +165,68 @@ export class ManifestService {
|
||||
}
|
||||
return `${APP_GLOBAL_PREFIX}/storage/sign?${params.toString()}`
|
||||
}
|
||||
|
||||
private resolveThumbnailStorageKey(storageConfig: StorageConfig, photoId: string): string | null {
|
||||
if (!photoId) {
|
||||
return null
|
||||
}
|
||||
const fileName = `${photoId}${DEFAULT_THUMBNAIL_EXTENSION}`
|
||||
const prefix = this.resolveThumbnailRemotePrefix(storageConfig)
|
||||
if (!prefix) {
|
||||
return fileName
|
||||
}
|
||||
return this.joinSegments(prefix, fileName)
|
||||
}
|
||||
|
||||
private resolveThumbnailRemotePrefix(storageConfig: StorageConfig): string | null {
|
||||
const directory = this.normalizeStorageSegment(DEFAULT_THUMBNAIL_DIRECTORY)
|
||||
if (!directory) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (storageConfig.provider) {
|
||||
case 'managed': {
|
||||
const managedConfig = storageConfig as ManagedStorageConfig
|
||||
const managedBase = this.extractManagedBasePrefix(managedConfig)
|
||||
return this.joinSegments(managedBase, directory)
|
||||
}
|
||||
case 's3':
|
||||
case 'oss':
|
||||
case 'cos': {
|
||||
const s3Config = storageConfig as S3CompatibleConfig
|
||||
const base = this.normalizeStorageSegment(s3Config.prefix)
|
||||
return this.joinSegments(base, directory)
|
||||
}
|
||||
default: {
|
||||
return directory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractManagedBasePrefix(config: ManagedStorageConfig): string | null {
|
||||
if (!config.basePrefix) {
|
||||
return null
|
||||
}
|
||||
return this.normalizeStorageSegment(config.basePrefix)
|
||||
}
|
||||
|
||||
private normalizeStorageSegment(value?: string | null): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const normalized = trimmed.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
private joinSegments(...segments: Array<string | null | undefined>): string | null {
|
||||
const filtered = segments.filter((segment): segment is string => typeof segment === 'string' && segment.length > 0)
|
||||
if (filtered.length === 0) {
|
||||
return null
|
||||
}
|
||||
return filtered.map((segment) => segment.replaceAll(/^\/+|\/+$/g, '')).join('/')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class StorageAccessController {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '安全访问尚未启用' })
|
||||
}
|
||||
const storageKey = query.objectKey?.trim() || query.key?.trim() || ''
|
||||
const { url, expiresAt } = await this.storageAccessService.issueSignedUrl({
|
||||
const { url, expiresAt, headers } = await this.storageAccessService.issueSignedUrl({
|
||||
storageKey,
|
||||
intent: query.intent?.trim() || undefined,
|
||||
ttlSeconds: query.ttl,
|
||||
@@ -27,18 +27,10 @@ export class StorageAccessController {
|
||||
referer: context.req.header('referer') ?? context.req.header('referrer') ?? null,
|
||||
})
|
||||
|
||||
if (this.shouldReturnJson(context, query.format)) {
|
||||
return { url, expiresAt }
|
||||
return {
|
||||
url,
|
||||
expiresAt,
|
||||
headers,
|
||||
}
|
||||
|
||||
return context.redirect(url, 302)
|
||||
}
|
||||
|
||||
private shouldReturnJson(context: Context, format?: string | null): boolean {
|
||||
if (format === 'json') {
|
||||
return true
|
||||
}
|
||||
const accept = context.req.header('accept')?.toLowerCase() ?? ''
|
||||
return accept.includes('application/json')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { B2Config, ManagedStorageConfig, S3CompatibleConfig, StorageConfig } from '@afilmory/builder'
|
||||
import { createS3Client } from '@afilmory/builder/s3/client.js'
|
||||
import { DATABASE_ONLY_PROVIDER, generateId, photoAccessLogs, photoAccessStats, photoAssets } from '@afilmory/db'
|
||||
import { generateId, photoAccessLogs, photoAccessStats, photoAssets } from '@afilmory/db'
|
||||
import { Sha256 } from '@aws-crypto/sha256-js'
|
||||
import { HttpRequest } from '@smithy/protocol-http'
|
||||
import { SignatureV4 } from '@smithy/signature-v4'
|
||||
@@ -40,6 +40,7 @@ export type IssueSignedUrlResult = {
|
||||
url: string
|
||||
expiresAt: string
|
||||
tokenId: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_SECONDS = 600
|
||||
@@ -89,6 +90,16 @@ export class StorageAccessService {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少有效的 storage key' })
|
||||
}
|
||||
|
||||
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
|
||||
const secureAccessEnabled = await this.resolveSecureAccessPreference(storageConfig, tenant.tenant.id)
|
||||
if (!secureAccessEnabled) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Secure access is not enabled.' })
|
||||
}
|
||||
const target = this.resolveRemoteTarget(storageConfig, normalizedKey, tenant.tenant.id)
|
||||
const ttl = this.normalizeTtlSeconds(options.ttlSeconds)
|
||||
const { url, expiresAt, headers } = await this.createProviderSignedUrl(target, ttl)
|
||||
const tokenId = generateId()
|
||||
|
||||
const record = await db
|
||||
.select({
|
||||
id: photoAssets.id,
|
||||
@@ -100,71 +111,56 @@ export class StorageAccessService {
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!record) {
|
||||
throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '未找到对应的图片资源' })
|
||||
}
|
||||
if (record.storageProvider === DATABASE_ONLY_PROVIDER) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前资源不支持生成访问链接' })
|
||||
}
|
||||
if (record) {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
|
||||
const secureAccessEnabled = await this.resolveSecureAccessPreference(storageConfig, tenant.tenant.id)
|
||||
if (!secureAccessEnabled) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'Secure access is not enabled.' })
|
||||
}
|
||||
const target = this.resolveRemoteTarget(storageConfig, normalizedKey, tenant.tenant.id)
|
||||
const ttl = this.normalizeTtlSeconds(options.ttlSeconds)
|
||||
const { url, expiresAt } = await this.createProviderSignedUrl(target, ttl)
|
||||
const tokenId = generateId()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.insert(photoAccessLogs).values({
|
||||
id: generateId(),
|
||||
tenantId: tenant.tenant.id,
|
||||
photoAssetId: record.id,
|
||||
photoId: record.photoId,
|
||||
storageKey: normalizedKey,
|
||||
provider: target.kind,
|
||||
intent: options.intent?.trim() || 'original',
|
||||
tokenId,
|
||||
signedUrl: url,
|
||||
status: 'issued',
|
||||
clientIp: options.clientIp ?? null,
|
||||
userAgent: options.userAgent ?? null,
|
||||
referer: options.referer ?? null,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await db
|
||||
.insert(photoAccessStats)
|
||||
.values({
|
||||
await db.insert(photoAccessLogs).values({
|
||||
id: generateId(),
|
||||
tenantId: tenant.tenant.id,
|
||||
photoAssetId: record.id,
|
||||
photoId: record.photoId,
|
||||
viewCount: 1,
|
||||
lastViewedAt: now,
|
||||
storageKey: normalizedKey,
|
||||
provider: target.kind,
|
||||
intent: options.intent?.trim() || 'original',
|
||||
tokenId,
|
||||
signedUrl: url,
|
||||
status: 'issued',
|
||||
clientIp: options.clientIp ?? null,
|
||||
userAgent: options.userAgent ?? null,
|
||||
referer: options.referer ?? null,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [photoAccessStats.tenantId, photoAccessStats.photoAssetId],
|
||||
set: {
|
||||
viewCount: sql`${photoAccessStats.viewCount} + 1`,
|
||||
lastViewedAt: now,
|
||||
updatedAt: now,
|
||||
photoId: record.photoId,
|
||||
},
|
||||
})
|
||||
|
||||
return { url, expiresAt, tokenId }
|
||||
await db
|
||||
.insert(photoAccessStats)
|
||||
.values({
|
||||
tenantId: tenant.tenant.id,
|
||||
photoAssetId: record.id,
|
||||
photoId: record.photoId,
|
||||
viewCount: 1,
|
||||
lastViewedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [photoAccessStats.tenantId, photoAccessStats.photoAssetId],
|
||||
set: {
|
||||
viewCount: sql`${photoAccessStats.viewCount} + 1`,
|
||||
lastViewedAt: now,
|
||||
updatedAt: now,
|
||||
photoId: record.photoId,
|
||||
},
|
||||
})
|
||||
}
|
||||
return { url, expiresAt, tokenId, headers }
|
||||
}
|
||||
|
||||
private async createProviderSignedUrl(
|
||||
target: RemoteAccessTarget,
|
||||
ttlSeconds: number,
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
): Promise<{ url: string; expiresAt: string; headers: Record<string, string> }> {
|
||||
if (target.kind === 's3') {
|
||||
return await this.createS3SignedUrl(target.config, target.objectKey, ttlSeconds)
|
||||
}
|
||||
@@ -363,7 +359,7 @@ export class StorageAccessService {
|
||||
config: S3CompatibleConfig,
|
||||
key: string,
|
||||
ttlSeconds: number,
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
): Promise<{ url: string; expiresAt: string; headers: Record<string, string> }> {
|
||||
if (!config.accessKeyId || !config.secretAccessKey) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: 'S3 存储配置缺少访问密钥' })
|
||||
}
|
||||
@@ -411,6 +407,7 @@ export class StorageAccessService {
|
||||
return {
|
||||
url: this.formatHttpRequestUrl(signed as HttpRequest),
|
||||
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
||||
headers: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,17 +420,21 @@ class B2SigningClient {
|
||||
config: B2Config,
|
||||
remoteKey: string,
|
||||
ttlSeconds: number,
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
): Promise<{ url: string; expiresAt: string; headers: Record<string, string> }> {
|
||||
const normalizedKey = this.encodeFileName(remoteKey)
|
||||
const authorization = await this.authorize(config)
|
||||
const bucketName = await this.resolveBucketName(config, authorization)
|
||||
const validDuration = Math.min(Math.max(ttlSeconds, MIN_TTL_SECONDS), MAX_TTL_SECONDS)
|
||||
const token = await this.getDownloadToken(config, authorization, remoteKey, validDuration)
|
||||
const baseUrl = authorization.downloadUrl.replace(/\/+$/, '')
|
||||
const url = `${baseUrl}/file/${bucketName}/${normalizedKey}?Authorization=${token}`
|
||||
|
||||
const url = `${config.customDomain || baseUrl}/file/${bucketName}/${normalizedKey}`
|
||||
return {
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + validDuration * 1000).toISOString(),
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { SiteSettingService } from 'core/modules/configuration/site-setting/site-setting.service'
|
||||
import { ManifestService } from 'core/modules/content/manifest/manifest.service'
|
||||
import { StorageAccessService } from 'core/modules/content/photo/access/storage-access.service'
|
||||
import type { Context } from 'hono'
|
||||
import { DOMParser } from 'linkedom'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -56,6 +57,7 @@ export class StaticWebService extends StaticAssetService {
|
||||
private readonly manifestService: ManifestService,
|
||||
private readonly siteSettingService: SiteSettingService,
|
||||
private readonly staticAssetHostService: StaticAssetHostService,
|
||||
private readonly storageAccessService: StorageAccessService,
|
||||
) {
|
||||
super({
|
||||
routeSegment: STATIC_WEB_ROUTE_SEGMENT,
|
||||
@@ -68,7 +70,8 @@ export class StaticWebService extends StaticAssetService {
|
||||
|
||||
protected override async decorateDocument(document: StaticAssetDocument): Promise<void> {
|
||||
const siteConfig = await this.siteSettingService.getSiteConfig()
|
||||
this.injectConfigScript(document, siteConfig)
|
||||
const secureAccessEnabled = await this.storageAccessService.isSecureAccessEnabled().catch(() => false)
|
||||
this.injectConfigScript(document, siteConfig, secureAccessEnabled)
|
||||
this.injectSiteMetadata(document, siteConfig)
|
||||
await this.injectManifestScript(document)
|
||||
}
|
||||
@@ -146,7 +149,11 @@ export class StaticWebService extends StaticAssetService {
|
||||
}
|
||||
}
|
||||
|
||||
private injectConfigScript(document: StaticAssetDocument, siteConfig: TenantSiteConfig): void {
|
||||
private injectConfigScript(
|
||||
document: StaticAssetDocument,
|
||||
siteConfig: TenantSiteConfig,
|
||||
secureAccessEnabled: boolean,
|
||||
): void {
|
||||
const configScript = document.head?.querySelector('#config')
|
||||
if (!configScript) {
|
||||
return
|
||||
@@ -154,6 +161,7 @@ export class StaticWebService extends StaticAssetService {
|
||||
|
||||
const payload = JSON.stringify({
|
||||
useCloud: true,
|
||||
secureAccessEnabled,
|
||||
})
|
||||
const siteConfigPayload = JSON.stringify(siteConfig)
|
||||
configScript.textContent = `window.__CONFIG__ = ${payload};window.__SITE_CONFIG__ = ${siteConfigPayload}`
|
||||
|
||||
@@ -59,7 +59,12 @@ export interface PhotoManifestItem extends PhotoInfo {
|
||||
id: string
|
||||
originalUrl: string
|
||||
format: string
|
||||
thumbnailUrl: string
|
||||
thumbnailUrl: string | null
|
||||
/**
|
||||
* Optional object storage key for the generated thumbnail when hosted privately.
|
||||
* Consumers can use this to request signed URLs.
|
||||
*/
|
||||
thumbnailKey?: string | null
|
||||
thumbHash: string | null
|
||||
width: number
|
||||
height: number
|
||||
|
||||
Reference in New Issue
Block a user