auto-save 2026-05-14 00:25 (+6, ~5)

This commit is contained in:
2026-05-14 00:26:10 +08:00
parent 540107d505
commit abeff424f6
11 changed files with 1262 additions and 57 deletions

View File

@@ -103,7 +103,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const inputLocked = isDownloading || d.submitting
return (
<div className="relative" style={{ width: "100%" }}>
<div className="relative" style={{ width: "100%", height: "100%" }}>
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{!videoExpanded && d.jobs.length > 0 && (
@@ -222,7 +222,6 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
icon={<Link2 className="h-4 w-4" />}
title="输入 · Input"
subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
width={320}
selected={selected}
hasTarget={false}
>
@@ -367,7 +366,7 @@ export function KeyframeNode({ data, selected }: any) {
const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16"
return (
<div className="relative" style={{ width: "100%" }}>
<div className="relative" style={{ width: "100%", height: "100%" }}>
{/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */}
{frames.length > 0 && jobId && (
<div
@@ -488,7 +487,6 @@ export function KeyframeNode({ data, selected }: any) {
icon={<ImageIcon className="h-4 w-4" />}
title="镜头拆解 · 元素提取"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
width={KEYFRAME_WIDTH}
selected={selected}
>
{frames.length > 0 ? (() => {
@@ -836,7 +834,7 @@ export function StoryboardNode({ data, selected }: any) {
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return (
<div className="relative" style={{ width: "100%" }}>
<div className="relative" style={{ width: "100%", height: "100%" }}>
{/* 节点上方:所有元素 crop 图(编排输入素材)· 跟 keyframe 节点样式一致 */}
{elementCrops.length > 0 && job && (
<div
@@ -919,7 +917,6 @@ export function StoryboardNode({ data, selected }: any) {
icon={<LayoutGrid className="h-4 w-4" />}
title="元素改造 · Storyboard"
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
width={IMAGEGEN_WIDTH}
selected={selected}
>
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
@@ -972,7 +969,7 @@ export function VideoGenNode({ data, selected }: any) {
return e
}
return (
<div className="relative" style={{ width: "100%" }}>
<div className="relative" style={{ width: "100%", height: "100%" }}>
{videos.length > 0 && (
<div
className="absolute left-0 right-0 grid grid-cols-3 gap-1.5"

View File

@@ -2,7 +2,7 @@
import { type ReactNode } from "react"
import { Handle, Position } from "@xyflow/react"
import { CheckCircle2, Loader2, AlertCircle } from "lucide-react"
import { ResizeRight } from "./resize-handle"
import { ResizeRight, ResizeBR } from "./resize-handle"
export type NodeKind = "input" | "process" | "ai" | "output"
export type NodeStatus = "pending" | "running" | "done" | "failed"
@@ -50,7 +50,7 @@ export function NodeShell({
<div
className={`glass-node ${selected ? "glass-node--selected" : ""} ${status === "running" ? "glass-node--running" : ""}`}
data-type={type}
style={{ width }}
style={{ width, height: "100%" }}
>
{hasTarget && <Handle type="target" position={Position.Left} />}
@@ -76,6 +76,7 @@ export function NodeShell({
{hasSource && <Handle type="source" position={Position.Right} />}
<ResizeRight />
<ResizeBR />
</div>
)
}

View File

@@ -2,49 +2,92 @@
import { type PointerEvent } from "react"
import { useReactFlow } from "@xyflow/react"
/** 节点右边缘 resize 把手:手写 pointer 事件改 node.width自适应 viewport zoom不走 xyflow NodeResizeControl在 glass-node overflow:hidden 下不工作)。 */
export function ResizeRight({ minWidth = 240, maxWidth = 1200 }: { minWidth?: number; maxWidth?: number }) {
const { setNodes, getZoom } = useReactFlow()
interface Bounds {
minWidth?: number
maxWidth?: number
minHeight?: number
maxHeight?: number
}
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const target = e.currentTarget
const nodeEl = target.closest(".react-flow__node") as HTMLElement | null
const nodeId = nodeEl?.getAttribute("data-id")
if (!nodeId || !nodeEl) return
target.setPointerCapture(e.pointerId)
/** 拖动期间持续更新 ReactFlow node 的 width/height同时写到 style.width/height 让 wrapper 真实渲染。
* 自适应 viewport zoom。不走 xyflow NodeResizeControlglass-node overflow:hidden + reserved-type CSS 干扰下不响应)。 */
function startResize(
e: PointerEvent<HTMLDivElement>,
setNodes: ReturnType<typeof useReactFlow>["setNodes"],
getZoom: ReturnType<typeof useReactFlow>["getZoom"],
axis: "x" | "y" | "xy",
bounds: Required<Bounds>,
) {
e.preventDefault()
e.stopPropagation()
const target = e.currentTarget
const nodeEl = target.closest(".react-flow__node") as HTMLElement | null
const nodeId = nodeEl?.getAttribute("data-id")
if (!nodeId || !nodeEl) return
target.setPointerCapture(e.pointerId)
const startX = e.clientX
const zoom = getZoom() || 1
const startWidth = parseFloat(getComputedStyle(nodeEl).width) / zoom
const startX = e.clientX
const startY = e.clientY
const zoom = getZoom() || 1
const startWidth = parseFloat(getComputedStyle(nodeEl).width) / zoom
const startHeight = parseFloat(getComputedStyle(nodeEl).height) / zoom
const onMove = (ev: globalThis.PointerEvent) => {
const dx = (ev.clientX - startX) / zoom
const next = Math.max(minWidth, Math.min(maxWidth, startWidth + dx))
setNodes((nodes) => nodes.map((n) => (n.id === nodeId ? { ...n, width: next, style: { ...n.style, width: next } } : n)))
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
try { target.releasePointerCapture(e.pointerId) } catch {}
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
const onMove = (ev: globalThis.PointerEvent) => {
const dx = (ev.clientX - startX) / zoom
const dy = (ev.clientY - startY) / zoom
const wantW = axis === "y" ? null : Math.max(bounds.minWidth, Math.min(bounds.maxWidth, startWidth + dx))
const wantH = axis === "x" ? null : Math.max(bounds.minHeight, Math.min(bounds.maxHeight, startHeight + dy))
setNodes((nodes) =>
nodes.map((n) => {
if (n.id !== nodeId) return n
const nextStyle = { ...n.style }
const patch: Record<string, unknown> = {}
if (wantW !== null) { patch.width = wantW; nextStyle.width = wantW }
if (wantH !== null) { patch.height = wantH; nextStyle.height = wantH }
return { ...n, ...patch, style: nextStyle }
}),
)
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
try { target.releasePointerCapture(e.pointerId) } catch {}
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
/** 右边缘把手 — 只改宽度 */
export function ResizeRight({ minWidth = 240, maxWidth = 1400 }: Bounds) {
const { setNodes, getZoom } = useReactFlow()
return (
<div
onPointerDown={onPointerDown}
onPointerDown={(e) => startResize(e, setNodes, getZoom, "x", { minWidth, maxWidth, minHeight: 0, maxHeight: 0 })}
title="拖动调整宽度"
className="nodrag absolute z-20 hover:bg-violet-400/60 active:bg-violet-400/80 transition rounded-r"
style={{ right: 0, top: 12, bottom: 12, width: 6, cursor: "ew-resize", touchAction: "none" }}
/>
)
}
/** 右下角把手 — 同时改宽和高 */
export function ResizeBR({
minWidth = 240, maxWidth = 1400, minHeight = 120, maxHeight = 1400,
}: Bounds) {
const { setNodes, getZoom } = useReactFlow()
return (
<div
onPointerDown={(e) => startResize(e, setNodes, getZoom, "xy", { minWidth, maxWidth, minHeight, maxHeight })}
title="拖动调整大小(宽 × 高)"
className="nodrag absolute z-30 hover:bg-violet-400/80 active:bg-violet-400 transition"
style={{
right: 0,
top: 12,
bottom: 12,
width: 6,
cursor: "ew-resize",
bottom: 0,
width: 14,
height: 14,
cursor: "nwse-resize",
touchAction: "none",
background: "linear-gradient(135deg, transparent 50%, rgba(167,139,250,0.55) 50%)",
}}
/>
)