import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react"; import { CustomSelect } from "./custom-select"; import { modelDurations, modelResolutions } from "./free-create/constants"; import type { ModelConfig } from "../types"; /** 无目录时的回落清单(展示名,与会话 params.model 历史值兼容)。 */ export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"]; export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"]; export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"]; export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"]; /** 临时只开放 ≤60 秒;90/120/180 长视频入口先隐藏。 */ export const OMNI_MAX_VIDEO_DURATION = 60; export const OMNI_VIDEO_DURATIONS = [ "智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", "45 秒", "60 秒", ]; export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"]; function toOptions(values: string[]) { return values.map((value) => ({ value, label: value })); } function withCurrent(values: string[], current: string) { return current && !values.includes(current) ? [current, ...values] : values; } /** 同一秒数允许「8秒 / 8 秒」等历史格式共存,不因展示格式触发参数变更。 */ export function normalizeDurationValue(value: string): string { const raw = String(value || "").trim(); const seconds = raw.match(/\d+(?:\.\d+)?/)?.[0]; if (seconds) return `seconds:${Number(seconds)}`; return `label:${raw.replace(/\s+/g, "").toLowerCase()}`; } function includesDuration(values: string[], current: string): boolean { const normalized = normalizeDurationValue(current); return values.some((value) => normalizeDurationValue(value) === normalized); } function modelOptionLabel(config: ModelConfig): string { return (config.display_name || config.name || "").trim(); } /** 忽略空格/横杠/大小写,兼容旧会话「Seedance 2.0 Mini」对上后台「Seedance-2.0-Mini」。 */ function normalizeModelKey(value: string): string { return String(value || "").toLowerCase().replace(/[\s_\-·.]+/g, ""); } /** 会话里存的是展示名;用 display_name / name 都能对上目录项。 */ export function findCatalogModel( configs: ModelConfig[] | undefined, label: string, capability: "video" | "image", ): ModelConfig | undefined { const list = (configs || []).filter((c) => c.capability === capability && c.status !== "disabled"); const key = (label || "").trim(); if (!key) return list[0]; const norm = normalizeModelKey(key); return ( list.find((c) => modelOptionLabel(c) === key) || list.find((c) => c.name === key) || list.find((c) => normalizeModelKey(modelOptionLabel(c)) === norm) || list.find((c) => normalizeModelKey(c.name) === norm) || list.find((c) => { const dn = normalizeModelKey(c.display_name || ""); const nm = normalizeModelKey(c.name || ""); return (dn && (dn.includes(norm) || norm.includes(dn))) || (nm && (nm.includes(norm) || norm.includes(nm))); }) ); } function catalogModelLabels( configs: ModelConfig[] | undefined, capability: "video" | "image", fallback: string[], ): string[] { const labels = (configs || []) .filter((c) => c.capability === capability) .map(modelOptionLabel) .filter(Boolean); const seen = new Set(); const unique = labels.filter((x) => (seen.has(x) ? false : (seen.add(x), true))); return unique.length ? unique : fallback; } function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string): boolean { // 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。 // 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。 return modelDurations(config).some((seconds) => seconds >= 30) || /seedance\s*2\.5/i.test(model || ""); } function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] { if (!isVideo) return [...OMNI_IMAGE_COUNTS]; const seconds = modelDurations(config).filter((n) => n <= OMNI_MAX_VIDEO_DURATION); if (!seconds.length) return [...OMNI_VIDEO_DURATIONS]; const segmented = canCreateSegmentedVideo(config, model) ? [45, 60].filter((n) => n <= OMNI_MAX_VIDEO_DURATION) : []; return ["智能时长", ...seconds, ...segmented] .filter((value, index, values) => values.indexOf(value) === index) .map((value) => typeof value === "number" ? `${value} 秒` : value); } export function OmniParamBar({ isVideo, disabled, model, resolution, ratio, duration, catalogModels, onModel, onResolution, onRatio, onDuration, }: { isVideo: boolean; disabled?: boolean; model: string; resolution: string; ratio: string; duration: string; catalogModels?: ModelConfig[]; onModel: (value: string) => void; onResolution: (value: string) => void; onRatio: (value: string) => void; onDuration: (value: string) => void; }) { const [menuOpen, setMenuOpen] = useState(false); const [menuPos, setMenuPos] = useState<{ top?: number; bottom?: number; left: number; width: number } | null>(null); const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true); const wrapRef = useRef(null); const menuRef = useRef(null); const capability = isVideo ? "video" as const : "image" as const; const selected = useMemo( () => findCatalogModel(catalogModels, model, capability), [catalogModels, model, capability], ); const models = withCurrent( catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS), model, ); const allowedRes = isVideo ? modelResolutions(selected) : []; const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution); const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration); useEffect(() => { setCustomOn(isVideo ? duration !== "智能时长" : true); }, [isVideo, duration]); function placeMenu() { const el = wrapRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const gutter = 8; const width = Math.min(330, window.innerWidth - gutter * 2); const spaceBelow = window.innerHeight - rect.bottom - gutter; const spaceAbove = rect.top - gutter; const up = spaceBelow < 280 && spaceAbove > spaceBelow; let left = rect.right - width; left = Math.min(Math.max(gutter, left), window.innerWidth - width - gutter); if (up) setMenuPos({ bottom: window.innerHeight - rect.top + 8, left, width }); else setMenuPos({ top: rect.bottom + 8, left, width }); } useLayoutEffect(() => { if (!menuOpen) { setMenuPos(null); return; } placeMenu(); }, [menuOpen, customOn]); useEffect(() => { if (!menuOpen) return; const onDown = (event: MouseEvent) => { const target = event.target as Node; if (wrapRef.current?.contains(target) || menuRef.current?.contains(target)) return; setMenuOpen(false); }; const onReposition = () => placeMenu(); document.addEventListener("mousedown", onDown); window.addEventListener("resize", onReposition); window.addEventListener("scroll", onReposition, true); return () => { document.removeEventListener("mousedown", onDown); window.removeEventListener("resize", onReposition); window.removeEventListener("scroll", onReposition, true); }; }, [menuOpen]); useEffect(() => { if (!selected || !isVideo) return; const nextRes = modelResolutions(selected); if (nextRes.length && resolution && !nextRes.includes(resolution)) { onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]); } const nextDur = durationLabelsForModel(selected, model, true); const seconds = Number(String(duration || "").replace(/\D/g, "")); // 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。 if (seconds > OMNI_MAX_VIDEO_DURATION) { onDuration(`${OMNI_MAX_VIDEO_DURATION} 秒`); return; } const isPlannedSegmentedDuration = seconds > 30 && seconds <= OMNI_MAX_VIDEO_DURATION && canCreateSegmentedVideo(selected, model); // 31–60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就偷偷改回 8 秒/智能时长。 if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) { onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长")); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected?.id, isVideo, model]); return ( <>
{menuOpen && menuPos ? createPortal(
{isVideo ? "时长" : "生成张数"} {isVideo ? (
) : null}
, document.body, ) : null}
); }