Browse Source

代码更新

songxh-popiart-node-canvas
宋向航 2 months ago
parent
commit
a92bcd0caf
  1. 17
      dev-server.codex.err.log
  2. 84
      dev-server.codex.log
  3. 550
      src/components/nodes/GenerateVideoNode.tsx
  4. 294
      src/components/nodes/GenerationPromptInput.tsx
  5. 98
      src/components/nodes/LLMGenerateNode.tsx
  6. 18
      src/components/nodes/PromptNode.tsx
  7. 69
      src/components/nodes/TextNodePlaceholder.tsx
  8. 128
      src/components/nodes/VideoInputNode.tsx
  9. 1
      src/store/utils/nodeDefaults.ts
  10. 1
      src/types/nodes.ts

17
dev-server.codex.err.log

@ -0,0 +1,17 @@
[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D`
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
Several Konva instances detected. It is not recommended to use multiple Konva instances in the same environment.
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
Several Konva instances detected. It is not recommended to use multiple Konva instances in the same environment.
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
[Models] fal: fal.ai API error: 401
⚠ Fast Refresh had to perform a full reload due to a runtime error.
Several Konva instances detected. It is not recommended to use multiple Konva instances in the same environment.

84
dev-server.codex.log

@ -0,0 +1,84 @@
Attention: Next.js now collects completely anonymous telemetry regarding usage.
This information is used to shape Next.js' roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://nextjs.org/telemetry
Creating turbopack project { dir: 'C:\\Users\\EDY\\Desktop\\node-banana-master', testMode: true }
> Ready on http://localhost:3000
> Server timeout set to 10 minutes
GET / 200 in 1942ms (compile: 1611ms, render: 332ms)
GET / 200 in 76ms (compile: 10ms, render: 66ms)
GET /api/env-status 200 in 75ms (compile: 62ms, render: 13ms)
GET /api/env-status 200 in 6ms (compile: 2ms, render: 4ms)
GET / 200 in 72ms (compile: 5ms, render: 67ms)
GET /api/env-status 200 in 6ms (compile: 2ms, render: 4ms)
GET /api/env-status 200 in 11ms (compile: 7ms, render: 4ms)
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 715ms (compile: 44ms, render: 671ms)
✓ Compiled in 195ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 653ms (compile: 3ms, render: 650ms)
✓ Compiled in 174ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 467ms (compile: 5ms, render: 463ms)
✓ Compiled in 226ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 577ms (compile: 2ms, render: 575ms)
✓ Compiled in 166ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 405ms (compile: 5ms, render: 400ms)
✓ Compiled in 187ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 404ms (compile: 2ms, render: 401ms)
✓ Compiled in 185ms
✓ Compiled in 198ms
✓ Compiled in 208ms
✓ Compiled in 199ms
✓ Compiled in 187ms
✓ Compiled in 192ms
✓ Compiled in 180ms
✓ Compiled in 183ms
✓ Compiled in 161ms
✓ Compiled in 190ms
✓ Compiled in 171ms
GET / 200 in 402ms (compile: 112ms, render: 289ms)
GET / 200 in 57ms (compile: 6ms, render: 51ms)
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 501ms (compile: 3ms, render: 498ms)
Deleted old log file: session-exec-2026-04-29T08-04-54-vpqtdk.json
POST /api/logs 200 in 53ms (compile: 31ms, render: 22ms)
Deleted old log file: session-exec-2026-04-29T08-11-48-ddisah.json
POST /api/logs 200 in 9ms (compile: 1581µs, render: 8ms)
✓ Compiled in 217ms
✓ Compiled in 182ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 832ms (compile: 4ms, render: 829ms)
✓ Compiled in 198ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 387ms (compile: 2ms, render: 385ms)
✓ Compiled in 239ms
✓ Compiled in 162ms
✓ Compiled in 179ms
✓ Compiled in 179ms
✓ Compiled in 194ms
✓ Compiled in 190ms
GET / 200 in 376ms (compile: 121ms, render: 255ms)
GET / 200 in 49ms (compile: 4ms, render: 45ms)
GET / 200 in 49ms (compile: 5ms, render: 44ms)
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 917ms (compile: 3ms, render: 914ms)
Deleted old log file: session-exec-2026-04-29T08-11-49-4cn1pc.json
POST /api/logs 200 in 12ms (compile: 3ms, render: 9ms)
Deleted old log file: session-exec-2026-04-29T08-11-50-7zc8dw.json
POST /api/logs 200 in 10ms (compile: 1839µs, render: 8ms)
✓ Compiled in 173ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 566ms (compile: 3ms, render: 564ms)
✓ Compiled in 160ms
✓ Compiled in 178ms
GET /api/models?provider=fal&capabilities=text-to-video,image-to-video,audio-to-video 500 in 520ms (compile: 1765µs, render: 519ms)
GET /api/models?provider=gemini&capabilities=text-to-video,image-to-video,audio-to-video 200 in 10ms (compile: 4ms, render: 6ms)
✓ Compiled in 165ms
✓ Compiled in 168ms
GET /api/models?provider=gemini&capabilities=text-to-video,image-to-video,audio-to-video 200 in 8ms (compile: 3ms, render: 5ms)
✓ Compiled in 170ms
✓ Compiled in 187ms
GET / 200 in 455ms (compile: 153ms, render: 302ms)
✓ Compiled in 172ms
✓ Compiled in 173ms
✓ Compiled in 179ms
✓ Compiled in 173ms
✓ Compiled in 170ms
✓ Compiled in 162ms
✓ Compiled in 215ms
✓ Compiled in 177ms
✓ Compiled in 162ms

550
src/components/nodes/GenerateVideoNode.tsx

@ -16,7 +16,7 @@ import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { GenerationPromptInput } from "./GenerationPromptInput";
import { GenerationPromptInput, LIBTV_VIDEO_PROMPT_PLACEHOLDER } from "./GenerationPromptInput";
import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry";
import { downloadMedia } from "@/utils/downloadMedia";
@ -46,6 +46,174 @@ function buildVeoInputSchema(modelId: string): ModelInputDef[] | undefined {
return inputs;
}
const VIDEO_REFERENCE_ACCEPTED_FORMATS = "image/png,image/jpeg,image/webp,video/mp4,video/webm,video/quicktime";
const MAX_REFERENCE_IMAGE_SIZE = 10 * 1024 * 1024;
const MAX_REFERENCE_VIDEO_SIZE = 200 * 1024 * 1024;
const BUILT_IN_VIDEO_MODELS: ProviderModel[] = [
{
id: "veo-3.1/text-to-video",
name: "Veo 3.1",
description: "Native Gemini Veo text-to-video generation.",
provider: "gemini",
capabilities: ["text-to-video"],
pricing: { type: "per-second", amount: 0.4, currency: "USD" },
},
{
id: "veo-3.1/image-to-video",
name: "Veo 3.1 I2V",
description: "Native Gemini Veo image-to-video generation.",
provider: "gemini",
capabilities: ["image-to-video"],
pricing: { type: "per-second", amount: 0.4, currency: "USD" },
},
{
id: "veo-3.1-fast/text-to-video",
name: "Veo 3.1 Fast",
description: "Fast native Gemini Veo text-to-video generation.",
provider: "gemini",
capabilities: ["text-to-video"],
pricing: { type: "per-second", amount: 0.15, currency: "USD" },
},
{
id: "veo-3.1-fast/image-to-video",
name: "Veo 3.1 Fast I2V",
description: "Fast native Gemini Veo image-to-video generation.",
provider: "gemini",
capabilities: ["image-to-video"],
pricing: { type: "per-second", amount: 0.15, currency: "USD" },
},
{
id: "bytedance/seedance-2/text-to-video",
name: "Seedance 2.0",
description: "Seedance 2.0 text-to-video generation via Kie.ai.",
provider: "kie",
capabilities: ["text-to-video"],
},
{
id: "bytedance/seedance-2/image-to-video",
name: "Seedance 2.0 I2V",
description: "Seedance 2.0 image-to-video generation via Kie.ai.",
provider: "kie",
capabilities: ["image-to-video"],
},
{
id: "kling-3.0/video/text-to-video",
name: "Kling 3.0",
description: "Kling 3.0 text-to-video generation via Kie.ai.",
provider: "kie",
capabilities: ["text-to-video"],
},
{
id: "wan/2-7-text-to-video",
name: "Wan 2.7",
description: "Wan 2.7 text-to-video generation via Kie.ai.",
provider: "kie",
capabilities: ["text-to-video"],
},
];
function isVideoReference(media: string | null | undefined): boolean {
if (!media) return false;
return media.startsWith("data:video/") || media.startsWith("blob:") || /\.(mp4|webm|mov)(\?|#|$)/i.test(media);
}
function formatVideoResolution(value: string): string {
return value.replace(/p$/i, "P").toUpperCase();
}
function getVideoSettingsConfig(modelId: string | undefined) {
const id = (modelId || "").toLowerCase();
if (id.startsWith("veo-3.1")) {
return {
aspectRatios: ["16:9", "9:16"],
resolutions: ["720p", "1080p", "4k"],
durations: ["4", "6", "8"],
};
}
if (id.includes("seedance")) {
return {
aspectRatios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
resolutions: id.includes("fast") ? ["480p", "720p"] : ["480p", "720p", "1080p"],
durations: ["5", "10", "15"],
};
}
if (id.includes("kling-3.0")) {
return {
aspectRatios: ["16:9", "9:16", "1:1"],
resolutions: [],
durations: ["3", "5", "10", "15"],
};
}
if (id.includes("kling")) {
return {
aspectRatios: ["16:9", "9:16", "1:1"],
resolutions: [],
durations: ["5", "10"],
};
}
if (id.includes("wan/2-7")) {
return {
aspectRatios: id.includes("text-to-video") ? ["16:9", "9:16", "1:1", "4:3", "3:4"] : ["16:9", "9:16", "1:1"],
resolutions: ["720p", "1080p"],
durations: ["5", "10", "15"],
};
}
if (id.includes("wan")) {
return {
aspectRatios: ["16:9", "9:16", "1:1"],
resolutions: ["720p", "1080p"],
durations: ["5", "10", "15"],
};
}
if (id.includes("grok")) {
return {
aspectRatios: ["2:3", "3:2", "1:1", "16:9", "9:16"],
resolutions: [],
durations: ["6", "10"],
};
}
return {
aspectRatios: ["16:9", "9:16", "1:1"],
resolutions: ["720p", "1080p"],
durations: ["5", "10"],
};
}
function getVideoParameterKeys(modelId: string | undefined) {
const id = (modelId || "").toLowerCase();
if (id.startsWith("veo-3.1")) {
return { aspect: "aspectRatio", resolution: "resolution", duration: "durationSeconds" };
}
if (id.includes("wan/2-7/text-to-video")) {
return { aspect: "ratio", resolution: "resolution", duration: "duration" };
}
if (id.includes("wan/2-7/image-to-video")) {
return { aspect: undefined, resolution: "resolution", duration: "duration" };
}
return { aspect: "aspect_ratio", resolution: "resolution", duration: "duration" };
}
function coerceVideoDuration(modelId: string | undefined, key: string, value: string): string | number {
const id = (modelId || "").toLowerCase();
if (key === "durationSeconds" || id.includes("seedance") || id.includes("wan/2-7")) {
return Number(value);
}
return value;
}
type GenerateVideoNodeType = Node<GenerateVideoNodeData, "generateVideo">;
export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVideoNodeType>) {
@ -74,6 +242,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
const { inlineParametersEnabled } = useInlineParameters();
const shouldShowInlineParameters = inlineParametersEnabled && !!selected;
const showLabels = useShowHandleLabels(selected);
const inputImageUploadRef = useRef<HTMLInputElement>(null);
// Register browse callback for floating header button
useEffect(() => {
@ -81,7 +250,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
return () => { browseRegistry.unregister(id); };
}, [id]);
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
// Get enabled providers
const enabledProviders = useMemo(() => {
@ -161,6 +330,46 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
updateNodeData(id, { inputPrompt });
}, [id, updateNodeData]);
const handleInputImageUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const isImage = file.type.match(/^image\/(png|jpeg|webp)$/);
const isVideo = file.type.match(/^video\/(mp4|webm|quicktime)$/);
if (!isImage && !isVideo) {
alert("Unsupported format. Use PNG, JPG, WebP, MP4, WebM, or QuickTime.");
return;
}
const maxSize = isVideo ? MAX_REFERENCE_VIDEO_SIZE : MAX_REFERENCE_IMAGE_SIZE;
if (file.size > maxSize) {
alert(isVideo ? "Video too large. Maximum size is 200MB." : "Image too large. Maximum size is 10MB.");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
updateNodeData(id, {
inputImages: [base64],
inputImageRefs: undefined,
});
};
reader.readAsDataURL(file);
},
[id, updateNodeData]
);
const handleClearInputReference = useCallback(() => {
updateNodeData(id, {
inputImages: [],
inputImageRefs: undefined,
});
}, [id, updateNodeData]);
// Handle provider change
const handleProviderChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
@ -351,6 +560,124 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
), [currentProvider]);
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;
const inputReferencePreview = (nodeData.inputImages || []).find(Boolean) || null;
const inputReferenceIsVideo = isVideoReference(inputReferencePreview);
const inputReferenceVideoUrl = useVideoBlobUrl(inputReferenceIsVideo ? inputReferencePreview : null);
const availableVideoModels = useMemo(() => {
const modelsById = new Map<string, ProviderModel>();
BUILT_IN_VIDEO_MODELS.forEach((model) => {
modelsById.set(model.id, model);
});
externalModels.forEach((model) => {
modelsById.set(model.id, model);
});
if (nodeData.selectedModel?.modelId && !modelsById.has(nodeData.selectedModel.modelId)) {
modelsById.set(nodeData.selectedModel.modelId, {
id: nodeData.selectedModel.modelId,
name: nodeData.selectedModel.displayName || nodeData.selectedModel.modelId,
description: null,
provider: nodeData.selectedModel.provider,
capabilities: (nodeData.selectedModel.capabilities as ModelCapability[] | undefined) || ["text-to-video"],
pricing: nodeData.selectedModel.pricing
? {
type: nodeData.selectedModel.pricing.type,
amount: nodeData.selectedModel.pricing.amount,
currency: "USD",
}
: undefined,
});
}
return Array.from(modelsById.values());
}, [externalModels, nodeData.selectedModel]);
const libtvVideoModelValue = nodeData.selectedModel?.modelId || "";
const libtvVideoModelLabel = nodeData.selectedModel?.displayName || "Seedance 2.0 VIP";
const libtvVideoModelOptions = useMemo(() => {
return availableVideoModels.map((model) => ({
value: model.id,
label: model.name,
}));
}, [availableVideoModels]);
const videoAspectRatio = String(
nodeData.parameters?.aspectRatio ??
nodeData.parameters?.aspect_ratio ??
nodeData.parameters?.ratio ??
"16:9"
);
const rawVideoResolution = String(
nodeData.parameters?.resolution ??
nodeData.parameters?.quality ??
nodeData.parameters?.mode ??
"720p"
);
const videoResolution = formatVideoResolution(rawVideoResolution);
const rawVideoDuration = nodeData.parameters?.durationSeconds ?? nodeData.parameters?.duration ?? nodeData.parameters?.duration_seconds ?? 5;
const videoDuration = `${String(rawVideoDuration).replace(/s$/i, "")}s`;
const libtvVideoMetaValue = `${videoAspectRatio}|${rawVideoResolution.toLowerCase()}|${String(rawVideoDuration).replace(/s$/i, "")}`;
const libtvVideoMetaOptions = useMemo(() => {
const config = getVideoSettingsConfig(nodeData.selectedModel?.modelId);
const resolutions = config.resolutions.length ? config.resolutions : [""];
return config.aspectRatios.flatMap((aspectRatio) =>
resolutions.flatMap((resolution) =>
config.durations.map((duration) => ({
value: `${aspectRatio}|${resolution}|${duration}`,
label: [
aspectRatio,
resolution ? formatVideoResolution(resolution) : null,
`${duration}s`,
].filter(Boolean).join(" 路 "),
}))
)
);
}, [nodeData.selectedModel?.modelId]);
const libtvVideoMetaLabel = `${videoAspectRatio} · ${videoResolution} · ${videoDuration}`;
const handleLibtvVideoModelChange = useCallback((value: string) => {
const model = availableVideoModels.find((item) => item.id === value);
if (!model) return;
updateNodeData(id, {
selectedModel: {
provider: model.provider || currentProvider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
pricing: model.pricing
? { type: model.pricing.type, amount: model.pricing.amount }
: undefined,
},
parameters: {},
inputSchema: buildVeoInputSchema(model.id),
});
}, [availableVideoModels, currentProvider, id, updateNodeData]);
const handleLibtvVideoMetaChange = useCallback((value: string) => {
const [aspectRatio, resolution, duration] = value.split("|");
const parameterKeys = getVideoParameterKeys(nodeData.selectedModel?.modelId);
const parameters = { ...(nodeData.parameters || {}) };
if (parameterKeys.aspect && aspectRatio) {
parameters[parameterKeys.aspect] = aspectRatio;
}
if (parameterKeys.resolution) {
if (resolution) {
parameters[parameterKeys.resolution] = resolution;
} else {
delete parameters[parameterKeys.resolution];
}
}
if (parameterKeys.duration && duration) {
parameters[parameterKeys.duration] = coerceVideoDuration(nodeData.selectedModel?.modelId, parameterKeys.duration, duration);
}
updateNodeData(id, { parameters });
}, [id, nodeData.parameters, nodeData.selectedModel?.modelId, updateNodeData]);
// Track previous status to detect error transitions
const prevStatusRef = useRef(nodeData.status);
@ -420,43 +747,76 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
onChange={handleInlinePromptChange}
onRun={handleRegenerate}
isRunning={isRunning}
modelLabel={nodeData.selectedModel?.displayName || "Generate Video"}
providerLabel={currentProvider}
modelLabel={libtvVideoModelLabel}
modelOptions={libtvVideoModelOptions}
modelValue={libtvVideoModelValue}
onModelChange={handleLibtvVideoModelChange}
imageMetaLabel={libtvVideoMetaLabel}
imageMetaOptions={libtvVideoMetaOptions}
imageMetaValue={libtvVideoMetaValue}
onImageMetaChange={handleLibtvVideoMetaChange}
imageCountLabel="1个"
costLabel={nodeData.selectedModel?.pricing?.amount ? String(nodeData.selectedModel.pricing.amount) : "108/135"}
placeholder={LIBTV_VIDEO_PROMPT_PLACEHOLDER}
variant="libtvVideo"
/>
{/* Tab bar for primary/fallback settings */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.selectedModel?.displayName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{/* Primary tab: external provider parameters */}
{settingsTab === "primary" && nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
/>
)}
{/* Fallback tab: fallback model parameters */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
<div className="hidden" aria-hidden="true">
{/* Hidden legacy controls kept mounted for schema loading and fallback behavior. */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.selectedModel?.displayName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{settingsTab === "primary" && nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
/>
)}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</div>
</InlineParameterPanel>
) : undefined}
>
<input
ref={inputImageUploadRef}
type="file"
accept={VIDEO_REFERENCE_ACCEPTED_FORMATS}
onChange={handleInputImageUpload}
className="hidden"
/>
{selected && (
<button
type="button"
onClick={() => inputImageUploadRef.current?.click()}
className="nodrag nopan absolute left-1/2 -top-10 z-20 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-800/95 px-3 text-xs font-medium text-neutral-100 shadow-xl transition-colors hover:bg-neutral-700 hover:text-white"
aria-label="Upload reference media"
title="Upload reference media"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4v10M12 4l-4 4M12 4l4 4M5 15v3a2 2 0 002 2h10a2 2 0 002-2v-3" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{"\u4e0a\u4f20"}
</button>
)}
{/* Dynamic input handles based on model schema */}
{nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (
// Render handles from schema, sorted by type (images first, text second)
@ -800,8 +1160,76 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)}
</>
) : (
<div className="w-full h-full min-h-[112px] bg-neutral-900/40 flex flex-col items-center justify-center">
{nodeData.status === "loading" ? (
<div
className="group relative w-full h-full min-h-[112px] bg-neutral-900/40 flex flex-col items-center justify-center"
style={{ containerType: "size" }}
>
{inputReferencePreview ? (
<>
{inputReferenceIsVideo ? (
<video
src={inputReferenceVideoUrl ?? undefined}
controls
className="h-full w-full object-cover"
preload="metadata"
playsInline
/>
) : (
<img
src={inputReferencePreview}
alt="Uploaded reference"
className="h-full w-full object-cover"
draggable={false}
/>
)}
<span className="absolute bottom-2 left-2 rounded bg-black/60 px-2 py-1 text-[10px] font-medium text-neutral-100">
{inputReferenceIsVideo ? "\u89c6\u9891\u53c2\u8003" : "\u56fe\u7247\u53c2\u8003"}
</span>
{selected && (
<button
type="button"
onClick={handleClearInputReference}
aria-label="Remove reference"
title="Remove reference"
className="nodrag nopan absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded bg-black/60 text-white opacity-0 transition-all hover:bg-red-600/80 group-hover:opacity-100 focus:opacity-100 focus:ring-1 focus:ring-red-400"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{nodeData.status === "loading" && (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
<svg
className="h-5 w-5 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{nodeData.status === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-red-900/50 px-2 text-center">
<span className="text-xs font-medium text-white">Generation failed</span>
<span className="text-[10px] text-white/75">{nodeData.error || "See toast for details"}</span>
</div>
)}
<span className="sr-only">Run to generate</span>
</>
) : nodeData.status === "loading" ? (
<svg
className="w-4 h-4 animate-spin text-neutral-400"
fill="none"
@ -826,9 +1254,53 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
{nodeData.error || "Failed"}
</span>
) : (
<span className="text-neutral-500 text-[10px]">
Run to generate
</span>
<>
<div
className="flex items-center justify-center rounded-lg bg-neutral-600/70 text-neutral-950"
style={{ width: "clamp(42px, 13cqw, 74px)", height: "clamp(42px, 13cqw, 74px)" }}
>
<svg
className="ml-0.5 text-neutral-950"
style={{ width: "clamp(23px, 7cqw, 38px)", height: "clamp(23px, 7cqw, 38px)" }}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5.8v12.4c0 .8.9 1.3 1.6.9l9.2-6.2a1.1 1.1 0 000-1.8L9.6 4.9C8.9 4.5 8 5 8 5.8z" />
</svg>
</div>
<div
className="absolute left-[7%] top-[52%] -translate-y-1/2 text-left"
style={{ fontSize: "clamp(12px, 2.2cqw, 20px)" }}
>
<div className="mb-[clamp(10px,2.2cqw,22px)] text-neutral-400">{"\u5c1d\u8bd5:"}</div>
<div className="flex flex-col font-semibold text-neutral-100" style={{ gap: "clamp(10px, 2.2cqw, 22px)" }}>
<div className="flex items-center gap-2">
<svg
style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path d="M5 7l7-3 7 3v10l-7 3-7-3V7zM5 7l7 3 7-3M12 10v10" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
</svg>
{"\u9996\u5c3e\u5e27\u751f\u6210\u89c6\u9891"}
</div>
<div className="flex items-center gap-2">
<svg
style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path d="M12 4l1.6 4.4L18 10l-4.4 1.6L12 16l-1.6-4.4L6 10l4.4-1.6L12 4z" fill="currentColor" />
</svg>
{"\u9996\u5e27\u751f\u6210\u89c6\u9891"}
</div>
</div>
</div>
<span className="sr-only">Run to generate</span>
</>
)}
</div>
)}

294
src/components/nodes/GenerationPromptInput.tsx

@ -12,7 +12,7 @@ interface GenerationPromptInputProps {
modelLabel?: string;
providerLabel?: string;
placeholder?: string;
variant?: "default" | "libtvImage";
variant?: "default" | "libtvImage" | "libtvVideo" | "libtvText";
imageMetaLabel?: string;
imageCountLabel?: string;
costLabel?: string;
@ -36,6 +36,8 @@ const LABELS = {
marker: "\u6807\u8bb0",
focus: "\u805a\u7126",
camera: "\u6444\u50cf\u673a",
motion: "\u8fd0\u955c",
roleLibrary: "\u89d2\u8272\u5e93",
imageCount: "1\u5f20",
run: "\u53d1\u9001",
expand: "\u653e\u5927",
@ -47,6 +49,12 @@ const LABELS = {
export const LIBTV_IMAGE_PROMPT_PLACEHOLDER =
"\u63cf\u8ff0\u4f60\u60f3\u8981\u751f\u6210\u7684\u753b\u9762\u5185\u5bb9\uff0c\u6309/\u547c\u51fa\u6307\u4ee4\uff0c@\u5f15\u7528\u7d20\u6750";
export const LIBTV_VIDEO_PROMPT_PLACEHOLDER =
"\u63cf\u8ff0\u4f60\u60f3\u8981\u751f\u6210\u7684\u89c6\u9891\u5185\u5bb9\uff0c@\u5f15\u7528\u7d20\u6750";
export const LIBTV_TEXT_PROMPT_PLACEHOLDER =
"\u5199\u4e0b\u4f60\u60f3\u8bb2\u7684\u6545\u4e8b\u3001\u573a\u666f\u6216\u89d2\u8272\u8bbe\u5b9a\u3002\u4f8b\u5982\uff1a\u4e00\u4e2a\u6765\u81ea\u672a\u6765\u7684\u673a\u5668\u4eba\uff0c\u5728\u57ce\u5e02\u5c4b\u9876\u770b\u661f\u661f\u3002";
export interface GenerationPromptOption {
value: string;
label: string;
@ -78,7 +86,7 @@ function RunIcon({ isRunning }: { isRunning: boolean }) {
);
}
function FeatureIcon({ icon }: { icon: "cube" | "target" | "focus" }) {
function FeatureIcon({ icon }: { icon: "cube" | "target" | "focus" | "camera" | "shield" }) {
if (icon === "cube") {
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@ -96,6 +104,23 @@ function FeatureIcon({ icon }: { icon: "cube" | "target" | "focus" }) {
);
}
if (icon === "camera") {
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 8h3l1.5-2h5L16 8h3v10H5V8zM12 11a2.5 2.5 0 100 5 2.5 2.5 0 000-5z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
}
if (icon === "shield") {
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4l7 3v5c0 4-2.8 6.7-7 8-4.2-1.3-7-4-7-8V7l7-3z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M9.5 12.2l1.6 1.6 3.7-4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 4H5a1 1 0 00-1 1v3M16 4h3a1 1 0 011 1v3M8 20H5a1 1 0 01-1-1v-3M16 20h3a1 1 0 001-1v-3M9 12h6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
@ -112,6 +137,7 @@ function SelectLikeButton({
ariaLabel,
title,
onUnavailable,
showChevron = true,
}: {
children: ReactNode;
options?: GenerationPromptOption[];
@ -121,6 +147,7 @@ function SelectLikeButton({
ariaLabel: string;
title?: string;
onUnavailable: () => void;
showChevron?: boolean;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@ -160,7 +187,7 @@ function SelectLikeButton({
<div ref={rootRef} className="relative shrink-0">
<button type="button" onClick={handleButtonClick} className={className} aria-label={ariaLabel} title={title}>
{children}
<DownIcon />
{showChevron && <DownIcon />}
</button>
{open && hasOptions && (
<div className="absolute bottom-full left-0 z-[10000] mb-2 max-h-56 min-w-[180px] overflow-auto rounded-md border border-neutral-700 bg-neutral-950 py-1 shadow-2xl">
@ -303,6 +330,72 @@ export function GenerationPromptInput({
</div>
);
const renderVideoTabs = () => (
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-3">
{[
{ label: "\u6587\u751f\u89c6\u9891", active: true },
{ label: "\u5168\u80fd\u53c2\u8003" },
{ label: "\u56fe\u751f\u89c6\u9891" },
{ label: "\u9996\u5c3e\u5e27" },
{ label: "\u56fe\u7247\u53c2\u8003" },
].map((tab) => (
<button
key={tab.label}
type="button"
onClick={() => {
if (!tab.active) notifyUnavailable(tab.label);
}}
className={`nodrag nopan h-7 rounded-md border px-2.5 text-xs transition-colors ${
tab.active
? "border-neutral-400 bg-neutral-700/60 text-white"
: "border-neutral-700 bg-neutral-800/40 text-neutral-500 hover:border-neutral-600 hover:text-neutral-300"
}`}
aria-pressed={tab.active}
>
{tab.label}
</button>
))}
</div>
);
const renderVideoActions = (inDialog = false) => (
<div className="relative px-3 pt-2">
<div className="flex items-center gap-2">
{[
{ label: LABELS.marker, icon: "target" as const },
{ label: LABELS.motion, icon: "camera" as const },
{ label: LABELS.roleLibrary, icon: "shield" as const },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => notifyUnavailable(item.label)}
className="nodrag nopan flex h-11 w-12 flex-col items-center justify-center gap-1 rounded-md border border-neutral-600/80 bg-neutral-800/80 text-[10px] text-neutral-400 transition-colors hover:border-neutral-500 hover:text-neutral-200"
>
<FeatureIcon icon={item.icon} />
<span>{item.label}</span>
</button>
))}
</div>
<button
type="button"
onClick={() => setExpanded(!inDialog)}
className="nodrag nopan absolute right-3 top-2 flex h-7 w-7 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-700/80 hover:text-neutral-100"
aria-label={inDialog ? "Collapse prompt panel" : "Expand prompt panel"}
title={inDialog ? LABELS.collapse : LABELS.expand}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{inDialog ? (
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
) : (
<path d="M8 4H4v4M16 4h4v4M4 16v4h4M20 16v4h-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
</button>
</div>
);
const renderToolbar = () => (
<div className="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-700/70 px-3 py-2">
<div className="flex min-w-0 items-center gap-4">
@ -378,6 +471,126 @@ export function GenerationPromptInput({
</div>
);
const renderVideoToolbar = () => (
<div className="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-700/70 px-3 py-2">
<div className="flex min-w-0 items-center gap-4">
<SelectLikeButton
className="nodrag nopan flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-neutral-100"
ariaLabel="Select video model"
title={modelLabel}
options={modelOptions}
value={modelValue}
onChange={onModelChange}
onUnavailable={() => notifyUnavailable(modelLabel)}
>
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 16l4-8 4 8 4-8 4 8M5.5 13h5M13.5 13h5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="truncate">{modelLabel}</span>
</SelectLikeButton>
<SelectLikeButton
className="nodrag nopan flex items-center gap-1.5 text-[11px] text-neutral-200"
ariaLabel="Select video settings"
title={imageMetaLabel}
options={imageMetaOptions}
value={imageMetaValue}
onChange={onImageMetaChange}
onUnavailable={() => notifyUnavailable(imageMetaLabel)}
>
<span className="h-2.5 w-4 rounded-sm border border-neutral-400" />
<span>{imageMetaLabel}</span>
<svg className="h-3.5 w-3.5 text-neutral-400" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 10v4h3l4 4V6l-4 4H5zM16 9c.8.8 1.2 1.8 1.2 3s-.4 2.2-1.2 3" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</SelectLikeButton>
</div>
<div className="flex shrink-0 items-center gap-3">
<button type="button" onClick={() => notifyUnavailable(LABELS.translate)} className="nodrag nopan text-neutral-200 transition-colors hover:text-white" aria-label="Translate">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 5h9M8.5 5c-.4 4-1.9 7-4.5 9M6 9c1.2 2 3 3.7 5.5 5M14 19l3-7 3 7M15 17h4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<SelectLikeButton
className="nodrag nopan flex items-center text-neutral-200 transition-colors hover:text-white"
ariaLabel="Select video settings"
title={imageMetaLabel}
options={imageMetaOptions}
value={imageMetaValue}
onChange={onImageMetaChange}
onUnavailable={() => notifyUnavailable(LABELS.settings)}
showChevron={false}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 8h14M8 8v8M5 16h14M16 8v8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
</SelectLikeButton>
<span className="nodrag nopan flex items-center gap-1 text-[11px] text-neutral-200">
{imageCountLabel}
<DownIcon />
</span>
<span className="flex items-center gap-1 text-[10px] text-neutral-500">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2L4 14h7l-1 8 10-13h-7l0-7z" />
</svg>
{costLabel}
</span>
<button
type="button"
onClick={handleRun}
disabled={isRunning}
className="nodrag nopan flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-neutral-300 text-neutral-950 transition-colors hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
aria-label={isRunning ? "Running" : "Run generation"}
title={isRunning ? "Running" : "Run generation"}
>
<RunIcon isRunning={isRunning} />
</button>
</div>
</div>
);
const renderTextToolbar = () => (
<div className="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-700/70 px-3 py-2">
<SelectLikeButton
className="nodrag nopan flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-neutral-100"
ariaLabel="Select text model"
title={modelLabel}
options={modelOptions}
value={modelValue}
onChange={onModelChange}
onUnavailable={() => notifyUnavailable(modelLabel)}
>
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4c2.3 0 4 1.3 4 3.2 0 1.4-.9 2.4-2.1 2.9 1.9.4 3.1 1.7 3.1 3.5 0 2.1-1.9 3.8-4.6 3.8H8V4h4zM10.2 9.3H12c1 0 1.7-.6 1.7-1.5S13 6.3 12 6.3h-1.8v3zM10.2 15.1h2.1c1.2 0 2-.7 2-1.8s-.8-1.8-2-1.8h-2.1v3.6z" fill="currentColor" />
</svg>
<span className="truncate">{modelLabel}</span>
</SelectLikeButton>
<div className="flex shrink-0 items-center gap-3">
<button type="button" onClick={() => notifyUnavailable(LABELS.translate)} className="nodrag nopan text-neutral-200 transition-colors hover:text-white" aria-label="Translate">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 5h9M8.5 5c-.4 4-1.9 7-4.5 9M6 9c1.2 2 3 3.7 5.5 5M14 19l3-7 3 7M15 17h4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<span className="flex items-center gap-1 text-[10px] text-neutral-500">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2L4 14h7l-1 8 10-13h-7l0-7z" />
</svg>
{costLabel}
</span>
<button
type="button"
onClick={handleRun}
disabled={isRunning}
className="nodrag nopan flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-neutral-300 text-neutral-950 transition-colors hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
aria-label={isRunning ? "Running" : "Run generation"}
title={isRunning ? "Running" : "Run generation"}
>
<RunIcon isRunning={isRunning} />
</button>
</div>
</div>
);
const renderLibtvTextarea = (expandedMode = false) => (
<textarea
value={localValue}
@ -397,6 +610,25 @@ export function GenerationPromptInput({
/>
);
const renderTextTextarea = (expandedMode = false) => (
<textarea
value={localValue}
onChange={(event) => setLocalValue(event.target.value)}
onFocus={() => setIsEditing(true)}
onBlur={handleBlur}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleRun();
}
}}
placeholder={placeholder}
className={`nodrag nopan nowheel block w-full resize-none bg-transparent px-3 py-4 text-xs leading-relaxed text-neutral-100 outline-none placeholder:text-neutral-400 ${
expandedMode ? "min-h-0 flex-1" : "min-h-[92px]"
}`}
/>
);
if (variant === "libtvImage") {
return (
<>
@ -425,6 +657,62 @@ export function GenerationPromptInput({
);
}
if (variant === "libtvVideo") {
return (
<>
<div className="nodrag nopan nowheel overflow-visible rounded-lg bg-[#242424]">
{renderVideoTabs()}
{renderVideoActions(false)}
{renderLibtvTextarea(false)}
{renderVideoToolbar()}
</div>
{expanded && typeof document !== "undefined" && createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/70 p-6"
onPointerDown={(event) => {
if (event.target === event.currentTarget) setExpanded(false);
}}
>
<div className="nodrag nopan nowheel flex h-[min(78vh,720px)] w-[min(920px,calc(100vw-48px))] flex-col overflow-visible rounded-xl border border-neutral-700 bg-[#242424] shadow-2xl">
{renderVideoTabs()}
{renderVideoActions(true)}
{renderLibtvTextarea(true)}
{renderVideoToolbar()}
</div>
</div>,
document.body
)}
</>
);
}
if (variant === "libtvText") {
return (
<>
<div className="nodrag nopan nowheel overflow-visible rounded-lg bg-[#242424]">
{renderTextTextarea(false)}
{renderTextToolbar()}
</div>
{expanded && typeof document !== "undefined" && createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/70 p-6"
onPointerDown={(event) => {
if (event.target === event.currentTarget) setExpanded(false);
}}
>
<div className="nodrag nopan nowheel flex h-[min(70vh,640px)] w-[min(820px,calc(100vw-48px))] flex-col overflow-visible rounded-xl border border-neutral-700 bg-[#242424] shadow-2xl">
{renderTextTextarea(true)}
{renderTextToolbar()}
</div>
</div>,
document.body
)}
</>
);
}
return (
<div className="nodrag nopan nowheel overflow-hidden rounded-lg bg-[#242424]">
<textarea

98
src/components/nodes/LLMGenerateNode.tsx

@ -1,14 +1,15 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { LLMGenerateNodeData, LLMProvider, LLMModelType } from "@/types";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { GenerationPromptInput } from "./GenerationPromptInput";
import { GenerationPromptInput, LIBTV_TEXT_PROMPT_PLACEHOLDER } from "./GenerationPromptInput";
import { SettingsTabBar } from "./SettingsTabBar";
import { TextNodePlaceholder } from "./TextNodePlaceholder";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
@ -131,6 +132,33 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
const provider = nodeData.provider || "google";
const availableModels = LLM_MODELS[provider] || LLM_MODELS.google;
const currentModel = nodeData.model || availableModels[0].value;
const currentModelLabel = availableModels.find((model) => model.value === currentModel)?.label || currentModel;
const libtvTextModelValue = `${provider}|${currentModel}`;
const libtvTextModelOptions = useMemo(() => (
LLM_PROVIDERS.flatMap((providerOption) =>
LLM_MODELS[providerOption.value].map((model) => ({
value: `${providerOption.value}|${model.value}`,
label: model.label,
}))
)
), []);
const handleLibtvTextModelChange = useCallback((value: string) => {
const [nextProviderRaw, nextModelRaw] = value.split("|");
const nextProvider = nextProviderRaw as LLMProvider;
const nextModel = nextModelRaw as LLMModelType;
if (!LLM_MODELS[nextProvider]?.some((model) => model.value === nextModel)) return;
const updates: Partial<LLMGenerateNodeData> = {
provider: nextProvider,
model: nextModel,
};
if (nextProvider === "anthropic" && (nodeData.temperature ?? 0.7) > 1) {
updates.temperature = 1;
}
updateNodeData(id, updates);
}, [id, nodeData.temperature, updateNodeData]);
return (
<BaseNode
@ -152,23 +180,28 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
onChange={handleInlinePromptChange}
onRun={handleRegenerate}
isRunning={isRunning}
modelLabel={nodeData.model || "LLM Generate"}
providerLabel={provider}
modelLabel={currentModelLabel}
modelOptions={libtvTextModelOptions}
modelValue={libtvTextModelValue}
onModelChange={handleLibtvTextModelChange}
costLabel="6"
placeholder={LIBTV_TEXT_PROMPT_PLACEHOLDER}
variant="libtvText"
/>
{/* Tab bar for primary/fallback settings */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.model || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{/* Primary tab: LLM-specific controls */}
{settingsTab === "primary" && (
<div className="space-y-1.5 max-w-[280px]">
<div className="hidden" aria-hidden="true">
{/* Hidden legacy controls kept mounted for existing parameter behavior. */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.model || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{settingsTab === "primary" && (
<div className="space-y-1.5 max-w-[280px]">
{/* Provider */}
<div className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Provider</label>
@ -229,18 +262,17 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
/>
</div>
</div>
)}
)}
{/* Fallback tab: temperature + maxTokens for fallback model */}
{settingsTab === "fallback" && nodeData.fallbackModel && (() => {
const fbParams = nodeData.fallbackParameters || {};
const fbTemp = (fbParams.temperature as number | undefined) ?? 0.7;
const fbMaxTokens = (fbParams.maxTokens as number | undefined) ?? 2048;
const fbProvider = nodeData.fallbackModel!.provider;
const isAnthropicFb = fbProvider === "anthropic";
{settingsTab === "fallback" && nodeData.fallbackModel && (() => {
const fbParams = nodeData.fallbackParameters || {};
const fbTemp = (fbParams.temperature as number | undefined) ?? 0.7;
const fbMaxTokens = (fbParams.maxTokens as number | undefined) ?? 2048;
const fbProvider = nodeData.fallbackModel!.provider;
const isAnthropicFb = fbProvider === "anthropic";
return (
<div className="space-y-1.5 max-w-[280px]">
return (
<div className="space-y-1.5 max-w-[280px]">
{/* Read-only model display */}
<div className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Model</label>
@ -279,8 +311,9 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
/>
</div>
</div>
);
})()}
);
})()}
</div>
</InlineParameterPanel>
) : undefined}
>
@ -401,10 +434,9 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
</div>
</div>
) : (
<div className="w-full h-full bg-neutral-900/40 flex items-center justify-center">
<span className="text-neutral-500 text-[10px]">
Run to generate
</span>
<div className="h-full w-full">
<TextNodePlaceholder />
<span className="sr-only">Run to generate</span>
</div>
)}
</div>

18
src/components/nodes/PromptNode.tsx

@ -4,8 +4,9 @@ import { useCallback, useState, useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { GenerationPromptInput } from "./GenerationPromptInput";
import { GenerationPromptInput, LIBTV_TEXT_PROMPT_PLACEHOLDER } from "./GenerationPromptInput";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { TextNodePlaceholder } from "./TextNodePlaceholder";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptNodeData } from "@/types";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
@ -116,9 +117,10 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
<GenerationPromptInput
value={localPrompt}
onChange={handlePanelPromptChange}
modelLabel={nodeData.variableName ? `@${nodeData.variableName}` : "Text Input"}
providerLabel={nodeData.isOptional ? "optional" : "required"}
placeholder="Write prompt content here."
modelLabel={nodeData.variableName ? `@${nodeData.variableName}` : "GVLM 3.1"}
costLabel="6"
placeholder={LIBTV_TEXT_PROMPT_PLACEHOLDER}
variant="libtvText"
/>
</InlineParameterPanel>
) : undefined}
@ -133,18 +135,20 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
/>
<HandleLabel label="Text" side="target" color="var(--handle-color-text)" visible={showLabels} />
<TextNodePlaceholder text={localPrompt} />
<textarea
value={localPrompt}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={hasIncomingTextConnection ? "Text from connected node (editable)..." : nodeData.isOptional ? "Optional prompt (leave empty to skip)..." : "Describe what to generate..."}
className="nodrag nopan nowheel w-full h-full p-3 pb-7 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-t-lg resize-none focus:outline-none placeholder:text-neutral-500"
className="nodrag nopan nowheel sr-only"
aria-label="Prompt text"
/>
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 bg-neutral-900/90 rounded-b-lg">
<div className="absolute bottom-2 left-2 z-10">
<button
onClick={() => setShowVarDialog(true)}
className="nodrag nopan text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
className={`nodrag nopan rounded bg-neutral-950/60 px-2 py-1 text-[10px] text-blue-300 transition-all hover:bg-neutral-900 hover:text-blue-200 focus:opacity-100 ${selected ? "opacity-100" : "opacity-0"}`}
title="Set variable name"
>
{nodeData.variableName ? `@${nodeData.variableName}` : "Add variable"}

69
src/components/nodes/TextNodePlaceholder.tsx

@ -0,0 +1,69 @@
"use client";
interface TextNodePlaceholderProps {
text?: string | null;
}
export function TextNodePlaceholder({ text }: TextNodePlaceholderProps) {
const hasText = !!text?.trim();
if (hasText) {
return (
<div className="relative h-full w-full overflow-auto rounded-lg bg-neutral-900/40 p-4 nowheel">
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-neutral-200">
{text}
</p>
</div>
);
}
return (
<div
className="relative flex h-full w-full flex-col items-center justify-center rounded-lg bg-neutral-900/40"
style={{ containerType: "size" }}
>
<svg
className="text-neutral-600"
style={{ width: "clamp(48px, 15cqw, 82px)", height: "clamp(48px, 15cqw, 82px)" }}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path d="M7 7h10M7 12h10M7 17h7" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
<div
className="absolute left-[7%] top-[58%] -translate-y-1/2 text-left"
style={{ fontSize: "clamp(12px, 2.2cqw, 20px)" }}
>
<div className="mb-[clamp(10px,2.2cqw,22px)] text-neutral-400">{"\u5c1d\u8bd5:"}</div>
<div className="flex flex-col font-semibold text-neutral-100" style={{ gap: "clamp(10px, 2.2cqw, 22px)" }}>
<div className="flex items-center gap-2">
<svg style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7 4h8l3 3v13H7V4zM15 4v4h4M9.5 12h5M9.5 15h5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{"\u81ea\u5df1\u7f16\u5199\u5185\u5bb9"}
</div>
<div className="flex items-center gap-2">
<svg style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 6.5v11l9-5.5-9-5.5z" fill="currentColor" />
</svg>
{"\u6587\u751f\u89c6\u9891"}
</div>
<div className="flex items-center gap-2">
<svg style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 5h14v14H5V5zM8 15l2.5-3 2 2.2L14 12l3 3.5M8.5 8.5h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{"\u56fe\u7247\u53cd\u63a8\u63d0\u793a\u8bcd"}
</div>
<div className="flex items-center gap-2">
<svg style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 10v4h3l4 4V6l-4 4H6zM17 9c.8.8 1.2 1.8 1.2 3s-.4 2.2-1.2 3" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{"\u6587\u5b57\u751f\u97f3\u4e50"}
</div>
</div>
</div>
</div>
);
}

128
src/components/nodes/VideoInputNode.tsx

@ -4,7 +4,7 @@ import { useCallback, useRef } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { InputNodePanel } from "./InputNodePanel";
import { GenerationPromptInput, LIBTV_VIDEO_PROMPT_PLACEHOLDER } from "./GenerationPromptInput";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoInputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
@ -87,29 +87,6 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
[id, updateNodeData]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0];
if (!file) return;
const dt = new DataTransfer();
dt.items.add(file);
if (fileInputRef.current) {
fileInputRef.current.files = dt.files;
fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
}
},
[]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleRemove = useCallback(() => {
updateNodeData(id, {
video: null,
@ -121,15 +98,14 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
});
}, [id, updateNodeData]);
const handleToggleOptional = useCallback(() => {
updateNodeData(id, { isOptional: !nodeData.isOptional });
}, [id, nodeData.isOptional, updateNodeData]);
const handlePanelPromptChange = useCallback((inputPrompt: string | null) => {
updateNodeData(id, { inputPrompt });
}, [id, updateNodeData]);
const videoDetails = [
nodeData.format,
nodeData.duration ? formatDuration(nodeData.duration) : null,
nodeData.dimensions ? `${nodeData.dimensions.width} x ${nodeData.dimensions.height}` : null,
].filter(Boolean).join(" · ") || null;
nodeData.dimensions ? `${nodeData.dimensions.width}:${nodeData.dimensions.height}` : null,
].filter(Boolean).join(" · ") || "16:9 · 720P · 5s";
return (
<BaseNode
@ -146,15 +122,15 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
nodeId={id}
showToggle={false}
>
<InputNodePanel
title={nodeData.video ? "Replace video" : "Upload video"}
description="MP4, WebM, or QuickTime files up to 200MB."
filename={nodeData.filename}
details={videoDetails}
isOptional={nodeData.isOptional}
onChoose={() => fileInputRef.current?.click()}
onClear={nodeData.video ? handleRemove : undefined}
onToggleOptional={handleToggleOptional}
<GenerationPromptInput
value={nodeData.inputPrompt}
onChange={handlePanelPromptChange}
modelLabel="Seedance 2.0 VIP"
imageMetaLabel={videoDetails}
imageCountLabel="1个"
costLabel="108/135"
placeholder={LIBTV_VIDEO_PROMPT_PLACEHOLDER}
variant="libtvVideo"
/>
</InlineParameterPanel>
) : undefined}
@ -167,6 +143,21 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
className="hidden"
/>
{selected && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="nodrag nopan absolute left-1/2 -top-10 z-20 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-800/95 px-3 text-xs font-medium text-neutral-100 shadow-xl transition-colors hover:bg-neutral-700 hover:text-white"
aria-label="Upload video"
title={nodeData.video ? "Replace video" : "Upload video"}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4v10M12 4l-4 4M12 4l4 4M5 15v3a2 2 0 002 2h10a2 2 0 002-2v-3" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{"\u4e0a\u4f20"}
</button>
)}
{nodeData.video ? (
<div className="relative group w-full h-full overflow-clip rounded-lg">
<video
@ -201,19 +192,54 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
</div>
) : (
<div
role="button"
tabIndex={0}
aria-label="Upload video file"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInputRef.current?.click(); } }}
onDrop={handleDrop}
onDragOver={handleDragOver}
className={`w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-900/60 transition-colors ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`}
className={`relative w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`}
style={{ containerType: "size" }}
>
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<span className="text-xs text-neutral-500 mt-2">{nodeData.isOptional ? "Optional" : "Drop video or click"}</span>
<div
className="flex items-center justify-center rounded-lg bg-neutral-600/70 text-neutral-950"
style={{ width: "clamp(42px, 13cqw, 74px)", height: "clamp(42px, 13cqw, 74px)" }}
>
<svg
className="ml-0.5 text-neutral-950"
style={{ width: "clamp(23px, 7cqw, 38px)", height: "clamp(23px, 7cqw, 38px)" }}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5.8v12.4c0 .8.9 1.3 1.6.9l9.2-6.2a1.1 1.1 0 000-1.8L9.6 4.9C8.9 4.5 8 5 8 5.8z" />
</svg>
</div>
<div
className="absolute left-[7%] top-[52%] -translate-y-1/2 text-left"
style={{ fontSize: "clamp(12px, 2.2cqw, 20px)" }}
>
<div className="mb-[clamp(10px,2.2cqw,22px)] text-neutral-400">{"\u5c1d\u8bd5:"}</div>
<div className="flex flex-col font-semibold text-neutral-100" style={{ gap: "clamp(10px, 2.2cqw, 22px)" }}>
<div className="flex items-center gap-2">
<svg
style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path d="M5 7l7-3 7 3v10l-7 3-7-3V7zM5 7l7 3 7-3M12 10v10" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
</svg>
{"\u9996\u5c3e\u5e27\u751f\u6210\u89c6\u9891"}
</div>
<div className="flex items-center gap-2">
<svg
style={{ width: "clamp(14px, 2.5cqw, 24px)", height: "clamp(14px, 2.5cqw, 24px)" }}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path d="M12 4l1.6 4.4L18 10l-4.4 1.6L12 16l-1.6-4.4L6 10l4.4-1.6L12 4z" fill="currentColor" />
</svg>
{"\u9996\u5e27\u751f\u6210\u89c6\u9891"}
</div>
</div>
</div>
<span className="sr-only">{nodeData.isOptional ? "Optional" : "No video selected"}</span>
</div>
)}

1
src/store/utils/nodeDefaults.ts

@ -107,6 +107,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
duration: null,
dimensions: null,
format: null,
inputPrompt: null,
} as VideoInputNodeData;
case "annotation":
return {

1
src/types/nodes.ts

@ -87,6 +87,7 @@ export interface VideoInputNodeData extends BaseNodeData {
duration: number | null; // Duration in seconds
dimensions: { width: number; height: number } | null;
format: string | null; // MIME type (video/mp4, video/webm, etc.)
inputPrompt?: string | null;
isOptional?: boolean;
}

Loading…
Cancel
Save