diff --git a/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx index bcee64af87..e35143844d 100644 --- a/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx @@ -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>(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() { /> )} - {durationItems && ( - + {durationRange && ( + } - /> + triggerLabel={`${effectiveDuration}s`} + > +
+
+
+ +
+ + {localDuration}s + +
+
+ {durationRange.min}s + {durationRange.max}s +
+
+
)} {hasSound && ( diff --git a/frontend/apps/artcraft-website/tailwind.config.js b/frontend/apps/artcraft-website/tailwind.config.js index c996c213d1..8f752737d6 100644 --- a/frontend/apps/artcraft-website/tailwind.config.js +++ b/frontend/apps/artcraft-website/tailwind.config.js @@ -65,6 +65,7 @@ module.exports = { background: "#242424", panel: "#1F1F1F", "panel-border": "#3F3F3F", + border: "#3F3F3F", controls: "#3E3E41", "controls-button": "#3E3E41", "controls-border": "#3f3f3f", diff --git a/frontend/libs/components/popover/src/lib/popover.tsx b/frontend/libs/components/popover/src/lib/popover.tsx index c9e67afcdb..42adb8c6fe 100644 --- a/frontend/libs/components/popover/src/lib/popover.tsx +++ b/frontend/libs/components/popover/src/lib/popover.tsx @@ -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; +}) => { + 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(null); const scrollContainerRef = useRef(null); + const panelContentRef = useRef(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 && }
)} @@ -130,8 +130,8 @@ export const SliderV2 = ({ >
@@ -188,7 +188,7 @@ export const SliderV2 = ({ {showIncrement && (