auto-save 2026-05-13 14:21 (~2)
This commit is contained in:
@@ -1649,6 +1649,19 @@
|
|||||||
"message": "auto-save 2026-05-13 14:10 (~2)",
|
"message": "auto-save 2026-05-13 14:10 (~2)",
|
||||||
"hash": "6d87afa",
|
"hash": "6d87afa",
|
||||||
"files_changed": 2
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T14:16:19+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 14:16 (~1)",
|
||||||
|
"hash": "4983d9a",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T06:17:39Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 14:16 (~1)",
|
||||||
|
"files_changed": 2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
import { type NodeProps } from "@xyflow/react"
|
import { type NodeProps } from "@xyflow/react"
|
||||||
import {
|
import {
|
||||||
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
||||||
@@ -345,6 +346,9 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
const st = keyframeStatus(d.job)
|
const st = keyframeStatus(d.job)
|
||||||
const frames = d.job?.frames ?? []
|
const frames = d.job?.frames ?? []
|
||||||
const jobId = d.job?.id
|
const jobId = d.job?.id
|
||||||
|
const [hover, setHover] = useState<{ idx: number; rect: DOMRect } | null>(null)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" style={{ width: KEYFRAME_WIDTH }}>
|
<div className="relative" style={{ width: KEYFRAME_WIDTH }}>
|
||||||
@@ -372,7 +376,9 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
|
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
|
||||||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 点击放大`}
|
onMouseEnter={(e) => setHover({ idx: f.index, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · hover 看大图 · 点击精细调整`}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -383,7 +389,6 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
{isSel && (
|
{isSel && (
|
||||||
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
||||||
)}
|
)}
|
||||||
{/* 左上角:已清洗 + 抠图数 */}
|
|
||||||
{(f.cleaned_url || (f.elements?.some((e) => e.cutout_id))) && (
|
{(f.cleaned_url || (f.elements?.some((e) => e.cutout_id))) && (
|
||||||
<div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
|
<div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
|
||||||
{f.cleaned_url && (
|
{f.cleaned_url && (
|
||||||
@@ -399,40 +404,9 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 时间戳 */}
|
|
||||||
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
|
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
|
||||||
{f.timestamp.toFixed(1)}s
|
{f.timestamp.toFixed(1)}s
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover 静态大图预览(关键帧素材给下游分镜头编排用) */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
|
|
||||||
style={{
|
|
||||||
bottom: "calc(100% + 10px)",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
|
|
||||||
<img
|
|
||||||
src={effectiveFrameUrl(jobId, f)}
|
|
||||||
alt={`preview ${f.index}`}
|
|
||||||
className="block"
|
|
||||||
style={{
|
|
||||||
width: KEYFRAME_WIDTH * 2,
|
|
||||||
maxWidth: "min(720px, 80vw)",
|
|
||||||
height: "auto",
|
|
||||||
maxHeight: "82vh",
|
|
||||||
objectFit: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
|
|
||||||
<span className="text-white text-[12.5px] font-medium">分镜 {f.index + 1}</span>
|
|
||||||
<span className="text-white/60 text-[11px] font-mono">{f.timestamp.toFixed(2)}s · 静态帧</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
{/* 删除按钮:hover 时右上角浮出 */}
|
{/* 删除按钮:hover 时右上角浮出 */}
|
||||||
{d.onDeleteFrame && (
|
{d.onDeleteFrame && (
|
||||||
@@ -486,6 +460,45 @@ export function KeyframeNode({ data, selected }: any) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</NodeShell>
|
</NodeShell>
|
||||||
|
|
||||||
|
{/* Portal hover 大预览 — 浮在缩略图上方(不挡其他界面) */}
|
||||||
|
{mounted && hover && jobId && (() => {
|
||||||
|
const hf = frames.find((x) => x.index === hover.idx)
|
||||||
|
if (!hf) return null
|
||||||
|
// 大图最大尺寸(按视频比例算)
|
||||||
|
const vidAspect = d.job && d.job.height > 0 ? d.job.height / d.job.width : 16 / 9
|
||||||
|
const maxH = Math.min(window.innerHeight * 0.7, hover.rect.top - 16)
|
||||||
|
const maxW = Math.min(window.innerWidth * 0.6, 600)
|
||||||
|
let h = maxH, w = h / vidAspect
|
||||||
|
if (w > maxW) { w = maxW; h = w * vidAspect }
|
||||||
|
// 水平居中到缩略图,clamp 在视口内
|
||||||
|
const centerX = hover.rect.left + hover.rect.width / 2
|
||||||
|
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[120] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
top: hover.rect.top - h - 12,
|
||||||
|
left,
|
||||||
|
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 30px 80px -10px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
|
||||||
|
<img
|
||||||
|
src={effectiveFrameUrl(jobId, hf)}
|
||||||
|
alt={`preview ${hf.index}`}
|
||||||
|
className="block"
|
||||||
|
style={{ width: w, height: h, objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
|
||||||
|
<span className="text-white text-[12.5px] font-medium">分镜 {hf.index + 1} · {hf.timestamp.toFixed(2)}s</span>
|
||||||
|
<span className="text-white/60 text-[11px] font-mono">点击进入精细调整</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -589,6 +602,9 @@ const IMAGEGEN_WIDTH = 360
|
|||||||
export function ImageGenNode({ data, selected }: any) {
|
export function ImageGenNode({ data, selected }: any) {
|
||||||
const d: NodeData = data
|
const d: NodeData = data
|
||||||
const job = d?.job
|
const job = d?.job
|
||||||
|
const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(null)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
// 上方浮条 = 所有 frame 的 elements crop("分镜头编排"的输入素材)
|
// 上方浮条 = 所有 frame 的 elements crop("分镜头编排"的输入素材)
|
||||||
type ElPreview = { frameIdx: number; elementId: string; name: string }
|
type ElPreview = { frameIdx: number; elementId: string; name: string }
|
||||||
@@ -612,55 +628,30 @@ export function ImageGenNode({ data, selected }: any) {
|
|||||||
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
||||||
style={{ bottom: "calc(100% + 12px)" }}
|
style={{ bottom: "calc(100% + 12px)" }}
|
||||||
>
|
>
|
||||||
{elementCrops.map((p) => (
|
{elementCrops.map((p) => {
|
||||||
<div
|
const key = `${p.frameIdx}_${p.elementId}`
|
||||||
key={`${p.frameIdx}_${p.elementId}`}
|
return (
|
||||||
className="group relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-black/40 overflow-hidden"
|
<div
|
||||||
style={{ aspectRatio: aspect }}
|
key={key}
|
||||||
>
|
className="relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-black/40 overflow-hidden"
|
||||||
<button
|
style={{ aspectRatio: aspect }}
|
||||||
onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }}
|
|
||||||
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 点击进入分镜头编排`}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
>
|
>
|
||||||
<img
|
<button
|
||||||
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
|
onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }}
|
||||||
alt={p.name}
|
onMouseEnter={(e) => setHover({ key, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })}
|
||||||
className="absolute inset-0 w-full h-full object-contain"
|
onMouseLeave={() => setHover(null)}
|
||||||
/>
|
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · hover 看大图 · 点击进入分镜头编排`}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
{/* Hover 大图预览 — 跟 keyframe 样式一致 */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
|
|
||||||
style={{
|
|
||||||
bottom: "calc(100% + 10px)",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
|
<img
|
||||||
<img
|
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
|
||||||
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
|
alt={p.name}
|
||||||
alt={`preview ${p.elementId}`}
|
className="absolute inset-0 w-full h-full object-contain"
|
||||||
className="block"
|
/>
|
||||||
style={{
|
</button>
|
||||||
width: IMAGEGEN_WIDTH * 2,
|
</div>
|
||||||
maxWidth: "min(720px, 80vw)",
|
)
|
||||||
height: "auto",
|
})}
|
||||||
maxHeight: "82vh",
|
|
||||||
objectFit: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
|
|
||||||
<span className="text-white text-[12.5px] font-medium">{p.name}</span>
|
|
||||||
<span className="text-white/60 text-[11px] font-mono">来自分镜 {p.frameIdx + 1}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -688,6 +679,41 @@ export function ImageGenNode({ data, selected }: any) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</NodeShell>
|
</NodeShell>
|
||||||
|
|
||||||
|
{/* Portal hover 大预览 — 视口居中 */}
|
||||||
|
{mounted && hoverKey && job && (() => {
|
||||||
|
const [fi, ei] = hoverKey.split("_")
|
||||||
|
const frameIdx = parseInt(fi, 10)
|
||||||
|
const p = elementCrops.find((x) => x.frameIdx === frameIdx && x.elementId === ei)
|
||||||
|
if (!p) return null
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[120] pointer-events-none flex items-center justify-center p-6"
|
||||||
|
style={{ animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||||
|
<div className="relative rounded-2xl overflow-hidden border border-violet-300/40 bg-black" style={{ boxShadow: "0 50px 120px -10px rgba(0,0,0,0.9), 0 0 0 1px rgba(255,255,255,0.06)" }}>
|
||||||
|
<img
|
||||||
|
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
|
||||||
|
alt={`preview ${p.elementId}`}
|
||||||
|
className="block"
|
||||||
|
style={{
|
||||||
|
maxHeight: "85vh",
|
||||||
|
maxWidth: "70vw",
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 bg-black/80 backdrop-blur-md">
|
||||||
|
<span className="text-white text-[13px] font-medium">{p.name}</span>
|
||||||
|
<span className="text-white/60 text-[11.5px] font-mono">来自分镜 {p.frameIdx + 1} · 点击进入分镜头编排</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user