Compare commits

...
26 changed files with 449 additions and 43 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

@@ -140,7 +140,11 @@ export const TopBar = ({
</div>
<div className="flex items-center justify-center gap-2 font-medium">
<SceneTitleInput pageName={pageName} />
{currentAppTabId === "3D" ? (
<SceneTitleInput pageName={pageName} />
) : (
<h1>{currentAppTabId === "2D" ? "Canvas" : "Generate"}</h1>
)}
</div>
<div className="flex justify-end gap-3.5">
@@ -0,0 +1,171 @@
import { useEffect, useState, useRef } from "react";
function getRandomImages(count: number) {
const images = Array.from({ length: count }, (_, i) => ({
id: i + 1,
src: `/resources/images/generate/image${i + 1}.png`,
alt: `Generated image ${i + 1}`,
}));
// Shuffle the array
return [...images].sort(() => Math.random() - 0.5);
}
interface ImageColumnProps {
images: Array<{ id: number; src: string; alt: string }>;
delay: number;
reverse?: boolean;
isLoaded: boolean;
duration?: number;
}
function ImageColumn({
images,
delay,
reverse = false,
duration = 100,
isLoaded,
}: ImageColumnProps) {
const columnRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!columnRef.current) return;
const column = columnRef.current;
const animation = column.animate(
[
{ transform: reverse ? "translateY(-50%)" : "translateY(0)" },
{ transform: reverse ? "translateY(0)" : "translateY(-50%)" },
],
{
duration: duration * 1000,
iterations: Infinity,
easing: "linear",
delay: delay + 1000,
fill: "both",
},
);
if (!isLoaded) {
animation.pause();
}
return () => {
animation.cancel();
};
}, [delay, duration, isLoaded, reverse]);
return (
<div className="relative h-screen overflow-hidden">
<div
ref={columnRef}
className="absolute left-0 right-0 flex flex-col gap-6"
style={{
transform: reverse ? "translateY(-50%)" : "translateY(0)",
willChange: "transform",
}}
>
{[...images, ...images].map((image, i) => (
<div
key={`${image.id}-${i}`}
className={`relative aspect-[4/5] w-full overflow-hidden rounded-lg shadow-lg transition-all duration-700 ease-out ${
isLoaded
? "translate-y-0 opacity-100"
: "translate-y-12 opacity-0"
}`}
style={{
transitionDelay: `${delay + (i % images.length) * 50}ms`,
}}
>
<img
src={image.src}
alt={image.alt}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
))}
</div>
</div>
);
}
export default function GalleryBackground() {
const [images, _setImages] = useState(() => getRandomImages(20));
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
// delay for smooth animation
const timer = setTimeout(() => {
setIsLoaded(true);
}, 100);
return () => clearTimeout(timer);
}, []);
// Columns for masonry layout
const columns = {
col1: images.filter((_, i) => i % 5 === 0),
col2: images.filter((_, i) => i % 5 === 1),
col3: images.filter((_, i) => i % 5 === 2),
col4: images.filter((_, i) => i % 5 === 3),
col5: images.filter((_, i) => i % 5 === 4),
};
return (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,rgba(0,0,0,0.00)_49%,rgba(0,0,0,0.60)_100%)]" />
<div className="fixed inset-0 z-0 overflow-hidden">
<div
className={`h-full w-full transition-opacity duration-1000 ${isLoaded ? "opacity-30" : "opacity-0"}`}
>
<div className="absolute inset-0 grid grid-cols-5 gap-6 px-6 opacity-35">
<ImageColumn
images={columns.col1}
delay={0}
isLoaded={isLoaded}
duration={100}
/>
<ImageColumn
images={columns.col2}
delay={columns.col1.length * 50}
isLoaded={isLoaded}
reverse={true}
duration={100}
/>
<ImageColumn
images={columns.col3}
delay={(columns.col1.length + columns.col2.length) * 50}
isLoaded={isLoaded}
duration={125}
/>
<ImageColumn
images={columns.col4}
delay={
(columns.col1.length +
columns.col2.length +
columns.col3.length) *
50
}
isLoaded={isLoaded}
reverse={true}
duration={160}
/>
<ImageColumn
images={columns.col5}
delay={
(columns.col1.length +
columns.col2.length +
columns.col3.length +
columns.col4.length) *
50
}
isLoaded={isLoaded}
duration={110}
/>
</div>
</div>
</div>
</>
);
}
@@ -1,28 +1,27 @@
import { useState, useRef } from "react";
import { JobContextType } from "libs/common/src/lib/types";
import { PromptBoxVideo } from "@storyteller/ui-promptbox";
import BackgroundGallery from "./BackgroundGallery";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { faFilm, faClock } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const ImageToVideo = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>("Kling 2.0");
const [useSystemPrompt, setUseSystemPrompt] = useState(false);
const [isImageBeingProcessed, setIsImageBeingProcessed] = useState(false);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string>("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleClose = () => {
setIsDialogOpen(false);
};
const handleClose = () => setIsDialogOpen(false);
const handleAddImage = (image: File) => {
setSelectedImage(image);
setIsImageBeingProcessed(true);
setImagePreviewUrl(URL.createObjectURL(image));
// TODO: Implement actual image processing here
// For now, we'll just simulate processing
setTimeout(() => {
setIsImageBeingProcessed(false);
}, 3000);
setTimeout(() => setIsImageBeingProcessed(false), 3000);
};
const jobContext: JobContextType = {
@@ -32,13 +31,41 @@ const ImageToVideo = () => {
clearJobTokens: () => {},
};
const modelList: PopoverItem[] = [
{
label: "Kling 2.0",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
selected: selectedModel === "Kling 2.0",
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Runway",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
selected: selectedModel === "Runway",
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
];
const handleModelSelect = (item: PopoverItem) => {
setSelectedModel(item.label);
};
return (
<div
ref={containerRef}
className="flex h-[calc(100vh-56px)] w-full rounded-lg bg-ui-background"
className="flex h-[calc(100vh-56px)] w-full rounded-lg bg-[#121212]"
>
<div className="h-full w-full p-4">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md bg-gray-800">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md pb-12">
<div className="relative z-20 mb-12 flex flex-col items-center justify-center text-center drop-shadow-xl">
<span className="text-7xl font-bold">Generate Video</span>
<span className="pt-2 text-xl opacity-80">
Add an image, prompt, then generate
</span>
</div>
<PromptBoxVideo
useJobContext={() => {
// TODO: Implement job context logic
@@ -50,6 +77,21 @@ const ImageToVideo = () => {
console.log("Enqueue pressed");
}}
/>
<BackgroundGallery />
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-2">
<PopoverMenu
items={modelList}
onSelect={handleModelSelect}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[280px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
</div>
</div>
</div>
</div>
@@ -1,7 +1,14 @@
import { ReactNode } from "react";
import { Popover, Transition } from "@headlessui/react";
import { ReactNode, useRef, useState, useEffect } from "react";
import {
Popover,
Transition,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronUp, faCircleCheck } from "@fortawesome/pro-solid-svg-icons";
export interface PopoverItem {
label: string;
@@ -9,6 +16,11 @@ export interface PopoverItem {
icon?: ReactNode;
action?: string;
disabled?: boolean;
description?: string;
badges?: Array<{
label: string;
icon?: ReactNode;
}>;
}
interface PopoverMenuProps {
@@ -19,8 +31,8 @@ interface PopoverMenuProps {
showAddButton?: boolean;
disableAddButton?: boolean;
showIconsInList?: boolean;
mode?: "default" | "toggle" | "button";
triggerLabel?: string;
mode?: "default" | "toggle" | "button" | "hoverSelect";
triggerLabel?: string | ReactNode;
children?: ReactNode;
buttonClassName?: string;
panelClassName?: string;
@@ -81,12 +93,97 @@ export const PopoverMenu = ({
end: "right-0",
};
// Track hover state and refs
const [isHovering, setIsHovering] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
const handleButtonMouseEnter = (open: boolean, openFn: () => void) => {
if (mode !== "hoverSelect") return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
if (!open) {
hoverTimeoutRef.current = setTimeout(() => {
openFn();
}, 100);
}
};
const handleButtonMouseLeave = (closeFn: () => void) => {
if (mode !== "hoverSelect") return;
setIsHovering(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
if (!isHovering) {
closeFn();
}
}, 300);
};
const handlePanelMouseEnter = () => {
if (mode !== "hoverSelect") return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
};
const handlePanelMouseLeave = (closeFn: () => void) => {
if (mode !== "hoverSelect") return;
setIsHovering(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
closeFn();
}, 300);
};
return (
<Popover className="relative">
{({ close }) => (
<>
<Popover.Button className={className}>
<>
<div className="relative inline-block">
<Popover>
{({ open, close }) => (
<>
<PopoverButton
className={className}
onMouseEnter={() =>
handleButtonMouseEnter(open, () => {
if (popoverButtonRef.current && !open) {
popoverButtonRef.current.click();
}
})
}
onMouseLeave={() => handleButtonMouseLeave(close)}
onClick={(e) => {
if (mode === "hoverSelect" && open) {
e.preventDefault();
e.stopPropagation();
}
}}
ref={popoverButtonRef}
>
{triggerIcon}
{mode === "toggle" && selectedItem ? (
<span className="truncate">{selectedItem.label}</span>
@@ -94,17 +191,19 @@ export const PopoverMenu = ({
{mode === "default" && triggerLabel ? (
<span className="truncate">{triggerLabel}</span>
) : null}
</>
</Popover.Button>
{mode === "hoverSelect" && selectedItem ? (
<div className="flex items-center gap-1.5">
<span className="opacity-70">{triggerLabel}</span>
<div className="flex items-center gap-2">
<span className="truncate">{selectedItem.label}</span>
<FontAwesomeIcon icon={faChevronUp} className="text-sm" />
</div>
</div>
) : null}
</PopoverButton>
<div
className={twMerge(
"absolute transform-gpu",
positionClasses[position],
alignClasses[align]
)}
>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom={
position === "bottom"
@@ -120,18 +219,23 @@ export const PopoverMenu = ({
: "-translate-y-2 opacity-0"
}
>
<Popover.Panel
<PopoverPanel
static
className={twMerge(
"absolute transform-gpu z-10",
positionClasses[position],
alignClasses[align],
position === "bottom" ? "origin-top" : "origin-bottom"
)}
>
<div
className={twMerge(
"z-10 min-w-48 rounded-lg bg-[#46464B] p-1.5 shadow-lg",
position === "top" && "mb-2",
position === "bottom" && "mt-2",
"z-10 w-56 mt-2 rounded-lg bg-[#46464B] p-1.5 shadow-lg",
position === "top" ? "mb-2" : "mt-2",
panelClassName
)}
onMouseEnter={handlePanelMouseEnter}
onMouseLeave={() => handlePanelMouseLeave(close)}
>
{panelTitle && (
<div className="mb-2 mt-0.5 flex justify-between px-1.5 text-sm font-normal text-white opacity-70">
@@ -151,6 +255,91 @@ export const PopoverMenu = ({
)}
{mode === "default" && children ? (
<div className="text-sm text-white">{children}</div>
) : mode === "hoverSelect" ? (
<div className="flex flex-col gap-0 text-sm text-white">
{items.map((item, index) => (
<div
key={index}
onClick={() => {
if (!item.disabled) {
handleItemClick(item, close);
}
}}
className={twMerge(
"group flex cursor-pointer items-start gap-2 rounded-lg px-2 py-2 transition-all",
item.selected
? "bg-black/40 border-l-4 border-primary"
: "hover:bg-black/20",
item.disabled
? "!cursor-not-allowed opacity-50"
: ""
)}
style={{ minHeight: 48 }}
>
<div className="flex items-center gap-2 w-full">
<div className="flex items-start gap-2 grow">
{showIconsInList && (
<span className="mt-1 flex h-5 w-5 items-center justify-center text-lg text-white/80">
{item.icon}
</span>
)}
<div className="flex flex-1 flex-col min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate font-semibold text-white text-base">
{item.label}
</span>
</div>
{item.description && (
<div className="truncate text-xs text-white/60 mt-0.5">
{item.description}
</div>
)}
<div className="flex flex-row gap-1 flex-wrap mt-1.5">
{item.badges &&
Array.isArray(item.badges) &&
item.badges.map((badge, i) => (
<div
key={i}
className="flex items-center gap-1 min-w-0"
>
<span className="inline-flex items-center rounded bg-black/40 px-1.5 py-0.5 text-xs font-medium text-white gap-1">
{badge?.icon && (
<span>{badge.icon}</span>
)}
{badge?.label || ""}
</span>
</div>
))}
</div>
</div>
</div>
{item.selected && (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center mr-1">
<FontAwesomeIcon icon={faCircleCheck} />
</span>
)}
</div>
</div>
))}
{showAddButton && onAdd && (
<Button
variant="secondary"
className={twMerge(
"w-full mb-0.5 mt-2 border-none py-1",
disableAddButton
? "cursor-not-allowed bg-[#7B7B84]/50 opacity-50"
: "bg-[#7B7B84] hover:bg-[#8c8c96]"
)}
onClick={onAdd}
disabled={disableAddButton}
>
+ Add
</Button>
)}
</div>
) : (
<div className="flex flex-col gap-0 text-sm text-white">
{items.map((item, index) => (
@@ -203,11 +392,11 @@ export const PopoverMenu = ({
</div>
)}
</div>
</Popover.Panel>
</PopoverPanel>
</Transition>
</div>
</>
)}
</Popover>
</>
)}
</Popover>
</div>
);
};
@@ -445,7 +445,7 @@ export const PromptBoxVideo = ({
>
{content}
</Modal>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
<div className="relative z-20 flex flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
@@ -523,7 +523,7 @@ export const PromptBoxVideo = ({
<textarea
ref={textareaRef}
rows={1}
placeholder="Choose the image from assets (+) and describe your what you want to happen in the video..."
placeholder="Choose the image from assets (+) and describe what you want to happen in the video..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}