Add video node toolbar interactions
This commit is contained in:
parent
2cad27cdcd
commit
c5c2d6eb93
@ -2,9 +2,11 @@
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Headphones,
|
||||
@ -15,6 +17,7 @@ import {
|
||||
Music2,
|
||||
Plus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
@ -108,6 +111,7 @@ type ComposerMenu =
|
||||
| "slash"
|
||||
| null;
|
||||
type MemberStoreTab = "vip" | "model" | "credits";
|
||||
type VideoToolbarMode = "clip" | "analysis" | null;
|
||||
|
||||
type CapturedTool = {
|
||||
type?: string;
|
||||
@ -341,6 +345,28 @@ type AngleEditorSettings = {
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
type VideoClipRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type VideoAnalysisRow = {
|
||||
id: string;
|
||||
sceneDescription: string;
|
||||
backgroundMusic: string;
|
||||
voiceAndSfx: string;
|
||||
imagePrompt: string;
|
||||
motionPrompt: string;
|
||||
keyframeUrl: string;
|
||||
};
|
||||
|
||||
type VideoAnalysisState = {
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
summary: string;
|
||||
rows: VideoAnalysisRow[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[];
|
||||
const capturedCounts = (capturedCanvasModules as any).counts || {};
|
||||
const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[];
|
||||
@ -446,6 +472,7 @@ const OFFICIAL_ZOOM_DOCK_SCALE = 0.65;
|
||||
const OFFICIAL_DROPDOWN_SCALE = 0.7;
|
||||
const OFFICIAL_NINE_GRID_WIDTH = 930;
|
||||
const OFFICIAL_NINE_GRID_HEIGHT = 520;
|
||||
const VIDEO_CLIP_FRAME_COUNT = 9;
|
||||
const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4";
|
||||
const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid";
|
||||
const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25";
|
||||
@ -1244,6 +1271,108 @@ function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) {
|
||||
return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node);
|
||||
}
|
||||
|
||||
function isVideoMediaNode(node: CloneNode | null | undefined): node is CloneNode {
|
||||
if (!node || node.kind !== "media") return false;
|
||||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "";
|
||||
return mediaType === "video";
|
||||
}
|
||||
|
||||
function getVideoNodePreviewUrl(node: CloneNode | null | undefined) {
|
||||
return getDraftString(node, "poster") || getNodeSourceMediaUrl(node) || getNodeMediaUrl(node);
|
||||
}
|
||||
|
||||
function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) {
|
||||
const params = getNodeParams(node);
|
||||
const paramsSettings =
|
||||
params.settings && typeof params.settings === "object"
|
||||
? (params.settings as Record<string, unknown>)
|
||||
: {};
|
||||
const numericValue = Number(
|
||||
params.duration ||
|
||||
paramsSettings.duration ||
|
||||
getDraftString(node, "duration") ||
|
||||
(node?.draftValues?.videoMeta && typeof node.draftValues.videoMeta === "object"
|
||||
? (node.draftValues.videoMeta as Record<string, unknown>).duration
|
||||
: 0)
|
||||
);
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) return numericValue;
|
||||
const outputLabel = getDraftString(node, "outputSizeLabel");
|
||||
const durationMatch = outputLabel.match(/(\d+(?:\.\d+)?)\s*s/i);
|
||||
if (durationMatch) {
|
||||
const parsed = Number(durationMatch[1] || "0");
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
function formatClipDurationLabel(seconds: number) {
|
||||
const normalized = Math.max(0.1, seconds);
|
||||
return `${normalized.toFixed(2)} s`;
|
||||
}
|
||||
|
||||
function normalizeVideoClipRange(value: unknown): VideoClipRange {
|
||||
if (!value || typeof value !== "object") return { start: 0.18, end: 0.52 };
|
||||
const candidate = value as Partial<VideoClipRange>;
|
||||
const start = Number(candidate.start);
|
||||
const end = Number(candidate.end);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
||||
return { start: 0.18, end: 0.52 };
|
||||
}
|
||||
return {
|
||||
start: Math.max(0, Math.min(start, 0.94)),
|
||||
end: Math.max(Math.min(start + 0.04, 1), Math.min(end, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVideoAnalysisRows(node: CloneNode, responseData?: {
|
||||
highlights?: string[];
|
||||
raw?: string;
|
||||
recommendedNextSteps?: string[];
|
||||
shotIdeas?: Array<{ title: string; description: string }>;
|
||||
summary?: string;
|
||||
}): VideoAnalysisRow[] {
|
||||
const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || "";
|
||||
const highlights = responseData?.highlights || [];
|
||||
const shotIdeas = responseData?.shotIdeas?.slice(0, 3) || [];
|
||||
const summary = responseData?.summary?.trim() || "";
|
||||
const fallbackPrompt = summary || responseData?.raw?.trim() || `${node.title} 的连续镜头推演`;
|
||||
const rows =
|
||||
shotIdeas.length > 0
|
||||
? shotIdeas.map((idea, index) => ({
|
||||
id: `${node.id}-analysis-${index}`,
|
||||
sceneDescription: idea.title || `镜头 ${index + 1}`,
|
||||
backgroundMusic: highlights[index] || highlights[0] || "氛围音乐待接入",
|
||||
voiceAndSfx: highlights[index + 1] || "无",
|
||||
imagePrompt: idea.description || fallbackPrompt,
|
||||
motionPrompt:
|
||||
responseData?.recommendedNextSteps?.[index] ||
|
||||
"保持镜头运动自然平稳,围绕主体动作推进。",
|
||||
keyframeUrl: previewUrl,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: `${node.id}-analysis-0`,
|
||||
sceneDescription: `${node.title} 开场镜头`,
|
||||
backgroundMusic: highlights[0] || "轻氛围背景音乐",
|
||||
voiceAndSfx: highlights[1] || "无",
|
||||
imagePrompt: fallbackPrompt,
|
||||
motionPrompt: "中景起势,镜头跟随主体动作缓慢推进。",
|
||||
keyframeUrl: previewUrl,
|
||||
},
|
||||
{
|
||||
id: `${node.id}-analysis-1`,
|
||||
sceneDescription: `${node.title} 情绪推进`,
|
||||
backgroundMusic: highlights[2] || highlights[0] || "音乐情绪逐渐抬升",
|
||||
voiceAndSfx: highlights[3] || "环境声增强",
|
||||
imagePrompt: fallbackPrompt,
|
||||
motionPrompt: "切换近景,强调表情和动作细节。",
|
||||
keyframeUrl: previewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getGridSplitCellLabel(index: number, split: Pick<GridSplitState, "columns">) {
|
||||
const row = Math.floor(index / split.columns) + 1;
|
||||
const column = (index % split.columns) + 1;
|
||||
@ -2391,11 +2520,18 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const [inspectorNodeId, setInspectorNodeId] = useState<string | null>(null);
|
||||
const [dockOverviewOpen, setDockOverviewOpen] = useState(false);
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [activeVideoToolbarMode, setActiveVideoToolbarMode] = useState<VideoToolbarMode>(null);
|
||||
const [videoClipRanges, setVideoClipRanges] = useState<Record<string, VideoClipRange>>({});
|
||||
const [videoAnalysisStates, setVideoAnalysisStates] = useState<Record<string, VideoAnalysisState>>({});
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((node) => node.id === selectedNodeId) || null,
|
||||
[nodes, selectedNodeId]
|
||||
);
|
||||
const selectedVideoMediaNode = useMemo(
|
||||
() => (isVideoMediaNode(selectedNode) && Boolean(getNodeMediaUrl(selectedNode)) ? selectedNode : null),
|
||||
[selectedNode]
|
||||
);
|
||||
|
||||
const contextNode = useMemo(
|
||||
() => nodes.find((node) => node.id === nodeContextMenu?.nodeId) || null,
|
||||
@ -2416,6 +2552,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
[angleEditorNodeId, nodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveVideoToolbarMode(null);
|
||||
}, [selectedVideoMediaNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
@ -4299,6 +4439,133 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
[edges, nodes, videoCanvasFunctionConfig]
|
||||
);
|
||||
|
||||
const updateVideoClipRange = useCallback((nodeId: string, nextRange: VideoClipRange) => {
|
||||
setVideoClipRanges((current) => ({
|
||||
...current,
|
||||
[nodeId]: {
|
||||
start: Math.max(0, Math.min(nextRange.start, 0.94)),
|
||||
end: Math.max(Math.min(nextRange.start + 0.04, 1), Math.min(nextRange.end, 1)),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const applyVideoClipSelection = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((item) => item.id === nodeId);
|
||||
if (!node) return;
|
||||
const existingRange = normalizeVideoClipRange(
|
||||
videoClipRanges[nodeId] || nodes.find((item) => item.id === nodeId)?.draftValues?.clipRange
|
||||
);
|
||||
const normalizedRange =
|
||||
existingRange.end > existingRange.start
|
||||
? existingRange
|
||||
: { start: existingRange.start, end: Math.min(1, existingRange.start + 0.12) };
|
||||
const durationSeconds = getVideoNodeDurationSeconds(node) * Math.max(0.04, normalizedRange.end - normalizedRange.start);
|
||||
setNodes((current) =>
|
||||
current.map((item) =>
|
||||
item.id === nodeId
|
||||
? {
|
||||
...item,
|
||||
draftValues: {
|
||||
...(item.draftValues || {}),
|
||||
clipDurationLabel: formatClipDurationLabel(durationSeconds),
|
||||
clipRange: normalizedRange,
|
||||
},
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
setActiveVideoToolbarMode(null);
|
||||
toast.success("已记录当前剪辑范围,视频裁剪导出后端待接入。");
|
||||
},
|
||||
[nodes, videoClipRanges]
|
||||
);
|
||||
|
||||
const downloadVideoNode = useCallback((node: CloneNode) => {
|
||||
const mediaUrl = getNodeMediaUrl(node);
|
||||
if (!mediaUrl) {
|
||||
toast.error("当前视频没有可下载地址");
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = mediaUrl;
|
||||
link.download = `${node.title || "libtv-video"}.mp4`;
|
||||
link.rel = "noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
toast.success("已开始下载当前视频");
|
||||
}, []);
|
||||
|
||||
const requestVideoAnalysis = useCallback(
|
||||
async (nodeId: string) => {
|
||||
const node = nodes.find((item) => item.id === nodeId);
|
||||
if (!node) return;
|
||||
const videoUrl = getNodeReferenceMediaUrl(node);
|
||||
if (!videoUrl) {
|
||||
toast.error("当前视频暂时没有可解析的素材地址");
|
||||
return;
|
||||
}
|
||||
if (videoAnalysisStates[nodeId]?.status === "loading" || videoAnalysisStates[nodeId]?.status === "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script");
|
||||
const prompt = composeFunctionPrompt(
|
||||
definition,
|
||||
`请解析当前视频,输出结构化视频故事表。重点补充镜头描述、背景音乐、人声/音效、图像生成提示词、视频运动提示词与关键帧建议。视频标题:${node.title}`
|
||||
);
|
||||
|
||||
setVideoAnalysisStates((current) => ({
|
||||
...current,
|
||||
[nodeId]: {
|
||||
error: undefined,
|
||||
rows: [],
|
||||
status: "loading",
|
||||
summary: "正在解析视频故事...",
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await workflowAnalysisService.analyze({
|
||||
mode: "video-analysis",
|
||||
nodeTitle: node.title,
|
||||
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||||
prompt,
|
||||
videoUrls: [videoUrl],
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || "视频解析失败");
|
||||
}
|
||||
const rows = buildVideoAnalysisRows(node, response.data);
|
||||
const summary =
|
||||
response.data?.summary?.trim() ||
|
||||
response.data?.highlights?.join(" · ") ||
|
||||
"已整理出当前视频的镜头节奏与生成建议。";
|
||||
setVideoAnalysisStates((current) => ({
|
||||
...current,
|
||||
[nodeId]: {
|
||||
rows,
|
||||
status: "ready",
|
||||
summary,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setVideoAnalysisStates((current) => ({
|
||||
...current,
|
||||
[nodeId]: {
|
||||
error: error instanceof Error ? error.message : "视频解析失败",
|
||||
rows: buildVideoAnalysisRows(node),
|
||||
status: "error",
|
||||
summary: "解析失败,先展示本地占位结构,方便继续编排。",
|
||||
},
|
||||
}));
|
||||
toast.error(error instanceof Error ? error.message : "视频解析失败");
|
||||
}
|
||||
},
|
||||
[nodes, videoAnalysisStates, videoCanvasFunctionConfig]
|
||||
);
|
||||
|
||||
const runPresetComposerSubmit = useCallback(
|
||||
async (
|
||||
nodeId: string,
|
||||
@ -4916,6 +5183,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
zoom: viewport.zoom,
|
||||
};
|
||||
}, [selectedNode, selectedTargetModuleKind, viewport.x, viewport.y, viewport.zoom]);
|
||||
const selectedVideoAnchor = useMemo<PresetControlAnchor | null>(() => {
|
||||
if (!selectedVideoMediaNode) return null;
|
||||
return {
|
||||
bottomY: Math.round(viewport.y + (selectedVideoMediaNode.y + selectedVideoMediaNode.height) * viewport.zoom),
|
||||
centerX: Math.round(viewport.x + (selectedVideoMediaNode.x + selectedVideoMediaNode.width / 2) * viewport.zoom),
|
||||
topY: Math.round(viewport.y + selectedVideoMediaNode.y * viewport.zoom),
|
||||
zoom: viewport.zoom,
|
||||
};
|
||||
}, [selectedVideoMediaNode, viewport.x, viewport.y, viewport.zoom]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -5055,6 +5331,56 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedVideoMediaNode && selectedVideoAnchor ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
<VideoMediaToolbar
|
||||
activeMode={activeVideoToolbarMode}
|
||||
anchor={selectedVideoAnchor}
|
||||
onAnalyze={() => {
|
||||
const nextMode = activeVideoToolbarMode === "analysis" ? null : "analysis";
|
||||
setActiveVideoToolbarMode(nextMode);
|
||||
if (nextMode === "analysis") {
|
||||
void requestVideoAnalysis(selectedVideoMediaNode.id);
|
||||
}
|
||||
}}
|
||||
onDownload={() => downloadVideoNode(selectedVideoMediaNode)}
|
||||
onHd={() => toast.info("视频高清后端待接入,按钮先保留。")}
|
||||
onPreview={() => setPreviewNode(selectedVideoMediaNode)}
|
||||
onToggleClip={() => setActiveVideoToolbarMode((current) => current === "clip" ? null : "clip")}
|
||||
/>
|
||||
{activeVideoToolbarMode === "clip" ? (
|
||||
<VideoClipEditorBar
|
||||
anchor={selectedVideoAnchor}
|
||||
clipRange={
|
||||
normalizeVideoClipRange(
|
||||
videoClipRanges[selectedVideoMediaNode.id] || selectedVideoMediaNode.draftValues?.clipRange
|
||||
)
|
||||
}
|
||||
durationSeconds={getVideoNodeDurationSeconds(selectedVideoMediaNode)}
|
||||
previewUrl={getVideoNodePreviewUrl(selectedVideoMediaNode) || getNodeMediaUrl(selectedVideoMediaNode)}
|
||||
title={selectedVideoMediaNode.title}
|
||||
onApply={() => applyVideoClipSelection(selectedVideoMediaNode.id)}
|
||||
onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)}
|
||||
onReset={() => updateVideoClipRange(selectedVideoMediaNode.id, { start: 0.18, end: 0.52 })}
|
||||
/>
|
||||
) : null}
|
||||
{activeVideoToolbarMode === "analysis" ? (
|
||||
<VideoAnalysisFloatingPanel
|
||||
anchor={selectedVideoAnchor}
|
||||
analysisState={
|
||||
videoAnalysisStates[selectedVideoMediaNode.id] || {
|
||||
rows: [],
|
||||
status: "idle",
|
||||
summary: "准备解析当前视频故事...",
|
||||
}
|
||||
}
|
||||
node={selectedVideoMediaNode}
|
||||
onClose={() => setActiveVideoToolbarMode(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? (
|
||||
<TextTargetModuleComposer
|
||||
anchor={selectedTargetAnchor}
|
||||
@ -5908,6 +6234,375 @@ function GridSplitActionToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
function VideoMediaToolbar({
|
||||
activeMode,
|
||||
anchor,
|
||||
onAnalyze,
|
||||
onDownload,
|
||||
onHd,
|
||||
onPreview,
|
||||
onToggleClip,
|
||||
}: {
|
||||
activeMode: VideoToolbarMode;
|
||||
anchor?: PresetControlAnchor;
|
||||
onAnalyze: () => void;
|
||||
onDownload: () => void;
|
||||
onHd: () => void;
|
||||
onPreview: () => void;
|
||||
onToggleClip: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute flex h-[58px] items-center gap-4 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]"
|
||||
style={
|
||||
anchor
|
||||
? {
|
||||
left: anchor.centerX,
|
||||
top: anchor.topY - (24 * anchor.zoom + 10),
|
||||
transform: `translateX(-50%) translateY(-100%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
|
||||
transformOrigin: "bottom center",
|
||||
}
|
||||
: {
|
||||
left: "50%",
|
||||
top: 168,
|
||||
transform: `translateX(-50%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
|
||||
transformOrigin: "top center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<VideoToolbarActionButton
|
||||
active={activeMode === "clip"}
|
||||
icon={<Scissors className="h-[18px] w-[18px]" strokeWidth={2.1} />}
|
||||
label="剪辑"
|
||||
onClick={onToggleClip}
|
||||
/>
|
||||
<VideoToolbarActionButton
|
||||
disabled
|
||||
icon={<SceneToolIcon kind="hd" />}
|
||||
label="高清"
|
||||
onClick={onHd}
|
||||
/>
|
||||
<VideoToolbarActionButton
|
||||
active={activeMode === "analysis"}
|
||||
icon={<VideoAnalysisGlyph />}
|
||||
label="解析"
|
||||
onClick={onAnalyze}
|
||||
/>
|
||||
<div className="h-9 w-px bg-white/8" />
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-white/84 transition hover:bg-white/8 hover:text-white"
|
||||
onClick={onDownload}
|
||||
type="button"
|
||||
>
|
||||
<Download className="h-[18px] w-[18px]" strokeWidth={2.1} />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-[24px] leading-none text-white/84 transition hover:bg-white/8 hover:text-white"
|
||||
onClick={onPreview}
|
||||
type="button"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoToolbarActionButton({
|
||||
active = false,
|
||||
disabled = false,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-2 rounded-[10px] px-3 transition",
|
||||
disabled
|
||||
? "cursor-not-allowed text-white/28"
|
||||
: active
|
||||
? "bg-white/10 text-white ring-1 ring-[#76b3ff]"
|
||||
: "text-white/84 hover:bg-white/8 hover:text-white"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-5 w-5 items-center justify-center">{icon}</span>
|
||||
<span className="whitespace-nowrap text-[15px]">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoAnalysisGlyph() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="h-[18px] w-[18px]" fill="none" viewBox="0 0 20 20">
|
||||
<rect height="16" rx="3" stroke="currentColor" strokeWidth="1.8" width="12" x="4" y="2" />
|
||||
<path d="M8 5.5h4M8 9h4M8 12.5h4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoClipEditorBar({
|
||||
anchor,
|
||||
clipRange,
|
||||
durationSeconds,
|
||||
onApply,
|
||||
onChange,
|
||||
onReset,
|
||||
previewUrl,
|
||||
title,
|
||||
}: {
|
||||
anchor?: PresetControlAnchor;
|
||||
clipRange: VideoClipRange;
|
||||
durationSeconds: number;
|
||||
onApply: () => void;
|
||||
onChange: (range: VideoClipRange) => void;
|
||||
onReset: () => void;
|
||||
previewUrl: string;
|
||||
title: string;
|
||||
}) {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dragHandle, setDragHandle] = useState<"start" | "end" | null>(null);
|
||||
const frames = useMemo(
|
||||
() => Array.from({ length: VIDEO_CLIP_FRAME_COUNT }).map((_, index) => ({ id: `${title}-${index}`, url: previewUrl })),
|
||||
[previewUrl, title]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragHandle) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = trackRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0) return;
|
||||
const ratio = Math.max(0, Math.min((event.clientX - rect.left) / rect.width, 1));
|
||||
if (dragHandle === "start") {
|
||||
onChange({
|
||||
end: clipRange.end,
|
||||
start: Math.min(ratio, clipRange.end - 0.04),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
end: Math.max(ratio, clipRange.start + 0.04),
|
||||
start: clipRange.start,
|
||||
});
|
||||
};
|
||||
const handlePointerUp = () => setDragHandle(null);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [clipRange.end, clipRange.start, dragHandle, onChange]);
|
||||
|
||||
const selectionWidth = Math.max(clipRange.end - clipRange.start, 0.04);
|
||||
const durationLabel = formatClipDurationLabel(durationSeconds * selectionWidth);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute overflow-visible"
|
||||
style={
|
||||
anchor
|
||||
? {
|
||||
left: anchor.centerX,
|
||||
top: anchor.bottomY + 12,
|
||||
transform: "translateX(-50%)",
|
||||
width: 920,
|
||||
}
|
||||
: {
|
||||
bottom: 54,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 920,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="relative flex h-[68px] items-center gap-4 rounded-[16px] border border-white/8 bg-[#262626]/98 px-5 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="relative h-[42px] overflow-hidden rounded-[12px] border border-white/8 bg-black/24"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
ref={trackRef}
|
||||
>
|
||||
<div className="absolute inset-0 flex">
|
||||
{frames.map((frame, index) => (
|
||||
<div className="relative h-full flex-1 border-r border-white/6 last:border-r-0" key={frame.id}>
|
||||
{frame.url ? <img alt="" className="h-full w-full object-cover opacity-82" draggable={false} src={frame.url} /> : null}
|
||||
<span className="absolute inset-0 bg-black/18" />
|
||||
{index !== frames.length - 1 ? <span className="absolute right-0 top-0 h-full w-px bg-white/8" /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-[3px] top-[3px] rounded-[10px] border border-white/88 bg-white/12 shadow-[0_0_0_1px_rgba(87,168,255,0.75)]"
|
||||
style={{ left: `${clipRange.start * 100}%`, width: `${selectionWidth * 100}%` }}
|
||||
>
|
||||
<button
|
||||
className="absolute left-[-8px] top-1/2 h-7 w-4 -translate-y-1/2 rounded-full bg-white shadow-[0_6px_18px_rgba(0,0,0,0.34)]"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
setDragHandle("start");
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
className="absolute right-[-8px] top-1/2 h-7 w-4 -translate-y-1/2 rounded-full bg-white shadow-[0_6px_18px_rgba(0,0,0,0.34)]"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
setDragHandle("end");
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<span className="absolute left-1/2 top-1/2 flex h-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-[10px] bg-black/72 px-3 text-[14px] font-semibold text-white">
|
||||
{durationLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[10px] text-white/44 transition hover:bg-white/8 hover:text-white/76"
|
||||
onClick={() => toast.info("视频音轨编辑后端待接入。")}
|
||||
type="button"
|
||||
>
|
||||
<Music2 className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[10px] text-white/74 transition hover:bg-white/8 hover:text-white"
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-[18px] w-[18px]" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-black shadow-[0_10px_24px_rgba(255,255,255,0.18)] transition hover:bg-white/90"
|
||||
onClick={onApply}
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-[20px] w-[20px]" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoAnalysisFloatingPanel({
|
||||
anchor,
|
||||
analysisState,
|
||||
node,
|
||||
onClose,
|
||||
}: {
|
||||
anchor?: PresetControlAnchor;
|
||||
analysisState: VideoAnalysisState;
|
||||
node: CloneNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || "";
|
||||
const rows = analysisState.rows;
|
||||
const floatingLeft =
|
||||
anchor
|
||||
? Math.max(
|
||||
24,
|
||||
Math.min(
|
||||
typeof window !== "undefined" ? window.innerWidth - 884 : anchor.centerX + 380,
|
||||
anchor.centerX + 380
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute overflow-hidden rounded-[14px] border border-white/8 bg-[#202020]/98 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)] backdrop-blur-[12px]"
|
||||
style={
|
||||
anchor
|
||||
? {
|
||||
height: 378,
|
||||
left: floatingLeft,
|
||||
top: anchor.topY + 18,
|
||||
width: 860,
|
||||
}
|
||||
: {
|
||||
height: 378,
|
||||
left: "50%",
|
||||
top: 240,
|
||||
transform: "translateX(-50%)",
|
||||
width: 860,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between border-b border-white/8 px-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-semibold text-white/86">
|
||||
<VideoAnalysisGlyph />
|
||||
视频故事
|
||||
</div>
|
||||
<button className="rounded-[8px] p-1 text-white/38 transition hover:bg-white/8 hover:text-white/78" onClick={onClose} type="button">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-[13px] text-white/56">{analysisState.summary}</div>
|
||||
{analysisState.error ? (
|
||||
<div className="mx-4 mb-3 rounded-[10px] border border-[#ff8b8b]/14 bg-[#ff8b8b]/8 px-3 py-2 text-[12px] text-[#ffb3b3]">
|
||||
{analysisState.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="h-[calc(100%-84px)] overflow-auto px-3 pb-3">
|
||||
<div className="min-w-[860px] overflow-hidden rounded-[10px] border border-white/8 bg-[#171717]">
|
||||
<div className="grid grid-cols-[1.2fr_1fr_0.9fr_1.3fr_1.3fr_108px] border-b border-white/8 bg-white/5 text-[12px] text-white/38">
|
||||
{["镜头描述", "背景音乐", "人声/音效", "图像生成提示词", "视频运动提示词", "关键帧"].map((item) => (
|
||||
<div className="border-r border-white/8 px-3 py-2 last:border-r-0" key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
{analysisState.status === "loading" && rows.length === 0 ? (
|
||||
Array.from({ length: 2 }).map((_, index) => (
|
||||
<div className="grid grid-cols-[1.2fr_1fr_0.9fr_1.3fr_1.3fr_108px] border-b border-white/6 last:border-b-0" key={`video-analysis-loading-${index}`}>
|
||||
{Array.from({ length: 6 }).map((_, cell) => (
|
||||
<div className="border-r border-white/6 px-3 py-3 last:border-r-0" key={`video-analysis-loading-${index}-${cell}`}>
|
||||
<div className="h-3 rounded-full bg-white/8" />
|
||||
<div className="mt-2 h-3 w-4/5 rounded-full bg-white/6" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<div className="grid grid-cols-[1.2fr_1fr_0.9fr_1.3fr_1.3fr_108px] border-b border-white/6 last:border-b-0" key={row.id}>
|
||||
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/78">{row.sceneDescription}</div>
|
||||
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.backgroundMusic}</div>
|
||||
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.voiceAndSfx}</div>
|
||||
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.imagePrompt}</div>
|
||||
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.motionPrompt}</div>
|
||||
<div className="flex items-center justify-center px-3 py-3">
|
||||
{row.keyframeUrl || previewUrl ? (
|
||||
<img alt="" className="h-[54px] w-[84px] rounded-[8px] object-cover" src={row.keyframeUrl || previewUrl} />
|
||||
) : (
|
||||
<div className="flex h-[54px] w-[84px] items-center justify-center rounded-[8px] bg-white/6 text-[11px] text-white/34">
|
||||
无预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-[220px] items-center justify-center text-[13px] text-white/34">暂无解析结果</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetWorkflowControls({
|
||||
anchor,
|
||||
functionConfig,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user