优化动图

This commit is contained in:
feie9456 2025-10-23 10:30:43 +08:00
parent 77bc2c5775
commit 590b26c420
10 changed files with 254 additions and 49 deletions

View File

@ -55,3 +55,36 @@ export async function extractFirstFrame(videoBuffer: Buffer): Promise<{ buffer:
try { await fs.unlink(outPath); } catch { } try { await fs.unlink(outPath); } catch { }
} }
} }
/**
* 使 ffprobe
*/
export async function getVideoDuration(videoBuffer: Buffer): Promise<number | null> {
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 { }
}
}

View File

@ -138,7 +138,7 @@ export async function saveImagePostToDB(
context: BrowserContext, context: BrowserContext,
aweme: DouyinImageAweme, aweme: DouyinImageAweme,
commentResp: DouyinCommentResponse, 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 rawJson?: any
) { ) {
if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失'); if (!aweme?.author?.sec_uid) throw new Error('作者 sec_uid 缺失');
@ -205,7 +205,7 @@ export async function saveImagePostToDB(
// Upsert ImageFiles按顺序 // Upsert ImageFiles按顺序
for (let i = 0; i < uploads.images.length; i++) { 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({ await prisma.imageFile.upsert({
where: { postId_order: { postId: imagePost.aweme_id, order: i } }, where: { postId_order: { postId: imagePost.aweme_id, order: i } },
create: { create: {
@ -215,12 +215,14 @@ export async function saveImagePostToDB(
width: typeof width === 'number' ? width : null, width: typeof width === 'number' ? width : null,
height: typeof height === 'number' ? height : null, height: typeof height === 'number' ? height : null,
animated: video || null, animated: video || null,
duration: typeof duration === 'number' ? duration : null,
}, },
update: { update: {
url, url,
width: typeof width === 'number' ? width : null, width: typeof width === 'number' ? width : null,
height: typeof height === 'number' ? height : null, height: typeof height === 'number' ? height : null,
animated: video || null, animated: video || null,
duration: typeof duration === 'number' ? duration : null,
}, },
}); });
} }

View File

@ -2,6 +2,7 @@ import type { BrowserContext } from 'playwright';
import { uploadFile, generateUniqueFileName } from '@/lib/minio'; import { uploadFile, generateUniqueFileName } from '@/lib/minio';
import { downloadBinary } from './network'; import { downloadBinary } from './network';
import { pickFirstUrl } from './utils'; import { pickFirstUrl } from './utils';
import { getVideoDuration } from './media';
/** /**
* MinIO退 * MinIO退
@ -29,9 +30,9 @@ export async function uploadAvatarFromUrl(
export async function handleImagePost( export async function handleImagePost(
context: BrowserContext, context: BrowserContext,
aweme: DouyinImageAweme 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 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++) { for (let i = 0; i < (aweme.images?.length || 0); i++) {
@ -52,10 +53,25 @@ export async function handleImagePost(
const safeVideoExt = videoExt || 'mp4'; const safeVideoExt = videoExt || 'mp4';
const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images'); const videoFileName = generateUniqueFileName(`${awemeId}/${i}_animated.${safeVideoExt}`, 'douyin/images');
const uploadedVideo = await uploadFile(videoBuffer, videoFileName, { 'Content-Type': videoContentType }); 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) { } catch (e) {
console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e); console.warn(`[image] 动图视频上传失败,跳过:`, (e as Error)?.message || e);
uploadedImages.push({ url: uploaded, width: img?.width, height: img?.height });
} }
} }
} else { } else {

View File

@ -80,18 +80,43 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
return; return;
} }
if (!images?.length) return; if (!images?.length) return;
const total = images.length;
const exact = ratio * total; // 计算每张图片的时长
const targetIdx = Math.min(total - 1, Math.floor(exact)); const durations = images.map(img => img.duration ?? SEGMENT_MS);
const remainder = exact - targetIdx; 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.idxRef.current = targetIdx;
imageCarouselState.setIdx(targetIdx); imageCarouselState.setIdx(targetIdx);
imageCarouselState.segStartRef.current = performance.now() - remainder * SEGMENT_MS; imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
playerState.setProgress((targetIdx + remainder) / total);
const el = scrollerRef.current; // 重新计算总进度
if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" }); let totalProgress = 0;
for (let i = 0; i < targetIdx; i++) {
totalProgress += 1;
}
totalProgress += remainder;
playerState.setProgress(totalProgress / images.length);
// 虚拟滚动不需要实际滚动 DOM
}; };
const togglePlay = async () => { const togglePlay = async () => {
@ -141,8 +166,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
imageCarouselState.idxRef.current = next; imageCarouselState.idxRef.current = next;
imageCarouselState.setIdx(next); imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now(); imageCarouselState.segStartRef.current = performance.now();
const el = scrollerRef.current; // 虚拟滚动不需要实际滚动 DOM
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
}; };
const nextImg = () => { const nextImg = () => {
@ -151,8 +175,7 @@ export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClient
imageCarouselState.idxRef.current = next; imageCarouselState.idxRef.current = next;
imageCarouselState.setIdx(next); imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now(); imageCarouselState.segStartRef.current = performance.now();
const el = scrollerRef.current; // 虚拟滚动不需要实际滚动 DOM
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
}; };
const handleDownload = () => { const handleDownload = () => {

View File

@ -1,4 +1,4 @@
import { forwardRef } from "react"; import { forwardRef, useEffect, useRef, useState } from "react";
import type { ImageData } from "../types"; import type { ImageData } from "../types";
interface ImageCarouselProps { interface ImageCarouselProps {
@ -9,18 +9,121 @@ interface ImageCarouselProps {
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>( export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => { ({ images, currentIndex, onTogglePlay }, ref) => {
const [offset, setOffset] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const playedVideos = useRef<Set<string>>(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 ( return (
<div <div
ref={ref} ref={ref}
className="flex h-full w-full snap-x snap-mandatory overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]" className="relative h-full w-full overflow-hidden"
> >
{images.map((img, i) => ( <div
ref={containerRef}
className="flex h-full w-full"
style={{
transform: `translateX(${offset}%)`,
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
}}
>
{visibleIndices.map((i) => {
const img = images[i];
return (
<div <div
key={img.id} key={img.id}
className="relative h-full min-w-full snap-center flex items-center justify-center bg-black/70 cursor-pointer" className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
style={{
transform: `translateX(${i * 100}%)`,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
}}
onClick={onTogglePlay} onClick={onTogglePlay}
>{ >
img.animated ? <video src={img.animated} autoPlay muted playsInline className="max-w-full max-h-full object-contain" /> : <img {img.animated ? (
<video
ref={(el) => handleVideoRef(el, img.id)}
src={img.animated}
muted
playsInline
className="max-w-full max-h-full object-contain"
onEnded={(e) => {
// 播放结束后停留在最后一帧
e.currentTarget.pause();
}}
/>
) : (
<img
src={img.url} src={img.url}
alt={`image-${i + 1}`} alt={`image-${i + 1}`}
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
@ -29,9 +132,11 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
height: img.height ? `${img.height}px` : undefined, height: img.height ? `${img.height}px` : undefined,
}} }}
/> />
} )}
</div>
);
})}
</div> </div>
))}
</div> </div>
); );
} }

View File

@ -46,9 +46,14 @@ export function useBackgroundCanvas({
} else { } else {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
if (scroller) { if (scroller) {
const currentImgContainer = scroller.children[idx] as HTMLElement; // 虚拟滚动:查找所有图片容器,找到当前显示的那个
if (currentImgContainer) { const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
sourceElement = currentImgContainer.querySelector("img"); 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;
}
} }
} }
} }

View File

@ -68,8 +68,17 @@ export function useImageCarousel({
let localIdx = idxRef.current; let localIdx = idxRef.current;
let elapsed = ts - start; 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 (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) { if (loopMode === "sequential" && neighbors?.next) {
@ -80,19 +89,28 @@ export function useImageCarousel({
} else { } else {
localIdx = localIdx + 1; localIdx = localIdx + 1;
} }
// 更新下一张图片的时长
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
} }
segStartRef.current = ts - elapsed; segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) { if (localIdx !== idxRef.current) {
idxRef.current = localIdx; idxRef.current = localIdx;
setIdx(localIdx); setIdx(localIdx);
const el = scrollerRef.current; // 虚拟滚动不需要实际滚动 DOM
if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" });
} }
const localSeg = Math.max(0, Math.min(1, elapsed / segmentMs)); const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
setSegProgress(localSeg); 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); rafRef.current = requestAnimationFrame(tick);
}; };
@ -102,7 +120,7 @@ export function useImageCarousel({
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null; rafRef.current = null;
}; };
}, [images?.length, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]); }, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
return { return {
idx, idx,

View File

@ -26,7 +26,7 @@ export type ImageData = {
aweme_id: string; aweme_id: string;
desc: string; desc: string;
created_at: string | Date; 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; music_url?: string | null;
author: User; author: User;
comments: Comment[]; comments: Comment[];

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER;

View File

@ -144,6 +144,7 @@ model ImageFile {
height Int? height Int?
animated String? // 如果是动图,存储 video 格式的 URL animated String? // 如果是动图,存储 video 格式的 URL
duration Int? // 动图时长(毫秒),仅当 animated 不为 null 时有值
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt