Browse Source

视频不能再组生成的问题

feature/handle
TianYun 1 month ago
parent
commit
43cc39fcff
  1. 35
      src/components/nodes/GenerateImageNode.tsx
  2. 41
      src/components/nodes/ImageInputNode.tsx
  3. 2
      src/components/nodes/SmartImageNode.tsx
  4. 56
      src/store/__tests__/workflowStore.integration.test.ts
  5. 7
      src/store/workflowStore.ts

35
src/components/nodes/GenerateImageNode.tsx

@ -73,6 +73,7 @@ const COMPOSER_PRIORITY_IMAGE_PARAMETERS = [
"resolution",
"imageSize",
];
const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600];
// Hardcoded Gemini image models (always available)
const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [
@ -125,8 +126,16 @@ export interface GenerateImageNodeViewProps {
export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeViewProps) {
const nodeData = data;
const { t } = useI18n();
const displayImage = data.previewImg || data.outputImage;
const preferredDisplayImage = data.previewImg || data.outputImage;
const [imageRetryNonce, setImageRetryNonce] = useState(0);
const [displayFallbackImage, setDisplayFallbackImage] = useState<string | null>(null);
const displayImage = displayFallbackImage || preferredDisplayImage;
const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id);
const outputImageSrc = adaptiveOutputImage
? imageRetryNonce > 0 && /^https?:\/\//i.test(adaptiveOutputImage) && !adaptiveOutputImage.includes("?")
? `${adaptiveOutputImage}${adaptiveOutputImage.includes("?") ? "&" : "?"}canvasRetry=${imageRetryNonce}`
: adaptiveOutputImage
: null;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const popiserverImageModels = useModelStore((state) => state.byKind.image);
// Use stable selector for API keys to prevent unnecessary re-fetches
@ -141,6 +150,11 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary");
const showFallbackSettings = !popiProviderMode && !!nodeData.fallbackModel;
useEffect(() => {
setDisplayFallbackImage(null);
setImageRetryNonce(0);
}, [preferredDisplayImage]);
useEffect(() => {
if (!showFallbackSettings && settingsTab === "fallback") {
setSettingsTab("primary");
@ -873,8 +887,25 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
{displayImage ? (
<>
<img
src={adaptiveOutputImage ?? undefined}
key={`${displayImage ?? "empty"}-${imageRetryNonce}`}
src={outputImageSrc ?? undefined}
alt="Generated"
onLoad={() => setImageRetryNonce(0)}
onError={() => {
if (nodeData.previewImg && displayImage === nodeData.previewImg && nodeData.outputImage && nodeData.outputImage !== nodeData.previewImg) {
setDisplayFallbackImage(nodeData.outputImage);
setImageRetryNonce(0);
return;
}
const retryIndex = imageRetryNonce;
const retryDelay = IMAGE_LOAD_RETRY_DELAYS_MS[retryIndex];
if (retryDelay !== undefined && displayImage && /^https?:\/\//i.test(displayImage)) {
window.setTimeout(() => {
setImageRetryNonce((current) => current === retryIndex ? current + 1 : current);
}, retryDelay);
}
}}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();

41
src/components/nodes/ImageInputNode.tsx

@ -20,6 +20,7 @@ import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAsset
type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">;
const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput;
const IMAGE_DIMENSION_TIMEOUT_MS = 10_000;
const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600];
export interface ImageInputNodeViewProps {
id: string;
@ -91,7 +92,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
const { t } = useI18n();
const nodeData = data;
const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id);
const displayImage = nodeData.previewImage || adaptiveImage || nodeData.image;
const preferredDisplayImage = nodeData.previewImage || adaptiveImage || nodeData.image;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const { setNodes } = useReactFlow();
@ -99,12 +100,23 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
const prevImageRef = useRef<string | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [imageRetryNonce, setImageRetryNonce] = useState(0);
const [displayFallbackImage, setDisplayFallbackImage] = useState<string | null>(null);
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const isUploading = nodeData.uploadStatus === "loading";
const displayImage = displayFallbackImage || preferredDisplayImage;
const imageSrc = displayImage
? imageRetryNonce > 0 && /^https?:\/\//i.test(displayImage) && !displayImage.includes("?")
? `${displayImage}${displayImage.includes("?") ? "&" : "?"}canvasRetry=${imageRetryNonce}`
: displayImage
: null;
useEffect(() => {
setDisplayFallbackImage(null);
setImageLoadFailed(false);
}, [displayImage]);
setImageRetryNonce(0);
}, [preferredDisplayImage]);
useEffect(() => {
if (!nodeData.image || !nodeData.dimensions || nodeData.image === prevImageRef.current) {
@ -180,7 +192,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Media upload failed";
updateNodeData(id, { uploadStatus: "error", uploadError: message });
updateNodeData(id, { uploadStatus: "idle", uploadError: message });
alert(message);
});
};
@ -467,10 +479,29 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
className="relative group h-full w-full cursor-zoom-in overflow-clip rounded-lg bg-neutral-800"
>
<img
src={displayImage ?? undefined}
key={`${displayImage ?? "empty"}-${imageRetryNonce}`}
src={imageSrc ?? undefined}
alt={nodeData.filename || t("imageInput.uploadedAlt")}
onLoad={() => setImageLoadFailed(false)}
onError={() => setImageLoadFailed(true)}
onError={() => {
if (nodeData.previewImage && displayImage === nodeData.previewImage && nodeData.image && nodeData.image !== nodeData.previewImage) {
setDisplayFallbackImage(nodeData.image);
setImageLoadFailed(false);
setImageRetryNonce(0);
return;
}
const retryIndex = imageRetryNonce;
const retryDelay = IMAGE_LOAD_RETRY_DELAYS_MS[retryIndex];
if (retryDelay !== undefined && displayImage && /^https?:\/\//i.test(displayImage)) {
window.setTimeout(() => {
setImageRetryNonce((current) => current === retryIndex ? current + 1 : current);
}, retryDelay);
return;
}
setImageLoadFailed(true);
}}
className="h-full w-full rounded-lg object-contain"
/>
{imageLoadFailed && (

2
src/components/nodes/SmartImageNode.tsx

@ -118,7 +118,7 @@ function SmartImageUploadToolbar({
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Media upload failed";
updateNodeData(id, { uploadStatus: "error", uploadError: message });
updateNodeData(id, { uploadStatus: "idle", uploadError: message });
alert(message);
});
};

56
src/store/__tests__/workflowStore.integration.test.ts

@ -1730,6 +1730,62 @@ describe("workflowStore integration tests", () => {
}
});
it("should execute smartVideo during selected node execution", async () => {
const mockFetch = vi.fn().mockImplementation(async (url: string) => {
if (url === "/api/generate/task/create") {
return {
ok: true,
json: () => Promise.resolve({ success: true, taskId: "task-1" }),
};
}
if (url.startsWith("/api/generate/task/poll")) {
return {
ok: true,
json: () => Promise.resolve({
status: "completed",
result: { success: true, video: "https://example.com/generated.mp4" },
}),
};
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", mockFetch);
useWorkflowStore.setState({
nodes: [
createTestNode("prompt-1", "prompt", { prompt: "test video" }),
createTestNode("smartVideo-1", "smartVideo", {
selectedModel: { provider: "popiserver", modelId: "video-model", displayName: "Video Model" },
durationSeconds: 5,
resolution: "720p",
inputImages: [],
inputPrompt: null,
outputVideo: null,
videoHistory: [],
status: "idle",
}),
],
edges: [
createTestEdge("prompt-1", "smartVideo-1", "output", "input"),
],
});
const store = useWorkflowStore.getState();
await store.executeSelectedNodes(["prompt-1", "smartVideo-1"]);
const videoNode = useWorkflowStore.getState().nodes.find(n => n.id === "smartVideo-1");
expect(videoNode?.data).toHaveProperty("status", "complete");
expect(videoNode?.data).toHaveProperty("outputVideo", "https://example.com/generated.mp4");
expect(mockFetch).toHaveBeenCalledWith(
"/api/generate/task/create",
expect.objectContaining({
body: expect.stringContaining('"mediaType":"video"'),
})
);
vi.unstubAllGlobals();
});
it("should stop execution on error (subsequent nodes not executed)", async () => {
vi.stubGlobal("fetch", vi.fn());

7
src/store/workflowStore.ts

@ -65,6 +65,7 @@ import { createDefaultImageModel } from "./utils/defaultImageModel";
import { isSmartImageGenerateMode } from "@/utils/smartImageMode";
import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode";
import { isSmartTextLlmMode } from "@/utils/smartTextMode";
import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode";
import {
getNodeOutputMediaType,
normalizeConnectionHandles,
@ -2919,6 +2920,12 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}
break;
}
case "smartVideo": {
if (isSmartVideoGenerateMode(node.id, get().edges)) {
await executeGenerateVideo(executionCtx, regenOptions);
}
break;
}
case "glbViewer":
await executeGlbViewer(executionCtx);
break;

Loading…
Cancel
Save