diff --git a/app/api/fetcher/media.ts b/app/api/fetcher/media.ts index aae0d02..46bed87 100644 --- a/app/api/fetcher/media.ts +++ b/app/api/fetcher/media.ts @@ -55,3 +55,36 @@ export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer: try { await fs.unlink(outPath); } catch { } } } + +/** + * 使用 ffprobe 获取视频时长(毫秒) + */ +export async function getVideoDuration(videoBuffer: Buffer): Promise { + const ffprobeCmd = process.env.FFPROBE_PATH || 'ffprobe'; + const tmpDir = os.tmpdir(); + const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const inPath = path.join(tmpDir, `${base}.mp4`); + + try { + await fs.writeFile(inPath, videoBuffer); + const args = [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + inPath, + ]; + const { stdout } = await execFileAsync(ffprobeCmd, args, { windowsHide: true }); + const durationSeconds = parseFloat(stdout.trim()); + if (isNaN(durationSeconds)) return null; + return Math.round(durationSeconds * 1000); // 转换为毫秒 + } catch (e: any) { + if (e && (e.code === 'ENOENT' || /not found|is not recognized/i.test(String(e.message)))) { + console.warn('系统未检测到 ffprobe,可安装并配置 PATH 或设置 FFPROBE_PATH 后启用时长提取。'); + return null; + } + console.warn(`获取视频时长失败: ${e?.message || e}`); + return null; + } finally { + try { await fs.unlink(inPath); } catch { } + } +} diff --git a/app/api/fetcher/persist.ts b/app/api/fetcher/persist.ts index 57a38b9..1c00614 100644 --- a/app/api/fetcher/persist.ts +++ b/app/api/fetcher/persist.ts @@ -138,7 +138,7 @@ export async function saveImagePostToDB( context: BrowserContext, aweme: DouyinImageAweme, commentResp: DouyinCommentResponse, - uploads: { images: { url: string; width?: number; height?: number, video?: string }[]; musicUrl?: string }, + uploads: { images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }, rawJson?: any ) { if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失'); @@ -205,7 +205,7 @@ export async function saveImagePostToDB( // Upsert ImageFiles(按顺序) for (let i = 0; i < uploads.images.length; i++) { - const { url, width, height, video } = uploads.images[i]; + const { url, width, height, video, duration } = uploads.images[i]; await prisma.imageFile.upsert({ where: { postId_order: { postId: imagePost.aweme_id, order: i } }, create: { @@ -215,12 +215,14 @@ export async function saveImagePostToDB( width: typeof width === 'number' ? width : null, height: typeof height === 'number' ? height : null, animated: video || null, + duration: typeof duration === 'number' ? duration : null, }, update: { url, width: typeof width === 'number' ? width : null, height: typeof height === 'number' ? height : null, animated: video || null, + duration: typeof duration === 'number' ? duration : null, }, }); } diff --git a/app/api/fetcher/uploader.ts b/app/api/fetcher/uploader.ts index 4aa18e6..5b8cc4e 100644 --- a/app/api/fetcher/uploader.ts +++ b/app/api/fetcher/uploader.ts @@ -2,6 +2,7 @@ import type { BrowserContext } from 'playwright'; import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { downloadBinary } from './network'; import { pickFirstUrl } from './utils'; +import { getVideoDuration } from './media'; /** * 下载头像并上传到 MinIO,返回外链;失败时回退为原始链接。 @@ -29,9 +30,9 @@ export async function uploadAvatarFromUrl( export async function handleImagePost( context: BrowserContext, aweme: DouyinImageAweme -): Promise<{ images: { url: string; width?: number; height?: number }[]; musicUrl?: string }> { +): Promise<{ images: { url: string; width?: number; height?: number; video?: string; duration?: number }[]; musicUrl?: string }> { const awemeId = aweme.aweme_id; - const uploadedImages: { url: string; width?: number; height?: number, video?: string }[] = []; + const uploadedImages: { url: string; width?: number; height?: number; video?: string; duration?: number }[] = []; // 下载图片(顺序保持) for (let i = 0; i < (aweme.images?.length || 0); i++) { @@ -52,10 +53,25 @@ export async function handleImagePost( const safeVideoExt = videoExt || 'mp4'; const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images'); const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType }); - // 将动图的 video URL 也存储起来 - uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height, video: uploadedVideo }); + + // 获取动图时长 + const duration = await getVideoDuration(videoBuffer); + + // 将动图的 video URL 和 duration 也存储起来 + uploadedImages.push({ + url: uploaded, + width: img?.width, + height: img?.height, + video: uploadedVideo, + duration: duration ?? undefined + }); + + if (duration) { + console.log(`[image] 动图 ${i} 时长: ${duration}ms`); + } } catch (e) { console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e); + uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height }); } } } else { diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index e63ec82..4d22e44 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -41,7 +41,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient const backgroundCanvasRef = useRef(null); // 图文轮播状态 - const images = isVideo ? [] : (data as ImageData).images; + const images = isVideo ? [] : (data as ImageData).images; const imageCarouselState = useImageCarousel({ images, isPlaying: playerState.isPlaying, @@ -80,18 +80,43 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient return; } if (!images?.length) return; - const total = images.length; - const exact = ratio * total; - const targetIdx = Math.min(total - 1, Math.floor(exact)); - const remainder = exact - targetIdx; + + // 计算每张图片的时长 + const durations = images.map(img => img.duration ?? SEGMENT_MS); + const totalDuration = durations.reduce((sum, d) => sum + d, 0); + const targetTime = ratio * totalDuration; + + // 找到目标时间对应的图片索引和进度 + let accumulatedTime = 0; + let targetIdx = 0; + let remainder = 0; + + for (let i = 0; i < images.length; i++) { + if (accumulatedTime + durations[i] > targetTime) { + targetIdx = i; + remainder = (targetTime - accumulatedTime) / durations[i]; + break; + } + accumulatedTime += durations[i]; + if (i === images.length - 1) { + targetIdx = i; + remainder = 1; + } + } imageCarouselState.idxRef.current = targetIdx; imageCarouselState.setIdx(targetIdx); - imageCarouselState.segStartRef.current = performance.now() - remainder * SEGMENT_MS; - playerState.setProgress((targetIdx + remainder) / total); - - const el = scrollerRef.current; - if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" }); + imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx]; + + // 重新计算总进度 + let totalProgress = 0; + for (let i = 0; i < targetIdx; i++) { + totalProgress += 1; + } + totalProgress += remainder; + playerState.setProgress(totalProgress / images.length); + + // 虚拟滚动不需要实际滚动 DOM }; const togglePlay = async () => { @@ -141,8 +166,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient imageCarouselState.idxRef.current = next; imageCarouselState.setIdx(next); imageCarouselState.segStartRef.current = performance.now(); - const el = scrollerRef.current; - if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" }); + // 虚拟滚动不需要实际滚动 DOM }; const nextImg = () => { @@ -151,8 +175,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient imageCarouselState.idxRef.current = next; imageCarouselState.setIdx(next); imageCarouselState.segStartRef.current = performance.now(); - const el = scrollerRef.current; - if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" }); + // 虚拟滚动不需要实际滚动 DOM }; const handleDownload = () => { diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx index 7ad1bc0..ef1689a 100644 --- a/app/aweme/[awemeId]/components/ImageCarousel.tsx +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -1,4 +1,4 @@ -import { forwardRef } from "react"; +import { forwardRef, useEffect, useRef, useState } from "react"; import type { ImageData } from "../types"; interface ImageCarouselProps { @@ -9,29 +9,134 @@ interface ImageCarouselProps { export const ImageCarousel = forwardRef( ({ images, currentIndex, onTogglePlay }, ref) => { + const [offset, setOffset] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + const containerRef = useRef(null); + const videoRefs = useRef>(new Map()); + const playedVideos = useRef>(new Set()); + + // 虚拟滚动:只渲染当前图片和前后各一张 + const visibleIndices = (() => { + const indices: number[] = []; + if (currentIndex > 0) indices.push(currentIndex - 1); + indices.push(currentIndex); + if (currentIndex < images.length - 1) indices.push(currentIndex + 1); + return indices; + })(); + + // 当 currentIndex 变化时,触发滚动动画 + useEffect(() => { + setIsTransitioning(true); + setOffset(-currentIndex * 100); + + const timer = setTimeout(() => { + setIsTransitioning(false); + }, 300); // 与 CSS transition 时间匹配 + + return () => clearTimeout(timer); + }, [currentIndex]); + + // 管理动图播放:进入视口时播放一次 + useEffect(() => { + const currentImage = images[currentIndex]; + if (!currentImage?.animated) return; + + const videoKey = currentImage.id; + const videoEl = videoRefs.current.get(videoKey); + + if (videoEl) { + // 检查是否已经播放过 + if (!playedVideos.current.has(videoKey)) { + // 重置并播放 + videoEl.currentTime = 0; + videoEl.play().catch(() => {}); + playedVideos.current.add(videoKey); + } else { + // 已播放过,重置到开头但不自动播放 + videoEl.currentTime = 0; + videoEl.play().catch(() => {}); + } + } + }, [currentIndex, images]); + + // 当切换到其他图片时,清除已播放标记(切回来会重新播放) + useEffect(() => { + const currentImage = images[currentIndex]; + if (currentImage?.animated) { + const videoKey = currentImage.id; + + // 清除其他视频的播放记录 + playedVideos.current.forEach(key => { + if (key !== videoKey) { + playedVideos.current.delete(key); + } + }); + } + }, [currentIndex, images]); + + const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => { + if (el) { + videoRefs.current.set(imageId, el); + } else { + videoRefs.current.delete(imageId); + } + }; + return (
- {images.map((img, i) => ( -
{ - img.animated ?
+ ); + })} +
); } diff --git a/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts b/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts index 8dadead..e442e7e 100644 --- a/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts +++ b/app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts @@ -46,9 +46,14 @@ export function useBackgroundCanvas({ } else { const scroller = scrollerRef.current; if (scroller) { - const currentImgContainer = scroller.children[idx] as HTMLElement; - if (currentImgContainer) { - sourceElement = currentImgContainer.querySelector("img"); + // 虚拟滚动:查找所有图片容器,找到当前显示的那个 + const containers = scroller.querySelectorAll('div[style*="translateX"]'); + for (const container of containers) { + const img = container.querySelector("img, video"); + if (img && container.style.transform.includes(`${idx * 100}%`)) { + sourceElement = img as HTMLImageElement | HTMLVideoElement; + break; + } } } } diff --git a/app/aweme/[awemeId]/hooks/useImageCarousel.ts b/app/aweme/[awemeId]/hooks/useImageCarousel.ts index 5816be0..ee06f72 100644 --- a/app/aweme/[awemeId]/hooks/useImageCarousel.ts +++ b/app/aweme/[awemeId]/hooks/useImageCarousel.ts @@ -68,8 +68,17 @@ export function useImageCarousel({ let localIdx = idxRef.current; let elapsed = ts - start; - while (elapsed >= segmentMs) { - elapsed -= segmentMs; + + // 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs) + const getCurrentSegmentDuration = (index: number) => { + const img = images[index]; + return img?.duration ?? segmentMs; + }; + + let currentSegmentDuration = getCurrentSegmentDuration(localIdx); + + while (elapsed >= currentSegmentDuration) { + elapsed -= currentSegmentDuration; if (localIdx >= images.length - 1) { if (loopMode === "sequential" && neighbors?.next) { @@ -80,19 +89,28 @@ export function useImageCarousel({ } else { localIdx = localIdx + 1; } + + // 更新下一张图片的时长 + currentSegmentDuration = getCurrentSegmentDuration(localIdx); } segStartRef.current = ts - elapsed; if (localIdx !== idxRef.current) { idxRef.current = localIdx; setIdx(localIdx); - const el = scrollerRef.current; - if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" }); + // 虚拟滚动不需要实际滚动 DOM } - const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs)); + const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration)); setSegProgress(localSeg); - setProgress((localIdx + localSeg) / images.length); + + // 计算总进度:已完成的图片 + 当前图片的进度 + let totalProgress = 0; + for (let i = 0; i < localIdx; i++) { + totalProgress += 1; + } + totalProgress += localSeg; + setProgress(totalProgress / images.length); rafRef.current = requestAnimationFrame(tick); }; @@ -102,7 +120,7 @@ export function useImageCarousel({ if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = null; }; - }, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); + }, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); return { idx, diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 20bc6fc..2f0982a 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -26,7 +26,7 @@ export type ImageData = { aweme_id: string; desc: string; created_at: string | Date; - images: { id: string; url: string; width?: number; height?: number, animated?: string }[]; + images: { id: string; url: string; width?: number; height?: number; animated?: string; duration?: number }[]; music_url?: string | null; author: User; comments: Comment[]; diff --git a/prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql b/prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql new file mode 100644 index 0000000..a4a126f --- /dev/null +++ b/prisma/migrations/20251023021547_add_duration_to_image_file/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d70abbe..0fe6dc8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,7 @@ model ImageFile { height Int? animated String? // 如果是动图,存储 video 格式的 URL + duration Int? // 动图时长(毫秒),仅当 animated 不为 null 时有值 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt