auto-save 2026-05-21 02:09 (~5)

This commit is contained in:
2026-05-21 02:09:03 +08:00
parent e85be866e8
commit fa6e32b7ad
5 changed files with 171 additions and 27 deletions

View File

@@ -1860,6 +1860,13 @@
"message": "auto-save 2026-05-20 22:54 (~3)", "message": "auto-save 2026-05-20 22:54 (~3)",
"hash": "7697754", "hash": "7697754",
"files_changed": 3 "files_changed": 3
},
{
"ts": "2026-05-20T23:55:28+08:00",
"type": "commit",
"message": "auto-save 2026-05-20 23:53 (~2)",
"hash": "e85be86",
"files_changed": 2
} }
] ]
} }

View File

@@ -1028,7 +1028,8 @@ input, textarea {
0 0 0 1px rgba(255, 255, 255, 0.22); 0 0 0 1px rgba(255, 255, 255, 0.22);
} }
.gallery-center-preview img { .gallery-center-preview img,
.gallery-center-preview video {
display: block; display: block;
width: min(74vw, 960px); width: min(74vw, 960px);
height: 82vh; height: 82vh;
@@ -1090,7 +1091,8 @@ input, textarea {
background: #fff; background: #fff;
} }
.gallery-drawer-card__image img { .gallery-drawer-card__image img,
.gallery-drawer-card__image video {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -108,3 +108,58 @@ export function HoverImagePreview({
</> </>
); );
} }
export function HoverVideoPreview({
src,
className,
aspectRatio,
}: {
src: string;
className?: string;
aspectRatio?: string;
}) {
const [preview, setPreview] = useState<PreviewState | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<>
<video
src={src}
className={className}
muted
loop
playsInline
preload="metadata"
onPointerMove={event => {
if (event.pointerType === 'touch') return;
setPreview(nextPreviewState(event, aspectRatio));
event.currentTarget.play().catch(() => undefined);
}}
onPointerLeave={event => {
setPreview(null);
event.currentTarget.pause();
}}
/>
{preview && mounted && createPortal(
<div
className="pointer-events-none fixed z-[90] overflow-hidden rounded-[8px] bg-black shadow-[0_24px_80px_-24px_rgba(0,0,0,0.86)] ring-1 ring-white/20"
style={{ left: preview.left, top: preview.top, width: preview.width, height: preview.height }}
>
<video
src={src}
className="h-full w-full object-contain"
muted
autoPlay
loop
playsInline
/>
</div>,
document.body
)}
</>
);
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types'; import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates'; import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
import { HoverImagePreview } from './HoverImagePreview'; import { HoverImagePreview, HoverVideoPreview } from './HoverImagePreview';
const PACK_DESCRIPTIONS: Record<PackKind, string> = { const PACK_DESCRIPTIONS: Record<PackKind, string> = {
patent: '六面视图 · 45° 立体图 · 局部放大', patent: '六面视图 · 45° 立体图 · 局部放大',
@@ -375,7 +375,30 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
const [showPromptId, setShowPromptId] = useState<string | null>(null); const [showPromptId, setShowPromptId] = useState<string | null>(null);
const videoTasks = session.videoTasks ?? []; const videoTasks = session.videoTasks ?? [];
const byTemplate = new Map(videoTasks.map(task => [task.templateId, task])); const byTemplate = new Map(videoTasks.map(task => [task.templateId, task]));
const submittedCount = VIDEO_TEMPLATES.filter(template => byTemplate.has(template.id)).length; const builtInIds = new Set<string>(VIDEO_TEMPLATES.map(template => template.id));
const extraTasks = videoTasks.filter(task => !builtInIds.has(task.templateId) && !/_part[12]$/.test(task.templateId));
const videoItems = [
...VIDEO_TEMPLATES.map(template => ({
id: template.id,
title: template.title,
description: template.description,
duration: template.duration,
ratio: template.ratio,
promptTemplate: template.promptTemplate,
template,
})),
...extraTasks.map(task => ({
id: task.templateId,
title: task.title,
description: task.description || '已回填的合成视频成片',
duration: task.duration,
ratio: task.ratio,
promptTemplate: task.prompt,
template: null,
})),
];
const submittedCount = videoItems.filter(item => byTemplate.has(item.id)).length;
const totalCount = Math.max(videoItems.length, 1);
return ( return (
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-video" aria-disabled={locked}> <section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-video" aria-disabled={locked}>
@@ -388,9 +411,9 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div> </div>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden"> <div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div className="h-full bg-gradient-to-r from-[#e6f578] to-[#8cb478]" style={{ width: `${Math.round((submittedCount / VIDEO_TEMPLATES.length) * 100)}%` }} /> <div className="h-full bg-gradient-to-r from-[#e6f578] to-[#8cb478]" style={{ width: `${Math.round((submittedCount / totalCount) * 100)}%` }} />
</div> </div>
<span className="text-[10px] font-mono text-white/35 shrink-0">{submittedCount}/{VIDEO_TEMPLATES.length}</span> <span className="text-[10px] font-mono text-white/35 shrink-0">{submittedCount}/{totalCount}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
@@ -399,24 +422,40 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div> </div>
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3"> <div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{VIDEO_TEMPLATES.map(template => { {videoItems.map(item => {
const isOpen = showPromptId === template.id; const isOpen = showPromptId === item.id;
const task = byTemplate.get(template.id); const task = byTemplate.get(item.id);
const loadingThis = videoLoading === template.id; const loadingThis = videoLoading === item.id;
return ( return (
<div key={template.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all"> <div key={item.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#e6f578]/15 to-[#8cb478]/15 ring-1 ring-[#e6f578]/20 flex flex-col items-center justify-center text-[#e6f578] text-[9px] font-mono gap-1"> <div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#e6f578]/15 to-[#8cb478]/15 ring-1 ring-[#e6f578]/20 flex flex-col items-center justify-center text-[#e6f578] text-[9px] font-mono gap-1">
{task?.videoUrl ? (
<div className="relative h-full w-full overflow-hidden rounded-[8px] bg-black">
<HoverVideoPreview
src={task.videoUrl}
aspectRatio={item.ratio}
className="h-full w-full object-contain"
/>
<span className="pointer-events-none absolute bottom-1 left-1 rounded-[6px] bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold text-[#e6f578]">
{item.duration}s
</span>
</div>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5.2v13.6L18.5 12 8 5.2z" /> <path d="M8 5.2v13.6L18.5 12 8 5.2z" />
</svg> </svg>
<span className="text-[8px]">{template.duration}s</span> <span className="text-[8px]">{item.duration}s</span>
</>
)}
</div> </div>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium text-white">{template.title}</span> <span className="text-[13px] font-medium text-white">{item.title}</span>
<span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span> <span className="chip chip-neutral text-[10px] py-0">{item.ratio}</span>
{!item.template && <span className="chip chip-live text-[10px] py-0"></span>}
</div> </div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p> <p className="text-[11px] text-white/45 line-clamp-1">{item.description}</p>
{task && ( {task && (
<div className="mt-2 rounded-lg bg-black/28 p-2 text-[10px] leading-relaxed text-white/48 ring-1 ring-white/[0.06]"> <div className="mt-2 rounded-lg bg-black/28 p-2 text-[10px] leading-relaxed text-white/48 ring-1 ring-white/[0.06]">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -431,7 +470,7 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div> </div>
)} )}
<button <button
onClick={() => setShowPromptId(isOpen ? null : template.id)} onClick={() => setShowPromptId(isOpen ? null : item.id)}
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1" className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
> >
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
@@ -441,13 +480,13 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</button> </button>
{isOpen && ( {isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto"> <pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate} {item.promptTemplate}
</pre> </pre>
)} )}
</div> </div>
<button <button
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : onGenerateVideo(primaryImage, template)} onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : item.template ? onGenerateVideo(primaryImage, item.template) : undefined}
disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded'} disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded' || (!item.template && !task?.taskId)}
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40" className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined} title={locked ? '四个图片包完成后解锁视频任务' : undefined}
> >

View File

@@ -19,10 +19,12 @@ type GalleryItem = {
description?: string; description?: string;
status?: string; status?: string;
aspectRatio?: string; aspectRatio?: string;
mediaType?: 'image' | 'video';
downloadName?: string;
}; };
type GalleryPanel = { type GalleryPanel = {
mode: 'pack' | 'empty' | 'project'; mode: 'pack' | 'empty' | 'project' | 'video';
kind?: PackKind; kind?: PackKind;
label: string; label: string;
description: string; description: string;
@@ -77,7 +79,26 @@ function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel {
return { mode: 'empty' as const, label: '文字', description: '文字区块暂无图片素材', total: 0, images: [] as GalleryItem[] }; return { mode: 'empty' as const, label: '文字', description: '文字区块暂无图片素材', total: 0, images: [] as GalleryItem[] };
} }
if (activeNav === 'pack-video') { if (activeNav === 'pack-video') {
return { mode: 'empty' as const, label: '视频', description: '视频区块暂无图片素材', total: 0, images: [] as GalleryItem[] }; const videos: GalleryItem[] = (session.videoTasks ?? [])
.filter(task => !/_part[12]$/.test(task.templateId))
.filter(task => task.videoUrl)
.map(task => ({
id: task.templateId,
url: task.videoUrl!,
title: task.title,
description: `${task.ratio} · ${task.duration}s · ${task.status}`,
status: task.status,
aspectRatio: task.ratio,
mediaType: 'video' as const,
downloadName: `${session.id}_${task.templateId}.mp4`,
}));
return {
mode: 'video' as const,
label: '视频',
description: '当前项目所有视频成片',
total: videos.length,
images: videos,
};
} }
return { return {
@@ -150,7 +171,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{gallery.images.map((image, index) => ( {gallery.images.map((image, index) => (
<div key={image.id} className="gallery-drawer-card"> <div key={image.id} className="gallery-drawer-card">
<div className="gallery-drawer-card__image" style={{ aspectRatio: aspectCss(image.aspectRatio) }}> <div className="gallery-drawer-card__image" style={{ aspectRatio: aspectCss(image.aspectRatio) }}>
{image.mediaType === 'video' ? (
<video src={image.url} controls muted playsInline preload="metadata" />
) : (
<img src={image.url} alt={image.title} /> <img src={image.url} alt={image.title} />
)}
</div> </div>
<div className="mt-3 min-w-0"> <div className="mt-3 min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@@ -160,6 +185,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{image.description && ( {image.description && (
<p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-white/42">{image.description}</p> <p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-white/42">{image.description}</p>
)} )}
{image.mediaType === 'video' && (
<a href={image.url} download={image.downloadName} className="mt-2 inline-flex text-[10px] font-semibold text-[#e6f578] hover:text-white">
</a>
)}
</div> </div>
</div> </div>
))} ))}
@@ -175,7 +205,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
const centerPreview = preview ? ( const centerPreview = preview ? (
<div className="gallery-center-preview" aria-hidden="true"> <div className="gallery-center-preview" aria-hidden="true">
{preview.mediaType === 'video' ? (
<video src={preview.url} muted autoPlay loop playsInline />
) : (
<img src={preview.url} alt="" /> <img src={preview.url} alt="" />
)}
</div> </div>
) : null; ) : null;
@@ -231,10 +265,17 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
title={`查看${image.title}`} title={`查看${image.title}`}
aria-label={`查看${image.title}`} aria-label={`查看${image.title}`}
> >
{image.mediaType === 'video' ? (
<video src={image.url} muted playsInline preload="metadata" className="h-full w-full object-contain" />
) : (
<img src={image.url} alt="" className="h-full w-full object-contain" /> <img src={image.url} alt="" className="h-full w-full object-contain" />
)}
<span className="absolute left-1 top-1 rounded-[6px] bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white"> <span className="absolute left-1 top-1 rounded-[6px] bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
{index + 1} {index + 1}
</span> </span>
{image.mediaType === 'video' && (
<span className="absolute right-1 top-1 rounded-[6px] bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold text-[#e6f578]"></span>
)}
{image.status === 'selected' && ( {image.status === 'selected' && (
<span className="absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full bg-[#e6f578] text-[10px] font-bold text-[#081006]"></span> <span className="absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full bg-[#e6f578] text-[10px] font-bold text-[#081006]"></span>
)} )}