mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
duration slider for video gen website (#1367)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Generated
+215
-85
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user