mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 08:26:47 -05:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e6e5cabb | ||
|
|
7c8419fabd | ||
|
|
130bfd71d6 | ||
|
|
d6772fdd94 | ||
|
|
0c11395467 | ||
|
|
773e5925b9 | ||
|
|
8d6fc363b1 | ||
|
|
f9f4c99e4f | ||
|
|
71adfd4701 | ||
|
|
c9da5cac18 | ||
|
|
83aad5eb54 |
@@ -80,7 +80,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
- run: ./node_modules/.bin/nx run artcraft:build
|
||||
working-directory: frontend
|
||||
- uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0
|
||||
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
|
||||
with:
|
||||
projectPath: crates/desktop/artcraft
|
||||
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
node-version: lts/*
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
npm cache clean --force
|
||||
|
||||
echo "Installing all workspace dependencies..."
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
|
||||
echo "=== Version Information ==="
|
||||
echo "Node version:"
|
||||
@@ -205,9 +205,8 @@ jobs:
|
||||
# Increase Node.js memory limit for the build
|
||||
export NODE_OPTIONS="--max-old-space-size=8192"
|
||||
|
||||
# Use npx to ensure we use the local nx version
|
||||
echo "Building with npx nx..."
|
||||
npx nx run artcraft:build
|
||||
echo "Building with nx..."
|
||||
./node_modules/.bin/nx run artcraft:build
|
||||
|
||||
echo "Checking build output..."
|
||||
# shellcheck disable=SC2012
|
||||
@@ -226,7 +225,7 @@ jobs:
|
||||
|
||||
|
||||
- name: build and publish
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
- name: install wreq dependencies
|
||||
run: |
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
npm cache clean --force
|
||||
|
||||
echo "Installing all workspace dependencies..."
|
||||
npm ci
|
||||
npm ci --ignore-scripts
|
||||
|
||||
echo "=== Version Information ==="
|
||||
echo "Node version:"
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
|
||||
|
||||
- name: build and publish
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
|
||||
env:
|
||||
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
|
||||
RUSTFLAGS: "-C target-feature=+crt-static"
|
||||
|
||||
Generated
+1
@@ -8075,6 +8075,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokens",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -21,10 +21,6 @@ impl TaskId {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TaskId {
|
||||
|
||||
+2
-2
@@ -141,8 +141,8 @@ mod tests {
|
||||
async fn test_requests() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Debug);
|
||||
|
||||
let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778";
|
||||
let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string());
|
||||
let refresh_token = std::env::var("WORLDLABS_REFRESH_TOKEN")?;
|
||||
let refresh_token = WorldLabsRefreshToken::new(refresh_token);
|
||||
|
||||
let result = google_refresh_token(GoogleRefreshTokenArgs {
|
||||
refresh_token: &refresh_token,
|
||||
|
||||
+1
-4
@@ -31,8 +31,6 @@ use sqlite_tasks::queries::task::Task;
|
||||
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{
|
||||
update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs,
|
||||
};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
@@ -255,8 +253,7 @@ async fn download_file(
|
||||
let filename = format!("fal_{}_{}.{}", generate_random_uuid(), index, extension);
|
||||
let download_path = tempdir.join(filename);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&bytes)?;
|
||||
tokio::fs::write(&download_path, bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ async fn download_generated_results(
|
||||
}
|
||||
ensure!(!urls.is_empty(), "Completed task has no downloadable results");
|
||||
let path = checkpoint_path(root, task);
|
||||
let mut checkpoint: AutoDownloadCheckpoint = match std::fs::read(&path) {
|
||||
let mut checkpoint: AutoDownloadCheckpoint = match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
AutoDownloadCheckpoint { download_time: Local::now(), saved: BTreeMap::new() }
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use errors::AnyhowResult;
|
||||
use reqwest::Client;
|
||||
use reqwest::Url;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
@@ -22,13 +20,7 @@ pub async fn simple_http_download<P: AsRef<Path>>(url: &Url, download_path: P) -
|
||||
//let response : Response = reqwest::get(url).await?;
|
||||
let bytes = response.bytes().await?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true) // To create a new file
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(download_path)?;
|
||||
|
||||
file.write_all(&bytes)?;
|
||||
tokio::fs::write(download_path, bytes).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+1
-4
@@ -33,8 +33,6 @@ use sqlite_tasks::queries::task::Task;
|
||||
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
|
||||
@@ -213,8 +211,7 @@ async fn download_generation_item(
|
||||
|
||||
info!("Writing to path: {:?}", download_path);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
tokio::fs::write(&download_path, image_bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
+1
-4
@@ -39,8 +39,6 @@ use sqlite_tasks::queries::task::Task;
|
||||
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
|
||||
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
@@ -378,8 +376,7 @@ async fn download_spz(
|
||||
|
||||
info!("Writing to path: {:?}", download_path);
|
||||
|
||||
let mut file = File::create(&download_path)?;
|
||||
file.write_all(&image_bytes)?;
|
||||
tokio::fs::write(&download_path, image_bytes).await?;
|
||||
|
||||
Ok(download_path)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ tokens.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
|
||||
# TODO(bt,2025-07-09): Update sqlx ASAP. We're stuck on 0.7 because of the complex use of internal types and macros in the database crate.
|
||||
# We're going to embed in-memory SQLite
|
||||
|
||||
@@ -22,7 +22,7 @@ impl TaskDbConnection {
|
||||
|
||||
info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref());
|
||||
|
||||
if let Err(err) = std::fs::remove_file(&database_file) {
|
||||
if let Err(err) = tokio::fs::remove_file(&database_file).await {
|
||||
error!("Error deleting SQLite database file: {:?}", err);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="SwaggerUI" />
|
||||
<title>Storyteller API</title>
|
||||
<link rel="stylesheet" href="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" integrity="sha384-3nuX7df3EaAoiqLBeyS1Ola0Gpg9ryJKVtarubwfnA1cOH8AWHUdbPSIvEqPZ9VH" crossorigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" crossorigin></script>
|
||||
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" integrity="sha384-7xcoc6ZKDFF7Ek627QTC3Bg/K+5Y36NJ8MWAE43D2m6+3Sh9XO3tdsfHhrS8gNIQ" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" integrity="sha384-FjFI+0PRyd5aAEoduVmUYk65iDC5+onNzam4WJtbAKKBim/kWvGUMk/2+9qRaYZb" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ export function GlobalFileDropHandler() {
|
||||
}
|
||||
};
|
||||
|
||||
setup();
|
||||
void setup();
|
||||
return () => { unlisteners.forEach((fn) => fn()); };
|
||||
|
||||
} else {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
type Mode = "image" | "text";
|
||||
type Variant = "object" | "world";
|
||||
@@ -69,10 +70,7 @@ const formatTime = (timestamp: number) => {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const generateId = () =>
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
const generateId = () => uuidv4();
|
||||
|
||||
const WORLD_MODEL_PAGE = ModelPage.ImageTo3DWorld;
|
||||
const OBJECT_MODEL_PAGE = ModelPage.ImageTo3DObject;
|
||||
@@ -256,7 +254,7 @@ export const ImageTo3DExperience = ({
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFiles(event.target.files);
|
||||
void handleFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -264,7 +262,7 @@ export const ImageTo3DExperience = ({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragActive(false);
|
||||
handleFiles(event.dataTransfer?.files);
|
||||
void handleFiles(event.dataTransfer?.files);
|
||||
};
|
||||
|
||||
const handlePickFromLibrary = () => {
|
||||
@@ -599,6 +597,9 @@ export const ImageTo3DExperience = ({
|
||||
<div className="flex justify-center">
|
||||
{uploadedPreview ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className={twMerge(
|
||||
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
|
||||
hasResults ? "aspect-square w-24" : "aspect-square w-48",
|
||||
@@ -716,7 +717,7 @@ export const ImageTo3DExperience = ({
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer?.files)
|
||||
handleWorldFiles(
|
||||
void handleWorldFiles(
|
||||
Array.from(e.dataTransfer.files).filter((f) =>
|
||||
f.type.startsWith("image/"),
|
||||
),
|
||||
@@ -754,6 +755,9 @@ export const ImageTo3DExperience = ({
|
||||
|
||||
const worldImageThumbnail = (img: WorldImage) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
key={img.id}
|
||||
className={twMerge(
|
||||
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
|
||||
@@ -944,7 +948,7 @@ export const ImageTo3DExperience = ({
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-auto grid h-full w-full grid-cols-[1fr_300px] gap-4 overflow-hidden pb-10",
|
||||
variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]",
|
||||
"max-w-[1600px]",
|
||||
)}
|
||||
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
|
||||
>
|
||||
@@ -1111,7 +1115,7 @@ export const ImageTo3DExperience = ({
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-between gap-2.5",
|
||||
hasResults ? "mt-3" : "mt-3",
|
||||
"mt-3",
|
||||
)}
|
||||
>
|
||||
<ClassyModelSelector
|
||||
@@ -1177,7 +1181,7 @@ export const ImageTo3DExperience = ({
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
handleWorldFiles(Array.from(e.target.files));
|
||||
void handleWorldFiles(Array.from(e.target.files));
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
@@ -1202,6 +1206,7 @@ export const ImageTo3DExperience = ({
|
||||
|
||||
{previewImage && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ function ItemActions({
|
||||
e.stopPropagation();
|
||||
const url = item.fullImage || item.thumbnail;
|
||||
if (!url) return;
|
||||
applyMakeVideoFromImage(url, item.id);
|
||||
void applyMakeVideoFromImage(url, item.id);
|
||||
},
|
||||
[item.fullImage, item.thumbnail, item.id],
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
|
||||
// the create pages' generation feed (rows + cards). All of them operate on
|
||||
@@ -130,7 +131,7 @@ export async function downloadMediaFilesToFolder(
|
||||
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
|
||||
try {
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: uuidv4(),
|
||||
url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: mediaToken || "",
|
||||
@@ -178,7 +179,7 @@ export function applyRecreateFromPromptData(data: {
|
||||
|
||||
for (const ci of contextImages) {
|
||||
const base = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: uuidv4(),
|
||||
url: ci.media_links.cdn_url,
|
||||
mediaToken: ci.media_token,
|
||||
};
|
||||
|
||||
+2
-2
@@ -295,8 +295,8 @@ export function useDesktopGenerationFeed(options: {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("task-queue-update", handleTaskUpdate);
|
||||
unlistenComplete.then((f) => f());
|
||||
unlistenFailed.then((f) => f());
|
||||
void unlistenComplete.then((f) => f());
|
||||
void unlistenFailed.then((f) => f());
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useDesktopUsername(): string | null {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await new UsersApi().GetSession();
|
||||
if (!cancelled && session.success && session.data?.user) {
|
||||
|
||||
@@ -66,9 +66,9 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
|
||||
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
|
||||
Currency
|
||||
</label>
|
||||
</span>
|
||||
<Select
|
||||
options={selectOptions}
|
||||
value={currency}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export const UploadFilesMedia = ({
|
||||
return;
|
||||
}
|
||||
|
||||
uploadPlane({
|
||||
void uploadPlane({
|
||||
title: uploadTitle.value,
|
||||
assetFile: assetFile.value,
|
||||
progressCallback: onUploadProgress,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PauseIcon, PlayIcon, SkipBackIcon, SkipForwardIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
|
||||
interface VideoTransportControlsProps {
|
||||
isPlaying: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (to: "first" | "last") => void;
|
||||
onTogglePlay: () => void;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
formatTime: (seconds: number) => string;
|
||||
}
|
||||
|
||||
/** The row under a video page's player: first / play / last frame, mute and volume, and the time. */
|
||||
export const VideoTransportControls = ({
|
||||
isPlaying,
|
||||
isMuted,
|
||||
volume,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
onTogglePlay,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
formatTime,
|
||||
}: VideoTransportControlsProps) => (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSeek("first")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="First Frame"
|
||||
>
|
||||
<SkipBackIcon className="text-sm text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
|
||||
>
|
||||
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} className="text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSeek("last")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="Last Frame"
|
||||
>
|
||||
<SkipForwardIcon className="text-sm text-white" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={onToggleMute}
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
>
|
||||
<DynamicIcon icon={isMuted ? VolumeXIcon : Volume2Icon} className="text-sm text-white" />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(Number.parseFloat(e.target.value))}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -26,7 +26,7 @@ export default function ProfileDropdown() {
|
||||
label: "Logout",
|
||||
icon: LogOutIcon,
|
||||
onClick: () => {
|
||||
logout();
|
||||
void logout();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,7 +67,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
|
||||
e.currentTarget.focus();
|
||||
} else if (scene.value.token) {
|
||||
setIsSaving(true);
|
||||
renameScene(
|
||||
void renameScene(
|
||||
scene.value.title!, //guarunteed by input
|
||||
scene.value.token,
|
||||
);
|
||||
|
||||
@@ -164,7 +164,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
|
||||
aria-label="Copy prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(prompt);
|
||||
void navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}}
|
||||
@@ -513,7 +513,7 @@ export const TaskQueue = () => {
|
||||
const [failed, setFailed] = useState<FailedTask[]>([]);
|
||||
const [lastReadAt, setLastReadAt] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("taskQueueLastReadAt");
|
||||
return stored ? parseInt(stored, 10) : 0;
|
||||
return stored ? Number.parseInt(stored, 10) : 0;
|
||||
});
|
||||
|
||||
// remove unread state; unread tracking handled via IDs below
|
||||
@@ -869,7 +869,7 @@ export const TaskQueue = () => {
|
||||
};
|
||||
|
||||
cleanupOldEntries();
|
||||
load();
|
||||
void load();
|
||||
const id = setInterval(load, 5000);
|
||||
|
||||
// Listen for cover image uploads to refresh and show new thumbnails
|
||||
@@ -883,17 +883,17 @@ export const TaskQueue = () => {
|
||||
|
||||
let unlistenComplete: Promise<UnlistenFn> | null = null;
|
||||
let unlistenFailed: Promise<UnlistenFn> | null = null;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
// Update immediately when Tauri signals a generation completion
|
||||
unlistenComplete = listen("generation-complete-event", () => {
|
||||
if (!cancelled) {
|
||||
load();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
// Also update immediately when a generation fails
|
||||
unlistenFailed = listen("generation-failed-event", () => {
|
||||
if (!cancelled) {
|
||||
load();
|
||||
void load();
|
||||
// Refunded credits may take a moment to settle in the database
|
||||
setTimeout(() => {
|
||||
useCreditsState.getState().fetchFromServer();
|
||||
@@ -906,10 +906,10 @@ export const TaskQueue = () => {
|
||||
clearInterval(id);
|
||||
window.removeEventListener("cover-image-uploaded", handleCoverUploaded);
|
||||
if (unlistenComplete) {
|
||||
unlistenComplete.then((f) => f());
|
||||
void unlistenComplete.then((f) => f());
|
||||
}
|
||||
if (unlistenFailed) {
|
||||
unlistenFailed.then((f) => f());
|
||||
void unlistenFailed.then((f) => f());
|
||||
}
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -125,7 +125,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
|
||||
|
||||
// Then convert blob URLs to base64 in the background for persistence
|
||||
if (meta.refImageUrls && meta.refImageUrls.length > 0) {
|
||||
Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
|
||||
void Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
|
||||
const current = readEntries();
|
||||
const found = current.find((e) => e.id === entryId);
|
||||
if (found) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const withProtectionRoute = <P extends object>(
|
||||
function ProtectionRoute(rest: P) {
|
||||
useSignals();
|
||||
useSignalEffect(() => {
|
||||
persistLogin();
|
||||
void persistLogin();
|
||||
});
|
||||
|
||||
// const { status, userInfo } = authentication;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useActiveJobs = () => {
|
||||
// then set new one with new interval
|
||||
clearInterval(curr.timer);
|
||||
const newTimer = setInterval(() => {
|
||||
PollRecentJobs();
|
||||
void PollRecentJobs();
|
||||
}, newTimeInterval);
|
||||
return {
|
||||
timer: newTimer,
|
||||
@@ -72,7 +72,7 @@ export const useActiveJobs = () => {
|
||||
useSignalEffect(() => {
|
||||
// if active jobs is not initiated, do do a first pull;
|
||||
if (!activeJobs.value) {
|
||||
PollRecentJobs();
|
||||
void PollRecentJobs();
|
||||
return;
|
||||
}
|
||||
// if there are no active jobs, stop making polls
|
||||
@@ -115,7 +115,7 @@ export const useActiveJobs = () => {
|
||||
|
||||
//else make and set timer
|
||||
const newTimer = setInterval(() => {
|
||||
PollRecentJobs();
|
||||
void PollRecentJobs();
|
||||
}, curr.interval);
|
||||
return {
|
||||
...curr,
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
//CASE 1: first load
|
||||
// if myMovies undefined, poll for the first time
|
||||
if (!userMovies.value) {
|
||||
PollUserGeneratedMovies();
|
||||
void PollUserGeneratedMovies();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
|
||||
//there are videos newly completed, set and poll
|
||||
lastCompletedWorkflow.current = completedWorkflowJobs.value;
|
||||
PollUserGeneratedMovies().then((ret: boolean) => {
|
||||
void PollUserGeneratedMovies().then((ret: boolean) => {
|
||||
if (ret) {
|
||||
addToast(
|
||||
ToastTypes.SUCCESS,
|
||||
@@ -60,7 +60,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
//CASE 1: first load
|
||||
// if audioItems undefined, poll for the first time
|
||||
if (!userAudioItems.value) {
|
||||
PollUserAudioItems();
|
||||
void PollUserAudioItems();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const useBackgroundLoadingMedia = () => {
|
||||
|
||||
//there are audio jobs newly completed, set and poll
|
||||
lastCompletedAudioJobs.current = completedAudioJobs.value;
|
||||
PollUserAudioItems().then((ret: boolean) => {
|
||||
void PollUserAudioItems().then((ret: boolean) => {
|
||||
if (ret) {
|
||||
addToast(
|
||||
ToastTypes.SUCCESS,
|
||||
|
||||
@@ -26,12 +26,12 @@ const ENV = {
|
||||
const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
|
||||
console.log("GlobalSettingsManager()");
|
||||
|
||||
SyncStorytellerApiConfig();
|
||||
void SyncStorytellerApiConfig();
|
||||
|
||||
useSignals();
|
||||
|
||||
useSignalEffect(() => {
|
||||
persistLogin();
|
||||
void persistLogin();
|
||||
});
|
||||
|
||||
/// Initizations that depends on ENV vars ///
|
||||
|
||||
@@ -152,7 +152,7 @@ export const MainApp = ({ sceneToken }: Props) => {
|
||||
// Session probe (runs once per shell mount).
|
||||
useEffect(() => {
|
||||
const usersApi = new UsersApi();
|
||||
usersApi.GetSession().then((result) => {
|
||||
void usersApi.GetSession().then((result) => {
|
||||
console.log(
|
||||
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
|
||||
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const MAX_GALLERY_SELECTIONS = 1;
|
||||
|
||||
@@ -64,7 +65,7 @@ export const BaseImageSelector = ({
|
||||
setIsBlankCanvasModalOpen(false);
|
||||
const blankImage: BaseSelectorImage = {
|
||||
url: "",
|
||||
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
mediaToken: `blank_canvas_${width}x${height}_${uuidv4()}`,
|
||||
isBlankCanvas: true,
|
||||
blankCanvasWidth: width,
|
||||
blankCanvasHeight: height,
|
||||
@@ -79,12 +80,12 @@ export const BaseImageSelector = ({
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
uploadImage({
|
||||
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
|
||||
title: `reference-image-${uuidv4()}`,
|
||||
assetFile: file,
|
||||
progressCallback: (newState) => {
|
||||
if (newState.status === UploaderStates.success && newState.data) {
|
||||
const mediaToken = newState.data || "";
|
||||
(async () => {
|
||||
void (async () => {
|
||||
let finalUrl = reader.result as string;
|
||||
let thumbnailUrlTemplate = undefined;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { GeneratedImage } from "@storyteller/tauri-events";
|
||||
import type { ModelCreator } from "@storyteller/model-list";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type TextToImageBatch = {
|
||||
id: string;
|
||||
@@ -43,11 +44,7 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
|
||||
subscriberId?: string,
|
||||
modelCreator?: ModelCreator,
|
||||
) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const batch: TextToImageBatch = {
|
||||
id,
|
||||
prompt,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { captureModelThumbnail } from "./captureModelThumbnail";
|
||||
import { completeGenerationAction } from "../Stores/completeGenerationResult";
|
||||
|
||||
export interface PendingExternalImage {
|
||||
url: string;
|
||||
@@ -72,11 +73,7 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
|
||||
meshOnly: boolean,
|
||||
subscriberId?: string,
|
||||
) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const result: ImageTo3DResult = {
|
||||
id,
|
||||
mode,
|
||||
@@ -90,52 +87,7 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
|
||||
set((s) => ({ results: [result, ...s.results] }));
|
||||
return id;
|
||||
},
|
||||
completeGeneration: (
|
||||
modelUrl: string,
|
||||
mediaToken: string,
|
||||
maybeSubscriberId?: string,
|
||||
) => {
|
||||
console.log("[ImageTo3DStore] completeGeneration", {
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
maybeSubscriberId,
|
||||
});
|
||||
const pending = maybeSubscriberId
|
||||
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
|
||||
: get().results.find((r) => r.status === "pending");
|
||||
|
||||
set((s) => {
|
||||
const results = [...s.results];
|
||||
const targetIdx = pending
|
||||
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
|
||||
: -1;
|
||||
|
||||
if (targetIdx === -1) {
|
||||
const generatedId =
|
||||
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
||||
const result: ImageTo3DResult = {
|
||||
id: generatedId,
|
||||
subscriberId: generatedId,
|
||||
mode: "image",
|
||||
timestamp: Date.now(),
|
||||
note: "Generated Model",
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
return { results: [result, ...results] };
|
||||
}
|
||||
|
||||
results[targetIdx] = {
|
||||
...results[targetIdx],
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
|
||||
return { results };
|
||||
});
|
||||
},
|
||||
completeGeneration: completeGenerationAction(set, "ImageTo3DStore", "Generated Model"),
|
||||
uploadCoverImage: async (mediaToken: string, thumbnailDataUrl: string) => {
|
||||
try {
|
||||
// Check if already uploaded
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { completeGenerationAction } from "../Stores/completeGenerationResult";
|
||||
|
||||
export type ImageTo3DWorldResult = {
|
||||
id: string;
|
||||
@@ -54,11 +55,7 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
|
||||
previewUrl: string | undefined,
|
||||
subscriberId?: string,
|
||||
) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const result: ImageTo3DWorldResult = {
|
||||
id,
|
||||
mode,
|
||||
@@ -71,52 +68,7 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
|
||||
set((s) => ({ results: [result, ...s.results] }));
|
||||
return id;
|
||||
},
|
||||
completeGeneration: (
|
||||
modelUrl: string,
|
||||
mediaToken: string,
|
||||
maybeSubscriberId?: string,
|
||||
) => {
|
||||
console.log("[ImageTo3DWorldStore] completeGeneration", {
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
maybeSubscriberId,
|
||||
});
|
||||
const pending = maybeSubscriberId
|
||||
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
|
||||
: get().results.find((r) => r.status === "pending");
|
||||
|
||||
set((s) => {
|
||||
const results = [...s.results];
|
||||
const targetIdx = pending
|
||||
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
|
||||
: -1;
|
||||
|
||||
if (targetIdx === -1) {
|
||||
const generatedId =
|
||||
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
|
||||
const result: ImageTo3DWorldResult = {
|
||||
id: generatedId,
|
||||
subscriberId: generatedId,
|
||||
mode: "image",
|
||||
timestamp: Date.now(),
|
||||
note: "Generated World",
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
return { results: [result, ...results] };
|
||||
}
|
||||
|
||||
results[targetIdx] = {
|
||||
...results[targetIdx],
|
||||
status: "completed",
|
||||
modelUrl,
|
||||
mediaToken,
|
||||
};
|
||||
|
||||
return { results };
|
||||
});
|
||||
},
|
||||
completeGeneration: completeGenerationAction(set, "ImageTo3DWorldStore", "Generated World"),
|
||||
uploadCoverFromPreview: async (mediaToken: string) => {
|
||||
try {
|
||||
const result = get().results.find((r) => r.mediaToken === mediaToken);
|
||||
|
||||
@@ -148,7 +148,7 @@ export const RemoveBackground = () => {
|
||||
}
|
||||
};
|
||||
|
||||
processExternalUrl();
|
||||
void processExternalUrl();
|
||||
}, [
|
||||
pendingExternalUrl,
|
||||
setPendingExternalUrl,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DynamicIcon } from "@storyteller/icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useStoryboardStore, type Board } from "./StoryboardStore";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
const PIXELS_PER_SECOND = 80;
|
||||
const MIN_BLOCK_WIDTH = 40;
|
||||
@@ -73,8 +74,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
|
||||
<label htmlFor="storyboard-title" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
|
||||
<input
|
||||
id="storyboard-title"
|
||||
type="text"
|
||||
value={board.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
@@ -84,8 +86,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
|
||||
<label htmlFor="storyboard-dialogue" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
|
||||
<textarea
|
||||
id="storyboard-dialogue"
|
||||
value={board.dialogue}
|
||||
onChange={(e) => onUpdate({ dialogue: e.target.value })}
|
||||
placeholder="Character dialogue..."
|
||||
@@ -95,8 +98,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
|
||||
<label htmlFor="storyboard-action" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
|
||||
<textarea
|
||||
id="storyboard-action"
|
||||
value={board.action}
|
||||
onChange={(e) => onUpdate({ action: e.target.value })}
|
||||
placeholder="On-screen action..."
|
||||
@@ -106,8 +110,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
|
||||
<label htmlFor="storyboard-notes" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
|
||||
<textarea
|
||||
id="storyboard-notes"
|
||||
value={board.notes}
|
||||
onChange={(e) => onUpdate({ notes: e.target.value })}
|
||||
placeholder="Director notes..."
|
||||
@@ -117,13 +122,14 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
|
||||
<label htmlFor="storyboard-duration" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
|
||||
<input
|
||||
id="storyboard-duration"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={board.duration}
|
||||
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
|
||||
onChange={(e) => onUpdate({ duration: Number.parseFloat(e.target.value) || 0 })}
|
||||
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -353,6 +359,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
|
||||
|
||||
{/* Color picker */}
|
||||
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
|
||||
<span className="sr-only">Stroke color</span>
|
||||
<span
|
||||
className="h-4 w-4 rounded-sm border border-white/20"
|
||||
style={{ background: color }}
|
||||
@@ -457,6 +464,9 @@ const ThumbnailItem = ({
|
||||
onDragEnd,
|
||||
}: ThumbnailItemProps) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragOver={(e) => {
|
||||
@@ -594,7 +604,7 @@ const Timeline = ({
|
||||
const delta = ev.clientX - resizeRef.current.startX;
|
||||
const newDuration = Math.max(
|
||||
MIN_DURATION,
|
||||
parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
|
||||
Number.parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
|
||||
);
|
||||
onUpdateDuration(resizeRef.current.id, newDuration);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type VideoItem = {
|
||||
media_token: string;
|
||||
@@ -33,11 +34,7 @@ type ImageToVideoState = {
|
||||
export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
|
||||
batches: [],
|
||||
startBatch: (prompt: string, modelLabel: string, subscriberId?: string) => {
|
||||
const id = subscriberId
|
||||
? subscriberId
|
||||
: crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
const id = subscriberId || uuidv4();
|
||||
const batch: ImageToVideoBatch = {
|
||||
id,
|
||||
prompt,
|
||||
@@ -61,7 +58,7 @@ export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
|
||||
? s.batches.findIndex((b) => b.id === pending.id)
|
||||
: -1;
|
||||
if (idx === -1) {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const id = uuidv4();
|
||||
const batch: ImageToVideoBatch = {
|
||||
id,
|
||||
prompt,
|
||||
|
||||
+19
-60
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { CheckIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SaveIcon, SkipBackIcon, SkipForwardIcon, SparklesIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { CheckIcon, ImagesIcon, RotateCwIcon, SaveIcon, SparklesIcon, VideoIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
@@ -13,6 +12,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
|
||||
|
||||
export const VideoFrameExtractor = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
@@ -420,7 +420,7 @@ export const VideoFrameExtractor = () => {
|
||||
const file = new File([blob], fileName, { type: "image/png" });
|
||||
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: uuidv4(),
|
||||
url: frameUrl,
|
||||
file: file,
|
||||
mediaToken: uploadResponse.data,
|
||||
@@ -515,6 +515,7 @@ export const VideoFrameExtractor = () => {
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
|
||||
<div
|
||||
role="presentation"
|
||||
ref={progressBarRef}
|
||||
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
|
||||
onClick={handleProgressBarClick}
|
||||
@@ -526,6 +527,7 @@ export const VideoFrameExtractor = () => {
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
|
||||
style={{
|
||||
left: `${duration ? (startTime / duration) * 100 : 0}%`,
|
||||
@@ -566,61 +568,18 @@ export const VideoFrameExtractor = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => seekToFrame("first")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="First Frame"
|
||||
>
|
||||
<SkipBackIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isPlaying ? PauseIcon : PlayIcon}
|
||||
className="text-white"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => seekToFrame("last")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="Last Frame"
|
||||
>
|
||||
<SkipForwardIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isMuted ? VolumeXIcon : Volume2Icon}
|
||||
className="text-sm text-white"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
setVolume(parseFloat(e.target.value))
|
||||
}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
<VideoTransportControls
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={seekToFrame}
|
||||
onTogglePlay={togglePlayPause}
|
||||
onToggleMute={toggleMute}
|
||||
onVolumeChange={setVolume}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -712,7 +671,7 @@ export const VideoFrameExtractor = () => {
|
||||
setNumFrames(
|
||||
Math.max(
|
||||
1,
|
||||
Math.min(50, parseInt(e.target.value) || 1),
|
||||
Math.min(50, Number.parseInt(e.target.value) || 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -735,7 +694,7 @@ export const VideoFrameExtractor = () => {
|
||||
value={frameDistance}
|
||||
onChange={(e) =>
|
||||
setFrameDistance(
|
||||
Math.max(1, parseInt(e.target.value) || 1),
|
||||
Math.max(1, Number.parseInt(e.target.value) || 1),
|
||||
)
|
||||
}
|
||||
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
|
||||
|
||||
+15
-57
@@ -1,11 +1,11 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIcon, SkipForwardIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { DropletIcon, ImagesIcon, RotateCwIcon, VideoIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import toast from "react-hot-toast";
|
||||
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
|
||||
|
||||
export const VideoWatermarkRemover = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
@@ -255,6 +255,7 @@ export const VideoWatermarkRemover = () => {
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
|
||||
<div
|
||||
role="presentation"
|
||||
ref={progressBarRef}
|
||||
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
|
||||
onClick={handleProgressBarClick}
|
||||
@@ -266,61 +267,18 @@ export const VideoWatermarkRemover = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => seekToFrame("first")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="First Frame"
|
||||
>
|
||||
<SkipBackIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isPlaying ? PauseIcon : PlayIcon}
|
||||
className="text-white"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => seekToFrame("last")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
title="Last Frame"
|
||||
>
|
||||
<SkipForwardIcon
|
||||
|
||||
className="text-sm text-white" />
|
||||
</button>
|
||||
<div className="mx-1 h-6 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
|
||||
>
|
||||
<DynamicIcon
|
||||
icon={isMuted ? VolumeXIcon : Volume2Icon}
|
||||
className="text-sm text-white"
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
setVolume(parseFloat(e.target.value))
|
||||
}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums text-white">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
<VideoTransportControls
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
volume={volume}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={seekToFrame}
|
||||
onTogglePlay={togglePlayPause}
|
||||
onToggleMute={toggleMute}
|
||||
onVolumeChange={setVolume}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface GenerationResult {
|
||||
id: string;
|
||||
mode: "image" | "text";
|
||||
timestamp: number;
|
||||
note?: string;
|
||||
status: "pending" | "completed";
|
||||
subscriberId: string;
|
||||
modelUrl?: string;
|
||||
mediaToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The results after a 3D job finishes: the one with that subscriber id (else the first still
|
||||
* pending) becomes completed with the model; with none to update, a completed one is added first.
|
||||
*/
|
||||
export function completeGenerationResult<T extends GenerationResult>(
|
||||
results: readonly T[],
|
||||
subscriberId: string | undefined,
|
||||
completed: { modelUrl: string; mediaToken: string; note: string },
|
||||
): T[] {
|
||||
const { modelUrl, mediaToken, note } = completed;
|
||||
const index = subscriberId
|
||||
? results.findIndex((r) => r.subscriberId === subscriberId)
|
||||
: results.findIndex((r) => r.status === "pending");
|
||||
if (index === -1) {
|
||||
const id = uuidv4();
|
||||
// The other fields of T are optional, as the 3D result types declare them.
|
||||
const added = { id, subscriberId: id, mode: "image", timestamp: Date.now(), note, status: "completed", modelUrl, mediaToken } as T;
|
||||
return [added, ...results];
|
||||
}
|
||||
const next = [...results];
|
||||
next[index] = { ...next[index], status: "completed", modelUrl, mediaToken };
|
||||
return next;
|
||||
}
|
||||
|
||||
/** A store's `completeGeneration`: logs the finished job under `store` and applies completeGenerationResult. */
|
||||
export function completeGenerationAction<T extends GenerationResult>(
|
||||
set: (update: (state: { results: T[] }) => { results: T[] }) => void,
|
||||
store: string,
|
||||
note: string,
|
||||
) {
|
||||
return (modelUrl: string, mediaToken: string, maybeSubscriberId?: string) => {
|
||||
console.log(`[${store}] completeGeneration`, { modelUrl, mediaToken, maybeSubscriberId });
|
||||
set((s) => ({
|
||||
results: completeGenerationResult(s.results, maybeSubscriberId, { modelUrl, mediaToken, note }),
|
||||
}));
|
||||
};
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export const completedAudioJobs = computed(() => {
|
||||
export const shouldPollActiveJobs = signal<boolean>(false);
|
||||
|
||||
export const startPollingActiveJobs = () => {
|
||||
PollRecentJobs(); //poll once regardless of polling intervals
|
||||
void PollRecentJobs(); //poll once regardless of polling intervals
|
||||
shouldPollActiveJobs.value = true;
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const login = async ({
|
||||
// technically user is login with the system now, HOWEVER,
|
||||
// in storyteller studio, only having a sesison is not enough,
|
||||
// we need session info and active subscription info as well
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
|
||||
window.location.href = "/"; // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
|
||||
};
|
||||
@@ -86,7 +86,7 @@ export const signUp = async ({
|
||||
// technically user is login with the system now, HOWEVER,
|
||||
// in storyteller studio, only having a sesison is not enough,
|
||||
// we need session info and active subscription info as well
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -95,12 +95,12 @@ export const persistLogin = async () => {
|
||||
if (authentication.status.value !== AUTH_STATUS.INIT) {
|
||||
return;
|
||||
}
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
};
|
||||
|
||||
// NB: Only for SyncStorytellerApiConfig.
|
||||
export const forceGetUserInfoAndSubcriptions = async () => {
|
||||
getUserInfoAndSubcriptions();
|
||||
void getUserInfoAndSubcriptions();
|
||||
};
|
||||
|
||||
async function getUserInfoAndSubcriptions() {
|
||||
|
||||
@@ -40,7 +40,7 @@ export const isJobStatusTerminal = (curr: JobStatus) => {
|
||||
};
|
||||
|
||||
export const isNumberString = (value: string) => {
|
||||
return !isNaN(Number(value));
|
||||
return !Number.isNaN(Number(value));
|
||||
};
|
||||
|
||||
export const isJobStatusError = (curr: JobStatus) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ set -euxo pipefail
|
||||
echo 'TEMPORARY HACK: Build internal dependencies of artcraft since nx is not finding them...'
|
||||
|
||||
# A 'clean' will remove nx
|
||||
npm install
|
||||
npm install --ignore-scripts
|
||||
|
||||
# TODO: Temp commented out
|
||||
nx build "@storyteller/api"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./thumbnail-utils";
|
||||
export * from "./media-format";
|
||||
export * from "./referral";
|
||||
export * from "./keyboard";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* For an element with `role="button"`: Enter or Space clicks it, as they would a native button.
|
||||
* Keys pressed on a control inside it are left to that control.
|
||||
*/
|
||||
export function clickOnEnterOrSpace(event: {
|
||||
key: string;
|
||||
preventDefault(): void;
|
||||
target: unknown;
|
||||
currentTarget: { click(): void };
|
||||
}) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,7 @@ export function WaveformAudioPlayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
ref={rootRef}
|
||||
className={`flex items-center gap-2.5 ${compact ? "px-2.5 py-2" : "px-3 py-2.5"} ${className}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -273,6 +274,7 @@ export function WaveformAudioPlayer({
|
||||
)}
|
||||
{(waveState === "pending" || waveState === "failed") && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute inset-0 flex cursor-pointer items-center gap-[1.5px]"
|
||||
onClick={handleBarSeek}
|
||||
>
|
||||
|
||||
@@ -135,7 +135,7 @@ export const ButtonDropdown = ({
|
||||
)}
|
||||
<div className="grow text-start">{option.label}</div>
|
||||
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
|
||||
{option.description && option.description}
|
||||
{option.description}
|
||||
</div>
|
||||
{showSelected && (
|
||||
<>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
|
||||
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Custom color</span>
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
|
||||
@@ -64,7 +64,6 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
type ModalMode = "select" | "view";
|
||||
|
||||
@@ -336,6 +337,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
{/* dropdown menu */}
|
||||
{mode !== "select" && (
|
||||
<div
|
||||
role="presentation"
|
||||
// Tile chrome stays under sticky page headers (e.g. the library
|
||||
// filter bar at z-10): z-[2]/z-[1] keep the in-tile ordering
|
||||
// (menu/checkbox above badges) without escaping the page layers.
|
||||
@@ -376,6 +378,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => setCtxMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
@@ -408,6 +411,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
{/* Bulk selection checkbox — top-left (only when a bulk handler is wired) */}
|
||||
{mode !== "select" && onBulkSelectToggle && (
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={bulkSelected}
|
||||
aria-label={`Select ${item.label}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className={twMerge(
|
||||
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded-[3px] border-2 cursor-pointer transition-all duration-100",
|
||||
bulkSelected
|
||||
@@ -415,7 +423,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
: "border-white/60 bg-black/40 hover:border-white",
|
||||
bulkSelectionMode
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100",
|
||||
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100",
|
||||
)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -403,7 +403,7 @@ const SIDEBAR_TAG_LIMIT = 5;
|
||||
|
||||
const hashString = (s: string): number => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
for (let i = 0; i < s.length; i++) h = Math.imul(Math.imul(h, 31) + s.charCodeAt(i), 1); // int32 wrap
|
||||
return h;
|
||||
};
|
||||
|
||||
@@ -948,7 +948,7 @@ export const GalleryModal = React.memo(
|
||||
// couldn't see it — making the library button appear broken.
|
||||
galleryModalDraggingUnder.value = false;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
setUsernameError(false);
|
||||
const name = await fetchCurrentUsername();
|
||||
if (cancelled) return;
|
||||
@@ -2710,7 +2710,6 @@ export const GalleryModal = React.memo(
|
||||
}}
|
||||
onBlur={handleConfirmRename}
|
||||
className="text-lg font-semibold bg-transparent px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
@@ -2739,6 +2738,7 @@ export const GalleryModal = React.memo(
|
||||
{folderMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[58]"
|
||||
onClick={() => setFolderMenuOpen(false)}
|
||||
/>
|
||||
@@ -3573,6 +3573,7 @@ export const GalleryModal = React.memo(
|
||||
<>
|
||||
{/* Backdrop to close */}
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[59]"
|
||||
onClick={() => setBulkFolderPopoverOpen(false)}
|
||||
/>
|
||||
@@ -3716,6 +3717,7 @@ export const GalleryModal = React.memo(
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -87,10 +87,10 @@ export function useMediaPromptTokens(
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list, order-independent.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
if (key.length > 0) void fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, string>();
|
||||
|
||||
@@ -60,10 +60,10 @@ export function usePrompts(
|
||||
}
|
||||
// Stable cache key for the effect's dep list. Sorting keeps it stable when
|
||||
// a poll returns the same set in a different order.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
if (key.length > 0) void fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTutorialModalStore } from "./help-menu-store";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
export type HelpMenuButtonProps = {
|
||||
items?: TutorialItem[];
|
||||
@@ -42,7 +43,7 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
|
||||
console.warn("Failed to fetch news feed, using bundled content.", e);
|
||||
}
|
||||
};
|
||||
fetchNews();
|
||||
void fetchNews();
|
||||
}, []);
|
||||
|
||||
const selectedPost = useMemo(
|
||||
@@ -109,6 +110,9 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
|
||||
<div className="flex flex-col gap-4 mt-2 h-[500px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
key={item.slug}
|
||||
onClick={() => setSelectedSlug(item.slug)}
|
||||
className="bg-white/5 hover:bg-white/[0.07] border border-white/15 hover:border-white/25 p-4 rounded-none cursor-pointer transition-colors group"
|
||||
@@ -312,7 +316,7 @@ export function HelpMenuButton({
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
if (progressTimerRef.current) {
|
||||
|
||||
@@ -104,7 +104,7 @@ export const InputVector = ({
|
||||
} as Record<string, string>;
|
||||
|
||||
const newValue =
|
||||
parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
|
||||
Number.parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
|
||||
|
||||
newVector[ref.current?.name ?? ""] = (
|
||||
Math.round(newValue * 1000) / 1000
|
||||
|
||||
@@ -22,10 +22,10 @@ export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
const newValue = event.target.value;
|
||||
if (
|
||||
newValue === "" ||
|
||||
(!isNaN(Number(newValue)) && Number(newValue) <= 100)
|
||||
(!Number.isNaN(Number(newValue)) && Number(newValue) <= 100)
|
||||
) {
|
||||
setInputValue(newValue);
|
||||
const numericValue = parseFloat(newValue);
|
||||
const numericValue = Number.parseFloat(newValue);
|
||||
if (newValue !== "") {
|
||||
onChange(Math.round(numericValue * 100) / 100);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
formatAspectRatio,
|
||||
formatDuration,
|
||||
formatResolution,
|
||||
clickOnEnterOrSpace,
|
||||
} from "@storyteller/common";
|
||||
import { TagsSection } from "./tags/TagsSection";
|
||||
|
||||
@@ -179,7 +180,7 @@ export function LightboxModal({
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
audioPlayerRef.current = audio;
|
||||
audio.play();
|
||||
void audio.play();
|
||||
setPlayingAudioToken(token);
|
||||
}
|
||||
},
|
||||
@@ -962,6 +963,16 @@ export function LightboxModal({
|
||||
}
|
||||
>
|
||||
<div
|
||||
role={isVideoRef ? "presentation" : "button"}
|
||||
tabIndex={isVideoRef ? undefined : 0}
|
||||
aria-label={
|
||||
isAudio
|
||||
? `${isPlayingThis ? "Pause" : "Play"} reference ${index + 1} audio`
|
||||
: isVideoRef
|
||||
? undefined
|
||||
: `Preview reference ${index + 1}`
|
||||
}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className="relative aspect-square w-12 overflow-hidden border border-white/15 bg-white/5 transition-colors hover:border-white/40 group cursor-pointer"
|
||||
onClick={() => {
|
||||
if (isAudio) {
|
||||
|
||||
@@ -141,6 +141,7 @@ export function TagChipInput({
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
role="presentation"
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-[3px] bg-ui-panel border border-ui-panel-border focus-within:border-primary/60 px-3 py-2.5 ${
|
||||
disabled ? "" : "cursor-text"
|
||||
}`}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function TagsSection({
|
||||
useEffect(() => {
|
||||
if (currentUsername !== undefined) return;
|
||||
let cancelled = false;
|
||||
resolveSessionUsername().then((username) => {
|
||||
void resolveSessionUsername().then((username) => {
|
||||
if (!cancelled) setResolvedUsername(username);
|
||||
});
|
||||
return () => {
|
||||
@@ -133,7 +133,7 @@ export function TagsSection({
|
||||
}
|
||||
ownTagsLoadedRef.current = true;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
const all: TagDetails[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
for (let page = 0; page < 50; page++) {
|
||||
|
||||
@@ -89,8 +89,9 @@ export const ArtCraftSignUp = ({
|
||||
{isSignUp ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className={FIELD_LABEL}>Username</label>
|
||||
<label htmlFor="signup-username" className={FIELD_LABEL}>Username</label>
|
||||
<Input
|
||||
id="signup-username"
|
||||
name="username"
|
||||
placeholder="Username"
|
||||
required
|
||||
@@ -99,8 +100,9 @@ export const ArtCraftSignUp = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className={FIELD_LABEL}>Email</label>
|
||||
<label htmlFor="signup-email" className={FIELD_LABEL}>Email</label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
@@ -112,8 +114,9 @@ export const ArtCraftSignUp = ({
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className={FIELD_LABEL}>Email or Username</label>
|
||||
<label htmlFor="signup-username-or-email" className={FIELD_LABEL}>Email or Username</label>
|
||||
<Input
|
||||
id="signup-username-or-email"
|
||||
name="usernameOrEmail"
|
||||
placeholder="you@example.com or username"
|
||||
required
|
||||
@@ -124,9 +127,10 @@ export const ArtCraftSignUp = ({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={FIELD_LABEL}>Password</label>
|
||||
<label htmlFor="signup-password" className={FIELD_LABEL}>Password</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Min. 8 characters"
|
||||
@@ -147,9 +151,10 @@ export const ArtCraftSignUp = ({
|
||||
|
||||
{isSignUp && (
|
||||
<div className="space-y-2">
|
||||
<label className={FIELD_LABEL}>Confirm Password</label>
|
||||
<label htmlFor="signup-confirm-password" className={FIELD_LABEL}>Confirm Password</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-confirm-password"
|
||||
name="confirmPassword"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Re-enter password"
|
||||
|
||||
@@ -104,7 +104,7 @@ export function LoginModal({
|
||||
};
|
||||
|
||||
const handleDiscordJoin = () => {
|
||||
window.open("https://discord.gg/75svZP2Vje", "_blank");
|
||||
window.open("https://discord.gg/75svZP2Vje", "_blank", "noopener,noreferrer");
|
||||
setShowDiscord(false);
|
||||
setShowSuccess(true);
|
||||
};
|
||||
@@ -229,6 +229,7 @@ export function LoginModal({
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className={`relative w-full ${loggedInUsername !== null ? "max-w-md" : "max-w-lg lg:max-w-5xl"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -541,6 +541,7 @@ export const Modal = ({
|
||||
return cloneElement(typedChild, {
|
||||
children: (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ cursor: "move", userSelect: "none" }}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
@@ -561,6 +562,7 @@ export const Modal = ({
|
||||
return cloneElement(typedChildren, {
|
||||
children: (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{ cursor: "move", userSelect: "none" }}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
@@ -626,8 +628,8 @@ export const Modal = ({
|
||||
let minHeight = 240;
|
||||
try {
|
||||
const cs = window.getComputedStyle(modalRef.current);
|
||||
const parsedMinW = parseFloat(cs.minWidth || "0");
|
||||
const parsedMinH = parseFloat(cs.minHeight || "0");
|
||||
const parsedMinW = Number.parseFloat(cs.minWidth || "0");
|
||||
const parsedMinH = Number.parseFloat(cs.minHeight || "0");
|
||||
if (!Number.isNaN(parsedMinW) && parsedMinW > 0)
|
||||
minWidth = Math.max(minWidth, parsedMinW);
|
||||
if (!Number.isNaN(parsedMinH) && parsedMinH > 0)
|
||||
@@ -743,6 +745,7 @@ export const Modal = ({
|
||||
|
||||
return handles.map((h) => (
|
||||
<div
|
||||
role="presentation"
|
||||
key={h.dir}
|
||||
className={`${baseClass} ${h.className}`}
|
||||
style={{ cursor: cursorMap[h.dir] }}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const collectTags = (board: Board): string[] => {
|
||||
board.itemOrder.forEach((id) => {
|
||||
board.items[id]?.tags.forEach((t) => seen.add(t));
|
||||
});
|
||||
return Array.from(seen).sort();
|
||||
return Array.from(seen).sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const itemMatchesText = (it: BoardItem, q: string): boolean => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
isActionReminderOpen,
|
||||
showActionReminder,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type { ImageBundle };
|
||||
|
||||
@@ -53,7 +55,7 @@ export const HistoryStack = ({
|
||||
|
||||
// This is used to force image reloads in different sessions
|
||||
// and prevent fetching CORS-tainted images from cache
|
||||
const sessionRandBuster = useRef(Math.random());
|
||||
const sessionRandBuster = useRef(uuidv4());
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevPendingCountRef = useRef<number>(0);
|
||||
|
||||
@@ -183,6 +185,9 @@ export const HistoryStack = ({
|
||||
for removal. Hovering reveals a striped overlay and a
|
||||
centered trash affordance. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className="group relative w-full cursor-pointer"
|
||||
title={
|
||||
tileCount > 1
|
||||
@@ -308,6 +313,7 @@ export const HistoryStack = ({
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute -right-0 -top-0 flex h-5 w-5 items-center justify-center rounded-bl-lg bg-red/50 opacity-0 transition-opacity hover:bg-red/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "./components/Model3DOverlay";
|
||||
import { CameraIcon, Maximize2Icon, RefreshCwIcon } from "lucide-react";
|
||||
import type { PageDrawAdapter } from "./adapter";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
|
||||
|
||||
@@ -857,7 +858,7 @@ const PageDraw = ({
|
||||
|
||||
const subscriberId: string =
|
||||
crypto?.randomUUID?.() ??
|
||||
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
`inpaint-${Date.now()}-${uuidv4()}`;
|
||||
|
||||
adapter.onEnqueueMeta?.({
|
||||
prompt,
|
||||
@@ -1040,7 +1041,7 @@ const PageDraw = ({
|
||||
await tryFit();
|
||||
};
|
||||
|
||||
autoFitCanvas();
|
||||
void autoFitCanvas();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseImageBitmap, baseImageInfo?.isBlankCanvas]);
|
||||
|
||||
|
||||
@@ -1501,7 +1501,7 @@ export const PaintSurface = ({
|
||||
|
||||
// Load the checkboard image for transparency background
|
||||
useEffect(() => {
|
||||
loadImageFromUrl(checkerboard).then(setCheckerImage);
|
||||
void loadImageFromUrl(checkerboard).then(setCheckerImage);
|
||||
}, []);
|
||||
|
||||
// Separate function to render transformers on their own layer
|
||||
|
||||
@@ -14,7 +14,7 @@ const ImagePreview: React.FC<ImagePreviewProps> = ({ image, onUpdate, onRemove }
|
||||
const relativeY = clientY - rect.top;
|
||||
// Calculate inverse value (1.0 at top, 0.0 at bottom)
|
||||
const value = Math.max(0, Math.min(1, 1 - (relativeY / height)));
|
||||
onUpdate(image.id, { weight: parseFloat(value.toFixed(1)) });
|
||||
onUpdate(image.id, { weight: Number.parseFloat(value.toFixed(1)) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,7 +61,8 @@ const ImagePreview: React.FC<ImagePreviewProps> = ({ image, onUpdate, onRemove }
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
<div
|
||||
role="presentation"
|
||||
ref={sliderRef}
|
||||
className="absolute inset-0 bg-gradient-to-b from-transparent to-black bg-opacity-40 cursor-ns-resize"
|
||||
onMouseDown={handleMouseDown}
|
||||
|
||||
@@ -122,7 +122,8 @@ export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`w-full h-full relative z-[99999] ${className}`}
|
||||
|
||||
@@ -119,9 +119,9 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
|
||||
if (colorToUse && colorToUse.startsWith("#")) {
|
||||
// Convert hex to HSVA (simplified - might want to change to proper converter later)
|
||||
const hex = colorToUse;
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16);
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16);
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16);
|
||||
|
||||
// Convert RGB to HSV (simplified)
|
||||
const max = Math.max(r, g, b);
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function SplitPane({
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="presentation"
|
||||
className="w-1 cursor-col-resize select-none bg-white/20
|
||||
transition hover:bg-white/30"
|
||||
onMouseDown={beginDrag}
|
||||
|
||||
@@ -1011,9 +1011,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
const pastedLineNode: LineNode = {
|
||||
...lineNode,
|
||||
id: newId,
|
||||
points: lineNode.points.map((point, index) =>
|
||||
index % 2 === 0 ? point + offset : point + offset,
|
||||
),
|
||||
points: lineNode.points.map((point) => point + offset),
|
||||
x: lineNode.x !== undefined ? lineNode.x + offset : undefined,
|
||||
y: lineNode.y !== undefined ? lineNode.y + offset : undefined,
|
||||
};
|
||||
@@ -1131,7 +1129,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
|
||||
const base64ToFile = (base64: string, filename: string): File => {
|
||||
const arr = base64.split(",");
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
|
||||
// data:<mime>;base64: the text between the first ":" and the ";" after it.
|
||||
const start = arr[0].indexOf(":") + 1;
|
||||
const end = arr[0].indexOf(";", start);
|
||||
const mime = (start > 0 && end !== -1 && arr[0].slice(start, end)) || "image/png";
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
@@ -1167,7 +1168,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
console.error("Failed to restore image:", error);
|
||||
}
|
||||
};
|
||||
loadImage();
|
||||
void loadImage();
|
||||
}
|
||||
|
||||
return node;
|
||||
|
||||
@@ -273,7 +273,7 @@ class DndAsset {
|
||||
return;
|
||||
}
|
||||
const inspection = this.clipNodeNamesCache.get(item.media_id);
|
||||
if (!inspection) {
|
||||
if (inspection === undefined) {
|
||||
// No inspection in flight (shouldn't happen — pointer-down starts
|
||||
// one): add unvalidated, matching the ungated click-to-add path.
|
||||
this.completeAnimationDrop(editor, item, target, undefined);
|
||||
|
||||
@@ -387,7 +387,7 @@ export const Stage3DBody = ({
|
||||
useEffect(() => {
|
||||
const handler = onImageDrop(
|
||||
(item: GalleryItem, position: { x: number; y: number }) => {
|
||||
(async () => {
|
||||
void (async () => {
|
||||
if (!editor) {
|
||||
console.warn("Cannot drop asset: editor engine not ready");
|
||||
return;
|
||||
@@ -485,6 +485,7 @@ export const Stage3DBody = ({
|
||||
<CompletionModal />
|
||||
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute left-0 top-0 w-full"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
@@ -523,6 +524,7 @@ export const Stage3DBody = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function toggleObjectVisibility(editor: Editor, uuid: string): void {
|
||||
if (!obj) return;
|
||||
const before = obj.visible;
|
||||
editor.bus.emit(new OutlinerItemVisibilityToggledEvent(uuid));
|
||||
editor.sceneManager?.hideObject(uuid);
|
||||
void editor.sceneManager?.hideObject(uuid);
|
||||
editor.history.record(
|
||||
new VisibilityAction(editor, uuid, before, !before),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { addClipToCharacter } from "../../../actions";
|
||||
import { AssetType, ToastTypes } from "../../../enums";
|
||||
import { usePageSceneStore } from "../../../PageSceneStore";
|
||||
import type { MediaItem } from "../../../models/assets";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
interface Props {
|
||||
debug?: string;
|
||||
@@ -110,6 +111,10 @@ export const ItemElement = ({ item }: Props) => {
|
||||
)}
|
||||
|
||||
<div
|
||||
// Only animations are added by a click; the rest are dragged in.
|
||||
role={item.type === AssetType.ANIMATION ? "button" : "presentation"}
|
||||
tabIndex={item.type === AssetType.ANIMATION ? 0 : undefined}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
className="pointer-events-none relative aspect-[16/12] w-full select-none overflow-hidden rounded-none border border-white/15 bg-brand-secondary-600 object-cover object-center transition-all group-hover:border-brand-primary"
|
||||
onPointerDown={(event) => dragAndDrop.onPointerDown(event, item, editor)}
|
||||
onClick={handleClick}
|
||||
|
||||
+7
-7
@@ -119,9 +119,9 @@ export const ControlPanelSceneObject = () => {
|
||||
|
||||
function localToEngine(xyz: Record<string, string>) {
|
||||
return {
|
||||
x: parseFloat(xyz.x),
|
||||
y: parseFloat(xyz.y),
|
||||
z: parseFloat(xyz.z),
|
||||
x: Number.parseFloat(xyz.x),
|
||||
y: Number.parseFloat(xyz.y),
|
||||
z: Number.parseFloat(xyz.z),
|
||||
};
|
||||
}
|
||||
function engineToLocal(xyz: XYZ) {
|
||||
@@ -245,7 +245,7 @@ export const ControlPanelSceneObject = () => {
|
||||
if (value === "" || value === "-" || value === ".") {
|
||||
return true;
|
||||
}
|
||||
return !/^-?[0-9]*(.[0-9]*)?$/.test(value);
|
||||
return !/^-?\d*(\.\d*)?$/.test(value);
|
||||
});
|
||||
|
||||
const handlePositionChange = (xyz: Record<string, string>) => {
|
||||
@@ -276,9 +276,9 @@ export const ControlPanelSceneObject = () => {
|
||||
|
||||
const handleUniformScaleChange = (scale: number) => {
|
||||
const updatedScaleValues: Record<string, string> = {};
|
||||
updatedScaleValues.x = (parseFloat(localScale.x) + scale).toString();
|
||||
updatedScaleValues.y = (parseFloat(localScale.y) + scale).toString();
|
||||
updatedScaleValues.z = (parseFloat(localScale.z) + scale).toString();
|
||||
updatedScaleValues.x = (Number.parseFloat(localScale.x) + scale).toString();
|
||||
updatedScaleValues.y = (Number.parseFloat(localScale.y) + scale).toString();
|
||||
updatedScaleValues.z = (Number.parseFloat(localScale.z) + scale).toString();
|
||||
|
||||
handleScaleChange(updatedScaleValues);
|
||||
};
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ export const DraggablePrecisionMutator = ({
|
||||
}, [handleMouseMove, handleMouseUp, precisions]);
|
||||
|
||||
return (
|
||||
<div className={classes} ref={divRef} onMouseDown={handleMouseDown}>
|
||||
<div role="presentation" className={classes} ref={divRef} onMouseDown={handleMouseDown}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@ export function objectMismatch(
|
||||
) {
|
||||
return Object.keys(inputObj).some(
|
||||
(currentKey: string) =>
|
||||
parseFloat(inputObj[currentKey]) !== refObj[currentKey],
|
||||
Number.parseFloat(inputObj[currentKey]) !== refObj[currentKey],
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -17,13 +17,13 @@ function sanitizeNumericInput(input: string): number {
|
||||
const integerPart = parts[0];
|
||||
const decimalPart = parts[1];
|
||||
|
||||
const decimal = decimalPart !== "" ? parseFloat(`.${decimalPart}`) : 0.0;
|
||||
const decimal = decimalPart !== "" ? Number.parseFloat(`.${decimalPart}`) : 0.0;
|
||||
|
||||
if (integerPart === undefined) {
|
||||
return decimal;
|
||||
}
|
||||
|
||||
const integer = parseInt(integerPart);
|
||||
const integer = Number.parseInt(integerPart);
|
||||
|
||||
if (decimalPart === undefined) {
|
||||
return integer;
|
||||
|
||||
@@ -45,7 +45,7 @@ export function EntranceDebugPanel() {
|
||||
max={12}
|
||||
step={0.25}
|
||||
value={scale}
|
||||
onChange={(e) => apply(parseFloat(e.target.value))}
|
||||
onChange={(e) => apply(Number.parseFloat(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="mt-2 flex justify-between tabular-nums text-white/50">
|
||||
|
||||
@@ -29,6 +29,7 @@ export const PoseModeSelector: React.FC<PoseModeSelectorProps> = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed left-1/2 top-32 flex -translate-x-1/2 transform items-center justify-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const DurationLabel = ({ className = "" }: { className?: string }) => {
|
||||
}, [editing]);
|
||||
|
||||
const commit = () => {
|
||||
const secs = parseFloat(value);
|
||||
const secs = Number.parseFloat(value);
|
||||
if (editor && Number.isFinite(secs)) setTimelineDuration(editor, secs);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ export const MotionPopover = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
id="motion-popover"
|
||||
className="border border-ui-panel-border bg-ui-controls pointer-events-auto absolute bottom-full z-50 mb-2 w-[180px] -translate-x-1/2 select-none rounded-none p-3"
|
||||
style={{ left: `${Math.max(12, Math.min(88, leftPercent))}%` }}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const TimelineBar = ({ readOnly = false }: { readOnly?: boolean }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
className="border border-ui-panel-border bg-ui-controls flex w-full select-none items-center gap-3 rounded-none px-4 py-2 text-white"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -50,7 +51,7 @@ export const TimelineBar = ({ readOnly = false }: { readOnly?: boolean }) => {
|
||||
onChange={(e) => {
|
||||
if (!editor) return;
|
||||
pauseTimeline(editor);
|
||||
seekTimeline(editor, parseFloat(e.target.value));
|
||||
seekTimeline(editor, Number.parseFloat(e.target.value));
|
||||
}}
|
||||
className="h-1 flex-1 cursor-pointer accent-white"
|
||||
/>
|
||||
|
||||
@@ -194,6 +194,7 @@ export const TimelineClipRow = ({
|
||||
<>
|
||||
{/* click-away backdrop */}
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0"
|
||||
style={{ zIndex: 9998 }}
|
||||
onClick={() => setPickerAnchor(null)}
|
||||
|
||||
@@ -316,6 +316,7 @@ export const TimelineEditor = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
id="timeline-editor"
|
||||
className="border border-ui-panel-border bg-ui-controls absolute bottom-4 left-1/2 w-[90vw] max-w-5xl -translate-x-1/2 select-none rounded-none p-3 text-white"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { EngineContext, setActiveEditor } from "./EngineContext";
|
||||
|
||||
import Editor from "../../engine/editor";
|
||||
import { ensureAmmoLoaded } from "../../engine/ammoLoader";
|
||||
import type { PageSceneAdapter } from "../../adapter";
|
||||
import { getSceneGenerationMetaData } from "../../sceneMetadata";
|
||||
import { usePageSceneStore } from "../../PageSceneStore";
|
||||
@@ -61,6 +62,9 @@ export const EngineProvider = ({
|
||||
// them to null on unmount).
|
||||
if (!sceneContainer || !editorCanvas || !camViewCanvas) return undefined;
|
||||
|
||||
// Ammo.js WASM is a global side-effect script the physics path depends on; the singleton
|
||||
// appends it to document.body once, however often the Editor is constructed.
|
||||
void ensureAmmoLoaded();
|
||||
const newEditor = new Editor(adapterRef.current);
|
||||
newEditor.initialize({
|
||||
sceneToken: sceneToken || "",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
let ammoLoadPromise: Promise<void> | null = null;
|
||||
|
||||
export const ensureAmmoLoaded = (): Promise<void> => {
|
||||
if (ammoLoadPromise) return ammoLoadPromise;
|
||||
if (ammoLoadPromise !== null) return ammoLoadPromise;
|
||||
|
||||
ammoLoadPromise = new Promise<void>((resolve, reject) => {
|
||||
if (typeof document === "undefined") {
|
||||
|
||||
@@ -31,7 +31,6 @@ import { ModalTransformController } from "./editor/ModalTransformController";
|
||||
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||
import { SparkRenderer } from "@sparkjsdev/spark";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import { ensureAmmoLoaded } from "./ammoLoader";
|
||||
import { EngineEventBus } from "./events/EngineEventBus";
|
||||
import { EngineStoreBridge } from "./EngineStoreBridge";
|
||||
import {
|
||||
@@ -187,11 +186,6 @@ class Editor {
|
||||
this.adapter = adapter;
|
||||
this.can_initialize = true;
|
||||
this.stats = new Stats();
|
||||
// Ammo.js WASM is a global side-effect script the physics path
|
||||
// depends on. The singleton ensures it's appended to document.body
|
||||
// exactly once, even across Editor re-construction.
|
||||
ensureAmmoLoaded();
|
||||
|
||||
// Version and name.
|
||||
this.version = 2.0;
|
||||
// Clock, scene and camera essentials.
|
||||
@@ -667,9 +661,9 @@ class Editor {
|
||||
};
|
||||
|
||||
if (!this.utils.isEmpty(cacheJson)) {
|
||||
this.save_manager.loadCache(cacheJson).then(applyIfLoaded);
|
||||
void this.save_manager.loadCache(cacheJson).then(applyIfLoaded);
|
||||
} else if (!this.utils.isEmpty(sceneToken)) {
|
||||
this.save_manager.loadScene(sceneToken).then(applyIfLoaded);
|
||||
void this.save_manager.loadScene(sceneToken).then(applyIfLoaded);
|
||||
} else {
|
||||
this.adapter.onSceneTitleChange?.({
|
||||
title: "Untitled New Scene",
|
||||
@@ -1065,7 +1059,7 @@ class Editor {
|
||||
}
|
||||
|
||||
this.lastFrameTime = frameTime;
|
||||
this.renderSingleFrame();
|
||||
void this.renderSingleFrame();
|
||||
}
|
||||
|
||||
startRenderLoop() {
|
||||
|
||||
+2
-2
@@ -151,7 +151,7 @@ export class ModalTransformController {
|
||||
}
|
||||
}
|
||||
this.target.position.copy(this.startPos).add(delta);
|
||||
this.editor.renderScene();
|
||||
void this.editor.renderScene();
|
||||
}
|
||||
|
||||
private axisVector(axis: "x" | "y" | "z"): THREE.Vector3 {
|
||||
@@ -190,7 +190,7 @@ export class ModalTransformController {
|
||||
|
||||
private cancel() {
|
||||
if (this.target) this.target.position.copy(this.startPos);
|
||||
this.editor.renderScene();
|
||||
void this.editor.renderScene();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
|
||||
@@ -137,19 +137,19 @@ export class SelectionBridge {
|
||||
version: String(this.engine.version),
|
||||
objectVectors: {
|
||||
position: {
|
||||
x: parseFloat(pos.x.toFixed(2)),
|
||||
y: parseFloat(pos.y.toFixed(2)),
|
||||
z: parseFloat(pos.z.toFixed(2)),
|
||||
x: Number.parseFloat(pos.x.toFixed(2)),
|
||||
y: Number.parseFloat(pos.y.toFixed(2)),
|
||||
z: Number.parseFloat(pos.z.toFixed(2)),
|
||||
},
|
||||
rotation: {
|
||||
x: parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
|
||||
y: parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
|
||||
z: parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
|
||||
x: Number.parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
|
||||
y: Number.parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
|
||||
z: Number.parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
|
||||
},
|
||||
scale: {
|
||||
x: parseFloat(scale.x.toFixed(6)),
|
||||
y: parseFloat(scale.y.toFixed(6)),
|
||||
z: parseFloat(scale.z.toFixed(6)),
|
||||
x: Number.parseFloat(scale.x.toFixed(6)),
|
||||
y: Number.parseFloat(scale.y.toFixed(6)),
|
||||
z: Number.parseFloat(scale.z.toFixed(6)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -259,9 +259,9 @@ export class MouseControls {
|
||||
|
||||
if (event.button !== 0 && this.camera) {
|
||||
const camera_pos = new THREE.Vector3(
|
||||
parseFloat(this.camera.position.x.toFixed(2)),
|
||||
parseFloat(this.camera.position.y.toFixed(2)),
|
||||
parseFloat(this.camera.position.z.toFixed(2)),
|
||||
Number.parseFloat(this.camera.position.x.toFixed(2)),
|
||||
Number.parseFloat(this.camera.position.y.toFixed(2)),
|
||||
Number.parseFloat(this.camera.position.z.toFixed(2)),
|
||||
);
|
||||
this.camera_last_pos.copy(camera_pos);
|
||||
}
|
||||
@@ -408,9 +408,9 @@ export class MouseControls {
|
||||
}
|
||||
|
||||
const camera_pos = new THREE.Vector3(
|
||||
parseFloat(this.camera.position.x.toFixed(2)),
|
||||
parseFloat(this.camera.position.y.toFixed(2)),
|
||||
parseFloat(this.camera.position.z.toFixed(2)),
|
||||
Number.parseFloat(this.camera.position.x.toFixed(2)),
|
||||
Number.parseFloat(this.camera.position.y.toFixed(2)),
|
||||
Number.parseFloat(this.camera.position.z.toFixed(2)),
|
||||
);
|
||||
if (this.camera_last_pos.equals(new THREE.Vector3(0, 0, 0))) {
|
||||
this.camera_last_pos.copy(camera_pos);
|
||||
|
||||
@@ -77,7 +77,7 @@ export class LoadingPlaceHolderManager {
|
||||
if (result === undefined) {
|
||||
return;
|
||||
}
|
||||
result.destroyWithScene(this.scene);
|
||||
void result.destroyWithScene(this.scene);
|
||||
this.messages.delete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ class Scene {
|
||||
) {
|
||||
this.version = version;
|
||||
this.name = name;
|
||||
this.gridHelper;
|
||||
this.scene = new THREE.Scene();
|
||||
this.hot_items = [];
|
||||
|
||||
@@ -112,7 +111,7 @@ class Scene {
|
||||
initialize() {
|
||||
this.scene = new THREE.Scene();
|
||||
this.placeholder_manager = new LoadingPlaceHolderManager(this.scene);
|
||||
this.placeholder_manager.initialize();
|
||||
void this.placeholder_manager.initialize();
|
||||
|
||||
this._createGrid();
|
||||
this._create_base_lighting();
|
||||
@@ -1026,21 +1025,20 @@ class Scene {
|
||||
onComplete: () => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GLTF> {
|
||||
return new Promise<GLTF>(async (resolve, reject) => {
|
||||
let buffer;
|
||||
try {
|
||||
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
|
||||
// Tauri; plain fetch under web hosts that allow it).
|
||||
buffer = await (
|
||||
await this.deps.fetchAsset(media_url, { signal })
|
||||
).arrayBuffer();
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name !== "AbortError") {
|
||||
console.error("load GLB from Tauri error:", error);
|
||||
}
|
||||
reject(error);
|
||||
return;
|
||||
let buffer: ArrayBuffer;
|
||||
try {
|
||||
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
|
||||
// Tauri; plain fetch under web hosts that allow it).
|
||||
buffer = await (
|
||||
await this.deps.fetchAsset(media_url, { signal })
|
||||
).arrayBuffer();
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name !== "AbortError") {
|
||||
console.error("load GLB from Tauri error:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return new Promise<GLTF>((resolve, reject) => {
|
||||
const glbLoader = new GLTFLoader();
|
||||
glbLoader.parse(
|
||||
buffer,
|
||||
@@ -1068,30 +1066,23 @@ class Scene {
|
||||
onComplete: () => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SplatMesh> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let buffer;
|
||||
try {
|
||||
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
|
||||
// Tauri; plain fetch under web hosts that allow it).
|
||||
buffer = await (
|
||||
await this.deps.fetchAsset(media_url, { signal })
|
||||
).arrayBuffer();
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name !== "AbortError") {
|
||||
console.error("load Splat from Tauri error:", error);
|
||||
}
|
||||
reject(error);
|
||||
return;
|
||||
let buffer: ArrayBuffer;
|
||||
try {
|
||||
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
|
||||
// Tauri; plain fetch under web hosts that allow it).
|
||||
buffer = await (
|
||||
await this.deps.fetchAsset(media_url, { signal })
|
||||
).arrayBuffer();
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name !== "AbortError") {
|
||||
console.error("load Splat from Tauri error:", error);
|
||||
}
|
||||
|
||||
new SplatMesh({
|
||||
fileBytes: buffer,
|
||||
onLoad: (mesh) => {
|
||||
resolve(mesh);
|
||||
onComplete();
|
||||
},
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const mesh = new SplatMesh({ fileBytes: buffer });
|
||||
await mesh.initialized;
|
||||
onComplete();
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,15 +32,19 @@ export type SceneObject = {
|
||||
};
|
||||
|
||||
export interface SceneManagerAPI {
|
||||
create(media_token: string, name: string, position: THREE.Vector3): void;
|
||||
retrieve(object_uuid: string): void;
|
||||
delete(object_uuid: string): void;
|
||||
create(
|
||||
media_token: string,
|
||||
name: string,
|
||||
position: THREE.Vector3,
|
||||
): Promise<THREE.Object3D | undefined>;
|
||||
retrieve(object_uuid: string): Promise<THREE.Object3D | undefined>;
|
||||
delete(object_uuid: string): Promise<void>;
|
||||
update(
|
||||
object_uuid: string,
|
||||
position: THREE.Vector3,
|
||||
rotation: THREE.Euler,
|
||||
scale: THREE.Vector3,
|
||||
): void;
|
||||
): Promise<void>;
|
||||
selected(): void;
|
||||
render_outliner(timeline_characters: { [key: string]: ClipGroup }): void;
|
||||
select_object(id: string): void;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Model, ModelInfo } from "@storyteller/model-list";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { clickOnEnterOrSpace } from "@storyteller/common";
|
||||
|
||||
interface PortalTooltipProps {
|
||||
children: React.ReactElement;
|
||||
@@ -249,6 +250,7 @@ function InfoHint({ content }: { content: ReactNode }) {
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
// Body-portaled, so inside a Radix modal (promptbox focus mode)
|
||||
// taps here must not count as outside clicks on the dialog.
|
||||
data-modal-outside-safe=""
|
||||
@@ -351,6 +353,10 @@ function RichListRow({
|
||||
}: RichListRowProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-disabled={item.disabled || undefined}
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
data-selected={item.selected ? "true" : undefined}
|
||||
onClick={() => {
|
||||
if (!item.disabled) onClick?.();
|
||||
@@ -602,6 +608,7 @@ function SubmenuFlyout({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
ref={triggerRef}
|
||||
onMouseEnter={scheduleOpen}
|
||||
onMouseLeave={scheduleClose}
|
||||
@@ -1277,6 +1284,10 @@ export const PopoverMenu = ({
|
||||
|
||||
const itemRow = (
|
||||
<div
|
||||
role="button"
|
||||
aria-disabled={item.disabled || undefined}
|
||||
tabIndex={0}
|
||||
onKeyDown={clickOnEnterOrSpace}
|
||||
data-selected={
|
||||
item.selected ? "true" : undefined
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function useCurrency(): UseCurrencyResult {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchRates().then((r) => {
|
||||
void fetchRates().then((r) => {
|
||||
if (!cancelled) {
|
||||
setRates(r);
|
||||
setRatesLoaded(true);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user