auto-save 2026-05-14 00:37 (+1, ~2)
This commit is contained in:
@@ -2665,6 +2665,19 @@
|
|||||||
"message": "auto-save 2026-05-14 00:25 (+6, ~5)",
|
"message": "auto-save 2026-05-14 00:25 (+6, ~5)",
|
||||||
"hash": "abeff42",
|
"hash": "abeff42",
|
||||||
"files_changed": 11
|
"files_changed": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T00:31:52+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-14 00:31 (~1)",
|
||||||
|
"hash": "5c9c80e",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T16:33:09Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 00:31 (~1)",
|
||||||
|
"files_changed": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
57
web/components/nodes/hover-preview.tsx
Normal file
57
web/components/nodes/hover-preview.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视觉类节点统一 hover 大预览:
|
||||||
|
* - 浮在缩略图正上方(bottom: calc(100% + 10px),居中),跟随 ReactFlow viewport 缩放
|
||||||
|
* - 固定 280px 宽,原比例高
|
||||||
|
* - 视频自动播放(muted loop),图片静态
|
||||||
|
* - pointer-events-none,不阻挡同行/邻近交互
|
||||||
|
* - 用法:<div className="group ..."> ... <HoverPreview ... /> </div>
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
imgSrc?: string
|
||||||
|
videoSrc?: string
|
||||||
|
poster?: string
|
||||||
|
aspect: string
|
||||||
|
label?: string
|
||||||
|
caption?: string
|
||||||
|
borderClass?: string
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HoverPreview({
|
||||||
|
imgSrc, videoSrc, poster, aspect,
|
||||||
|
label, caption,
|
||||||
|
borderClass = "border-violet-300/55",
|
||||||
|
width = 280,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
|
||||||
|
style={{
|
||||||
|
bottom: "calc(100% + 10px)",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
transformOrigin: "bottom center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`rounded-lg overflow-hidden border-2 bg-black shadow-2xl ${borderClass}`} style={{ width }}>
|
||||||
|
<div style={{ aspectRatio: aspect }}>
|
||||||
|
{videoSrc ? (
|
||||||
|
<video src={videoSrc} poster={poster} muted loop playsInline autoPlay className="w-full h-full object-cover" />
|
||||||
|
) : imgSrc ? (
|
||||||
|
<img src={imgSrc} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-black/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(label || caption) && (
|
||||||
|
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
{caption && <span className="text-white/60 font-mono">{caption}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||||
|
import { HoverPreview } from "./hover-preview"
|
||||||
import {
|
import {
|
||||||
type Job, type ImageRef,
|
type Job, type ImageRef,
|
||||||
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||||||
@@ -124,45 +125,54 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
|||||||
{[...d.jobs].reverse().map((j) => {
|
{[...d.jobs].reverse().map((j) => {
|
||||||
const isActive = j.id === d.activeJobId
|
const isActive = j.id === d.activeJobId
|
||||||
const ready = !!j.video_url
|
const ready = !!j.video_url
|
||||||
|
const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={j.id}
|
key={j.id}
|
||||||
type="button"
|
className={`group relative shrink-0 rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (isActive && ready) setVideoExpanded(true)
|
|
||||||
else d.onSwitchJob(j.id)
|
|
||||||
}}
|
|
||||||
title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · ${isActive ? "点击展开" : "点击切换"}` : "下载中…"}
|
|
||||||
className={`shrink-0 group relative rounded-md overflow-hidden border shadow-lg transition hover:-translate-y-0.5 ${
|
|
||||||
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
|
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
|
||||||
}`}
|
}`}
|
||||||
style={{ height: 64, aspectRatio: ready ? `${j.width}/${j.height}` : "9/16" }}
|
style={{ height: 80, aspectRatio: aspectStr }}
|
||||||
>
|
>
|
||||||
{ready ? (
|
<button
|
||||||
<video
|
type="button"
|
||||||
src={videoUrl(j.id)}
|
onClick={(e) => {
|
||||||
muted
|
e.stopPropagation()
|
||||||
loop
|
if (isActive && ready) setVideoExpanded(true)
|
||||||
playsInline
|
else d.onSwitchJob(j.id)
|
||||||
preload="metadata"
|
}}
|
||||||
className="block w-full h-full object-cover bg-black"
|
title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · ${isActive ? "点击展开" : "点击切换"}` : "下载中…"}
|
||||||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
|
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
|
||||||
onMouseLeave={(e) => {
|
>
|
||||||
const v = e.target as HTMLVideoElement
|
{ready ? (
|
||||||
v.pause()
|
<video
|
||||||
v.currentTime = 0
|
src={videoUrl(j.id)}
|
||||||
}}
|
muted
|
||||||
/>
|
loop
|
||||||
) : (
|
playsInline
|
||||||
<div className="w-full h-full bg-black/60 flex items-center justify-center">
|
preload="metadata"
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
|
poster=""
|
||||||
|
className="block w-full h-full object-cover bg-black"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-black/60 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
|
||||||
|
{ready ? `${j.duration.toFixed(1)}s` : "…"}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
{ready && (
|
||||||
|
<HoverPreview
|
||||||
|
videoSrc={videoUrl(j.id)}
|
||||||
|
aspect={aspectStr}
|
||||||
|
label={`${j.width}×${j.height}`}
|
||||||
|
caption={`${j.duration.toFixed(1)}s`}
|
||||||
|
borderClass="border-violet-300/60"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
|
</div>
|
||||||
{ready ? `${j.duration.toFixed(1)}s` : "…"}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,10 +377,10 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||||
{/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */}
|
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
|
||||||
{frames.length > 0 && jobId && (
|
{frames.length > 0 && jobId && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||||||
style={{ bottom: "calc(100% + 12px)" }}
|
style={{ bottom: "calc(100% + 12px)" }}
|
||||||
>
|
>
|
||||||
{frames.map((f) => {
|
{frames.map((f) => {
|
||||||
@@ -378,12 +388,13 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.index}
|
key={f.index}
|
||||||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
|
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
|
||||||
isSel
|
isSel
|
||||||
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
||||||
: "border-white/30 dark:border-white/20"
|
: "border-white/30 dark:border-white/20"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
|
height: 80,
|
||||||
aspectRatio: d.job && d.job.height > 0
|
aspectRatio: d.job && d.job.height > 0
|
||||||
? `${d.job.width}/${d.job.height}`
|
? `${d.job.width}/${d.job.height}`
|
||||||
: "16/9",
|
: "16/9",
|
||||||
@@ -972,10 +983,10 @@ export function VideoGenNode({ data, selected }: any) {
|
|||||||
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
<div className="relative" style={{ width: "100%", height: "100%" }}>
|
||||||
{videos.length > 0 && (
|
{videos.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"
|
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||||||
style={{ bottom: "calc(100% + 12px)" }}
|
style={{ bottom: "calc(100% + 12px)" }}
|
||||||
>
|
>
|
||||||
{videos.slice(0, 6).map((v, i) => {
|
{videos.map((v, i) => {
|
||||||
const videoSrc = apiAssetUrl(v.url)
|
const videoSrc = apiAssetUrl(v.url)
|
||||||
const posterSrc = apiAssetUrl(v.poster_url)
|
const posterSrc = apiAssetUrl(v.poster_url)
|
||||||
const ready = v.status === "completed" && !!videoSrc
|
const ready = v.status === "completed" && !!videoSrc
|
||||||
@@ -983,10 +994,10 @@ export function VideoGenNode({ data, selected }: any) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={v.id}
|
key={v.id}
|
||||||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 bg-black ${
|
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-black ${
|
||||||
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
|
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
|
||||||
}`}
|
}`}
|
||||||
style={{ aspectRatio: aspect }}
|
style={{ height: 80, aspectRatio: aspect }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1007,12 +1018,6 @@ export function VideoGenNode({ data, selected }: any) {
|
|||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
const el = e.target as HTMLVideoElement
|
|
||||||
el.pause()
|
|
||||||
el.currentTime = 0
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : posterSrc ? (
|
) : posterSrc ? (
|
||||||
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
|
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
|
||||||
|
|||||||
Reference in New Issue
Block a user