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)",
"hash": "7697754",
"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);
}
.gallery-center-preview img {
.gallery-center-preview img,
.gallery-center-preview video {
display: block;
width: min(74vw, 960px);
height: 82vh;
@@ -1090,7 +1091,8 @@ input, textarea {
background: #fff;
}
.gallery-drawer-card__image img {
.gallery-drawer-card__image img,
.gallery-drawer-card__image video {
display: block;
width: 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 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 { HoverImagePreview } from './HoverImagePreview';
import { HoverImagePreview, HoverVideoPreview } from './HoverImagePreview';
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
patent: '六面视图 · 45° 立体图 · 局部放大',
@@ -375,7 +375,30 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
const [showPromptId, setShowPromptId] = useState<string | null>(null);
const videoTasks = session.videoTasks ?? [];
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 (
<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 className="mt-2 flex items-center gap-2">
<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>
<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 className="flex items-center gap-2 shrink-0">
@@ -399,24 +422,40 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div>
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{VIDEO_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
const task = byTemplate.get(template.id);
const loadingThis = videoLoading === template.id;
{videoItems.map(item => {
const isOpen = showPromptId === item.id;
const task = byTemplate.get(item.id);
const loadingThis = videoLoading === item.id;
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">
{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">
<path d="M8 5.2v13.6L18.5 12 8 5.2z" />
</svg>
<span className="text-[8px]">{template.duration}s</span>
<span className="text-[8px]">{item.duration}s</span>
</>
)}
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium text-white">{template.title}</span>
<span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span>
<span className="text-[13px] font-medium text-white">{item.title}</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>
<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 && (
<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">
@@ -431,7 +470,7 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div>
)}
<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"
>
<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>
{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">
{template.promptTemplate}
{item.promptTemplate}
</pre>
)}
</div>
<button
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : onGenerateVideo(primaryImage, template)}
disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded'}
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : item.template ? onGenerateVideo(primaryImage, item.template) : undefined}
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"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>

View File

@@ -19,10 +19,12 @@ type GalleryItem = {
description?: string;
status?: string;
aspectRatio?: string;
mediaType?: 'image' | 'video';
downloadName?: string;
};
type GalleryPanel = {
mode: 'pack' | 'empty' | 'project';
mode: 'pack' | 'empty' | 'project' | 'video';
kind?: PackKind;
label: 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[] };
}
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 {
@@ -150,7 +171,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{gallery.images.map((image, index) => (
<div key={image.id} className="gallery-drawer-card">
<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} />
)}
</div>
<div className="mt-3 min-w-0">
<div className="flex min-w-0 items-center gap-2">
@@ -160,6 +185,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{image.description && (
<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>
))}
@@ -175,7 +205,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
const centerPreview = preview ? (
<div className="gallery-center-preview" aria-hidden="true">
{preview.mediaType === 'video' ? (
<video src={preview.url} muted autoPlay loop playsInline />
) : (
<img src={preview.url} alt="" />
)}
</div>
) : null;
@@ -231,10 +265,17 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
title={`查看${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" />
)}
<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}
</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' && (
<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>
)}