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)",
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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 { 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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user