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
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} />;
|
|
}
|
|
|