You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

164 lines
5.2 KiB

"use client";
import { useCallback, useRef } from "react";
import { UploadOutlined } from "@ant-design/icons";
import { Node, NodeProps } from "@xyflow/react";
import { Spin } from "antd";
import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { SmartAudioNodeData } from "@/types";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { deriveSmartAudioMode } from "@/utils/smartAudioMode";
import { AudioInputNodeView } from "./AudioInputNode";
import { GenerateAudioNodeView } from "./GenerateAudioNode";
type SmartAudioNodeType = Node<SmartAudioNodeData, "smartAudio">;
const MAX_FILE_SIZE = 50 * 1024 * 1024;
function SmartAudioUploadToolbar({
id,
selected,
isUploading,
}: {
id: string;
selected?: boolean;
isUploading: boolean;
}) {
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const fileInputRef = useRef<HTMLInputElement>(null);
const applyAudio = useCallback(
(audioFile: string, filename: string, format: string, duration: number | null) => {
updateNodeData(id, {
audioFile,
audioFileRef: undefined,
filename,
format,
duration,
});
},
[id, updateNodeData]
);
const handleFile = useCallback(
(file: File) => {
if (!file.type.match(/^audio\//)) {
alert(t("upload.audioUnsupported"));
return;
}
if (file.size > MAX_FILE_SIZE) {
alert(t("upload.audioTooLarge"));
return;
}
updateNodeData(id, { uploadStatus: "loading", uploadError: null });
const metadataUrl = URL.createObjectURL(file);
const applyUpload = (duration: number | null) => {
URL.revokeObjectURL(metadataUrl);
uploadFileToGatewayMedia(file, "audio")
.then((uploaded) => {
updateNodeData(id, { uploadStatus: "idle", uploadError: null });
applyAudio(uploaded.url, uploaded.filename, uploaded.contentType, duration);
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Media upload failed";
updateNodeData(id, { uploadStatus: "error", uploadError: message });
alert(message);
});
};
const audio = new Audio(metadataUrl);
audio.onloadedmetadata = () => applyUpload(Number.isFinite(audio.duration) ? audio.duration : null);
audio.onerror = () => applyUpload(null);
},
[applyAudio, id, t, updateNodeData]
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) handleFile(file);
},
[handleFile]
);
const handleOpenFileDialog = useCallback(() => {
if (isUploading) return;
selectSingleNode(id);
fileInputRef.current?.click();
}, [id, isUploading, selectSingleNode]);
return (
<>
<input
ref={fileInputRef}
type="file"
accept="audio/mp3,audio/mpeg,audio/wav,audio/ogg,audio/aac,audio/mp4,audio/*"
onChange={handleFileChange}
className="hidden"
/>
{selected && (
<div className="nodrag nopan absolute left-1/2 -top-[86px] z-[10002] flex -translate-x-1/2 items-center gap-2">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleOpenFileDialog();
}}
aria-label={t("imageInput.localUpload")}
disabled={isUploading}
className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100 disabled:cursor-wait disabled:opacity-60"
>
<UploadOutlined className="text-base" />
<span>{t("imageInput.localUpload")}</span>
</button>
</div>
)}
</>
);
}
function SmartAudioEmptyView({
id,
data,
selected,
}: {
id: string;
data: SmartAudioNodeData;
selected?: 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} />
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
</div>
);
}
export function SmartAudioNode({ id, data, selected }: NodeProps<SmartAudioNodeType>) {
const edges = useWorkflowStore((state) => state.edges);
const mode = deriveSmartAudioMode(id, data, edges);
if (mode === "generate") {
return <GenerateAudioNodeView id={id} data={data} selected={selected} />;
}
if (mode === "audio") {
return <AudioInputNodeView id={id} data={data} selected={selected} />;
}
return <SmartAudioEmptyView id={id} data={data} selected={selected} />;
}