Files
yingqing/core/frontend/src/components/omni-param-bar.tsx
T
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00

328 lines
12 KiB
TypeScript

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<string>();
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<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<>
<label className="omni-parameter omni-parameter-model">
<CustomSelect
fill
size="sm"
aria-label={isVideo ? "视频模型" : "图片模型"}
disabled={disabled}
value={model}
onChange={onModel}
options={toOptions(models)}
/>
</label>
<label className={`omni-parameter${isVideo ? "" : " is-hidden"}`}>
<CustomSelect
fill
size="sm"
aria-label="分辨率"
disabled={disabled}
value={resolution}
onChange={onResolution}
options={toOptions(resolutions)}
/>
</label>
<label className="omni-parameter">
<CustomSelect
fill
size="sm"
aria-label="画面比例"
disabled={disabled}
value={ratio}
onChange={onRatio}
options={toOptions(withCurrent(OMNI_RATIOS, ratio))}
/>
</label>
<div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
<button
type="button"
className="omni-duration-trigger"
aria-expanded={menuOpen}
disabled={disabled}
onClick={() => setMenuOpen((open) => !open)}
>
<span>{duration}</span>
<ChevronDown />
</button>
{menuOpen && menuPos
? createPortal(
<div
ref={menuRef}
className="omni-duration-menu is-portal"
style={{
position: "fixed",
top: menuPos.top,
bottom: menuPos.bottom,
left: menuPos.left,
width: menuPos.width,
right: "auto",
zIndex: 10000,
}}
>
<span className="omni-duration-title">{isVideo ? "时长" : "生成张数"}</span>
{isVideo ? (
<div className="omni-duration-modes">
<button
type="button"
className={!customOn ? "active" : ""}
onClick={() => {
setCustomOn(false);
onDuration("智能时长");
setMenuOpen(false);
}}
>
<Sparkles />
<span>智能时长</span>
</button>
<button
type="button"
className={customOn ? "active" : ""}
onClick={() => setCustomOn(true)}
>
<SlidersHorizontal />
<span>自定义时长</span>
</button>
</div>
) : null}
<div className="omni-duration-values" hidden={isVideo && !customOn}>
{(isVideo ? durations.filter((value) => value !== "智能时长") : durations).map((value) => (
<button
type="button"
key={value}
className={duration === value ? "active" : ""}
onClick={() => {
onDuration(value);
setCustomOn(true);
setMenuOpen(false);
}}
>
{value}
</button>
))}
</div>
</div>,
document.body,
)
: null}
</div>
</>
);
}