优化动图
This commit is contained in:
parent
77bc2c5775
commit
590b26c420
@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ImageFile" ADD COLUMN "duration" INTEGER;
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user