Browse Source

bug 修改

feature/handle
TianYun 1 month ago
parent
commit
d8a1db6f8b
  1. 1
      next.config.ts
  2. 6
      src/components/__tests__/VideoStitchNode.test.tsx
  3. 39
      src/components/composer/GenerationComposer.tsx
  4. 48
      src/components/nodes/SmartAudioNode.tsx
  5. 58
      src/components/nodes/SmartImageNode.tsx
  6. 52
      src/components/nodes/SmartVideoNode.tsx
  7. 9
      src/components/nodes/VideoTrimNode.tsx
  8. 5
      src/hooks/useVideoBlobUrl.ts
  9. 2
      src/utils/mediaResolver.ts
  10. 3
      src/utils/smartGenerateUpload.ts

1
next.config.ts

@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
allowedDevOrigins: ["192.168.77.32"],
experimental: {
proxyClientMaxBodySize: "32mb",
serverActions: {

6
src/components/__tests__/VideoStitchNode.test.tsx

@ -123,12 +123,10 @@ describe("VideoStitchNode", () => {
});
describe("Thumbnail Source Resolution", () => {
it("routes remote video thumbnails through the media proxy", () => {
it("keeps remote video thumbnails unchanged", () => {
const remoteUrl = "https://cdn.example.com/video.mp4?token=a b";
expect(getVideoStitchThumbnailSrc(remoteUrl)).toBe(
`/api/proxy-media?url=${encodeURIComponent(remoteUrl)}`
);
expect(getVideoStitchThumbnailSrc(remoteUrl)).toBe(remoteUrl);
});
it("keeps local video sources unchanged", () => {

39
src/components/composer/GenerationComposer.tsx

@ -656,34 +656,17 @@ function isVideoHandle(handleId: string | null | undefined): boolean {
return handleId === "video" || handleId.startsWith("video-") || handleId.includes("video");
}
function isImageHandle(handleId: string | null | undefined): boolean {
if (!handleId) return false;
return handleId === "image" || handleId.startsWith("image-") || handleId.includes("frame");
}
function isTextHandle(handleId: string | null | undefined): boolean {
if (!handleId) return false;
return handleId === "text" || handleId.startsWith("text-") || handleId.includes("prompt");
}
function isAudioHandle(handleId: string | null | undefined): boolean {
if (!handleId) return false;
return handleId === "audio" || handleId.startsWith("audio-") || handleId.includes("audio");
}
function getConnectedEdgeIdsByType(
targetNodeId: string,
nodes: WorkflowNode[],
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType | "text"
): string[] {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const handleIds = [edge.targetHandle, edge.sourceHandle];
if (type === "image") return handleIds.some(isImageHandle);
if (type === "video") return handleIds.some(isVideoHandle);
if (type === "audio") return handleIds.some(isAudioHandle);
return handleIds.some(isTextHandle);
const sourceNode = nodes.find((node) => node.id === edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => edge.id);
}
@ -1822,12 +1805,12 @@ export function GenerationComposer() {
const connectedTextItems = useMemo(() => getConnectedTextItems(connectedInputs), [connectedInputs]);
const connectedTextCards = useMemo<ComposerConnectedTextItem[]>(() => {
if (!context.node) return connectedTextItems.map((text) => ({ text }));
const textEdgeIds = getConnectedEdgeIdsByType(context.node.id, edges, "text");
const textEdgeIds = getConnectedEdgeIdsByType(context.node.id, nodes, edges, "text");
return connectedTextItems.map((text, index) => ({
text,
...(textEdgeIds[index] ? { edgeId: textEdgeIds[index] } : {}),
}));
}, [connectedTextItems, context.node, edges]);
}, [connectedTextItems, context.node, edges, nodes]);
const promptValue = draft.prompt;
const promptReadOnly = false;
const activeCapability = context.mode === "empty-create"
@ -1892,34 +1875,34 @@ export function GenerationComposer() {
const referenceImageInputs = useMemo<ComposerReferenceInput[]>(() => {
const imageEdgeIds =
context.mode === "node-edit" && context.node && connectedReferenceImages.length > 0
? getConnectedEdgeIdsByType(context.node.id, edges, "image")
? getConnectedEdgeIdsByType(context.node.id, nodes, edges, "image")
: [];
return referenceImages.map((url, index) => ({
url,
...(imageEdgeIds[index] ? { edgeId: imageEdgeIds[index] } : {}),
}));
}, [connectedReferenceImages.length, context.mode, context.node, edges, referenceImages]);
}, [connectedReferenceImages.length, context.mode, context.node, edges, nodes, referenceImages]);
const referenceVideoInputs = useMemo<ComposerReferenceInput[]>(() => {
const videoEdgeIds =
context.mode === "node-edit" && context.node
? getConnectedEdgeIdsByType(context.node.id, edges, "video")
? getConnectedEdgeIdsByType(context.node.id, nodes, edges, "video")
: [];
return connectedReferenceVideos.map((url, index) => ({
url,
...(connectedVideoDurations[index] && connectedVideoDurations[index] > 0 ? { duration: connectedVideoDurations[index] } : {}),
...(videoEdgeIds[index] ? { edgeId: videoEdgeIds[index] } : {}),
}));
}, [connectedReferenceVideos, connectedVideoDurations, context.mode, context.node, edges]);
}, [connectedReferenceVideos, connectedVideoDurations, context.mode, context.node, edges, nodes]);
const referenceAudioInputs = useMemo<ComposerReferenceInput[]>(() => {
const audioEdgeIds =
context.mode === "node-edit" && context.node
? getConnectedEdgeIdsByType(context.node.id, edges, "audio")
? getConnectedEdgeIdsByType(context.node.id, nodes, edges, "audio")
: [];
return connectedReferenceAudio.map((url, index) => ({
url,
...(audioEdgeIds[index] ? { edgeId: audioEdgeIds[index] } : {}),
}));
}, [connectedReferenceAudio, context.mode, context.node, edges]);
}, [connectedReferenceAudio, context.mode, context.node, edges, nodes]);
const baseReferenceMaterials = useMemo(() => buildComposerReferenceMaterials(
referenceImageInputs,
referenceVideoInputs,

48
src/components/nodes/SmartAudioNode.tsx

@ -8,7 +8,8 @@ import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { SmartAudioNodeData } from "@/types";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { deriveSmartAudioMode } from "@/utils/smartAudioMode";
import { isSmartGenerateUploadEnabled } from "@/utils/smartGenerateUpload";
import { deriveSmartAudioMode, isSmartAudioGenerateMode } from "@/utils/smartAudioMode";
import { AudioInputNodeView } from "./AudioInputNode";
import { GenerateAudioNodeView } from "./GenerateAudioNode";
@ -19,16 +20,43 @@ function SmartAudioUploadToolbar({
id,
selected,
isUploading,
addToGenerationHistory = false,
}: {
id: string;
selected?: boolean;
isUploading: boolean;
addToGenerationHistory?: boolean;
}) {
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const fileInputRef = useRef<HTMLInputElement>(null);
const buildUploadedAudioPatch = useCallback(
(audioUrl: string): Partial<SmartAudioNodeData> => {
if (!addToGenerationHistory) return {};
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = (currentNode?.data ?? {}) as SmartAudioNodeData;
const timestamp = Date.now();
return {
outputAudio: audioUrl,
audioHistory: [
{
id: `${timestamp}`,
audio: audioUrl,
timestamp,
prompt: currentData.inputPrompt || "",
model: currentData.selectedModel?.modelId || "",
},
...(currentData.audioHistory || []),
].slice(0, 50),
selectedAudioHistoryIndex: 0,
};
},
[addToGenerationHistory, id]
);
const applyAudio = useCallback(
(audioFile: string, filename: string, format: string, duration: number | null) => {
updateNodeData(id, {
@ -37,9 +65,10 @@ function SmartAudioUploadToolbar({
filename,
format,
duration,
...buildUploadedAudioPatch(audioFile),
});
},
[id, updateNodeData]
[buildUploadedAudioPatch, id, updateNodeData]
);
const handleFile = useCallback(
@ -128,17 +157,24 @@ function SmartAudioEmptyView({
id,
data,
selected,
addToGenerationHistory = false,
}: {
id: string;
data: SmartAudioNodeData;
selected?: boolean;
addToGenerationHistory?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateAudioNodeView id={id} data={data} selected={selected} />
<SmartAudioUploadToolbar id={id} selected={selected} isUploading={isUploading} />
<SmartAudioUploadToolbar
id={id}
selected={selected}
isUploading={isUploading}
addToGenerationHistory={addToGenerationHistory}
/>
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
@ -151,8 +187,12 @@ function SmartAudioEmptyView({
export function SmartAudioNode({ id, data, selected }: NodeProps<SmartAudioNodeType>) {
const edges = useWorkflowStore((state) => state.edges);
const mode = deriveSmartAudioMode(id, data, edges);
const smartGenerateUploadEnabled = isSmartGenerateUploadEnabled();
if (mode === "generate") {
if (mode === "generate" || (smartGenerateUploadEnabled && isSmartAudioGenerateMode(id, edges))) {
if (smartGenerateUploadEnabled) {
return <SmartAudioEmptyView id={id} data={data} selected={selected} addToGenerationHistory />;
}
return <GenerateAudioNodeView id={id} data={data} selected={selected} />;
}

58
src/components/nodes/SmartImageNode.tsx

@ -8,6 +8,7 @@ import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { SmartImageNodeData } from "@/types";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { isSmartGenerateUploadEnabled } from "@/utils/smartGenerateUpload";
import { deriveSmartImageMode } from "@/utils/smartImageMode";
import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi";
import { GenerateImageNodeView } from "./GenerateImageNode";
@ -36,10 +37,12 @@ function SmartImageUploadToolbar({
id,
selected,
isUploading,
addToGenerationHistory = false,
}: {
id: string;
selected?: boolean;
isUploading: boolean;
addToGenerationHistory?: boolean;
}) {
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
@ -47,6 +50,41 @@ function SmartImageUploadToolbar({
const fileInputRef = useRef<HTMLInputElement>(null);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const buildUploadedImagePatch = useCallback(
(
imageUrl: string,
dimensions: { width: number; height: number } | null,
previewImg?: string
): Partial<SmartImageNodeData> => {
if (!addToGenerationHistory) return {};
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = (currentNode?.data ?? {}) as SmartImageNodeData;
const timestamp = Date.now();
return {
outputImage: imageUrl,
previewImg,
imageHistory: [
{
id: `${timestamp}`,
image: imageUrl,
previewImg,
timestamp,
prompt: currentData.inputPrompt || "",
aspectRatio: currentData.aspectRatio,
model: currentData.selectedModel?.modelId || currentData.model || "",
modelDisplayName: currentData.selectedModel?.displayName,
modelProvider: currentData.selectedModel?.provider,
},
...(currentData.imageHistory || []),
].slice(0, 50),
selectedHistoryIndex: 0,
dimensions,
};
},
[addToGenerationHistory, id]
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@ -73,6 +111,7 @@ function SmartImageUploadToolbar({
uploadError: null,
filename: uploaded.filename,
dimensions,
...buildUploadedImagePatch(uploaded.url, dimensions),
});
})
.catch((error) => {
@ -127,9 +166,11 @@ function SmartImageUploadToolbar({
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = currentNode?.data as SmartImageNodeData | undefined;
if (currentData?.assetId !== preview.assetId) return;
const imageUrl = resolved.imageUrl;
if (!imageUrl) return;
updateNodeData(id, {
image: resolved.imageUrl,
image: imageUrl,
imageRef: undefined,
assetId: resolved.assetId,
previewImage: resolved.previewImageUrl,
@ -137,6 +178,7 @@ function SmartImageUploadToolbar({
assetDetailError: null,
filename: resolved.filename,
dimensions,
...buildUploadedImagePatch(imageUrl, dimensions, resolved.previewImageUrl),
});
};
@ -163,7 +205,7 @@ function SmartImageUploadToolbar({
assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail",
});
});
}, [id, updateNodeData]);
}, [buildUploadedImagePatch, id, updateNodeData]);
return (
<>
@ -225,17 +267,24 @@ function SmartImageNeutralView({
id,
data,
selected,
addToGenerationHistory = false,
}: {
id: string;
data: SmartImageNodeData;
selected?: boolean;
addToGenerationHistory?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateImageNodeView id={id} data={data} selected={selected} />
<SmartImageUploadToolbar id={id} selected={selected} isUploading={isUploading} />
<SmartImageUploadToolbar
id={id}
selected={selected}
isUploading={isUploading}
addToGenerationHistory={addToGenerationHistory}
/>
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
@ -250,6 +299,9 @@ export function SmartImageNode({ id, data, selected }: NodeProps<SmartImageNodeT
const mode = deriveSmartImageMode(id, data, edges);
if (mode === "generate") {
if (isSmartGenerateUploadEnabled()) {
return <SmartImageNeutralView id={id} data={data} selected={selected} addToGenerationHistory />;
}
return <GenerateImageNodeView id={id} data={data} selected={selected} />;
}

52
src/components/nodes/SmartVideoNode.tsx

@ -8,6 +8,7 @@ import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { SmartVideoNodeData } from "@/types";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { isSmartGenerateUploadEnabled } from "@/utils/smartGenerateUpload";
import { deriveSmartVideoMode } from "@/utils/smartVideoMode";
import { getPopiVideoAssetPreview, resolvePopiVideoAsset } from "@/lib/popiAssetApi";
import { GenerateVideoNodeView } from "./GenerateVideoNode";
@ -40,10 +41,12 @@ function SmartVideoUploadToolbar({
id,
selected,
isUploading,
addToGenerationHistory = false,
}: {
id: string;
selected?: boolean;
isUploading: boolean;
addToGenerationHistory?: boolean;
}) {
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
@ -51,6 +54,33 @@ function SmartVideoUploadToolbar({
const fileInputRef = useRef<HTMLInputElement>(null);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const buildUploadedVideoPatch = useCallback(
(videoUrl: string): Partial<SmartVideoNodeData> => {
if (!addToGenerationHistory) return {};
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = (currentNode?.data ?? {}) as SmartVideoNodeData;
const timestamp = Date.now();
return {
outputVideo: videoUrl,
videoHistory: [
{
id: `${timestamp}`,
video: videoUrl,
timestamp,
prompt: currentData.inputPrompt || "",
model: currentData.selectedModel?.modelId || "",
modelDisplayName: currentData.selectedModel?.displayName,
modelProvider: currentData.selectedModel?.provider,
},
...(currentData.videoHistory || []),
].slice(0, 50),
selectedVideoHistoryIndex: 0,
};
},
[addToGenerationHistory, id]
);
const applyVideo = useCallback(
(videoSrc: string, filename: string, format: string, duration: number | null, dimensions: { width: number; height: number } | null) => {
updateNodeData(id, {
@ -64,9 +94,10 @@ function SmartVideoUploadToolbar({
format,
duration,
dimensions,
...buildUploadedVideoPatch(videoSrc),
});
},
[id, updateNodeData]
[buildUploadedVideoPatch, id, updateNodeData]
);
const handleFileChange = useCallback(
@ -153,9 +184,11 @@ function SmartVideoUploadToolbar({
const currentNode = useWorkflowStore.getState().getNodeById(id);
const currentData = currentNode?.data as SmartVideoNodeData | undefined;
if (currentData?.assetId !== preview.assetId) return;
const videoUrl = resolved.videoUrl;
if (!videoUrl) return;
updateNodeData(id, {
video: resolved.videoUrl,
video: videoUrl,
videoRef: undefined,
assetId: resolved.assetId,
previewVideoPoster: resolved.previewPosterUrl || undefined,
@ -165,6 +198,7 @@ function SmartVideoUploadToolbar({
format: "video/asset",
duration: resolved.duration || metadata.duration,
dimensions: resolved.dimensions || metadata.dimensions,
...buildUploadedVideoPatch(videoUrl),
});
};
@ -202,7 +236,7 @@ function SmartVideoUploadToolbar({
assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail",
});
});
}, [id, updateNodeData]);
}, [buildUploadedVideoPatch, id, updateNodeData]);
const handleOpenFileDialog = useCallback(() => {
if (isUploading) return;
@ -270,17 +304,24 @@ function SmartVideoNeutralView({
id,
data,
selected,
addToGenerationHistory = false,
}: {
id: string;
data: SmartVideoNodeData;
selected?: boolean;
addToGenerationHistory?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateVideoNodeView id={id} data={data} selected={selected} />
<SmartVideoUploadToolbar id={id} selected={selected} isUploading={isUploading} />
<SmartVideoUploadToolbar
id={id}
selected={selected}
isUploading={isUploading}
addToGenerationHistory={addToGenerationHistory}
/>
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
@ -295,6 +336,9 @@ export function SmartVideoNode({ id, data, selected }: NodeProps<SmartVideoNodeT
const mode = deriveSmartVideoMode(id, data, edges);
if (mode === "generate") {
if (isSmartGenerateUploadEnabled()) {
return <SmartVideoNeutralView id={id} data={data} selected={selected} addToGenerationHistory />;
}
return <GenerateVideoNodeView id={id} data={data} selected={selected} />;
}

9
src/components/nodes/VideoTrimNode.tsx

@ -24,13 +24,6 @@ function formatTime(seconds: number): string {
return `${mins}:${secs.toFixed(1).padStart(4, "0")}`;
}
function getPlayableMetadataUrl(url: string): string {
if (url.startsWith("http://") || url.startsWith("https://")) {
return `/api/proxy-media?url=${encodeURIComponent(url)}`;
}
return url;
}
export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeType>) {
const { t } = useI18n();
const nodeData = data;
@ -118,7 +111,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
video.src = sourceVideoUrl;
});
} else {
video.src = getPlayableMetadataUrl(sourceVideoUrl);
video.src = sourceVideoUrl;
}
return () => {

5
src/hooks/useVideoBlobUrl.ts

@ -11,7 +11,7 @@ import { isHttpMediaUrl, resolveVideoPlaybackSrc } from "@/utils/mediaResolver";
*
* - If input is null, returns null
* - If input is already a blob URL, returns it as-is
* - If input is an HTTP URL, routes it through the same-origin media proxy
* - If input is an HTTP URL, passes it through unchanged
* - If input is a data URL, immediately returns it as fallback, then swaps
* to a blob URL once conversion completes (~50ms)
* - Revokes previous blob URLs on input change and unmount
@ -39,8 +39,7 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null {
return;
}
// Remote videos can fail in <video> because of CORS or missing range headers.
// Keep the stored URL unchanged and only proxy the playback source.
// Keep remote URLs unchanged so browser playback matches the stored media URL.
if (isHttpMediaUrl(videoUrl)) {
setBlobUrl(resolveVideoPlaybackSrc(videoUrl));
return;

2
src/utils/mediaResolver.ts

@ -5,5 +5,5 @@ export function isHttpMediaUrl(src: string | null | undefined): boolean {
}
export function resolveVideoPlaybackSrc(src: string): string {
return isHttpMediaUrl(src) ? `/api/proxy-media?url=${encodeURIComponent(src)}` : src;
return src;
}

3
src/utils/smartGenerateUpload.ts

@ -0,0 +1,3 @@
export function isSmartGenerateUploadEnabled(): boolean {
return true;
}
Loading…
Cancel
Save