duration slider for video gen website (#1367)

This commit is contained in:
BFlat
2026-04-14 21:31:45 +07:00
committed by GitHub
parent 4a1d1acddd
commit 99b831c3b0
5 changed files with 370 additions and 120 deletions
@@ -9,6 +9,7 @@ import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
import type { OmniGenVideoModelInfo } from "@storyteller/api";
import { ToggleButton } from "@storyteller/ui-button";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
PromptBox,
@@ -133,6 +134,32 @@ function buildSizePopoverItems(
}));
}
function resolveDurationForModel(
model: OmniGenVideoModelInfo,
current: number | null,
): number | null {
if (current == null) return model.duration_seconds_default ?? null;
if (
model.duration_seconds_min != null &&
model.duration_seconds_max != null
) {
if (
current >= model.duration_seconds_min &&
current <= model.duration_seconds_max
) {
return current;
}
return model.duration_seconds_default ?? model.duration_seconds_min;
}
if (model.duration_seconds_options?.length) {
if (model.duration_seconds_options.includes(current)) return current;
return (
model.duration_seconds_default ?? model.duration_seconds_options[0]!
);
}
return model.duration_seconds_default ?? null;
}
// ── Component ────────────────────────────────────────────────────────────
export default function CreateVideo() {
@@ -334,16 +361,45 @@ export default function CreateVideo() {
),
[selectedModel?.aspect_ratio_options, selectedSize],
);
const durationItems = useMemo(
(): PopoverItem[] | null =>
selectedModel?.duration_seconds_options
? selectedModel.duration_seconds_options.map((d) => ({
label: `${d}s`,
selected:
d === (duration ?? selectedModel.duration_seconds_default),
}))
: null,
[selectedModel, duration],
const durationRange = useMemo((): { min: number; max: number } | null => {
if (!selectedModel) return null;
if (
selectedModel.duration_seconds_min != null &&
selectedModel.duration_seconds_max != null &&
selectedModel.duration_seconds_max > selectedModel.duration_seconds_min
) {
return {
min: selectedModel.duration_seconds_min,
max: selectedModel.duration_seconds_max,
};
}
if (
selectedModel.duration_seconds_options &&
selectedModel.duration_seconds_options.length > 1
) {
const opts = [...selectedModel.duration_seconds_options].sort(
(a, b) => a - b,
);
return { min: opts[0]!, max: opts[opts.length - 1]! };
}
return null;
}, [selectedModel]);
const effectiveDuration =
duration ?? selectedModel?.duration_seconds_default ?? 5;
const [localDuration, setLocalDuration] = useState(effectiveDuration);
const durationTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
clearTimeout(durationTimerRef.current);
setLocalDuration(effectiveDuration);
return () => clearTimeout(durationTimerRef.current);
}, [effectiveDuration]);
const handleDurationSlide = useCallback(
(v: number) => {
setLocalDuration(v);
clearTimeout(durationTimerRef.current);
durationTimerRef.current = setTimeout(() => setDuration(v), 300);
},
[setDuration],
);
const resolutionItems = useMemo(
(): PopoverItem[] | null =>
@@ -419,10 +475,12 @@ export default function CreateVideo() {
(item: PopoverItem) => {
const model = item.action ? _modelLookup.get(item.action) : undefined;
if (!model) return;
const currentDuration = useCreateVideoStore.getState().ui.duration;
const nextDuration = resolveDurationForModel(model, currentDuration);
setUi({
selectedModelId: model.model,
selectedSize: model.aspect_ratio_default ?? "wide_sixteen_by_nine",
duration: model.duration_seconds_default ?? null,
duration: nextDuration,
resolution: model.resolution_default ?? null,
generateWithSound: false,
inputMode: "keyframe",
@@ -462,14 +520,6 @@ export default function CreateVideo() {
[setSelectedSize],
);
const handleDurationChange = useCallback(
(item: PopoverItem) => {
const seconds = parseInt(item.label, 10);
if (!isNaN(seconds)) setDuration(seconds);
},
[setDuration],
);
const handleResolutionChange = useCallback(
(item: PopoverItem) =>
setResolution(LABEL_TO_RES[item.label] ?? item.label),
@@ -829,17 +879,10 @@ export default function CreateVideo() {
/>
</Tooltip>
)}
{durationItems && (
<Tooltip
content="Duration"
position="top"
className="z-50"
closeOnClick
>
{durationRange && (
<Tooltip content="Duration" position="top" className="z-50">
<PopoverMenu
items={durationItems}
onSelect={handleDurationChange}
mode="toggle"
mode="default"
panelTitle="Duration"
triggerIcon={
<FontAwesomeIcon
@@ -847,7 +890,31 @@ export default function CreateVideo() {
className="h-3.5 w-3.5"
/>
}
/>
triggerLabel={`${effectiveDuration}s`}
>
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
<div className="flex items-center gap-2.5">
<div className="flex-1">
<SliderV2
min={durationRange.min}
max={durationRange.max}
value={localDuration}
onChange={handleDurationSlide}
step={1}
suffix="s"
variant="filled"
/>
</div>
<span className="text-base-fg min-w-6 shrink-0 text-sm font-medium tabular-nums">
{localDuration}s
</span>
</div>
<div className="text-base-fg/40 mt-1.5 flex justify-between px-0.5 text-[11px] tabular-nums">
<span>{durationRange.min}s</span>
<span>{durationRange.max}s</span>
</div>
</div>
</PopoverMenu>
</Tooltip>
)}
{hasSound && (
@@ -65,6 +65,7 @@ module.exports = {
background: "#242424",
panel: "#1F1F1F",
"panel-border": "#3F3F3F",
border: "#3F3F3F",
controls: "#3E3E41",
"controls-button": "#3E3E41",
"controls-border": "#3f3f3f",
@@ -1,4 +1,11 @@
import { ReactNode, useRef, useEffect, useState, useCallback } from "react";
import {
ReactNode,
useRef,
useEffect,
useLayoutEffect,
useState,
useCallback,
} from "react";
import { createPortal } from "react-dom";
import {
Popover,
@@ -179,6 +186,48 @@ export interface PopoverItem {
selectedRight?: ReactNode;
}
const ViewportClamp = ({
targetRef,
}: {
targetRef: React.RefObject<HTMLDivElement | null>;
}) => {
useLayoutEffect(() => {
const el = targetRef.current;
if (!el) return;
el.style.visibility = "hidden";
el.style.transform = "";
let done = false;
const clamp = () => {
if (done || !el.isConnected) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const margin = 8;
const vw = window.innerWidth;
let dx = 0;
if (rect.right > vw - margin) {
dx = vw - margin - rect.right;
} else if (rect.left < margin) {
dx = margin - rect.left;
}
el.style.transform = dx !== 0 ? `translateX(${dx}px)` : "";
el.style.visibility = "";
done = true;
};
clamp();
const raf = requestAnimationFrame(clamp);
const onResize = () => {
done = false;
clamp();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, [targetRef]);
return null;
};
interface PopoverMenuProps {
items?: PopoverItem[];
onSelect?: (item: PopoverItem) => void;
@@ -230,6 +279,7 @@ export const PopoverMenu = ({
const [openTooltipIdx, setOpenTooltipIdx] = useState<number | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const panelContentRef = useRef<HTMLDivElement>(null);
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
const [scrollReady, setScrollReady] = useState(false);
@@ -509,7 +559,9 @@ export const PopoverMenu = ({
position === "bottom" ? "origin-top" : "origin-bottom",
)}
>
{open && <ViewportClamp targetRef={panelContentRef} />}
<div
ref={panelContentRef}
className={twMerge(
"z-10 min-w-48 mt-2 rounded-lg bg-ui-panel p-1.5 shadow-lg border border-ui-panel-border overflow-visible",
position === "top" ? "mb-2" : "mt-2",
@@ -113,7 +113,7 @@ export const SliderV2 = ({
{showDecrement && (
<Button
icon={faMinus}
className="focus-visible:outline-primary my-auto mr-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
className="focus-visible:outline-primary my-auto mr-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-primary/30"
onClick={handleDecrement}
/>
)}
@@ -130,8 +130,8 @@ export const SliderV2 = ({
>
<div
className={twMerge(
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
isDragging && "!bg-brand-primary/50"
"absolute h-full bg-primary/30 transition-colors duration-300 group-hover:bg-primary/50",
isDragging && "!bg-primary/50"
)}
style={{ width: `${percentage}%` }}
>
@@ -188,7 +188,7 @@ export const SliderV2 = ({
{showIncrement && (
<Button
icon={faPlus}
className="focus-visible:outline-primary my-auto ml-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
className="focus-visible:outline-primary my-auto ml-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-primary/30"
onClick={handleIncrement}
/>
)}
+215 -85
View File
File diff suppressed because it is too large Load Diff