auto-save 2026-05-21 02:09 (~5)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user