角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
328 lines
12 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|