Compare commits

...
56 changed files with 3582 additions and 1027 deletions
@@ -1,15 +1,18 @@
import { faEye, faEyeSlash, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import {
faEye,
faEyeSlash,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { UsersApi } from "@storyteller/api";
import { GoogleLoginButton } from "./GoogleLoginButton";
interface SignupFormProps {
onSuccess: (isNewUser?: boolean) => void;
signupSource: string;
className?: string;
showGoogleButton?: boolean;
autoFocus?: boolean;
}
@@ -17,7 +20,6 @@ export const SignupForm = ({
onSuccess,
signupSource,
className = "",
showGoogleButton = true,
autoFocus = false,
}: SignupFormProps) => {
const [email, setEmail] = useState("");
@@ -101,62 +103,70 @@ export const SignupForm = ({
)}
*/}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-sm text-center animate-in fade-in slide-in-from-top-2">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
onKeyDown={(e) => e.key === "Enter" && handleSignup()}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleSignup}
disabled={isLoading}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleSignup();
}}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create Account"
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
</Button>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Password
</label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<div className="pt-2">
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create Account"
)}
</Button>
</div>
</form>
</div>
);
};
@@ -1,7 +1,10 @@
import { type ReactNode } from "react";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird, type IconDefinition } from "@fortawesome/pro-solid-svg-icons";
import {
faSpinnerThird,
type IconDefinition,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import Seo from "../../components/seo";
@@ -71,7 +74,7 @@ export function CreateMediaPageShell({
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
</div>
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 pt-20">
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
<FontAwesomeIcon
icon={heroIcon}
className="mb-6 text-5xl text-white/20"
@@ -108,14 +111,15 @@ export function CreateMediaPageShell({
<div className="flex h-screen w-full bg-[#101014] text-white">
<Seo title={title} description={description} />
{/* Glow orbs */}
{glowOrbs ?? (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
</div>
)}
{/* Glow orbs — only show on empty state, hide when gallery has content */}
{!hasContent &&
(glowOrbs ?? (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
</div>
))}
<div className="relative z-[1] h-full w-full">
<div className="flex h-full w-full flex-col">
@@ -134,12 +138,10 @@ export function CreateMediaPageShell({
{hasContent && (
<div
className="h-full w-full overflow-y-auto pt-20"
className="h-full w-full overflow-y-auto pt-[60px] sm:pt-[78px]"
style={{ paddingBottom: bottomOffset }}
>
<div className="px-2 md:px-3 lg:px-4">
{gridContent}
</div>
<div className="px-3">{gridContent}</div>
</div>
)}
@@ -149,7 +151,10 @@ export function CreateMediaPageShell({
className="animate-fade-in-up fixed bottom-3 left-6 z-20 hidden items-center gap-5 lg:flex"
style={{ animationDelay: "300ms" }}
>
<div className="rounded-xl border border-white/10 px-3 py-2 shadow-lg backdrop-blur-xl" style={{ backgroundColor: "rgba(30, 30, 33, 0.85)" }}>
<div
className="rounded-xl border border-white/10 px-3 py-2 shadow-lg backdrop-blur-xl"
style={{ backgroundColor: "rgba(30, 30, 33, 0.85)" }}
>
<PopoverMenu
items={modelItems}
onSelect={onModelChange}
@@ -2,6 +2,10 @@ import { memo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube, faImage, faVideo } from "@fortawesome/pro-solid-svg-icons";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
import {
getModelCreatorIconPath,
getModelDisplayName,
} from "../../lib/omni-gen-hooks";
import type { GalleryItem } from "./useGalleryData";
// ── Persistent aspect ratio cache ─────────────────────────────────────────
@@ -71,6 +75,26 @@ export const GalleryCard = memo(function GalleryCard({
containIntrinsicSize: "auto 200px",
};
const modelDisplayName = item.modelId
? getModelDisplayName(item.modelId)
: null;
const modelIconPath = item.modelId
? getModelCreatorIconPath(item.modelId)
: null;
const mediaIcon =
item.mediaClass === "video"
? faVideo
: item.mediaClass === "dimensional"
? faCube
: faImage;
const mediaLabel =
item.mediaClass === "video"
? "Video"
: item.mediaClass === "dimensional"
? "3D"
: "Image";
return (
<button
className="group relative block w-full overflow-hidden rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus:ring-2 focus:ring-primary-400"
@@ -105,29 +129,29 @@ export const GalleryCard = memo(function GalleryCard({
) : (
<div className="flex h-full w-full items-center justify-center">
<FontAwesomeIcon
icon={
item.mediaClass === "video"
? faVideo
: item.mediaClass === "dimensional"
? faCube
: faImage
}
icon={mediaIcon}
className="text-xl text-white/20"
/>
</div>
)}
{item.mediaClass === "video" && (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
<FontAwesomeIcon icon={faVideo} className="text-[8px]" />
Video
{/* Hover overlay with media type + model badges */}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-1.5 bg-gradient-to-t from-black/60 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
<FontAwesomeIcon icon={mediaIcon} className="text-[8px]" />
{mediaLabel}
</div>
)}
{item.mediaClass === "dimensional" && (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
<FontAwesomeIcon icon={faCube} className="text-[8px]" />
3D
</div>
)}
{modelDisplayName && modelIconPath && (
<div className="flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
<img
src={modelIconPath}
alt=""
className="h-3 w-3 icon-auto-contrast"
/>
<span className="max-w-[100px] truncate">{modelDisplayName}</span>
</div>
)}
</div>
</button>
);
});
@@ -16,10 +16,10 @@ const BREAKPOINT_COLS = {
640: 2,
};
// 4px gap on both axes — use Tailwind arbitrary values for exact pixels.
// ml-[-4px] on container offsets the first column's pl-[4px].
const MASONRY_CLASS = "flex w-auto ml-[-4px]";
const COLUMN_CLASS = "pl-[4px]";
// 12px gap on both axes (≈ Tailwind gap-3).
// ml-[-12px] on container offsets the first column's pl-[12px].
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
const COLUMN_CLASS = "pl-[12px]";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -82,10 +82,11 @@ export function GenerationGalleryGrid({
columnClassName={COLUMN_CLASS}
>
{inProgressJobs.map((job) => (
<div key={job.id} className="mb-[4px]">
<div key={job.id} className="mb-[12px]">
<PendingCard
id={job.id}
prompt={job.prompt}
modelId={job.modelId}
modelLabel={job.modelLabel}
progress={job.progress}
estimatedTimeLeftMs={job.estimatedTimeLeftMs}
@@ -93,7 +94,7 @@ export function GenerationGalleryGrid({
</div>
))}
{failedJobs.map((job) => (
<div key={job.id} className="mb-[4px]">
<div key={job.id} className="mb-[12px]">
<FailedCard
id={job.id}
prompt={job.prompt}
@@ -105,12 +106,12 @@ export function GenerationGalleryGrid({
</div>
))}
{newlyCompletedItems.map((item) => (
<div key={`new-${item.id}`} className="mb-[4px]">
<div key={`new-${item.id}`} className="mb-[12px]">
<GalleryCard item={item} onClick={onGalleryItemClick} />
</div>
))}
{filteredGalleryItems.map((item) => (
<div key={item.id} className="mb-[4px]">
<div key={item.id} className="mb-[12px]">
<GalleryCard item={item} onClick={onGalleryItemClick} />
</div>
))}
@@ -1,9 +1,11 @@
import { memo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
export interface PendingCardProps {
id: string;
modelId: string;
modelLabel: string;
prompt: string;
progress?: number;
@@ -22,6 +24,7 @@ const formatTimeLeft = (ms: number): string => {
};
export const PendingCard = memo(function PendingCard({
modelId,
modelLabel,
prompt,
progress,
@@ -36,6 +39,8 @@ export const PendingCard = memo(function PendingCard({
? formatTimeLeft(estimatedTimeLeftMs)
: null;
const iconPath = getModelCreatorIconPath(modelId);
return (
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
<div className="animate-shimmer h-full w-full" />
@@ -53,19 +58,28 @@ export const PendingCard = memo(function PendingCard({
<span className="text-[10px] text-white/30">{timeLabel}</span>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6">
<p className="truncate text-xs text-white/70">{prompt}</p>
<div className="mt-1 flex items-center gap-2">
<p className="truncate text-[10px] text-white/40">{modelLabel}</p>
{progressPercent != null && (
<div className="h-1 min-w-0 flex-1 rounded-full bg-white/10">
<div
className="h-1 rounded-full bg-primary-400 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent px-3 pb-2.5 pt-8">
<p className="line-clamp-3 text-xs leading-relaxed text-white/80">
{prompt}
</p>
<div className="mt-1.5 flex items-center gap-1.5">
<img
src={iconPath}
alt=""
className="h-3.5 w-3.5 flex-shrink-0 icon-auto-contrast"
/>
<p className="truncate text-[10px] font-medium text-white/50">
{modelLabel}
</p>
</div>
{progressPercent != null && (
<div className="mt-1.5 h-1 w-full rounded-full bg-white/10">
<div
className="h-1 rounded-full bg-primary-400 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
</div>
</div>
);
@@ -15,6 +15,7 @@ export interface GalleryItem {
fullImage: string | null;
createdAt: string;
mediaClass: string;
modelId?: string;
batchImageToken?: string;
}
@@ -56,7 +57,7 @@ export function useGalleryData(options: {
const thumbnail = isDimensional
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.MEDIUM,
size: THUMBNAIL_SIZES.LARGE,
});
return {
@@ -66,6 +67,7 @@ export function useGalleryData(options: {
fullImage: item.media_links?.cdn_url || null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
modelId: item.maybe_model_type || undefined,
batchImageToken: item.maybe_batch_token,
};
}, []);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { JobsApi, JobStatus } from "@storyteller/api";
import { JobsApi, JobStatus, MediaFilesApi } from "@storyteller/api";
import type { Job } from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import {
getModelDisplayName,
getProviderDisplayName,
@@ -13,6 +14,7 @@ import type { GalleryItem } from "./useGalleryData";
export interface InProgressJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
progress: number;
estimatedTimeLeftMs?: number;
@@ -21,6 +23,7 @@ export interface InProgressJob {
export interface FailedJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
failureReason?: string;
failureMessage?: string;
@@ -102,6 +105,7 @@ function jobToInProgress(job: Job): InProgressJob {
return {
id: job.job_token,
prompt: getPrompt(job),
modelId: job.request.maybe_model_type ?? "",
modelLabel: getModelLabel(job),
progress,
estimatedTimeLeftMs,
@@ -124,6 +128,7 @@ function jobToFailed(job: Job): FailedJob {
return {
id: job.job_token,
prompt: getPrompt(job),
modelId: job.request.maybe_model_type ?? "",
modelLabel: getModelLabel(job),
failureReason,
failureMessage,
@@ -145,9 +150,51 @@ function jobToGalleryItem(job: Job): GalleryItem | null {
createdAt:
result.maybe_successfully_completed_at || job.updated_at,
mediaClass: mediaType === "video" ? "video" : "image",
modelId: job.request.maybe_model_type ?? undefined,
};
}
/** Expand a single GalleryItem into its batch siblings (if any). */
async function expandBatchItems(
item: GalleryItem,
mediaFilesApi: MediaFilesApi,
): Promise<GalleryItem[]> {
try {
const mediaResponse = await mediaFilesApi.GetMediaFileByToken({
mediaFileToken: item.id,
});
const batchToken = (mediaResponse.data as any)?.maybe_batch_token;
if (!batchToken) return [item];
const batchResponse = await mediaFilesApi.GetMediaFilesByBatchToken({
batchToken,
});
if (!batchResponse.success || !batchResponse.data?.length) return [item];
return batchResponse.data
.map((file: any): GalleryItem | null => {
const cdnUrl = file.media_links?.cdn_url;
if (!cdnUrl) return null;
const thumbnail = getMediaThumbnail(file.media_links, item.mediaClass, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: file.token,
label: item.label,
thumbnail: thumbnail || cdnUrl,
fullImage: cdnUrl,
createdAt: item.createdAt,
mediaClass: item.mediaClass,
modelId: item.modelId,
batchImageToken: batchToken,
};
})
.filter((i): i is GalleryItem => i !== null);
} catch {
return [item];
}
}
// ── Hook ───────────────────────────────────────────────────────────────────
export function useGenerationJobs(options: {
@@ -155,6 +202,7 @@ export function useGenerationJobs(options: {
}) {
const { mediaType } = options;
const apiRef = useRef(new JobsApi());
const mediaApiRef = useRef(new MediaFilesApi());
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
const [failed, setFailed] = useState<FailedJob[]>([]);
@@ -209,11 +257,16 @@ export function useGenerationJobs(options: {
.map(jobToGalleryItem)
.filter((item): item is GalleryItem => item !== null);
if (items.length > 0) {
setNewlyCompleted((prev) => {
// Deduplicate by id
const existingIds = new Set(prev.map((i) => i.id));
const fresh = items.filter((i) => !existingIds.has(i.id));
return [...fresh, ...prev];
// Expand batch items (fetch siblings) then update state
Promise.all(
items.map((item) => expandBatchItems(item, mediaApiRef.current)),
).then((expanded) => {
const allItems = expanded.flat();
setNewlyCompleted((prev) => {
const existingIds = new Set(prev.map((i) => i.id));
const fresh = allItems.filter((i) => !existingIds.has(i.id));
return [...fresh, ...prev];
});
});
}
}
@@ -2,14 +2,7 @@ import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { toast } from "@storyteller/ui-toaster";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
useEffect,
useState,
useMemo,
useCallback,
useRef,
} from "react";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -39,7 +32,6 @@ import {
} from "@storyteller/model-list";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
import dayjs from "dayjs";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaOptionsType } from "embla-carousel";
@@ -58,7 +50,7 @@ export interface LightboxItem {
}
interface ContextImage {
media_links: { cdn_url: string; maybe_thumbnail_template: string };
media_links: { cdn_url: string; maybe_thumbnail_template: string | null };
media_token: string;
semantic: string;
}
@@ -147,6 +139,8 @@ interface LightboxProps {
onNavigatePrev?: () => void;
onNavigateNext?: () => void;
onDeleted?: (id: string) => void;
/** When false, suppress batch carousel (batch siblings shown as separate gallery cards instead). Default true. */
showBatchCarousel?: boolean;
}
export function Lightbox({
@@ -161,6 +155,7 @@ export function Lightbox({
onNavigatePrev,
onNavigateNext,
onDeleted,
showBatchCarousel = true,
}: LightboxProps) {
const [mediaLoaded, setMediaLoaded] = useState(false);
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
@@ -171,7 +166,9 @@ export function Lightbox({
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [isPromptClamped, setIsPromptClamped] = useState(false);
const [discoveredBatchToken, setDiscoveredBatchToken] = useState<string | null>(null);
const [discoveredBatchToken, setDiscoveredBatchToken] = useState<
string | null
>(null);
const promptRef = useRef<HTMLDivElement>(null);
const promptCopy = useCopyFeedback();
@@ -215,10 +212,7 @@ export function Lightbox({
if (batchToken) setDiscoveredBatchToken(batchToken);
}
if (
mediaResponse.success &&
mediaResponse.data?.maybe_prompt_token
) {
if (mediaResponse.success && mediaResponse.data?.maybe_prompt_token) {
const promptResponse = await promptsApi.GetPromptsByToken({
token: mediaResponse.data.maybe_prompt_token,
});
@@ -248,7 +242,9 @@ export function Lightbox({
}, [mediaToken, isOpen, mediaFilesApi, promptsApi]);
// Fetch batch images (from prop or auto-discovered batch token)
const effectiveBatchToken = propBatchImageToken || discoveredBatchToken;
const effectiveBatchToken = showBatchCarousel
? propBatchImageToken || discoveredBatchToken
: undefined;
useEffect(() => {
if (!effectiveBatchToken || !isOpen) {
@@ -363,11 +359,19 @@ export function Lightbox({
const selectedImageUrl = effectiveImageUrls[selectedIndex] ?? null;
const selectedMediaToken = useMemo(() => {
return batchTokens?.[selectedIndex] ?? propMediaTokens?.[selectedIndex] ?? mediaToken;
return (
batchTokens?.[selectedIndex] ??
propMediaTokens?.[selectedIndex] ??
mediaToken
);
}, [batchTokens, propMediaTokens, selectedIndex, mediaToken]);
const isVideo = selectedImageUrl ? isVideoUrl(selectedImageUrl) : (propMediaClass === "video");
const is3D = selectedImageUrl ? is3DModelUrl(selectedImageUrl) : (propMediaClass === "dimensional");
const isVideo = selectedImageUrl
? isVideoUrl(selectedImageUrl)
: propMediaClass === "video";
const is3D = selectedImageUrl
? is3DModelUrl(selectedImageUrl)
: propMediaClass === "dimensional";
// Keyboard navigation
useEffect(() => {
@@ -410,12 +414,13 @@ export function Lightbox({
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl h-[680px] w-[1100px] max-w-[95vw] max-h-[90vh] p-0"
className="rounded-xl h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/5 shadow-2xl"
backdropClassName="!bg-black/80"
showClose={false}
>
<div className="flex h-full">
<div className="flex flex-col sm:flex-row h-full">
{/* Media preview panel */}
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black">
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden rounded-t-xl sm:rounded-l-xl sm:rounded-tr-none bg-black">
{/* Close button */}
<button
onClick={onClose}
@@ -444,8 +449,16 @@ export function Lightbox({
autoPlay
muted
playsInline
disablePictureInPicture
controlsList="nodownload noplaybackrate nofullscreen"
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
ref={(el) => {
if (el) {
el.setAttribute("webkit-playsinline", "true");
el.setAttribute("x-webkit-airplay", "deny");
}
}}
>
<source src={selectedImageUrl} type="video/mp4" />
</video>
@@ -493,7 +506,10 @@ export function Lightbox({
{effectiveImageUrls.length > 1 && (
<div className="mt-3 px-2 pb-2">
<div className="embla-thumbs overflow-hidden" ref={emblaThumbsRef}>
<div
className="embla-thumbs overflow-hidden"
ref={emblaThumbsRef}
>
<div className="embla-thumbs__container flex gap-2 justify-center">
{effectiveImageUrls.map((url, idx) => (
<button
@@ -521,7 +537,7 @@ export function Lightbox({
)}
{!mediaLoaded && selectedImageUrl && !isVideo && !is3D && (
<div className="absolute inset-0 flex items-center justify-center bg-ui-panel">
<div className="absolute inset-0 flex items-center justify-center bg-black">
<LoadingSpinner className="h-12 w-12 text-base-fg" />
</div>
)}
@@ -533,7 +549,7 @@ export function Lightbox({
e.stopPropagation();
onNavigatePrev();
}}
className="absolute left-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100"
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
aria-label="Previous item"
>
<FontAwesomeIcon icon={faChevronLeft} className="text-lg" />
@@ -545,7 +561,7 @@ export function Lightbox({
e.stopPropagation();
onNavigateNext();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100"
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
aria-label="Next item"
>
<FontAwesomeIcon icon={faChevronRight} className="text-lg" />
@@ -554,8 +570,8 @@ export function Lightbox({
</div>
{/* Info sidebar */}
<div className="flex h-full w-[300px] shrink-0 flex-col bg-ui-panel rounded-r-xl">
<div className="flex-1 overflow-y-auto p-5 flex flex-col gap-5 min-h-0">
<div className="flex w-full sm:w-[300px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5 min-h-0">
{promptData.loading ? (
<div className="space-y-6 animate-pulse">
<div className="space-y-2">
@@ -594,9 +610,7 @@ export function Lightbox({
icon={promptCopy.copied ? faCheck : faCopy}
className="h-3 w-3"
/>
<span>
{promptCopy.copied ? "Copied" : "Copy"}
</span>
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
</button>
)}
</div>
@@ -616,9 +630,7 @@ export function Lightbox({
(isPromptClamped || isPromptExpanded) && (
<button
className="flex w-full items-center justify-center gap-1 text-xs text-white/70 hover:text-white transition-colors py-1"
onClick={() =>
setIsPromptExpanded((prev) => !prev)
}
onClick={() => setIsPromptExpanded((prev) => !prev)}
>
<span>
{isPromptExpanded ? "Show less" : "Show more"}
@@ -639,10 +651,12 @@ export function Lightbox({
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map(
(contextImage, index) => {
const { thumbnail } =
getContextImageThumbnail(contextImage, {
const { thumbnail } = getContextImageThumbnail(
contextImage,
{
size: THUMBNAIL_SIZES.SMALL,
});
},
);
return (
<a
key={contextImage.media_token}
@@ -716,7 +730,7 @@ export function Lightbox({
</div>
{/* Action buttons */}
<div className="p-4 pt-2 space-y-2 border-t border-white/5">
<div className="p-4 space-y-2 border-t border-white/5">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
@@ -1,5 +1,7 @@
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
@@ -12,7 +14,12 @@ import { Link, useLocation } from "react-router-dom";
import { Button } from "@storyteller/ui-button";
import { UsersApi, UserInfo, CreditsApi } from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCoins, faGrid2 } from "@fortawesome/pro-solid-svg-icons";
import {
faCoins,
faGrid2,
faBars,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { TaskQueue } from "./task-queue";
const NAV_ITEMS = [
@@ -94,7 +101,6 @@ export default function Navbar() {
const handleLogout = async () => {
const api = new UsersApi();
await api.Logout();
// Reload to clear state/cache
window.location.href = "/";
};
@@ -108,243 +114,316 @@ export default function Navbar() {
}, []);
return (
<Disclosure
as="nav"
className={twMerge(
"z-20 fixed top-0 left-0 w-full transition-all duration-200 bg-transparent",
scrolled || alwaysSolid
? "bg-[#1b1b1f]/70 backdrop-blur-lg"
: "bg-transparent",
)}
>
<div className="mx-auto max-w-screen sm:px-6 lg:px-8 px-4 md:px-16 xl:px-4">
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
<Link to="/">
<img
alt="ArtCraft"
src="/images/artcraft-logo.png"
className="h-6 w-auto"
/>
</Link>
</div>
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
{NAV_ITEMS.map((item) => {
const isCurrent =
item.href === "/"
? location.pathname === "/"
: location.pathname === item.href ||
location.pathname.startsWith(item.href + "/");
return (
<Link
key={item.name}
to={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
"nav-link",
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all",
)}
>
<span className="relative z-10">{item.name}</span>
<span
className={twMerge(
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
isCurrent ? "" : "",
)}
aria-hidden="true"
>
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent ? "visible-line" : "",
)}
/>
</span>
</Link>
);
})}
</div>
</div>
<div className="flex items-center">
{isLoading ? (
// Loading placeholder
<div className="hidden md:ml-4 md:flex items-center gap-2.5 opacity-0"></div>
) : user ? (
// Logged In State
<div className="hidden md:ml-4 md:flex items-center gap-2.5">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
{credits !== null && (
<div className="flex items-center gap-2 px-4 text-[15px] font-semibold text-white/90">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-sm"
<Disclosure as="nav" className="z-20 fixed top-0 left-0 w-full">
{({ open }) => (
<div
className={twMerge(
"transition-all duration-200",
scrolled || alwaysSolid || open ? "bg-[#1b1b1f]" : "bg-transparent",
)}
>
<div className="mx-auto max-w-screen px-4">
<div className="flex h-12 sm:h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
<Link to="/">
<img
alt="ArtCraft"
src="/images/artcraft-logo.png"
className="h-5 sm:h-6 w-auto"
/>
{credits.toLocaleString()} Credits
</Link>
</div>
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
{NAV_ITEMS.map((item) => {
const isCurrent =
item.href === "/"
? location.pathname === "/"
: location.pathname === item.href ||
location.pathname.startsWith(item.href + "/");
return (
<Link
key={item.name}
to={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
"nav-link",
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all",
)}
>
<span className="relative z-10">{item.name}</span>
<span
className={twMerge(
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
)}
aria-hidden="true"
>
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent ? "visible-line" : "",
)}
/>
</span>
</Link>
);
})}
</div>
</div>
<div className="flex items-center">
{isLoading ? (
<div className="hidden md:ml-4 md:flex items-center gap-2.5 opacity-0"></div>
) : user ? (
// ── Desktop: Logged In ─────────────────────────
<div className="hidden md:ml-4 md:flex items-center gap-2.5">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
{credits !== null && (
<div className="flex items-center gap-2 px-4 text-[15px] font-semibold text-white/90">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-sm"
/>
{credits.toLocaleString()} Credits
</div>
)}
<Link
to="/library"
className="flex h-[38px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-base-fg bg-ui-controls hover:bg-ui-controls/80 border border-ui-controls-border shadow-sm transition-all duration-150 active:scale-95"
>
<FontAwesomeIcon icon={faGrid2} className="text-xs" />
My Library
</Link>
<TaskQueue />
<Menu as="div" className="relative ml-3">
<div>
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
<span className="sr-only">Open user menu</span>
<img
className="h-8 w-8 rounded-full border border-white/10"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
modal={false}
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
>
<div className="px-4 py-3 border-b border-white/10">
<p className="text-sm text-white font-semibold truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-white/5" : "",
"block w-full text-left px-4 py-2 text-sm text-white/70",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
) : (
// ── Desktop: Logged Out ────────────────────────
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
<div className="flex gap-2">
<Link to="/login">
<Button
variant="primary"
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
)}
<Link
to="/library"
className="flex h-[38px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-base-fg bg-ui-controls hover:bg-ui-controls/80 border border-ui-controls-border shadow-sm transition-all duration-150 active:scale-95"
>
<FontAwesomeIcon icon={faGrid2} className="text-xs" />
My Library
</Link>
{/* ── Mobile: hamburger + task queue ──────────── */}
<div className="flex items-center gap-2 md:hidden">
{user && <TaskQueue />}
<DisclosureButton className="flex h-9 w-9 items-center justify-center rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-colors">
<span className="sr-only">Open main menu</span>
<FontAwesomeIcon
icon={open ? faXmark : faBars}
className="text-lg"
/>
</DisclosureButton>
</div>
</div>
</div>
</div>
<TaskQueue />
{/* ── Mobile slide-down panel ─────────────────────── */}
<Transition
as={Fragment}
enter="transition duration-150 ease-out"
enterFrom="opacity-0 -translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transition duration-100 ease-in"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2"
>
<DisclosurePanel className="md:hidden border-t border-white/10 bg-[#1b1b1f]/95 backdrop-blur-xl">
<div className="px-3 pb-3 pt-3">
{/* Nav links */}
<div className="flex flex-col">
{NAV_ITEMS.map((item) => {
const isCurrent =
item.href === "/"
? location.pathname === "/"
: location.pathname === item.href ||
location.pathname.startsWith(item.href + "/");
return (
<DisclosureButton
key={item.name}
as={Link}
to={item.href}
className={twMerge(
"rounded-md px-3 py-[7px] text-[13px] font-medium transition-colors",
isCurrent
? "bg-white/10 text-white"
: "text-white/60 active:bg-white/5",
)}
>
{item.name}
</DisclosureButton>
);
})}
</div>
<Menu as="div" className="relative ml-3">
<div>
<MenuButton className="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
<span className="sr-only">Open user menu</span>
<div className="my-3 border-t border-white/[0.06]" />
{!isLoading && user ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5">
<DisclosureButton
as={Link}
to="/library"
className="flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium text-white/60 bg-white/[0.06] active:bg-white/10 transition-colors"
>
<FontAwesomeIcon
icon={faGrid2}
className="text-[10px]"
/>
Library
</DisclosureButton>
<DisclosureButton
as={Link}
to="/pricing"
className="flex h-8 items-center rounded-md px-2.5 text-[12px] font-medium text-white/60 bg-white/[0.06] active:bg-white/10 transition-colors"
>
Pricing
</DisclosureButton>
{credits !== null && (
<span className="flex items-center gap-2 ml-auto text-[12px] font-medium text-white/80">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[10px]"
/>
{credits.toLocaleString()} Credits
</span>
)}
</div>
<div className="flex items-center gap-2">
<img
className="h-8 w-8 rounded-full border border-white/10"
className="h-7 w-7 rounded-full border border-white/10 shrink-0"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
</MenuButton>
<span className="truncate text-[13px] font-medium text-white/80 flex-1">
{user.display_name || user.username}
</span>
<DisclosureButton
as="button"
onClick={handleLogout}
className="flex h-7 items-center rounded-md px-2.5 text-[12px] font-medium text-red-400/70 active:bg-red-500/10 transition-colors shrink-0"
>
Sign out
</DisclosureButton>
</div>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
modal={false}
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
>
<div className="px-4 py-3 border-b border-white/10">
<p className="text-sm text-white font-semibold truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-white/5" : "",
"block w-full text-left px-4 py-2 text-sm text-white/70",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
) : !isLoading ? (
<div className="flex gap-2">
<DisclosureButton as={Link} to="/login" className="flex-1">
<Button className="w-full bg-white text-black hover:bg-white/90 text-[13px] font-semibold h-9 rounded-lg justify-center">
Login
</Button>
</DisclosureButton>
<DisclosureButton as={Link} to="/signup" className="flex-1">
<Button
variant="primary"
className="w-full text-[13px] font-semibold h-9 rounded-lg justify-center"
>
Sign up
</Button>
</DisclosureButton>
</div>
) : null}
</div>
) : (
// Logged Out State
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center gap-6">
<Link
to="/pricing"
className="text-[15px] font-semibold text-white/60 hover:text-white transition-colors"
>
Pricing
</Link>
<div className="flex gap-2">
<Link to="/login">
<Button
variant="primary"
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
)}
</DisclosurePanel>
</Transition>
{/* Mobile Menu Button - simplified for now */}
<div className="-ml-2 flex items-center md:hidden gap-3">
{!user && (
<div className="flex gap-2">
<Link to="/login">
<Button className="bg-white/60 text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg">
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="text-xs font-semibold px-3 py-1.5 h-auto"
>
Sign up
</Button>
</Link>
</div>
)}
{user && (
<img
className="h-8 w-8 rounded-full border border-white/10"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
)}
</div>
</div>
<style>{`
.link-underline {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
transition: transform 220ms ease, opacity 220ms ease;
}
.nav-link:hover .link-underline {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.visible-line {
transform: scaleX(1) translateX(0);
opacity: 1;
}
`}</style>
</div>
</div>
{/* <DisclosurePanel className="md:hidden">
<div className="space-y-1 px-2 pt-2 pb-3 sm:px-3">
{navigation.map((item) => (
<DisclosureButton
key={item.name}
as="a"
href={item.href}
aria-current={item.current ? "page" : undefined}
className={twMerge(
item.current
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white",
"block rounded-md px-3 py-2 text-base font-medium"
)}
>
{item.name}
</DisclosureButton>
))}
</div>
</DisclosurePanel> */}
<style>{`
.link-underline {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
transition: transform 220ms ease, opacity 220ms ease;
}
.nav-link:hover .link-underline {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.visible-line {
transform: scaleX(1) translateX(0);
opacity: 1;
}
`}</style>
)}
</Disclosure>
);
}
@@ -11,6 +11,8 @@ import {
faBomb,
faCircleExclamation,
faTriangleExclamation,
faCopy,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -27,6 +29,8 @@ import dayjs from "dayjs";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { Lightbox } from "../lightbox/lightbox";
import { showToast } from "../toast/toast";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
import { twMerge } from "tailwind-merge";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -39,6 +43,7 @@ type InProgressTask = {
canDismiss?: boolean;
estimatedTimeLeftMs?: number;
modelType?: string;
prompt?: string;
};
type CompletedTask = {
@@ -50,6 +55,7 @@ type CompletedTask = {
updatedAt?: Date;
imageUrls?: string[];
mediaTokens?: string[];
prompt?: string;
};
type FailedTask = {
@@ -60,6 +66,7 @@ type FailedTask = {
status: string;
failureReason?: string;
failureMessage?: string;
prompt?: string;
};
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -106,6 +113,92 @@ const FAILED_STATUSES = new Set([
JobStatus.CANCELLED_BY_SYSTEM,
]);
// ── Shared sub-components ─────────────────────────────────────────────────
const PromptLine = ({
prompt,
className,
}: {
prompt: string;
className?: string;
}) => {
const [marqueePlaying, setMarqueePlaying] = useState(false);
const [promptOverflows, setPromptOverflows] = useState(false);
const promptRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = promptRef.current;
if (el) setPromptOverflows(el.scrollWidth > el.clientWidth);
}, [prompt]);
return (
<Tooltip
content={prompt.length > 300 ? prompt.slice(0, 300) + "\u2026" : prompt}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={400}
>
<div
className={twMerge("mt-1 overflow-hidden", className)}
onMouseEnter={
promptOverflows ? () => setMarqueePlaying(true) : undefined
}
onMouseLeave={
promptOverflows ? () => setMarqueePlaying(false) : undefined
}
>
<div
ref={promptRef}
key={marqueePlaying ? "playing" : "idle"}
className="whitespace-nowrap text-[11px] italic text-base-fg/40"
style={
marqueePlaying
? {
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
: undefined
}
>
{prompt}
</div>
</div>
</Tooltip>
);
};
const CopyPromptButton = ({ prompt }: { prompt: string }) => {
const [copied, setCopied] = useState(false);
return (
<Tooltip
content={copied ? "Copied!" : "Copy prompt"}
position="bottom"
strategy="fixed"
className="text-xs"
zIndex={50}
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}}
>
<FontAwesomeIcon
icon={copied ? faCheck : faCopy}
className={copied ? "text-green-400" : ""}
/>
</button>
</Tooltip>
);
};
// ── Card Components ────────────────────────────────────────────────────────
const InProgressCard = ({
@@ -123,6 +216,10 @@ const InProgressCard = ({
? formatTimeLeft(task.estimatedTimeLeftMs)
: null;
const isSeedance2 = task.modelType === "seedance_2p0";
const modelIconPath = task.modelType
? getModelCreatorIconPath(task.modelType)
: null;
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
@@ -136,6 +233,13 @@ const InProgressCard = ({
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5 truncate font-medium text-base-fg/90">
{modelIconPath && (
<img
src={modelIconPath}
alt=""
className="h-3.5 w-3.5 flex-shrink-0 icon-auto-contrast"
/>
)}
{task.title}
{isSeedance2 && (
<Tooltip
@@ -175,19 +279,23 @@ const InProgressCard = ({
{timeLabel && (
<div className="mt-1 text-xs text-base-fg/50">{timeLabel}</div>
)}
{task.prompt && <PromptLine prompt={task.prompt} className="mt-0" />}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
</div>
);
@@ -225,7 +333,7 @@ const CompletedCard = ({
</div>
)}
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-base-fg/90">
{task.title}
</div>
@@ -239,19 +347,23 @@ const CompletedCard = ({
{dayjs(task.completedAt).format("MMM D, h:mm A")}
</div>
)}
{task.prompt && <PromptLine prompt={task.prompt} />}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
);
};
@@ -285,57 +397,62 @@ const FailedCard = ({
{task.subtitle}
</div>
)}
<div className="mt-1 flex items-center gap-1.5">
<span className="rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
{statusLabel}
</span>
{task.failureReason && (
<Tooltip
content={task.failureReason}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap bg-red-500/90 text-xs"
zIndex={50}
delay={300}
>
<span className="truncate text-[11px] text-red-400/60">
{task.failureReason}
</span>
</Tooltip>
<div className="min-w-0 overflow-hidden">
<Tooltip
content={
task.failureMessage ? (
<div>
<div className="font-semibold">
{task.failureReason}
</div>
<div className="mt-0.5 font-normal opacity-80">
{task.failureMessage}
</div>
</div>
) : (
task.failureReason
)
}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap bg-danger text-xs"
zIndex={50}
delay={300}
>
<div className="cursor-default truncate text-[11px] text-red-400/80 underline decoration-red-400/30 decoration-dashed underline-offset-2">
{task.failureReason}
</div>
</Tooltip>
</div>
)}
</div>
{task.failureMessage && (
<Tooltip
content={task.failureMessage}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={200}
>
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
{task.failureMessage}
</div>
</Tooltip>
)}
{task.prompt && <PromptLine prompt={task.prompt} />}
{task.failedAt && (
<div className="mt-0.5 text-[11px] text-base-fg/40">
{dayjs(task.failedAt).format("MMM D, h:mm A")}
</div>
)}
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
<div className="ml-auto flex shrink-0 items-center gap-1">
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
</div>
</div>
);
@@ -343,6 +460,10 @@ const FailedCard = ({
// ── Job → Task transformers ────────────────────────────────────────────────
function getPrompt(job: Job): string {
return job.request.maybe_raw_inference_text || "";
}
function formatTitleParts(job: Job) {
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
const modelTypeStr = job.request.maybe_model_type ?? "";
@@ -418,6 +539,7 @@ function jobsToInProgress(jobs: Job[]): InProgressTask[] {
canDismiss,
estimatedTimeLeftMs,
modelType: modelType ?? undefined,
prompt: getPrompt(j) || undefined,
};
});
@@ -453,6 +575,7 @@ function jobsToCompleted(jobs: Job[]): CompletedTask[] {
mediaTokens: j.maybe_result?.entity_token
? [j.maybe_result.entity_token]
: [],
prompt: getPrompt(j) || undefined,
};
});
}
@@ -473,8 +596,7 @@ function jobsToFailed(jobs: Job[]): FailedTask[] {
undefined
: j.status.maybe_extra_status_description || undefined;
const failureMessage =
j.status.maybe_extra_status_description &&
failureCategory !== "unknown"
j.status.maybe_extra_status_description && failureCategory !== "unknown"
? j.status.maybe_extra_status_description
: undefined;
@@ -486,6 +608,7 @@ function jobsToFailed(jobs: Job[]): FailedTask[] {
status: j.status.status,
failureReason,
failureMessage,
prompt: getPrompt(j) || undefined,
};
});
}
@@ -502,7 +625,9 @@ export const TaskQueue = () => {
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxMediaToken, setLightboxMediaToken] = useState<string | undefined>();
const [lightboxMediaToken, setLightboxMediaToken] = useState<
string | undefined
>();
const [lightboxCdnUrl, setLightboxCdnUrl] = useState<string | undefined>();
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
const prevFailedIdsRef = useRef<Set<string>>(new Set());
@@ -795,9 +920,7 @@ export const TaskQueue = () => {
<InProgressCard
key={t.id}
task={t}
onDismiss={
t.canDismiss ? () => dismissTask(t.id) : undefined
}
onDismiss={t.canDismiss ? () => dismissTask(t.id) : undefined}
/>
))}
</div>
@@ -868,7 +991,7 @@ export const TaskQueue = () => {
return (
<>
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative">
<div className="relative task-queue-trigger">
{badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[17px] w-[17px] items-center justify-center rounded-full bg-primary-400 text-[13px] font-medium text-white">
{badgeCount}
@@ -877,7 +1000,7 @@ export const TaskQueue = () => {
<PopoverMenu
mode="default"
buttonClassName="h-[38px] w-[38px] !p-0 relative"
panelClassName="w-[360px] p-2 bg-ui-panel mt-2.5"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-ui-panel mt-2.5"
position="bottom"
align="end"
triggerIcon={
@@ -994,6 +1117,17 @@ export const TaskQueue = () => {
mediaToken={lightboxMediaToken}
cdnUrl={lightboxCdnUrl}
/>
{/* On mobile, make the PopoverMenu's wrapper static so the absolute
panel positions against the fixed navbar (full width), then pin
the panel to viewport edges with left/right insets. */}
<style>{`
@media (max-width: 639px) {
.task-queue-trigger .relative.inline-block {
position: static !important;
}
}
`}</style>
</>
);
};
@@ -0,0 +1,915 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlus,
faArrowLeft,
faUpload,
faUserGroup,
faSpinnerThird,
faXmark,
faPen,
faTrashAlt,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import {
CharactersApi,
Character,
MediaUploadApi,
} from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore } from "./characters-store";
interface CharactersModalProps {
isOpen: boolean;
onClose: () => void;
onSelectCharacter?: (character: Character) => void;
}
type ModalView = "list" | "create" | "edit";
interface UploadedImage {
file: File;
url: string;
mediaToken?: string;
}
interface PendingCharacter {
name: string;
previewUrl?: string;
}
const POLL_INTERVAL_MS = 5000;
export const CharactersModal = ({
isOpen,
onClose,
onSelectCharacter,
}: CharactersModalProps) => {
const [view, setView] = useState<ModalView>("list");
const [editingCharacter, setEditingCharacter] = useState<Character | null>(
null,
);
const [pendingCharacters, setPendingCharacters] = useState<
PendingCharacter[]
>([]);
const [refreshKey, setRefreshKey] = useState(0);
const handleClose = () => {
setView("list");
setEditingCharacter(null);
onClose();
};
const handleEdit = (character: Character) => {
setEditingCharacter(character);
setView("edit");
};
const handleEditDone = () => {
setEditingCharacter(null);
setView("list");
setRefreshKey((k) => k + 1);
};
const handleCreated = (pending: PendingCharacter) => {
setPendingCharacters((prev) => [pending, ...prev]);
setView("list");
setRefreshKey((k) => k + 1);
};
const removePending = useCallback((name: string) => {
setPendingCharacters((prev) => prev.filter((p) => p.name !== name));
}, []);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={view === "list" ? "Characters" : undefined}
className="max-w-[800px] h-[600px] max-h-[80vh] overflow-hidden"
>
<div
className="overflow-y-auto"
style={{
maxHeight:
view === "list"
? "min(calc(600px - 5rem), calc(80vh - 5rem))"
: "min(calc(600px - 2.5rem), calc(80vh - 2.5rem))",
}}
>
{view === "list" ? (
<CharacterListView
key={refreshKey}
onCreateClick={() => setView("create")}
onSelectCharacter={onSelectCharacter}
onEditCharacter={handleEdit}
pendingCharacters={pendingCharacters}
onPendingResolved={removePending}
/>
) : view === "create" ? (
<NewCharacterView
onBack={() => setView("list")}
onCreated={handleCreated}
/>
) : editingCharacter ? (
<EditCharacterView
character={editingCharacter}
onBack={() => {
setEditingCharacter(null);
setView("list");
}}
onSaved={handleEditDone}
/>
) : null}
</div>
</Modal>
);
};
// ---------------------------------------------------------------------------
// Character List View
// ---------------------------------------------------------------------------
const CharacterListView = ({
onCreateClick,
onSelectCharacter,
onEditCharacter,
pendingCharacters,
onPendingResolved,
}: {
onCreateClick: () => void;
onSelectCharacter?: (character: Character) => void;
onEditCharacter: (character: Character) => void;
pendingCharacters: PendingCharacter[];
onPendingResolved: (name: string) => void;
}) => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<number | undefined>(undefined);
const [confirmDelete, setConfirmDelete] = useState<Character | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
const storeRemoveCharacter = useCharactersStore((s) => s.removeCharacter);
const syncToStore = useCallback(
(chars: Character[]) => {
storeSetCharacters(
chars.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
storeSetLoaded(true);
},
[storeSetCharacters, storeSetLoaded],
);
const fetchCharacters = useCallback(
async (nextCursor?: number) => {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
try {
const api = new CharactersApi();
const res = await api.ListCharacters({ cursor: nextCursor });
if (res.success && res.data) {
setCharacters((prev) => {
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
syncToStore(updated);
return updated;
});
const nextPage = res.pagination?.next_cursor;
setCursor(nextPage ?? undefined);
setHasMore(!!nextPage);
}
} catch {
storeSetLoaded(true);
} finally {
setLoading(false);
loadingMoreRef.current = false;
}
},
[syncToStore, storeSetLoaded],
);
useEffect(() => {
fetchCharacters();
}, [fetchCharacters]);
// Poll for pending characters becoming active
useEffect(() => {
if (pendingCharacters.length === 0) return;
const interval = setInterval(async () => {
try {
const api = new CharactersApi();
const res = await api.ListCharacters({});
if (res.success && res.data) {
const serverNames = new Set(res.data.map((c) => c.name));
for (const pending of pendingCharacters) {
if (serverNames.has(pending.name)) {
onPendingResolved(pending.name);
}
}
setCharacters(res.data);
syncToStore(res.data);
}
} catch {
// Silently retry on next interval
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [pendingCharacters, onPendingResolved, syncToStore]);
// Infinite scroll via IntersectionObserver
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && cursor) {
fetchCharacters(cursor);
}
},
{ threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, cursor, fetchCharacters]);
const handleDelete = async (character: Character) => {
setIsDeleting(true);
try {
const api = new CharactersApi();
const res = await api.DeleteCharacter({
characterToken: character.token,
});
if (res.success) {
setCharacters((prev) =>
prev.filter((c) => c.token !== character.token),
);
storeRemoveCharacter(character.token);
toast.success(`Character "${character.name}" deleted`);
} else {
toast.error(res.errorMessage || "Failed to delete character");
}
} catch {
toast.error("Failed to delete character");
} finally {
setIsDeleting(false);
setConfirmDelete(null);
}
};
return (
<div className="flex flex-col">
{loading && characters.length === 0 ? (
<div className="grid grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
>
<div className="aspect-square w-full overflow-hidden">
<div
className="h-full w-full bg-white/[0.06]"
style={{
animation: `charPulse 1.8s ease-in-out ${i * 0.07}s infinite`,
}}
/>
</div>
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
<div
className="h-3 w-2/3 rounded bg-white/[0.08]"
style={{
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
}}
/>
</div>
</div>
))}
<style>{`
@keyframes charPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
) : (
<div className="grid grid-cols-4 gap-3">
{/* Create New card */}
<button
onClick={onCreateClick}
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-white/[0.05] text-white/60 transition-colors hover:border-white/25 hover:text-white/80"
>
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
<FontAwesomeIcon icon={faPlus} className="text-lg" />
<span className="text-sm font-medium">Create New</span>
</div>
</button>
{/* Pending (creating) characters */}
{pendingCharacters.map((pending) => (
<div
key={`pending-${pending.name}`}
className="relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
>
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
{pending.previewUrl ? (
<img
src={pending.previewUrl}
alt={pending.name}
className="h-full w-full object-cover object-top opacity-50"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<FontAwesomeIcon icon={faUserGroup} className="text-2xl" />
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-lg text-white/80 animate-spin"
/>
<span className="text-xs font-medium text-white/80">
Creating...
</span>
</div>
</div>
<div className="px-2 py-1.5 text-center">
<p className="truncate text-xs font-medium text-white/50">
{pending.name}
</p>
</div>
</div>
))}
{characters.map((character) => {
const isUserCreated = character.is_user_created !== false;
return (
<div
key={character.token}
className="group relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05] transition-colors hover:border-white/25 hover:bg-white/10"
>
<button
onClick={() => onSelectCharacter?.(character)}
className="flex flex-1 flex-col"
>
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
{character.maybe_avatar?.cdn_url ? (
<img
src={character.maybe_avatar.cdn_url}
alt={character.name}
className="h-full w-full object-cover object-top"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<FontAwesomeIcon
icon={faUserGroup}
className="text-2xl"
/>
</div>
)}
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-white/80">
{character.name}
</p>
</div>
</button>
{/* Edit / Delete overlay buttons (user-created only) */}
{isUserCreated && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faPen} className="text-[10px]" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
</button>
</div>
)}
</div>
);
})}
</div>
)}
{/* Sentinel for infinite scroll */}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-4">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-white/30 animate-spin"
/>
</div>
)}
{/* Delete confirmation overlay */}
{confirmDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-xl border border-white/10 bg-[#1e1e22] p-6 shadow-2xl">
<h3 className="mb-2 text-lg font-semibold text-white">
Delete character?
</h3>
<p className="mb-5 text-sm text-white/70">
This will permanently delete{" "}
<strong>{confirmDelete.name}</strong>. This action cannot be
undone.
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
className="border-none"
onClick={() => setConfirmDelete(null)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
className="bg-red-500 hover:bg-red-600"
onClick={() => handleDelete(confirmDelete)}
loading={isDeleting}
disabled={isDeleting}
>
Delete
</Button>
</div>
</div>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Edit Character View
// ---------------------------------------------------------------------------
const EditCharacterView = ({
character,
onBack,
onSaved,
}: {
character: Character;
onBack: () => void;
onSaved: () => void;
}) => {
const updateCharacterInStore = useCharactersStore((s) => s.updateCharacter);
const [name, setName] = useState(character.name);
const [description, setDescription] = useState(
character.maybe_description ?? "",
);
const [saving, setSaving] = useState(false);
const hasChanges =
name.trim() !== character.name ||
(description.trim() || "") !== (character.maybe_description ?? "");
const handleSave = async () => {
if (!name.trim()) {
toast.error("Name cannot be empty");
return;
}
setSaving(true);
try {
const api = new CharactersApi();
const res = await api.EditCharacter({
token: character.token,
updated_name: name.trim() !== character.name ? name.trim() : undefined,
updated_description:
(description.trim() || "") !== (character.maybe_description ?? "")
? description.trim() || null
: undefined,
});
if (res.success) {
toast.success("Character updated");
updateCharacterInStore(character.token, { name: name.trim() });
onSaved();
} else {
toast.error(res.errorMessage || "Failed to update character");
}
} catch {
toast.error("Failed to update character");
} finally {
setSaving(false);
}
};
return (
<div className="flex flex-col">
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-3 pb-0">
<button
onClick={onBack}
className="flex items-center justify-center text-white/60 transition-colors hover:text-white"
>
<FontAwesomeIcon icon={faArrowLeft} />
</button>
<h2 className="text-xl font-bold text-white">Edit Character</h2>
</div>
{/* Avatar preview */}
{character.maybe_avatar?.cdn_url && (
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/[0.05]">
<img
src={character.maybe_avatar.cdn_url}
alt={character.name}
className="max-h-full max-w-full object-contain"
/>
</div>
)}
{/* Name input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-character-name" className="text-sm font-medium text-white/80">
Name
</label>
<input
id="edit-character-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
autoComplete="off"
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
/>
</div>
{/* Description input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-character-description" className="text-sm font-medium text-white/80">
Description <span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
id="edit-character-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description..."
rows={3}
autoComplete="off"
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" className="border-none" onClick={onBack}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
loading={saving}
disabled={saving || !name.trim() || !hasChanges}
>
Save
</Button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// New Character View
// ---------------------------------------------------------------------------
const NewCharacterView = ({
onBack,
onCreated,
}: {
onBack: () => void;
onCreated: (pending: PendingCharacter) => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
const [creating, setCreating] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const processFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter((f) =>
f.type.startsWith("image/"),
);
if (imageFiles.length === 0) {
toast.error("Please upload image files");
return;
}
const file = imageFiles[0]!;
const newImages: UploadedImage[] = [
{ file, url: URL.createObjectURL(file) },
];
setImages((prev) => {
prev.forEach((img) => URL.revokeObjectURL(img.url));
return newImages;
});
setUploading(true);
const uploadApi = new MediaUploadApi();
const updatedImages: UploadedImage[] = [];
for (const img of newImages) {
try {
const res = await uploadApi.UploadImage({
uuid: uuidv4(),
blob: img.file,
fileName: img.file.name,
maybe_title: `character_ref_${name || "unnamed"}`,
});
if (res.success && res.data) {
updatedImages.push({ ...img, mediaToken: res.data });
} else {
toast.error(`Failed to upload ${img.file.name}`);
updatedImages.push(img);
}
} catch {
toast.error(`Failed to upload ${img.file.name}`);
updatedImages.push(img);
}
}
setImages((prev) =>
prev.map((existing) => {
const updated = updatedImages.find((u) => u.url === existing.url);
return updated || existing;
}),
);
setUploading(false);
},
[name],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) setIsDragging(true);
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
},
[isDragging],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const { clientX: x, clientY: y } = e;
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files?.length) {
processFiles(e.dataTransfer.files);
}
},
[processFiles],
);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
processFiles(e.target.files);
}
};
const removeImage = (index: number) => {
setImages((prev) => {
const removed = prev[index];
if (removed) URL.revokeObjectURL(removed.url);
return prev.filter((_, i) => i !== index);
});
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error("Please enter a character name");
return;
}
const uploadedImages = images.filter((img) => img.mediaToken);
if (uploadedImages.length < 1) {
toast.error("Please upload a reference image");
return;
}
setCreating(true);
try {
const api = new CharactersApi();
const res = await api.CreateCharacter({
image_media_token: uploadedImages[0]!.mediaToken!,
model: "seedance_2p0",
uuid_idempotency_token: uuidv4(),
character_name: name.trim(),
character_description: description.trim() || null,
});
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" is being created`);
addCharacterToStore({
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
onCreated({
name: name.trim(),
previewUrl: uploadedImages[0]!.url,
});
} else {
toast.error(res.errorMessage || "Failed to create character");
}
} catch {
toast.error("Failed to create character");
} finally {
setCreating(false);
}
};
// Cleanup object URLs on unmount
useEffect(() => {
return () => {
images.forEach((img) => URL.revokeObjectURL(img.url));
};
}, []);
return (
<div className="flex flex-col">
<div className="flex flex-col gap-4">
{/* Header with back button */}
<div className="flex items-center gap-3 pb-0">
<button
onClick={onBack}
className="flex items-center justify-center text-white/60 transition-colors hover:text-white"
>
<FontAwesomeIcon icon={faArrowLeft} />
</button>
<h2 className="text-xl font-bold text-white">New Character</h2>
</div>
{/* Image upload area */}
<div
ref={dropZoneRef}
className={twMerge(
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/20 bg-white/[0.05] transition-colors overflow-hidden",
isDragging && "border-blue-400 bg-blue-500/10",
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{images.length > 0 ? (
<div
className="group relative flex h-full w-full items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<img
src={images[0]!.url}
alt="Reference"
className="max-h-full max-w-full object-contain"
/>
{!images[0]!.mediaToken && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-white animate-spin"
/>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
removeImage(0);
}}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
>
<FontAwesomeIcon icon={faXmark} className="text-sm" />
</button>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
<FontAwesomeIcon
icon={faUpload}
className="mb-2 text-xl text-white/40"
/>
<p className="text-sm">Upload reference image</p>
<p className="mb-3 text-xs text-white/40">
Click or drag an image here
</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
{/* Name input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="character-name" className="text-sm font-medium text-white/80">
Name
</label>
<input
id="character-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
autoComplete="off"
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
/>
</div>
{/* Description input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="character-description" className="text-sm font-medium text-white/80">
Description <span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
id="character-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description..."
rows={3}
autoComplete="off"
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" className="border-none" onClick={onBack}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreate}
loading={creating}
disabled={
creating ||
uploading ||
!name.trim() ||
images.filter((i) => i.mediaToken).length < 1
}
>
Create
</Button>
</div>
</div>
);
};
@@ -158,7 +158,7 @@ export const ImagePickerModal = ({
className="max-w-7xl"
>
<div className="flex flex-col" style={{ height: "min(80vh, 800px)" }}>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{images.length === 0 && loading ? (
<div className="flex h-full items-center justify-center">
<FontAwesomeIcon
@@ -171,7 +171,7 @@ export const ImagePickerModal = ({
No images found
</div>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 pt-1">
{images.map((img) => {
const isSelected = selected.has(img.token);
return (
@@ -213,7 +213,7 @@ export const ImagePickerModal = ({
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/10 px-4 py-3">
<div className="flex items-center justify-between pt-3">
<span className="text-sm text-white/50">
{selected.size} of {maxSelect} selected
</span>
@@ -11,7 +11,6 @@ import {
faPlus,
faSpinner,
faSpinnerThird,
faTrashAlt,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { faImage } from "@fortawesome/pro-regular-svg-icons";
@@ -44,7 +43,7 @@ interface ImagePromptRowProps {
referenceImages: RefImage[];
setReferenceImages: (images: RefImage[]) => void;
onPickFromLibrary?: () => void;
onClearAll?: () => void;
onClearAll?: () => void; // unused, kept for API compat
className?: string;
// Video mode props
@@ -53,6 +52,7 @@ interface ImagePromptRowProps {
endFrameImage?: RefImage;
setEndFrameImage?: (image?: RefImage) => void;
showEndFrameSection?: boolean;
onPickEndFrameFromLibrary?: () => void;
}
export const ImagePromptRow = ({
@@ -67,6 +67,7 @@ export const ImagePromptRow = ({
endFrameImage,
setEndFrameImage,
showEndFrameSection,
onPickEndFrameFromLibrary,
}: ImagePromptRowProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const endFrameInputRef = useRef<HTMLInputElement>(null);
@@ -227,13 +228,16 @@ export const ImagePromptRow = ({
/>
)}
<div
className={twMerge("glass flex rounded-t-xl", className)}
className={twMerge(
"glass flex flex-col sm:flex-row rounded-t-xl",
className,
)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="flex min-w-0 flex-1 gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faImage} className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
@@ -248,7 +252,7 @@ export const ImagePromptRow = ({
<span className="text-[13px] text-white/60">{sectionSubtitle}</span>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{allowReorder ? (
<DndContext
sensors={sensors}
@@ -325,7 +329,7 @@ export const ImagePromptRow = ({
>
<button
onClick={() => fileInputRef.current?.click()}
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
@@ -336,7 +340,7 @@ export const ImagePromptRow = ({
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
@@ -349,10 +353,10 @@ export const ImagePromptRow = ({
{/* End frame section */}
{isVideo && showEndFrameSection && (
<div className="flex min-w-0 flex-1 items-stretch gap-3 pe-3">
<div className="flex min-w-0 flex-1 items-stretch gap-2 px-3 py-2 sm:py-0 sm:pe-3 sm:ps-0 border-t sm:border-t-0 border-white/10">
<div className="flex grow gap-1">
<div className="w-[1px] bg-white/10" />
<div className="flex grow flex-col gap-1 p-2">
<div className="hidden sm:block w-[1px] bg-white/10" />
<div className="flex grow flex-col gap-1 sm:p-2">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faImage} className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
@@ -367,7 +371,7 @@ export const ImagePromptRow = ({
</div>
<div className="flex items-center gap-2">
{endFrameImage ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<img
src={endFrameImage.url}
alt="End frame"
@@ -375,23 +379,61 @@ export const ImagePromptRow = ({
/>
<button
onClick={() => setEndFrameImage?.(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
</div>
) : uploadingEndFrame ? (
<div className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-6 w-6 text-white"
/>
</div>
) : onPickEndFrameFromLibrary ? (
<Tooltip
interactive
position="top"
delay={100}
className="bg-ui-controls text-base-fg border border-ui-panel-border p-2 -mb-0.5"
closeOnClick
content={
<div className="flex flex-col gap-1.5">
<Button
variant="primary"
onClick={() => endFrameInputRef.current?.click()}
icon={faPlus}
className="w-full"
>
Upload
</Button>
<Button
variant="action"
onClick={onPickEndFrameFromLibrary}
icon={faImages}
className="w-full bg-white/15 hover:bg-white/20"
>
Pick from library
</Button>
</div>
}
>
<button
onClick={() => endFrameInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
</Tooltip>
) : (
<button
onClick={() => endFrameInputRef.current?.click()}
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
@@ -402,21 +444,6 @@ export const ImagePromptRow = ({
</div>
</div>
)}
{/* Clear all refs */}
{onClearAll && (
<div className="flex items-center">
<div className="h-full w-[1px] bg-white/10" />
<div className="p-2">
<button
onClick={onClearAll}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/50 transition-colors hover:bg-white/10 hover:text-white/80"
>
<FontAwesomeIcon icon={faTrashAlt} className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
</>
);
@@ -431,7 +458,7 @@ const ImageThumbnail = ({
image: RefImage;
onRemove: (id: string) => void;
}) => (
<div className="group glass relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<img
src={image.url}
alt="Reference"
@@ -442,7 +469,7 @@ const ImageThumbnail = ({
e.stopPropagation();
onRemove(image.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
@@ -479,7 +506,7 @@ const SortableImage = ({
style={style}
{...(allowReorder ? { ...attributes, ...listeners } : {})}
className={twMerge(
"group glass relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
allowReorder
? "cursor-move hover:border-white/80"
: "cursor-pointer hover:border-white/80",
@@ -498,7 +525,7 @@ const SortableImage = ({
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
@@ -510,7 +537,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
const previewUrl = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]);
return (
<div className="glass relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="absolute inset-0">
<img
src={previewUrl}
@@ -179,7 +179,7 @@ export const MediaReferenceRow = ({
onChange={handleAudioUpload}
/>
<div
className={twMerge("glass flex", className)}
className={twMerge("glass flex flex-col sm:flex-row", className)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
@@ -200,7 +200,7 @@ export const MediaReferenceRow = ({
{totalVideoDuration}/{maxVideoRefDuration}s
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{referenceVideos.map((video) => (
<VideoRefTile
key={video.id}
@@ -209,7 +209,7 @@ export const MediaReferenceRow = ({
/>
))}
{uploadingVideo && (
<div className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
@@ -220,7 +220,7 @@ export const MediaReferenceRow = ({
{canAddVideo && (
<button
onClick={() => videoInputRef.current?.click()}
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
@@ -231,7 +231,7 @@ export const MediaReferenceRow = ({
</div>
</div>
<div className="w-[1px] self-stretch bg-white/10" />
<div className="h-[1px] sm:h-auto sm:w-[1px] self-stretch bg-white/10 mx-3 sm:mx-0" />
{/* Audio section */}
<div className="flex grow gap-2 px-3 py-2">
@@ -249,7 +249,7 @@ export const MediaReferenceRow = ({
{totalAudioDuration}/{maxAudioRefDuration}s
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{referenceAudios.map((audio, i) => (
<AudioRefTile
key={audio.id}
@@ -259,7 +259,7 @@ export const MediaReferenceRow = ({
/>
))}
{uploadingAudio && (
<div className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
@@ -270,7 +270,7 @@ export const MediaReferenceRow = ({
{canAddAudio && (
<button
onClick={() => audioInputRef.current?.click()}
className="flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
@@ -294,7 +294,7 @@ const VideoRefTile = ({
video: RefVideo;
onRemove: (id: string) => void;
}) => (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<video
src={video.url}
muted
@@ -309,7 +309,7 @@ const VideoRefTile = ({
e.stopPropagation();
onRemove(video.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
@@ -348,7 +348,7 @@ const AudioRefTile = ({
);
return (
<div className="group relative flex aspect-square w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<button
onClick={handleTogglePlay}
className="flex h-full w-full items-center justify-center"
@@ -369,7 +369,7 @@ const AudioRefTile = ({
e.stopPropagation();
onRemove(audio.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-black group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
</button>
@@ -0,0 +1,654 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUserGroup } from "@fortawesome/pro-solid-svg-icons";
import { faVideo, faMusic } from "@fortawesome/pro-regular-svg-icons";
import type { MentionItem } from "./types";
interface MentionTextareaProps {
value: string;
onChange: (value: string) => void;
mentionItems: MentionItem[];
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
colorMap: Record<string, string>;
}
interface MentionState {
isOpen: boolean;
triggerIndex: number;
query: string;
activeIndex: number;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHTML(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Cursor helpers for contentEditable
// ---------------------------------------------------------------------------
function getCaretOffset(el: HTMLElement): number {
try {
const sel = window.getSelection();
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
return 0;
}
const anchorNode = sel.anchorNode;
const anchorOffset = sel.anchorOffset;
let offset = 0;
if (anchorNode === el) {
for (let i = 0; i < anchorOffset; i++) {
const child = el.childNodes[i];
if (!child) break;
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return offset;
}
function countBefore(parent: Node): boolean {
for (const child of Array.from(parent.childNodes)) {
if (child === anchorNode) {
if (child.nodeType === Node.TEXT_NODE) {
offset += anchorOffset;
}
return true;
}
if (child.contains(anchorNode)) {
return countBefore(child);
}
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return false;
}
countBefore(el);
return offset;
} catch {
return 0;
}
}
function setCaretOffset(el: HTMLElement, offset: number) {
try {
let remaining = offset;
function findPosition(parent: Node): boolean {
for (let i = 0; i < parent.childNodes.length; i++) {
const child = parent.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
const len = child.textContent?.length ?? 0;
if (remaining <= len) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(child, remaining);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= len;
} else if (child.nodeName === "BR") {
if (remaining === 0) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(parent, i);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= 1;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (findPosition(child)) {
return true;
}
}
}
return false;
}
if (!findPosition(el)) {
const sel = window.getSelection();
if (sel) {
sel.selectAllChildren(el);
sel.collapseToEnd();
}
}
} catch {
// DOM changed during caret restore
}
}
function scrollCaretIntoView(el: HTMLElement) {
try {
const sel = window.getSelection();
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
const range = sel.getRangeAt(0).cloneRange();
range.collapse(false);
const span = document.createElement("span");
span.textContent = "\u200B";
range.insertNode(span);
const spanRect = span.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (spanRect.bottom > elRect.bottom) {
el.scrollTop += spanRect.bottom - elRect.bottom;
} else if (spanRect.top < elRect.top) {
el.scrollTop -= elRect.top - spanRect.top;
}
const parent = span.parentNode;
if (parent) {
const next = span.nextSibling;
parent.removeChild(span);
const restored = document.createRange();
if (next) {
restored.setStartBefore(next);
} else if (parent.lastChild) {
restored.setStartAfter(parent.lastChild);
} else {
restored.selectNodeContents(parent);
}
restored.collapse(true);
sel.removeAllRanges();
sel.addRange(restored);
}
} catch {
// DOM changed during measurement
}
}
// ---------------------------------------------------------------------------
// Mention Dropdown
// ---------------------------------------------------------------------------
function MentionDropdown({
items,
activeIndex,
onSelect,
onHover,
position,
}: {
items: MentionItem[];
activeIndex: number;
onSelect: (item: MentionItem) => void;
onHover: (index: number) => void;
position: { left: number; bottom: number };
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const activeItem = list.children[1]?.children[activeIndex] as HTMLElement;
activeItem?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
return (
<div
ref={listRef}
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl"
style={{ left: position.left, bottom: position.bottom }}
>
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
Mentions
</div>
<div>
{items.map((item, i) => (
<button
key={item.label}
type="button"
className={twMerge(
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors cursor-pointer",
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(item);
}}
onMouseEnter={() => onHover(i)}
>
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
{(item.type === "character" || item.type === "image") && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<FontAwesomeIcon
icon={faUserGroup}
className="h-3.5 w-3.5 text-white/60"
/>
) : item.type === "video" && item.preview ? (
<video
src={item.preview}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<FontAwesomeIcon
icon={item.type === "video" ? faVideo : faMusic}
className="h-3.5 w-3.5 text-white/60"
/>
)}
</div>
<span className="font-medium">{item.label}</span>
</button>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// MentionTextarea Component
// ---------------------------------------------------------------------------
export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
function MentionTextarea(
{
value,
onChange,
mentionItems,
placeholder,
className,
onKeyDown: externalOnKeyDown,
onFocus,
onBlur,
disabled,
colorMap,
},
ref,
) {
const editorRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => editorRef.current!, []);
const isInternalUpdate = useRef(false);
const isComposing = useRef(false);
const pendingCaret = useRef<number | null>(null);
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
triggerIndex: -1,
query: "",
activeIndex: 0,
});
const [dropdownPos, setDropdownPos] = useState<{
left: number;
bottom: number;
}>({ left: 0, bottom: 0 });
const filteredItems = useMemo(() => {
if (!mentionState.isOpen) return [];
return mentionItems.filter((item) =>
mentionState.query
? item.label.toLowerCase().includes(mentionState.query.toLowerCase())
: true,
);
}, [mentionItems, mentionState.isOpen, mentionState.query]);
// Build a regex that matches any known mention label
const mentionRegex = useMemo(() => {
const labels = mentionItems.map((item) => item.label);
if (labels.length === 0) return null;
const sorted = [...labels].sort((a, b) => b.length - a.length);
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
return new RegExp(`(${pattern})`, "g");
}, [mentionItems]);
// Build innerHTML with colored @mentions inline
const buildHTML = useCallback(
(text: string): string => {
if (!text) return "";
if (!mentionRegex) {
let html = escapeHTML(text);
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) html += "<br>";
return html;
}
let html = "";
let lastIndex = 0;
const regex = new RegExp(mentionRegex);
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0];
const color = colorMap[fullMatch];
if (match.index > lastIndex) {
html += escapeHTML(text.slice(lastIndex, match.index));
}
if (color) {
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
} else {
html += escapeHTML(fullMatch);
}
lastIndex = match.index + fullMatch.length;
}
if (lastIndex < text.length) {
html += escapeHTML(text.slice(lastIndex));
}
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) {
html += "<br>";
}
return html;
},
[colorMap, mentionRegex],
);
// Sync DOM when value changes from parent
useEffect(() => {
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
const el = editorRef.current;
if (!el) return;
const sel = window.getSelection();
if (document.activeElement === el && sel && !sel.isCollapsed) return;
try {
const caret = pendingCaret.current ?? getCaretOffset(el);
el.innerHTML = buildHTML(value);
if (pendingCaret.current !== null) {
setCaretOffset(el, pendingCaret.current);
pendingCaret.current = null;
} else if (document.activeElement === el) {
setCaretOffset(el, caret);
}
} catch {
el.innerHTML = buildHTML(value);
}
}, [value, buildHTML]);
// Get pixel coordinates of a text offset relative to the wrapper
const getOffsetRect = useCallback((charOffset: number) => {
try {
const el = editorRef.current;
if (!el) return null;
const saved = getCaretOffset(el);
setCaretOffset(el, charOffset);
const sel = window.getSelection();
if (!sel?.rangeCount) {
setCaretOffset(el, saved);
return null;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const wrapperRect = el.parentElement!.getBoundingClientRect();
setCaretOffset(el, saved);
return {
left: rect.left - wrapperRect.left,
bottom: wrapperRect.bottom - rect.top,
};
} catch {
return null;
}
}, []);
// Detect @mention trigger from cursor position
const detectMention = useCallback(
(text: string, cursorPos: number) => {
const textBefore = text.slice(0, cursorPos);
const lastAt = textBefore.lastIndexOf("@");
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
const query = text.slice(lastAt, cursorPos);
if (!query.includes("\n")) {
const pos = getOffsetRect(lastAt);
if (pos) setDropdownPos(pos);
setMentionState({
isOpen: true,
triggerIndex: lastAt,
query,
activeIndex: 0,
});
return;
}
}
setMentionState((prev) =>
prev.isOpen ? { ...prev, isOpen: false } : prev,
);
},
[getOffsetRect],
);
const handleInput = useCallback(() => {
if (isComposing.current) return;
const el = editorRef.current;
if (!el) return;
try {
let text = el.innerText;
if (text.endsWith("\n")) {
text = text.slice(0, -1);
}
const caret = getCaretOffset(el);
const html = buildHTML(text);
if (el.innerHTML !== html) {
el.innerHTML = html;
setCaretOffset(el, caret);
}
isInternalUpdate.current = true;
onChange(text);
detectMention(text, caret);
requestAnimationFrame(() => {
scrollCaretIntoView(el);
});
} catch {
const text = el.innerText?.replace(/\n$/, "") ?? "";
isInternalUpdate.current = true;
onChange(text);
}
}, [onChange, buildHTML, detectMention]);
const handleCompositionStart = useCallback(() => {
isComposing.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
isComposing.current = false;
handleInput();
}, [handleInput]);
// Select a mention from the dropdown
const handleSelect = useCallback(
(item: MentionItem) => {
const el = editorRef.current;
if (!el) return;
const caretPos = getCaretOffset(el);
const before = value.slice(0, mentionState.triggerIndex);
const after = value.slice(caretPos);
const mention = `${item.label} `;
const newValue = before + mention + after;
pendingCaret.current = before.length + mention.length;
setMentionState({
isOpen: false,
triggerIndex: -1,
query: "",
activeIndex: 0,
});
onChange(newValue);
requestAnimationFrame(() => {
el.focus();
});
},
[value, mentionState.triggerIndex, onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (mentionState.isOpen && filteredItems.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionState((prev) => ({
...prev,
activeIndex: Math.min(
prev.activeIndex + 1,
filteredItems.length - 1,
),
}));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionState((prev) => ({
...prev,
activeIndex: Math.max(prev.activeIndex - 1, 0),
}));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
handleSelect(filteredItems[mentionState.activeIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionState((prev) => ({ ...prev, isOpen: false }));
return;
}
}
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
e.preventDefault();
document.execCommand("insertLineBreak");
handleInput();
scrollCaretIntoView(editorRef.current!);
return;
}
externalOnKeyDown?.(e);
},
[
mentionState.isOpen,
mentionState.activeIndex,
filteredItems,
handleSelect,
externalOnKeyDown,
handleInput,
],
);
const handleClick = useCallback(() => {
const el = editorRef.current;
if (!el) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
detectMention(value, getCaretOffset(el));
}, [value, detectMention]);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
},
[],
);
return (
<div className="relative flex-1 min-w-0 pb-[7px]">
{!value && placeholder && (
<div
className={twMerge(
className,
"absolute inset-0 pointer-events-none text-white/50 z-[1]",
)}
>
{placeholder}
</div>
)}
<div
ref={editorRef}
contentEditable={!disabled}
onInput={handleInput}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onClick={handleClick}
onPaste={handlePaste}
onFocus={onFocus}
onBlur={onBlur}
className={twMerge(
className,
"outline-none whitespace-pre-wrap break-words overflow-y-auto",
)}
/>
{mentionState.isOpen && filteredItems.length > 0 && (
<MentionDropdown
items={filteredItems}
activeIndex={mentionState.activeIndex}
onSelect={handleSelect}
onHover={(i) =>
setMentionState((prev) => ({ ...prev, activeIndex: i }))
}
position={dropdownPos}
/>
)}
</div>
);
},
);
@@ -9,10 +9,15 @@ import {
} from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMusic, faVideo } from "@fortawesome/pro-solid-svg-icons";
import {
faMusic,
faUserGroup,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { GenerateButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { ImagePromptRow } from "./ImagePromptRow";
import { MentionTextarea } from "./MentionTextarea";
import type { RefImage, MentionItem } from "./types";
// ── @-mention color palette ─────────────────────────────────────────────
@@ -33,7 +38,13 @@ const VIDEO_COLORS = [
const AUDIO_COLORS = ["rgb(192, 132, 252)", "rgb(232, 121, 249)"];
function getMentionColor(label: string): string {
const CHARACTER_COLORS = [
"rgb(45, 212, 191)", // teal
"rgb(34, 197, 94)", // emerald
"rgb(14, 165, 233)", // sky
];
function getMentionColor(label: string, mentionItems?: MentionItem[]): string {
const imgMatch = label.match(/^@Image(\d+)$/);
if (imgMatch)
return IMAGE_COLORS[(parseInt(imgMatch[1]) - 1) % IMAGE_COLORS.length];
@@ -43,6 +54,12 @@ function getMentionColor(label: string): string {
const audMatch = label.match(/^@Audio(\d+)$/);
if (audMatch)
return AUDIO_COLORS[(parseInt(audMatch[1]) - 1) % AUDIO_COLORS.length];
// Character mentions: match by name from mentionItems
if (mentionItems) {
const charItems = mentionItems.filter((m) => m.type === "character");
const idx = charItems.findIndex((m) => m.label === label);
if (idx !== -1) return CHARACTER_COLORS[idx % CHARACTER_COLORS.length];
}
return "rgb(255, 255, 255)";
}
@@ -77,16 +94,16 @@ interface PromptBoxProps {
// Pick from library
onPickFromLibrary?: () => void;
onPickEndFrameFromLibrary?: () => void;
// Clear all references (images, end frame, videos, audios)
onClearAllRefs?: () => void;
// Clear session button
showClearSession?: boolean;
onClearSession?: () => void;
// Media reference row (video/audio refs, rendered between image row and prompt)
mediaReferenceRow?: ReactNode;
// Model selector (rendered above the toolbar, typically hidden on desktop via lg:hidden)
modelSelector?: ReactNode;
// @-mention support (enables colored prompt overlay + autocomplete)
mentionItems?: MentionItem[];
}
@@ -114,16 +131,17 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
leftToolbar,
rightToolbar,
onPickFromLibrary,
onPickEndFrameFromLibrary,
onClearAllRefs,
showClearSession,
onClearSession,
mediaReferenceRow,
modelSelector,
mentionItems,
},
ref,
) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const mentionEditorRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [showImagePrompts, setShowImagePrompts] = useState(false);
@@ -157,6 +175,14 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}
}, [prompt]);
// Move caret to end on mount so autoFocus doesn't leave it at position 0
useEffect(() => {
const ta = textareaRef.current;
if (ta && ta.value.length > 0) {
ta.setSelectionRange(ta.value.length, ta.value.length);
}
}, []);
// Sync scroll between textarea and highlight overlay
const handleScroll = useCallback(() => {
if (highlightRef.current && textareaRef.current) {
@@ -259,16 +285,45 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
],
);
// Build a regex that matches all known @-mention labels
const mentionRegex = useMemo(() => {
if (!mentionItems?.length) return null;
// Escape special regex characters in labels and sort longest first
const escaped = mentionItems
.map((m) => m.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.sort((a, b) => b.length - a.length);
return new RegExp(`(${escaped.join("|")})`, "g");
}, [mentionItems]);
// Set of valid mention labels for fast lookup
const mentionLabelSet = useMemo(
() => new Set(mentionItems?.map((m) => m.label)),
[mentionItems],
);
// Build label → color map for MentionTextarea
const mentionColorMap = useMemo(() => {
if (!mentionItems?.length) return {};
const map: Record<string, string> = {};
for (const item of mentionItems) {
map[item.label] = getMentionColor(item.label, mentionItems);
}
return map;
}, [mentionItems]);
// Render highlighted prompt with colored @-mentions
const renderHighlightedPrompt = useCallback(() => {
if (!hasMentionItems) return null;
const parts = prompt.split(/(@(?:Image|Video|Audio)\d+)/g);
if (!hasMentionItems || !mentionRegex) return null;
const parts = prompt.split(mentionRegex);
return parts.map((part, i) => {
if (/^@(?:Image|Video|Audio)\d+$/.test(part)) {
if (mentionLabelSet.has(part)) {
return (
<span
key={i}
style={{ color: getMentionColor(part), fontWeight: 600 }}
style={{
color: getMentionColor(part, mentionItems),
fontWeight: 600,
}}
>
{part}
</span>
@@ -276,21 +331,10 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}
return <span key={i}>{part}</span>;
});
}, [prompt, hasMentionItems]);
}, [prompt, hasMentionItems, mentionRegex, mentionLabelSet, mentionItems]);
return (
<div ref={ref}>
{showClearSession && (
<div className="mb-2 flex w-full justify-end">
<button
onClick={onClearSession}
className="rounded-md bg-red-500/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red-500/30"
>
Clear session
</button>
</div>
)}
<div className="relative flex flex-col">
{isImageRowVisible && (
<ImagePromptRow
@@ -304,6 +348,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
endFrameImage={endFrameImage}
setEndFrameImage={onEndFrameImageChange}
showEndFrameSection={showEndFrameSection}
onPickEndFrameFromLibrary={onPickEndFrameFromLibrary}
/>
)}
@@ -311,7 +356,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
<div
className={twMerge(
"glass rounded-xl p-4 !transition-all duration-200",
"glass rounded-xl p-3 sm:p-4 !transition-all duration-200",
hasAnyRowAbove && "rounded-t-none",
isFocused && "ring-1 ring-primary",
)}
@@ -350,95 +395,135 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
)}
<div className="relative flex-1">
{/* Highlighted prompt overlay for @-mentions */}
{hasMentionItems && (
<div
ref={highlightRef}
aria-hidden
className="pointer-events-none absolute inset-0 max-h-[5.5em] overflow-y-auto whitespace-pre-wrap break-words text-sm text-white"
>
{renderHighlightedPrompt()}
</div>
)}
<textarea
ref={textareaRef}
rows={1}
autoFocus
placeholder={placeholder}
className={twMerge(
"max-h-[5.5em] w-full flex-1 resize-none overflow-y-auto bg-transparent text-md text-white placeholder-white/50 focus:outline-none",
hasMentionItems && "text-transparent caret-white",
)}
value={prompt}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onScroll={handleScroll}
/>
{/* @-mention autocomplete dropdown */}
{mentionOpen && filteredMentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 overflow-hidden rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
Reference Files
</div>
{filteredMentionItems.map((item, i) => (
<button
key={item.label}
className={twMerge(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors",
i === mentionIndex
? "bg-white/10"
: "hover:bg-white/5",
)}
onMouseDown={(e) => {
e.preventDefault();
insertMention(item.label);
}}
onMouseEnter={() => setMentionIndex(i)}
{hasMentionItems && mentionItems ? (
<MentionTextarea
ref={mentionEditorRef}
value={prompt}
onChange={onPromptChange}
mentionItems={mentionItems}
placeholder={placeholder}
className="max-h-[5.5em] w-full text-white"
colorMap={mentionColorMap}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
e.preventDefault();
onSubmit();
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
) : (
<>
{hasMentionItems && (
<div
ref={highlightRef}
aria-hidden
className="pointer-events-none absolute inset-0 max-h-[5.5em] overflow-y-auto whitespace-pre-wrap break-words text-sm text-white"
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
{item.type === "image" && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "video" && item.preview ? (
<video
src={item.preview}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<FontAwesomeIcon
icon={item.type === "video" ? faVideo : faMusic}
className="h-3.5 w-3.5 text-white/60"
/>
)}
{renderHighlightedPrompt()}
</div>
)}
<textarea
ref={textareaRef}
rows={1}
autoFocus
placeholder={placeholder}
className={twMerge(
"max-h-[5.5em] w-full flex-1 resize-none overflow-y-auto bg-transparent text-md text-white placeholder-white/50 focus:outline-none",
hasMentionItems && "text-transparent caret-white",
)}
value={prompt}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onScroll={handleScroll}
/>
{mentionOpen && filteredMentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
Mentions
</div>
<span
className="font-medium"
style={{ color: getMentionColor(item.label) }}
>
{item.label}
</span>
</button>
))}
</div>
{filteredMentionItems.map((item, i) => (
<button
key={item.label}
className={twMerge(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors",
i === mentionIndex
? "bg-white/10"
: "hover:bg-white/5",
)}
onMouseDown={(e) => {
e.preventDefault();
insertMention(item.label);
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
{(item.type === "image" ||
item.type === "character") &&
item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "video" && item.preview ? (
<video
src={item.preview}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<FontAwesomeIcon
icon={faUserGroup}
className="h-3.5 w-3.5 text-white/60"
/>
) : (
<FontAwesomeIcon
icon={
item.type === "video" ? faVideo : faMusic
}
className="h-3.5 w-3.5 text-white/60"
/>
)}
</div>
<span
className="font-medium"
style={{
color: getMentionColor(
item.label,
mentionItems,
),
}}
>
{item.label}
</span>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">{leftToolbar}</div>
<div className="flex items-center gap-2">
{/* Toolbar */}
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{modelSelector && (
<div className="lg:hidden">{modelSelector}</div>
)}
{leftToolbar}
</div>
<div className="flex items-center gap-2 sm:shrink-0">
{rightToolbar}
<GenerateButton
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
className="flex flex-1 sm:flex-none items-center justify-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
onClick={onSubmit}
disabled={disabled ?? (!prompt.trim() || isSubmitting)}
loading={isSubmitting}
@@ -0,0 +1,36 @@
import { create } from "zustand";
export interface StoredCharacter {
character_token: string;
name: string;
avatar_image_url?: string;
}
interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
}
export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>
c.character_token === token ? { ...c, ...updates } : c,
),
})),
removeCharacter: (token) =>
set((state) => ({
characters: state.characters.filter((c) => c.character_token !== token),
})),
setLoaded: (loaded) => set({ loaded }),
}));
@@ -2,4 +2,7 @@ export { PromptBox } from "./PromptBox";
export { ImagePromptRow } from "./ImagePromptRow";
export { ImagePickerModal } from "./ImagePickerModal";
export { MediaReferenceRow } from "./MediaReferenceRow";
export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
export type { StoredCharacter } from "./characters-store";
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
@@ -23,6 +23,6 @@ export interface RefAudio {
export interface MentionItem {
label: string;
type: "image" | "video" | "audio";
type: "image" | "video" | "audio" | "character";
preview?: string;
}
@@ -8,6 +8,7 @@ export interface ImageCostParams {
model: string;
aspectRatio?: string;
resolution?: string;
quality?: string;
numImages: number;
hasReferenceImages: boolean;
imageMediaTokenCount?: number;
@@ -29,6 +30,7 @@ export function useImageCostEstimate(params: ImageCostParams): number | null {
model: params.model,
aspect_ratio: params.aspectRatio ?? null,
resolution: params.resolution ?? null,
quality: params.quality ?? null,
image_batch_count: params.numImages,
image_media_tokens: params.hasReferenceImages
? new Array(params.imageMediaTokenCount ?? 1).fill("placeholder")
@@ -69,6 +71,7 @@ export interface VideoCostParams {
aspectRatio?: string;
resolution?: string | null;
duration?: number | null;
numVideos?: number;
hasStartFrame: boolean;
hasEndFrame: boolean;
isReferenceMode: boolean;
@@ -94,6 +97,7 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
resolution: params.resolution ?? null,
duration_seconds: params.duration ?? null,
generate_audio: params.generateAudio ?? null,
video_batch_count: params.numVideos ?? 1,
};
// Wire up frame/reference tokens based on mode
@@ -128,6 +132,7 @@ export function useVideoCostEstimate(params: VideoCostParams): number | null {
params.aspectRatio,
params.resolution,
params.duration,
params.numVideos,
params.hasStartFrame,
params.hasEndFrame,
params.isReferenceMode,
@@ -36,7 +36,7 @@ function fetchImageModelsOnce() {
(res) => {
imageModelsFetching = false;
if (res.success && res.models?.length) {
imageModelsCache = res.models;
imageModelsCache = res.models.filter((m) => m.is_disabled !== true);
} else {
imageModelsError = "No image models returned from API";
console.warn("[OmniGen] Image models response:", res);
@@ -61,7 +61,7 @@ function fetchVideoModelsOnce() {
(res) => {
videoModelsFetching = false;
if (res.success && res.models?.length) {
videoModelsCache = res.models;
videoModelsCache = res.models.filter((m) => m.is_disabled !== true);
} else {
videoModelsError = "No video models returned from API";
console.warn("[OmniGen] Video models response:", res);
@@ -85,6 +85,7 @@ const ASPECT_RATIO_PROPORTIONS: Record<string, [number, number]> = {
tall_nine_by_twenty_one: [9, 21],
auto: [1, 1],
auto_2k: [1, 1],
auto_3k: [1, 1],
auto_4k: [1, 1],
};
@@ -9,7 +9,7 @@ interface AspectRatioPickerProps {
handleAspectRatioSelect: (selected: string) => void;
}
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_4k"]);
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_3k", "auto_4k"]);
export const AspectRatioPicker = ({
aspectRatioOptions,
@@ -74,6 +74,7 @@ const ASPECT_RATIO_LABELS: Record<string, string> = {
tall_nine_by_sixteen: "9:16 (Tall)",
tall_nine_by_twenty_one: "9:21 (Tall)",
auto_2k: "Auto (2K)",
auto_3k: "Auto (3K)",
auto_4k: "Auto (4K)",
square_hd: "Square (HD)",
wide: "Wide",
@@ -10,6 +10,7 @@ interface GenerationCountPickerProps {
batchSizeOptions?: number[] | null;
currentCount: number;
handleCountChange: (count: number) => void;
panelTitle?: string;
}
export const GenerationCountPicker = ({
@@ -17,6 +18,7 @@ export const GenerationCountPicker = ({
batchSizeOptions,
currentCount,
handleCountChange,
panelTitle = "No. of images",
}: GenerationCountPickerProps) => {
const maxCount = batchSizeMax ?? DEFAULT_GENERATION_COUNT;
const hasPredefinedOptions = !!batchSizeOptions?.length;
@@ -59,7 +61,7 @@ export const GenerationCountPicker = ({
items={generationCountOptions}
onSelect={onSelect}
mode="toggle"
panelTitle="No. of images"
panelTitle={panelTitle}
triggerIcon={<FontAwesomeIcon icon={faCopy} className="h-4 w-4" />}
buttonClassName="h-9"
/>
@@ -0,0 +1,59 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGem } from "@fortawesome/pro-solid-svg-icons";
import { PopoverItem, PopoverMenu } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
interface QualityPickerProps {
qualityOptions: string[];
defaultQuality?: string;
currentQuality?: string;
handleQualitySelect: (selected: string) => void;
}
const QUALITY_LABELS: Record<string, string> = {
high: "High",
medium: "Medium",
low: "Low",
};
const LABEL_TO_QUALITY: Record<string, string> = Object.fromEntries(
Object.entries(QUALITY_LABELS).map(([k, v]) => [v, k]),
);
export const QualityPicker = ({
qualityOptions,
defaultQuality,
currentQuality,
handleQualitySelect,
}: QualityPickerProps) => {
const activeQuality = currentQuality ?? defaultQuality ?? undefined;
const handleSelectAdapter = (item: PopoverItem) => {
const quality = LABEL_TO_QUALITY[item.label];
if (quality) handleQualitySelect(quality);
};
const qualityList: PopoverItem[] = qualityOptions.map((q) => ({
label: QUALITY_LABELS[q] ?? q,
selected: activeQuality === q,
}));
return (
<Tooltip
content="Quality"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={qualityList}
onSelect={handleSelectAdapter}
mode="toggle"
panelTitle="Quality"
triggerIcon={
<FontAwesomeIcon icon={faGem} className="h-3.5 w-3.5" />
}
/>
</Tooltip>
);
};
@@ -24,6 +24,7 @@ export type ImageUiState = {
aspectRatio: string;
numImages: number;
resolution: string | undefined;
quality: string | undefined;
};
type CreateImageState = {
@@ -49,6 +50,7 @@ const DEFAULT_UI: ImageUiState = {
aspectRatio: "square",
numImages: 1,
resolution: undefined,
quality: undefined,
};
export const useCreateImageStore = create<CreateImageState>((set) => ({
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { faImage } from "@fortawesome/pro-solid-svg-icons";
import { FilterMediaClasses } from "@storyteller/api";
import type { OmniGenImageModelInfo } from "@storyteller/api";
import { type PopoverItem } from "@storyteller/ui-popover";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
PromptBox,
ImagePickerModal,
@@ -19,13 +20,11 @@ import {
} from "../../components/generation-gallery";
import { Lightbox } from "../../components/lightbox/lightbox";
import { useCreateImageStore } from "./create-image-store";
import {
enqueueImageGeneration,
startPolling,
} from "./generate-image-api";
import { enqueueImageGeneration, startPolling } from "./generate-image-api";
import { AspectRatioPicker } from "./components/AspectRatioPicker";
import { GenerationCountPicker } from "./components/GenerationCountPicker";
import { ResolutionPicker } from "./components/ResolutionPicker";
import { QualityPicker } from "./components/QualityPicker";
import { useImageCostEstimate } from "../../lib/cost-estimate-api";
import {
useOmniGenImageModels,
@@ -77,9 +76,11 @@ export default function CreateImage() {
const selectedModel = useMemo((): OmniGenImageModelInfo | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
return apiModels.find((m) => m.model === ui.selectedModelId) ??
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0];
apiModels[0]
);
}
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
}, [apiModels, ui.selectedModelId]);
@@ -101,13 +102,17 @@ export default function CreateImage() {
(v: string | undefined) => setUi({ resolution: v }),
[setUi],
);
const quality = ui.quality;
const setQuality = useCallback(
(v: string | undefined) => setUi({ quality: v }),
[setUi],
);
const [isGenerating, setIsGenerating] = useState(false);
const [referenceImages, setReferenceImages] = useState<RefImage[]>([]);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
// Batch store (enqueue flow only)
const batches = useCreateImageStore((s) => s.batches);
const startBatch = useCreateImageStore((s) => s.startBatch);
const setBatchJobToken = useCreateImageStore((s) => s.setBatchJobToken);
const completeBatch = useCreateImageStore((s) => s.completeBatch);
@@ -128,20 +133,25 @@ export default function CreateImage() {
// Lightbox
const flatItems = useMemo(() => {
const filtered = gallery.items.filter((i) => !newlyCompletedTokens.has(i.id));
const filtered = gallery.items.filter(
(i) => !newlyCompletedTokens.has(i.id),
);
return [...jobs.newlyCompleted, ...filtered];
}, [jobs.newlyCompleted, gallery.items, newlyCompletedTokens]);
const lightbox = useLightboxNav(flatItems);
// Derived
const hasAspectRatios = (selectedModel?.aspect_ratio_options?.length ?? 0) > 0;
const hasAspectRatios =
(selectedModel?.aspect_ratio_options?.length ?? 0) > 0;
const hasResolutions = (selectedModel?.resolution_options?.length ?? 0) > 0;
const hasQualityOptions = (selectedModel?.quality_options?.length ?? 0) > 0;
const estimatedCredits = useImageCostEstimate({
model: selectedModel?.model ?? "",
aspectRatio: aspectRatio,
resolution: hasResolutions ? resolution : undefined,
quality: hasQualityOptions ? quality : undefined,
numImages,
hasReferenceImages: referenceImages.length > 0,
});
@@ -205,6 +215,7 @@ export default function CreateImage() {
model.batch_size_default ?? 1,
),
resolution: model.resolution_default ?? undefined,
quality: model.default_quality ?? undefined,
});
},
[setUi],
@@ -214,12 +225,14 @@ export default function CreateImage() {
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
const maxImages = selectedModel?.image_refs_max ?? 1;
const availableSlots = Math.max(0, maxImages - referenceImages.length);
const newImages: RefImage[] = images.slice(0, availableSlots).map((img) => ({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
file: new File([], "library-image"),
mediaToken: img.token,
}));
const newImages: RefImage[] = images
.slice(0, availableSlots)
.map((img) => ({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
file: new File([], "library-image"),
mediaToken: img.token,
}));
setReferenceImages([...referenceImages, ...newImages]);
},
[referenceImages, selectedModel],
@@ -248,7 +261,10 @@ export default function CreateImage() {
numImages,
aspectRatio: aspectRatio,
resolution: hasResolutions ? resolution : undefined,
imageMediaTokens: imageMediaTokens?.length ? imageMediaTokens : undefined,
quality: hasQualityOptions ? quality : undefined,
imageMediaTokens: imageMediaTokens?.length
? imageMediaTokens
: undefined,
});
if (!result.success || !result.jobToken) {
@@ -281,8 +297,20 @@ export default function CreateImage() {
setIsGenerating(false);
}
}, [
prompt, isGenerating, selectedModel, numImages, aspectRatio, resolution,
hasResolutions, referenceImages, startBatch, setBatchJobToken, completeBatch, failBatch,
prompt,
isGenerating,
selectedModel,
numImages,
aspectRatio,
resolution,
hasResolutions,
quality,
hasQualityOptions,
referenceImages,
startBatch,
setBatchJobToken,
completeBatch,
failBatch,
]);
// ── Render ────────────────────────────────────────────────────────────
@@ -318,7 +346,7 @@ export default function CreateImage() {
}
promptBox={
<div
className="animate-fade-in-up fixed bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-2 sm:px-4"
style={{ animationDelay: "150ms" }}
>
<PromptBox
@@ -330,18 +358,38 @@ export default function CreateImage() {
credits={estimatedCredits}
placeholder="Describe what you want in the image..."
supportsImagePrompts={!!selectedModel?.image_refs_supported}
maxImagePromptCount={selectedModel?.image_refs_max ?? 1}
maxImagePromptCount={selectedModel?.image_refs_max ?? 6}
referenceImages={referenceImages}
onReferenceImagesChange={setReferenceImages}
onPickFromLibrary={() => setIsImagePickerOpen(true)}
showClearSession={batches.length > 0}
onClearSession={useCreateImageStore.getState().reset}
modelSelector={
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
mode="toggle"
panelTitle="Select Model"
showIconsInList
triggerIcon={
<img
src={getModelCreatorIconPath(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
}
/>
</Tooltip>
}
leftToolbar={
<>
{hasAspectRatios && selectedModel && (
<AspectRatioPicker
aspectRatioOptions={selectedModel.aspect_ratio_options ?? []}
defaultAspectRatio={selectedModel.aspect_ratio_default ?? undefined}
aspectRatioOptions={
selectedModel.aspect_ratio_options ?? []
}
defaultAspectRatio={
selectedModel.aspect_ratio_default ?? undefined
}
currentAspectRatio={aspectRatio}
handleAspectRatioSelect={setAspectRatio}
/>
@@ -349,11 +397,23 @@ export default function CreateImage() {
{hasResolutions && selectedModel && (
<ResolutionPicker
resolutionOptions={selectedModel.resolution_options ?? []}
defaultResolution={selectedModel.resolution_default ?? undefined}
defaultResolution={
selectedModel.resolution_default ?? undefined
}
currentResolution={resolution}
handleResolutionSelect={setResolution}
/>
)}
{hasQualityOptions && selectedModel && (
<QualityPicker
qualityOptions={selectedModel.quality_options ?? []}
defaultQuality={
selectedModel.default_quality ?? undefined
}
currentQuality={quality}
handleQualitySelect={setQuality}
/>
)}
</>
}
rightToolbar={
@@ -385,6 +445,7 @@ export default function CreateImage() {
cdnUrl={lightbox.lightboxItem?.fullImage}
mediaClass={lightbox.lightboxItem?.mediaClass}
batchImageToken={lightbox.lightboxItem?.batchImageToken}
showBatchCarousel={false}
onNavigatePrev={lightbox.navigatePrev}
onNavigateNext={lightbox.navigateNext}
onDeleted={gallery.removeItem}
@@ -10,6 +10,7 @@ export interface GenerateImageParams {
numImages?: number;
aspectRatio?: string;
resolution?: string;
quality?: string;
imageMediaTokens?: string[];
}
@@ -24,6 +25,7 @@ export async function enqueueImageGeneration(
idempotency_token: crypto.randomUUID(),
aspect_ratio: params.aspectRatio ?? null,
resolution: params.resolution ?? null,
quality: params.quality ?? null,
image_batch_count: params.numImages ?? 1,
image_media_tokens: params.imageMediaTokens?.length
? params.imageMediaTokens
@@ -27,6 +27,7 @@ export type VideoUiState = {
resolution: string | null;
generateWithSound: boolean;
inputMode: VideoInputMode;
numVideos: number;
};
type CreateVideoState = {
@@ -50,6 +51,7 @@ const DEFAULT_UI: VideoUiState = {
resolution: null,
generateWithSound: false,
inputMode: "keyframe",
numVideos: 1,
};
export const useCreateVideoStore = create<CreateVideoState>((set) => ({
@@ -3,10 +3,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClock,
faFilm,
faTriangleExclamation,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import { FilterMediaClasses } from "@storyteller/api";
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";
@@ -15,6 +14,8 @@ import {
PromptBox,
ImagePickerModal,
MediaReferenceRow,
CharactersModal,
useCharactersStore,
type RefImage,
type RefVideo,
type RefAudio,
@@ -35,7 +36,11 @@ import {
enqueueVideoGeneration,
startVideoPolling,
} from "./generate-video-api";
import { AspectRatioIcon, AutoIcon } from "../create-image/components/AspectRatioIcon";
import {
AspectRatioIcon,
AutoIcon,
} from "../create-image/components/AspectRatioIcon";
import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker";
import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
import {
useOmniGenVideoModels,
@@ -49,7 +54,7 @@ const DEFAULT_MODEL_ID = "seedance_2p0";
const VIDEO_FILTER = [FilterMediaClasses.VIDEO];
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_4k"]);
const AUTO_RATIOS = new Set(["auto", "auto_2k", "auto_3k", "auto_4k"]);
// ── Aspect ratio labels (shared with image page) ─────────────────────────
@@ -67,12 +72,28 @@ const AR_LABELS: Record<string, string> = {
tall_nine_by_sixteen: "9:16 (Tall)",
tall_nine_by_twenty_one: "9:21 (Tall)",
auto_2k: "Auto (2K)",
auto_3k: "Auto (3K)",
auto_4k: "Auto (4K)",
square_hd: "Square (HD)",
wide: "Wide",
tall: "Tall",
};
const RES_LABELS: Record<string, string> = {
half_k: "0.5K",
four_eighty_p: "480p",
seven_twenty_p: "720p",
one_k: "1K",
ten_eighty_p: "1080p",
two_k: "2K",
three_k: "3K",
four_k: "4K",
};
const LABEL_TO_RES: Record<string, string> = Object.fromEntries(
Object.entries(RES_LABELS).map(([k, v]) => [v, k]),
);
// ── Model lookup ─────────────────────────────────────────────────────────
let _modelLookup = new Map<string, OmniGenVideoModelInfo>();
@@ -128,9 +149,11 @@ export default function CreateVideo() {
const selectedModel = useMemo((): OmniGenVideoModelInfo | undefined => {
if (!apiModels.length) return undefined;
if (ui.selectedModelId) {
return apiModels.find((m) => m.model === ui.selectedModelId) ??
return (
apiModels.find((m) => m.model === ui.selectedModelId) ??
apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ??
apiModels[0];
apiModels[0]
);
}
return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0];
}, [apiModels, ui.selectedModelId]);
@@ -153,6 +176,11 @@ export default function CreateVideo() {
[setUi],
);
const generateWithSound = ui.generateWithSound;
const numVideos = ui.numVideos;
const setNumVideos = useCallback(
(v: number) => setUi({ numVideos: v }),
[setUi],
);
const [isGenerating, setIsGenerating] = useState(false);
// Reference media
@@ -161,9 +189,37 @@ export default function CreateVideo() {
const [referenceVideos, setReferenceVideos] = useState<RefVideo[]>([]);
const [referenceAudios, setReferenceAudios] = useState<RefAudio[]>([]);
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [isEndFramePickerOpen, setIsEndFramePickerOpen] = useState(false);
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
// Characters store for @-mentions
const storedCharacters = useCharactersStore((s) => s.characters);
const charactersLoaded = useCharactersStore((s) => s.loaded);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
// Load characters on mount if not already loaded
useEffect(() => {
if (charactersLoaded) return;
const api = new CharactersApi();
api
.ListCharacters()
.then((res) => {
if (res.success && res.data) {
storeSetCharacters(
res.data.map((c) => ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
}
storeSetLoaded(true);
})
.catch(() => storeSetLoaded(true));
}, [charactersLoaded, storeSetCharacters, storeSetLoaded]);
// Batch store (enqueue flow only)
const batches = useCreateVideoStore((s) => s.batches);
const startBatch = useCreateVideoStore((s) => s.startBatch);
const setBatchJobToken = useCreateVideoStore((s) => s.setBatchJobToken);
const completeBatch = useCreateVideoStore((s) => s.completeBatch);
@@ -172,7 +228,8 @@ export default function CreateVideo() {
// Derived model capabilities
const hasSizeOptions = (selectedModel?.aspect_ratio_options?.length ?? 0) > 0;
const hasResolutionOptions = (selectedModel?.resolution_options?.length ?? 0) > 0;
const hasResolutionOptions =
(selectedModel?.resolution_options?.length ?? 0) > 0;
const hasSound = !!selectedModel?.show_generate_with_sound_toggle;
const supportsImagePrompts =
!!selectedModel?.starting_keyframe_supported ||
@@ -184,8 +241,11 @@ export default function CreateVideo() {
!!selectedModel?.audio_references_supported;
const inputMode = ui.inputMode;
const isReferenceMode = supportsRefMode && inputMode === "reference";
const hasEndFrame = !!(selectedModel?.ending_keyframe_supported && !isReferenceMode);
const needsImage = !!selectedModel?.starting_keyframe_required && referenceImages.length === 0;
const hasEndFrame = !!(
selectedModel?.ending_keyframe_supported && !isReferenceMode
);
const needsImage =
!!selectedModel?.starting_keyframe_required && referenceImages.length === 0;
// Jobs + gallery
const jobs = useGenerationJobs({ mediaType: "video" });
@@ -201,7 +261,9 @@ export default function CreateVideo() {
// Lightbox
const flatItems = useMemo(() => {
const filtered = gallery.items.filter((i) => !newlyCompletedTokens.has(i.id));
const filtered = gallery.items.filter(
(i) => !newlyCompletedTokens.has(i.id),
);
return [...jobs.newlyCompleted, ...filtered];
}, [jobs.newlyCompleted, gallery.items, newlyCompletedTokens]);
@@ -213,6 +275,7 @@ export default function CreateVideo() {
aspectRatio: selectedSize,
resolution,
duration: duration ?? selectedModel?.duration_seconds_default ?? null,
numVideos,
hasStartFrame: !isReferenceMode && referenceImages.length > 0,
hasEndFrame: !isReferenceMode && hasEndFrame && !!endFrameImage,
isReferenceMode,
@@ -220,34 +283,55 @@ export default function CreateVideo() {
generateAudio: hasSound ? generateWithSound : undefined,
});
// Characters are only supported for seedance_2p0
const isSeedance2p0 = selectedModel?.model === "seedance_2p0";
const activeCharacters = isSeedance2p0 ? storedCharacters : [];
// Popover items
const mentionItems = useMemo((): MentionItem[] => {
if (!isReferenceMode) return [];
return [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined,
})),
];
}, [isReferenceMode, referenceImages, referenceVideos, referenceAudios]);
const refItems: MentionItem[] = isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined,
})),
]
: [];
const charItems: MentionItem[] = activeCharacters.map((char) => ({
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
}));
return [...refItems, ...charItems];
}, [
isReferenceMode,
referenceImages,
referenceVideos,
referenceAudios,
activeCharacters,
]);
const modelItems = useMemo(
() => buildModelPopoverItems(apiModels, selectedModel?.model ?? ""),
[apiModels, selectedModel?.model],
);
const sizeItems = useMemo(
() => buildSizePopoverItems(selectedModel?.aspect_ratio_options ?? [], selectedSize),
() =>
buildSizePopoverItems(
selectedModel?.aspect_ratio_options ?? [],
selectedSize,
),
[selectedModel?.aspect_ratio_options, selectedSize],
);
const durationItems = useMemo(
@@ -255,7 +339,8 @@ export default function CreateVideo() {
selectedModel?.duration_seconds_options
? selectedModel.duration_seconds_options.map((d) => ({
label: `${d}s`,
selected: d === (duration ?? selectedModel.duration_seconds_default),
selected:
d === (duration ?? selectedModel.duration_seconds_default),
}))
: null,
[selectedModel, duration],
@@ -264,7 +349,7 @@ export default function CreateVideo() {
(): PopoverItem[] | null =>
selectedModel?.resolution_options
? selectedModel.resolution_options.map((r) => ({
label: r,
label: RES_LABELS[r] ?? r,
selected: r === (resolution ?? selectedModel.resolution_default),
}))
: null,
@@ -341,11 +426,31 @@ export default function CreateVideo() {
resolution: model.resolution_default ?? null,
generateWithSound: false,
inputMode: "keyframe",
numVideos: Math.min(
model.batch_size_max ?? 4,
model.batch_size_default ?? 1,
),
});
setReferenceImages([]);
setEndFrameImage(undefined);
setReferenceVideos([]);
setReferenceAudios([]);
// Only clear media that the new model doesn't support
const newSupportsKeyframe =
!!model.starting_keyframe_supported ||
!!model.starting_keyframe_required;
if (!newSupportsKeyframe) {
setReferenceImages([]);
}
if (!model.ending_keyframe_supported) {
setEndFrameImage(undefined);
}
const newSupportsRefs =
!!model.image_references_supported ||
!!model.video_references_supported ||
!!model.audio_references_supported;
if (!newSupportsRefs) {
setReferenceVideos([]);
setReferenceAudios([]);
}
},
[setUi],
);
@@ -366,7 +471,8 @@ export default function CreateVideo() {
);
const handleResolutionChange = useCallback(
(item: PopoverItem) => setResolution(item.label),
(item: PopoverItem) =>
setResolution(LABEL_TO_RES[item.label] ?? item.label),
[setResolution],
);
@@ -387,96 +493,165 @@ export default function CreateVideo() {
const handleLibraryImageSelect = useCallback(
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
const maxImages = isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1;
const maxImages = isReferenceMode
? (selectedModel?.image_references_max ?? 3)
: 1;
const availableSlots = Math.max(0, maxImages - referenceImages.length);
const newImages: RefImage[] = images.slice(0, availableSlots).map((img) => ({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
file: new File([], "library-image"),
mediaToken: img.token,
}));
const newImages: RefImage[] = images
.slice(0, availableSlots)
.map((img) => ({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
file: new File([], "library-image"),
mediaToken: img.token,
}));
setReferenceImages([...referenceImages, ...newImages]);
},
[referenceImages, isReferenceMode, selectedModel],
);
const handleEndFrameLibrarySelect = useCallback(
(images: { token: string; url: string; thumbnailUrl: string }[]) => {
const img = images[0];
if (!img) return;
setEndFrameImage({
id: Math.random().toString(36).substring(7),
url: img.thumbnailUrl || img.url,
file: new File([], "library-image"),
mediaToken: img.token,
});
},
[],
);
const handleGenerate = useCallback(async () => {
if (!prompt.trim() || isGenerating || needsImage || !selectedModel) return;
setIsGenerating(true);
const batchId = startBatch(prompt, selectedModel.full_name ?? selectedModel.model);
try {
const startFrameToken =
!isReferenceMode && supportsImagePrompts && referenceImages.length > 0
? referenceImages[0].mediaToken
: undefined;
const endFrameToken =
!isReferenceMode && hasEndFrame && endFrameImage?.mediaToken
? endFrameImage.mediaToken
: undefined;
const referenceImageTokens =
isReferenceMode && referenceImages.length > 0
? referenceImages.map((img) => img.mediaToken).filter((t) => t.length > 0)
: undefined;
const referenceVideoTokens =
isReferenceMode && referenceVideos.length > 0
? referenceVideos.map((v) => v.mediaToken).filter((t) => t.length > 0)
: undefined;
const referenceAudioTokens =
isReferenceMode && referenceAudios.length > 0
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
: undefined;
const startFrameToken =
!isReferenceMode && supportsImagePrompts && referenceImages.length > 0
? referenceImages[0].mediaToken
: undefined;
const endFrameToken =
!isReferenceMode && hasEndFrame && endFrameImage?.mediaToken
? endFrameImage.mediaToken
: undefined;
const referenceImageTokens =
isReferenceMode && referenceImages.length > 0
? referenceImages
.map((img) => img.mediaToken)
.filter((t) => t.length > 0)
: undefined;
const referenceVideoTokens =
isReferenceMode && referenceVideos.length > 0
? referenceVideos.map((v) => v.mediaToken).filter((t) => t.length > 0)
: undefined;
const referenceAudioTokens =
isReferenceMode && referenceAudios.length > 0
? referenceAudios.map((a) => a.mediaToken).filter((t) => t.length > 0)
: undefined;
const result = await enqueueVideoGeneration({
prompt: prompt.trim(),
model: selectedModel.model,
aspectRatio: selectedSize,
duration: duration ?? selectedModel.duration_seconds_default ?? undefined,
resolution: hasResolutionOptions
? (resolution ?? selectedModel.resolution_default ?? undefined)
: undefined,
generateAudio: hasSound ? generateWithSound : undefined,
startFrameImageMediaToken: startFrameToken?.length ? startFrameToken : undefined,
endFrameImageMediaToken: endFrameToken?.length ? endFrameToken : undefined,
referenceImageMediaTokens: referenceImageTokens?.length ? referenceImageTokens : undefined,
referenceVideoMediaTokens: referenceVideoTokens?.length ? referenceVideoTokens : undefined,
referenceAudioMediaTokens: referenceAudioTokens?.length ? referenceAudioTokens : undefined,
});
// Extract character tokens from @-mentions in prompt
const mentionedCharacters = activeCharacters.filter((c) =>
prompt.includes(`@${c.name}`),
);
const referenceCharacterTokens =
mentionedCharacters.length > 0
? mentionedCharacters.map((c) => c.character_token)
: undefined;
if (!result.success || !result.jobToken) {
failBatch(batchId, result.error ?? "Failed to start generation");
setIsGenerating(false);
return;
const baseParams = {
prompt: prompt.trim(),
model: selectedModel.model,
numVideos: 1,
aspectRatio: selectedSize,
duration:
duration ?? selectedModel.duration_seconds_default ?? undefined,
resolution: hasResolutionOptions
? (resolution ?? selectedModel.resolution_default ?? undefined)
: undefined,
generateAudio: hasSound ? generateWithSound : undefined,
startFrameImageMediaToken: startFrameToken?.length
? startFrameToken
: undefined,
endFrameImageMediaToken: endFrameToken?.length
? endFrameToken
: undefined,
referenceImageMediaTokens: referenceImageTokens?.length
? referenceImageTokens
: undefined,
referenceVideoMediaTokens: referenceVideoTokens?.length
? referenceVideoTokens
: undefined,
referenceAudioMediaTokens: referenceAudioTokens?.length
? referenceAudioTokens
: undefined,
referenceCharacterTokens,
};
// Enqueue each video as a separate job so they complete independently
const modelLabel = selectedModel.full_name ?? selectedModel.model;
const count = Math.max(1, numVideos);
for (let i = 0; i < count; i++) {
const batchId = startBatch(prompt, modelLabel);
try {
const result = await enqueueVideoGeneration(baseParams);
if (!result.success || !result.jobToken) {
failBatch(batchId, result.error ?? "Failed to start generation");
continue;
}
setBatchJobToken(batchId, result.jobToken);
const stopPolling = startVideoPolling(
result.jobToken,
(video) => {
completeBatch(batchId, video);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failBatch(batchId, reason);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollingCleanupsRef.current.set(batchId, stopPolling);
} catch {
failBatch(batchId, "Network error - please try again");
}
setBatchJobToken(batchId, result.jobToken);
window.dispatchEvent(new Event("credits-change"));
window.dispatchEvent(new Event("task-queue-update"));
const stopPolling = startVideoPolling(
result.jobToken,
(video) => {
completeBatch(batchId, video);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
(reason) => {
failBatch(batchId, reason);
pollingCleanupsRef.current.delete(batchId);
window.dispatchEvent(new Event("task-queue-update"));
},
);
pollingCleanupsRef.current.set(batchId, stopPolling);
} catch {
failBatch(batchId, "Network error - please try again");
} finally {
setIsGenerating(false);
}
window.dispatchEvent(new Event("credits-change"));
window.dispatchEvent(new Event("task-queue-update"));
setIsGenerating(false);
}, [
prompt, isGenerating, needsImage, isReferenceMode, selectedModel, selectedSize,
duration, resolution, generateWithSound, hasResolutionOptions, hasSound,
supportsImagePrompts, hasEndFrame, referenceImages, endFrameImage,
referenceVideos, referenceAudios, startBatch, setBatchJobToken, completeBatch, failBatch,
prompt,
isGenerating,
needsImage,
isReferenceMode,
selectedModel,
selectedSize,
numVideos,
duration,
resolution,
generateWithSound,
hasResolutionOptions,
hasSound,
supportsImagePrompts,
hasEndFrame,
referenceImages,
endFrameImage,
referenceVideos,
referenceAudios,
activeCharacters,
startBatch,
setBatchJobToken,
completeBatch,
failBatch,
]);
// ── Render ────────────────────────────────────────────────────────────
@@ -521,10 +696,10 @@ export default function CreateVideo() {
}
promptBox={
<div
className="animate-fade-in-up fixed bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[730px] px-4"
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-2 sm:px-4"
style={{ animationDelay: "150ms" }}
>
{selectedModel?.model === "seedance_2p0" && (
{/* {selectedModel?.model === "seedance_2p0" && (
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-yellow-500/40 px-3.5 py-2.5 text-xs text-yellow-200 shadow-lg backdrop-blur-xl bg-yellow-800/60">
<FontAwesomeIcon icon={faTriangleExclamation} className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-yellow-400" />
<span>
@@ -532,7 +707,7 @@ export default function CreateVideo() {
Seedance may reject safe inputs unexpectedly. Try several short generations before longer ones.
</span>
</div>
)}
)} */}
<PromptBox
ref={promptBoxRef}
prompt={prompt}
@@ -542,7 +717,9 @@ export default function CreateVideo() {
credits={estimatedCredits}
placeholder="Describe the video you want to generate..."
supportsImagePrompts={supportsImagePrompts}
maxImagePromptCount={isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1}
maxImagePromptCount={
isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1
}
referenceImages={referenceImages}
onReferenceImagesChange={setReferenceImages}
isVideo
@@ -550,15 +727,38 @@ export default function CreateVideo() {
endFrameImage={endFrameImage}
onEndFrameImageChange={setEndFrameImage}
showEndFrameSection={hasEndFrame}
onPickFromLibrary={supportsImagePrompts ? () => setIsImagePickerOpen(true) : undefined}
onPickFromLibrary={
supportsImagePrompts
? () => setIsImagePickerOpen(true)
: undefined
}
onPickEndFrameFromLibrary={
hasEndFrame ? () => setIsEndFramePickerOpen(true) : undefined
}
modelSelector={
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelItems}
onSelect={handleModelChange}
mode="toggle"
panelTitle="Select Model"
showIconsInList
triggerIcon={
<img
src={getModelCreatorIconPath(selectedModel?.model ?? "")}
alt=""
className="h-4 w-4 icon-auto-contrast"
/>
}
/>
</Tooltip>
}
onClearAllRefs={() => {
setReferenceImages([]);
setEndFrameImage(undefined);
setReferenceVideos([]);
setReferenceAudios([]);
}}
showClearSession={batches.length > 0}
onClearSession={useCreateVideoStore.getState().reset}
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
mediaReferenceRow={
isReferenceMode ? (
@@ -566,18 +766,38 @@ export default function CreateVideo() {
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={selectedModel?.video_references_max ?? 3}
maxVideoRefDuration={selectedModel?.video_references_max_total_duration_seconds ?? 30}
maxVideoRefDuration={
selectedModel?.video_references_max_total_duration_seconds ??
30
}
referenceAudios={referenceAudios}
onReferenceAudiosChange={setReferenceAudios}
maxAudioCount={selectedModel?.audio_references_max ?? 2}
maxAudioRefDuration={selectedModel?.audio_references_max_total_duration_seconds ?? 30}
maxAudioRefDuration={
selectedModel?.audio_references_max_total_duration_seconds ??
30
}
/>
) : undefined
}
rightToolbar={
<GenerationCountPicker
batchSizeMax={selectedModel?.batch_size_max ?? 4}
batchSizeOptions={selectedModel?.batch_size_options}
currentCount={numVideos}
handleCountChange={setNumVideos}
panelTitle="No. of videos"
/>
}
leftToolbar={
<>
{hasSizeOptions && (
<Tooltip content="Aspect Ratio" position="top" className="z-50" closeOnClick>
<Tooltip
content="Aspect Ratio"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={sizeItems}
onSelect={handleSizeChange}
@@ -595,37 +815,87 @@ export default function CreateVideo() {
</Tooltip>
)}
{resolutionItems && (
<Tooltip content="Resolution" position="top" className="z-50" closeOnClick>
<PopoverMenu items={resolutionItems} onSelect={handleResolutionChange} mode="toggle" panelTitle="Resolution" />
<Tooltip
content="Resolution"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={resolutionItems}
onSelect={handleResolutionChange}
mode="toggle"
panelTitle="Resolution"
/>
</Tooltip>
)}
{durationItems && (
<Tooltip content="Duration" position="top" className="z-50" closeOnClick>
<Tooltip
content="Duration"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={durationItems}
onSelect={handleDurationChange}
mode="toggle"
panelTitle="Duration"
triggerIcon={<FontAwesomeIcon icon={faClock} className="h-3.5 w-3.5" />}
triggerIcon={
<FontAwesomeIcon
icon={faClock}
className="h-3.5 w-3.5"
/>
}
/>
</Tooltip>
)}
{hasSound && (
<Tooltip content={generateWithSound ? "Sound: ON" : "Sound: OFF"} position="top" className="z-50" delay={200}>
<Tooltip
content={generateWithSound ? "Sound: ON" : "Sound: OFF"}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={generateWithSound}
icon={faWaveformLines}
activeIcon={faWaveformLines}
onClick={() => setUi({ generateWithSound: !generateWithSound })}
className={generateWithSound ? "bg-primary/40 hover:bg-primary/50 border-primary/30" : undefined}
onClick={() =>
setUi({ generateWithSound: !generateWithSound })
}
className={
generateWithSound
? "bg-primary/40 hover:bg-primary/50 border-primary/30"
: undefined
}
/>
</Tooltip>
)}
{inputModeItems && (
<Tooltip content="Input Mode" position="top" className="z-50" closeOnClick>
<PopoverMenu items={inputModeItems} onSelect={handleInputModeChange} mode="toggle" panelTitle="Input Mode" />
<Tooltip
content="Input Mode"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={inputModeItems}
onSelect={handleInputModeChange}
mode="toggle"
panelTitle="Input Mode"
/>
</Tooltip>
)}
{isSeedance2p0 && (
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg shadow-sm transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
>
@Characters
</button>
)}
</>
}
/>
@@ -639,9 +909,28 @@ export default function CreateVideo() {
onSelect={handleLibraryImageSelect}
maxSelect={Math.max(
1,
(isReferenceMode ? (selectedModel?.image_references_max ?? 3) : 1) - referenceImages.length,
(isReferenceMode
? (selectedModel?.image_references_max ?? 3)
: 1) - referenceImages.length,
)}
/>
<ImagePickerModal
isOpen={isEndFramePickerOpen}
onClose={() => setIsEndFramePickerOpen(false)}
onSelect={handleEndFrameLibrarySelect}
maxSelect={1}
/>
<CharactersModal
isOpen={isCharactersModalOpen}
onClose={() => setIsCharactersModalOpen(false)}
onSelectCharacter={(character) => {
const mention = `@${character.name}`;
const spaceBefore =
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
setPrompt(prompt + spaceBefore + mention + " ");
setIsCharactersModalOpen(false);
}}
/>
<Lightbox
isOpen={lightbox.lightboxOpen}
onClose={lightbox.closeLightbox}
@@ -649,6 +938,7 @@ export default function CreateVideo() {
cdnUrl={lightbox.lightboxItem?.fullImage}
mediaClass={lightbox.lightboxItem?.mediaClass}
batchImageToken={lightbox.lightboxItem?.batchImageToken}
showBatchCarousel={false}
onNavigatePrev={lightbox.navigatePrev}
onNavigateNext={lightbox.navigateNext}
onDeleted={gallery.removeItem}
@@ -7,6 +7,7 @@ import type { GeneratedVideo } from "./create-video-store";
export interface GenerateVideoParams {
prompt: string;
model: string;
numVideos?: number;
aspectRatio?: string;
duration?: number;
resolution?: string;
@@ -16,6 +17,7 @@ export interface GenerateVideoParams {
referenceImageMediaTokens?: string[];
referenceVideoMediaTokens?: string[];
referenceAudioMediaTokens?: string[];
referenceCharacterTokens?: string[];
}
// ── Enqueue generation ───────────────────────────────────────────────────
@@ -31,6 +33,7 @@ export async function enqueueVideoGeneration(
resolution: params.resolution ?? null,
duration_seconds: params.duration ?? null,
generate_audio: params.generateAudio ?? null,
video_batch_count: params.numVideos ?? 1,
start_frame_image_media_token: params.startFrameImageMediaToken ?? null,
end_frame_image_media_token: params.endFrameImageMediaToken ?? null,
reference_image_media_tokens: params.referenceImageMediaTokens?.length
@@ -42,6 +45,9 @@ export async function enqueueVideoGeneration(
reference_audio_media_tokens: params.referenceAudioMediaTokens?.length
? params.referenceAudioMediaTokens
: null,
reference_character_tokens: params.referenceCharacterTokens?.length
? params.referenceCharacterTokens
: null,
};
try {
@@ -5,6 +5,7 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link } from "react-router-dom";
import { PasswordResetApi } from "@storyteller/api";
@@ -44,12 +45,6 @@ const ForgotPassword = () => {
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleRequestReset();
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
@@ -59,7 +54,7 @@ const ForgotPassword = () => {
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
{!submitted ? (
<>
<div className="text-center mb-8">
@@ -69,7 +64,13 @@ const ForgotPassword = () => {
</p>
</div>
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleRequestReset();
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
@@ -80,30 +81,34 @@ const ForgotPassword = () => {
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email or Username
</label>
<input
<Input
id="reset-email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="you@example.com"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<Button
id="send-reset-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleRequestReset}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Send Reset Code"
)}
</Button>
</div>
<div className="pt-2">
<Button
id="send-reset-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Send Reset Code"
)}
</Button>
</div>
</form>
</>
) : (
<div className="text-center py-8">
@@ -116,12 +121,12 @@ const ForgotPassword = () => {
<span className="text-white font-medium">{email}</span>
</p>
<Link to="/forgot-password/verify">
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mb-3">
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
Enter Verification Code
</Button>
</Link>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
onClick={() => setSubmitted(false)}
>
Try another email
@@ -139,6 +144,10 @@ const ForgotPassword = () => {
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
};
@@ -1,14 +1,13 @@
import {
faArrowLeft,
faLock,
faEye,
faEyeSlash,
faSpinnerThird,
faKey,
faCheckCircle,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { PasswordResetApi, BillingApi } from "@storyteller/api";
@@ -102,12 +101,6 @@ const VerifyReset = () => {
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleRedeemReset();
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
@@ -117,7 +110,7 @@ const VerifyReset = () => {
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
{!success ? (
<>
<div className="text-center mb-8">
@@ -129,7 +122,13 @@ const VerifyReset = () => {
</p>
</div>
<div className="space-y-5">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleRedeemReset();
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
@@ -141,26 +140,17 @@ const VerifyReset = () => {
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Verification Code
</label>
<div className="relative">
<FontAwesomeIcon
icon={faKey}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
/>
<input
id="verification-code"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
className={`w-full bg-black/20 border ${
fieldErrors.verificationCode
? "border-red-500/50"
: "border-white/10"
} focus:border-primary/50 rounded-xl pl-11 pr-4 py-3 text-white placeholder-white/20 outline-none transition-colors`}
/>
</div>
<Input
id="verification-code"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
isError={!!fieldErrors.verificationCode}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
{fieldErrors.verificationCode && (
<p className="text-red-400 text-xs ml-1 mt-1">
<p className="text-red-400 text-xs ml-1">
{fieldErrors.verificationCode}
</p>
)}
@@ -172,21 +162,14 @@ const VerifyReset = () => {
New Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
/>
<input
<Input
id="new-password"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
className={`w-full bg-black/20 border ${
fieldErrors.newPassword
? "border-red-500/50"
: "border-white/10"
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
isError={!!fieldErrors.newPassword}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -199,7 +182,7 @@ const VerifyReset = () => {
</button>
</div>
{fieldErrors.newPassword && (
<p className="text-red-400 text-xs ml-1 mt-1">
<p className="text-red-400 text-xs ml-1">
{fieldErrors.newPassword}
</p>
)}
@@ -211,22 +194,14 @@ const VerifyReset = () => {
Verify New Password
</label>
<div className="relative">
<FontAwesomeIcon
icon={faLock}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
/>
<input
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter new password again"
className={`w-full bg-black/20 border ${
fieldErrors.confirmPassword
? "border-red-500/50"
: "border-white/10"
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
isError={!!fieldErrors.confirmPassword}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -241,25 +216,30 @@ const VerifyReset = () => {
</button>
</div>
{fieldErrors.confirmPassword && (
<p className="text-red-400 text-xs ml-1 mt-1">
<p className="text-red-400 text-xs ml-1">
{fieldErrors.confirmPassword}
</p>
)}
</div>
<Button
id="change-password-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleRedeemReset}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Change Password"
)}
</Button>
</div>
<div className="pt-2">
<Button
id="change-password-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Change Password"
)}
</Button>
</div>
</form>
<div className="mt-8 text-center text-sm">
<Link
@@ -285,7 +265,7 @@ const VerifyReset = () => {
</p>
<Button
id="back-to-homepage-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
onClick={() => navigate(redirectTo)}
>
{redirectLabel}
@@ -295,6 +275,10 @@ const VerifyReset = () => {
)}
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
};
@@ -234,7 +234,7 @@ const LandingSD2 = () => {
description="Seedance 2.0 is available now in ArtCraft. Generate jaw-dropping AI videos with the fastest open desktop app for artists."
/>
{/* Hero Section */}
<main className="relative mb-8 md:mb-16 pt-24 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
<main className="relative mb-8 md:mb-16 pt-20 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
{/* Glowing Gradient Orb Background */}
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none z-0 overflow-hidden"
@@ -298,9 +298,7 @@ const LandingSD2 = () => {
className="relative mx-auto mt-4 w-full max-w-[1200px]"
data-animate
>
<div
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu"
>
<div className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu">
<div style={{ paddingTop: "56.25%" }} className="relative">
<iframe
src={HERO_VIMEO_URL}
@@ -359,10 +357,11 @@ const LandingSD2 = () => {
<button
key={index}
onClick={() => setActiveFeature(index)}
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${activeFeature === index
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${
activeFeature === index
? "bg-primary border-primary text-white shadow-lg shadow-primary/20"
: "bg-white/5 border-white/10 text-white/60 hover:bg-white/10"
}`}
}`}
>
{feature.title}
</button>
@@ -421,26 +420,29 @@ const LandingSD2 = () => {
>
<div className="max-w-lg">
<div
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${activeFeature === index
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${
activeFeature === index
? "bg-primary text-white shadow-lg shadow-primary/30 scale-110"
: "bg-white/10 text-white/50"
}`}
}`}
>
<FontAwesomeIcon icon={feature.icon} />
</div>
<h3
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${activeFeature === index
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${
activeFeature === index
? "text-white"
: "text-white/40"
}`}
}`}
>
{feature.title}
</h3>
<p
className={`text-xl leading-relaxed transition-all duration-500 ${activeFeature === index
className={`text-xl leading-relaxed transition-all duration-500 ${
activeFeature === index
? "text-white/80"
: "text-white/30"
}`}
}`}
>
{feature.description}
</p>
@@ -816,7 +818,10 @@ const LandingSD2 = () => {
</p>
<div className="relative z-10 w-full flex flex-col items-center">
<LandingActionButtons onDownloadClick={onDownloadClick} creditsButtonText="Get Now" />
<LandingActionButtons
onDownloadClick={onDownloadClick}
creditsButtonText="Get Now"
/>
<div className="mt-8 text-sm text-white/80 font-medium">
Free to use.
</div>
@@ -219,7 +219,6 @@ const Landing = () => {
}
}, [activeFeature]);
return (
<div
ref={mainRef}
@@ -230,7 +229,7 @@ const Landing = () => {
description="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
/>
{/* Hero Section */}
<main className="relative mb-8 md:mb-16 pt-24 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
<main className="relative mb-8 md:mb-16 pt-20 sm:pt-24 min-h-[400px] sm:min-h-[600px] md:min-h-[700px] flex items-center justify-center px-2 sm:px-4 md:px-0">
{/* Glowing Gradient Orb Background */}
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none z-0 overflow-hidden"
@@ -293,10 +292,11 @@ const Landing = () => {
className="relative mx-auto mt-4 w-full max-w-[1200px]"
data-animate
>
<div
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu"
>
<div className="relative w-full rounded-2xl overflow-hidden" style={{ paddingTop: "56.25%" }}>
<div className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu">
<div
className="relative w-full rounded-2xl overflow-hidden"
style={{ paddingTop: "56.25%" }}
>
<iframe
src="https://player.vimeo.com/video/1179924350?h=8b9b3f0f35&autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
className="absolute inset-0 w-full h-full rounded-2xl"
@@ -139,7 +139,7 @@ export default function Library() {
const thumbnail = isDimensional
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.MEDIUM,
size: THUMBNAIL_SIZES.LARGE,
});
return {
@@ -315,13 +315,15 @@ export default function Library() {
}
return (
<div className="relative min-h-screen w-full bg-dots pt-20 pb-8 px-4 md:px-8 lg:px-12">
<div className="relative min-h-screen w-full bg-dots pt-14 sm:pt-20 pb-8 px-3 sm:px-4 md:px-8 lg:px-12">
<div className="mx-auto max-w-[1600px]">
{/* Header — sticky below navbar */}
<div className="sticky top-16 z-10 -mx-4 md:-mx-8 lg:-mx-12 px-4 md:px-8 lg:px-12 pb-4 pt-4 bg-[#101014]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">My Library</h1>
<div className="sticky top-12 sm:top-16 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 shrink-0">
<h1 className="text-lg sm:text-2xl font-bold text-white">
My Library
</h1>
<button
onClick={() => {
setAllItems([]);
@@ -331,30 +333,30 @@ export default function Library() {
isLoadingRef.current = false;
loadItems(true);
}}
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 sm:h-8 sm:w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Refresh library"
>
<FontAwesomeIcon
icon={faArrowsRotate}
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
className={`text-xs sm:text-sm ${initialLoading ? "animate-spin" : ""}`}
/>
</button>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-lg p-1">
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-lg p-1 overflow-x-auto">
{FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => navigate(filter.route)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
activeFilter === filter.id
? "bg-ui-controls text-white"
: "text-white/60 hover:text-white"
}`}
>
<FontAwesomeIcon icon={filter.icon} className="text-xs" />
{filter.label}
<span className="hidden sm:inline">{filter.label}</span>
</button>
))}
</div>
@@ -368,7 +370,7 @@ export default function Library() {
<div className="space-y-6">
<div>
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div
key={i}
@@ -417,7 +419,7 @@ export default function Library() {
<h3 className="text-sm font-medium text-white/50 mb-2">
{date}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
{dateItems.map((item) => (
<button
key={item.id}
@@ -507,6 +509,7 @@ export default function Library() {
cdnUrl={lightboxItem?.fullImage}
mediaClass={lightboxItem?.mediaClass}
batchImageToken={lightboxItem?.batchImageToken}
showBatchCarousel={false}
onNavigatePrev={navigatePrev}
onNavigateNext={navigateNext}
onDeleted={handleItemDeleted}
@@ -1,11 +1,15 @@
import { faEye, faEyeSlash, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import {
faEye,
faEyeSlash,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import Seo from "../../components/seo";
import { GoogleLoginButton } from "../../components/auth";
const Login = () => {
const navigate = useNavigate();
@@ -58,7 +62,7 @@ const Login = () => {
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
@@ -81,7 +85,13 @@ const Login = () => {
</div>
*/}
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
@@ -92,12 +102,12 @@ const Login = () => {
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email or Username
</label>
<input
<Input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
@@ -113,12 +123,12 @@ const Login = () => {
</Link>
</div>
<div className="relative">
<input
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -130,18 +140,23 @@ const Login = () => {
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Log in"
)}
</Button>
</div>
<div className="pt-2">
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Log in"
)}
</Button>
</div>
</form>
</div>
<div className="mt-8 text-center text-sm text-white/60">
@@ -1,14 +1,12 @@
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUser,
faEnvelope,
faLock,
faCheckCircle,
faExclamationCircle,
faInfoCircle,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import Seo from "../../components/seo";
import { UsersApi, BillingApi } from "@storyteller/api";
@@ -360,9 +358,9 @@ const Onboarding = () => {
</div>
)}
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12">
<div className="bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-3xl md:text-3xl font-bold mb-4 text-white">
<h1 className="text-2xl font-bold mb-2">
{currentStep === "password" &&
(isNewAccount ? "Secure Your Account" : "Set Your Password")}
{currentStep === "email" &&
@@ -371,7 +369,7 @@ const Onboarding = () => {
: "Verify Your Email")}
{currentStep === "username" && "Choose A Username"}
</h1>
<p className="text-white/70">
<p className="text-white/60 text-sm">
{currentStep === "password" &&
"Create a password to access your account across devices."}
{currentStep === "email" &&
@@ -383,43 +381,41 @@ const Onboarding = () => {
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
{currentStep === "password" && (
<>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faLock} className="mr-2" />
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Password
</label>
<input
<Input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Enter your password"
minLength={8}
/>
</div>
<div>
<label
htmlFor="passwordConfirmation"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faLock} className="mr-2" />
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Confirm Password
</label>
<input
<Input
type="password"
id="passwordConfirmation"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
required
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Confirm your password"
minLength={8}
/>
@@ -428,85 +424,49 @@ const Onboarding = () => {
)}
{currentStep === "email" && (
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faEnvelope} className="mr-2" />
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email Address
</label>
<input
<Input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="your@email.com"
/>
</div>
)}
{currentStep === "username" && (
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-white/80 mb-2"
>
<FontAwesomeIcon icon={faUser} className="mr-2" />
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Username
</label>
<input
<Input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 bg-[#252529] border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-primary transition-colors"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Your display name"
/>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400 text-sm flex items-start gap-3">
<FontAwesomeIcon
icon={faExclamationCircle}
className="text-red-400 mt-0.5 flex-shrink-0"
/>
<span className="flex-1">{error}</span>
</div>
)}
<div className="flex gap-3">
<div className="flex gap-3 pt-2">
<Button
type="submit"
className="flex-1 bg-primary hover:bg-primary-600 px-6 py-3 text-sm font-bold rounded-xl justify-center"
className="flex-1 bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
disabled={isSubmitting}
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving...
</span>
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Continue"
)}
@@ -515,7 +475,7 @@ const Onboarding = () => {
<Button
type="button"
onClick={handleSkip}
className="bg-white/10 hover:bg-white/20 px-6 py-3 text-sm font-bold rounded-xl justify-center"
className="bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10 px-6"
>
Skip
</Button>
@@ -92,7 +92,7 @@ const Pricing = () => {
</div>
</main>
) : (
<main className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<main className="relative z-10 pt-28 sm:pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<PricingTable
title="Invest in Yourself"
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
@@ -14,7 +14,7 @@ const Signup = () => {
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
<p className="text-white/60 text-sm">Join thousands of creators</p>
@@ -104,6 +104,15 @@
animation: toast-in 0.2s ease-out both;
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
@@ -49,15 +49,15 @@
{
"path": "../../libs/components/close-button/tsconfig.lib.json"
},
{
"path": "../../libs/components/tooltip/tsconfig.lib.json"
},
{
"path": "../../libs/components/viewer-3d/tsconfig.lib.json"
},
{
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/tooltip/tsconfig.lib.json"
},
{
"path": "../../libs/components/toaster/tsconfig.lib.json"
},
@@ -79,6 +79,9 @@
{
"path": "../../libs/markdown-content/tsconfig.lib.json"
},
{
"path": "../../libs/components/input/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
},
+6 -3
View File
@@ -14,15 +14,15 @@
{
"path": "../../libs/components/close-button"
},
{
"path": "../../libs/components/tooltip"
},
{
"path": "../../libs/components/viewer-3d"
},
{
"path": "../../libs/components/action-reminder-modal"
},
{
"path": "../../libs/components/tooltip"
},
{
"path": "../../libs/components/toaster"
},
@@ -44,6 +44,9 @@
{
"path": "../../libs/markdown-content"
},
{
"path": "../../libs/components/input"
},
{
"path": "../../libs/components/button"
},
@@ -18,6 +18,7 @@ export enum CommonAspectRatio {
Wide = "wide",
Tall = "tall",
Auto2k = "auto_2k",
Auto3k = "auto_3k",
Auto4k = "auto_4k",
SquareHd = "square_hd",
}
@@ -7,6 +7,7 @@ export enum CommonImageModel {
Flux1Schnell = "flux_1_schnell",
FluxPro11 = "flux_pro_1p1",
FluxPro11Ultra = "flux_pro_1p1_ultra",
GptImage1 = "gpt_image_1",
GptImage1p5 = "gpt_image_1p5",
NanaBanana = "nano_banana",
NanaBanana2 = "nano_banana_2",
@@ -3,7 +3,11 @@
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum CommonVideoResolution {
HalfK = "half_k",
FourEightyP = "four_eighty_p",
SevenTwentyP = "seven_twenty_p",
OneK = "one_k",
TenEightyP = "ten_eighty_p",
TwoK = "two_k",
ThreeK = "three_k",
FourK = "four_k",
+3
View File
@@ -53,6 +53,7 @@ export interface OmniGenVideoRequest {
reference_image_media_tokens?: string[] | null;
reference_video_media_tokens?: string[] | null;
reference_audio_media_tokens?: string[] | null;
reference_character_tokens?: string[] | null;
negative_prompt?: string | null;
}
@@ -75,6 +76,7 @@ export interface OmniGenVideoGenerateResponse {
export interface OmniGenImageModelInfo {
model: string;
is_disabled: boolean | null;
full_name: string | null;
aspect_ratio_options: string[] | null;
aspect_ratio_default: string | null;
@@ -106,6 +108,7 @@ export interface OmniGenImageModelsResponse {
export interface OmniGenVideoModelInfo {
model: string;
is_disabled: boolean | null;
full_name: string | null;
aspect_ratio_options: string[] | null;
aspect_ratio_default: string | null;
@@ -941,7 +941,7 @@ export const Modal = ({
<ModalExpandContext.Provider value={{ expanded, toggleExpanded }}>
<div
className="flex min-h-full items-center justify-center p-4 text-center"
className="flex min-h-full items-center justify-center p-0 sm:p-4 text-center"
style={
allowBackgroundInteraction
? { pointerEvents: "none" }
@@ -14,12 +14,25 @@ export function imageResolutionToCommonVideoResolution(
resolution: string | undefined,
): CommonVideoResolution | null {
switch (resolution) {
case "half_k":
return CommonVideoResolution.HalfK;
case "four_eighty_p":
case "480p":
return CommonVideoResolution.FourEightyP;
case "seven_twenty_p":
case "720p":
return CommonVideoResolution.SevenTwentyP;
case "one_k":
case "1k":
return CommonVideoResolution.OneK;
case "ten_eighty_p":
case "1080p":
return CommonVideoResolution.TenEightyP;
case "two_k":
case "2k":
return CommonVideoResolution.TwoK;
case "three_k":
return CommonVideoResolution.ThreeK;
case "four_k":
case "4k":
return CommonVideoResolution.FourK;
@@ -125,6 +125,7 @@ function commonAspectRatioToProportions(
// Auto
case CommonAspectRatio.Auto:
case CommonAspectRatio.Auto2k:
case CommonAspectRatio.Auto3k:
case CommonAspectRatio.Auto4k:
return [1, 1]; // fallback, Auto uses the sparkle icon instead
@@ -33,6 +33,7 @@ export const AspectRatioPicker = ({
const isAutoRatio =
useAspectRatio === CommonAspectRatio.Auto ||
useAspectRatio === CommonAspectRatio.Auto2k ||
useAspectRatio === CommonAspectRatio.Auto3k ||
useAspectRatio === CommonAspectRatio.Auto4k;
const handleSelectAdapter = (item: PopoverItem) => {
@@ -44,6 +45,7 @@ export const AspectRatioPicker = ({
return (
ratio === CommonAspectRatio.Auto ||
ratio === CommonAspectRatio.Auto2k ||
ratio === CommonAspectRatio.Auto3k ||
ratio === CommonAspectRatio.Auto4k
);
};
@@ -123,6 +125,8 @@ const getAspectRatioTextLabel = (aspectRatio: CommonAspectRatio): string => {
// With resolution baked in
case CommonAspectRatio.Auto2k:
return "Auto (2K)";
case CommonAspectRatio.Auto3k:
return "Auto (3K)";
case CommonAspectRatio.Auto4k:
return "Auto (4K)";
case CommonAspectRatio.SquareHd:
@@ -176,6 +180,8 @@ const popOverLabelToAspectRatio = (
return CommonAspectRatio.TallNineByTwentyOne;
case "Auto (2K)":
return CommonAspectRatio.Auto2k;
case "Auto (3K)":
return CommonAspectRatio.Auto3k;
case "Auto (4K)":
return CommonAspectRatio.Auto4k;
case "Square (HD)":
@@ -93,12 +93,16 @@ export const ResolutionPicker = ({
const getResolutionIcon = (resolution: CommonResolution): IconDefinition => {
switch (resolution) {
case CommonResolution.HalfK:
case CommonResolution.FourEightyP:
case CommonResolution.SevenTwentyP:
case CommonResolution.OneK:
case CommonResolution.TenEightyP:
return faStandardDefinition;
case CommonResolution.TwoK:
return faHighDefinition;
case CommonResolution.ThreeK:
case CommonResolution.FourK:
return faHighDefinition; // TODO: Upgrade Font Awesome to include 4K icon
return faHighDefinition;
default:
console.error("Unknown resolution in icon mapping:", resolution);
return faStandardDefinition; // Fail open-ish
@@ -107,10 +111,20 @@ const getResolutionIcon = (resolution: CommonResolution): IconDefinition => {
const getResolutionTextLabel = (resolution: CommonResolution): string => {
switch (resolution) {
case CommonResolution.HalfK:
return "0.5K";
case CommonResolution.FourEightyP:
return "480p";
case CommonResolution.SevenTwentyP:
return "720p";
case CommonResolution.OneK:
return "1K";
case CommonResolution.TenEightyP:
return "1080p";
case CommonResolution.TwoK:
return "2K";
case CommonResolution.ThreeK:
return "3K";
case CommonResolution.FourK:
return "4K";
default:
@@ -125,10 +139,20 @@ const popOverLabelToResolution = (
model: ImageModel,
): CommonResolution => {
switch (label) {
case "0.5K":
return CommonResolution.HalfK;
case "480p":
return CommonResolution.FourEightyP;
case "720p":
return CommonResolution.SevenTwentyP;
case "1K":
return CommonResolution.OneK;
case "1080p":
return CommonResolution.TenEightyP;
case "2K":
return CommonResolution.TwoK;
case "3K":
return CommonResolution.ThreeK;
case "4K":
return CommonResolution.FourK;
}
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../slider-v2"
},
{
"path": "../button-icon-select"
},
@@ -14,9 +17,6 @@
{
"path": "../camera-settings-modal"
},
{
"path": "../toaster"
},
{
"path": "../../tauri-utils"
},
@@ -26,29 +26,38 @@
{
"path": "../../model-list"
},
{
"path": "../modal"
},
{
"path": "../popover"
},
{
"path": "../slider-v2"
},
{
"path": "../../common"
},
{
"path": "../../api"
"path": "../tooltip"
},
{
"path": "../action-reminder-modal"
},
{
"path": "../label"
},
{
"path": "../button"
},
{
"path": "../input"
},
{
"path": "../gallery-modal"
},
{
"path": "../tooltip"
"path": "../toaster"
},
{
"path": "../button"
"path": "../../api"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../slider-v2/tsconfig.lib.json"
},
{
"path": "../button-icon-select/tsconfig.lib.json"
},
@@ -55,9 +58,6 @@
{
"path": "../camera-settings-modal/tsconfig.lib.json"
},
{
"path": "../toaster/tsconfig.lib.json"
},
{
"path": "../../tauri-utils/tsconfig.lib.json"
},
@@ -67,29 +67,38 @@
{
"path": "../../model-list/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../slider-v2/tsconfig.lib.json"
},
{
"path": "../../common/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
"path": "../tooltip/tsconfig.lib.json"
},
{
"path": "../action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../label/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
},
{
"path": "../tooltip/tsconfig.lib.json"
"path": "../toaster/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -32,6 +32,7 @@ export enum CommonAspectRatio {
// Auto values that bake in resolution
Auto2k = "auto_2k",
Auto3k = "auto_3k",
Auto4k = "auto_4k",
// Defined aspect ratios that bake in resolution
@@ -3,7 +3,12 @@
/// Not all models have each of these aspect ratios,
/// but Tauri will interpolate if a mistake is sent.
export enum CommonResolution {
HalfK = "half_k",
FourEightyP = "four_eighty_p",
SevenTwentyP = "seven_twenty_p",
OneK = "one_k",
TenEightyP = "ten_eighty_p",
TwoK = "two_k",
ThreeK = "three_k",
FourK = "four_k",
}