Compare commits

..
Author SHA1 Message Date
Brandon Thomas ea7a7595c3 fix builds 2026-05-08 21:40:11 -04:00
Brandon Thomas c6fd68a064 fix frontend errors 2026-05-08 21:13:02 -04:00
427 changed files with 48004 additions and 9690 deletions
@@ -24,13 +24,6 @@ pub enum BeebleSpecificApiError {
message: String,
webhook_url: String,
},
/// 400 Bad Request — the video has too many frames.
VideoHasTooManyFrames {
max_frames: u64,
detected_frames: u64,
message: String,
},
}
impl Error for BeebleSpecificApiError {}
@@ -46,9 +39,6 @@ impl Display for BeebleSpecificApiError {
Self::BadWebhookUrl { message, webhook_url } => {
write!(f, "Beeble API: Bad webhook URL '{}': {}", webhook_url, message)
}
Self::VideoHasTooManyFrames { max_frames, detected_frames, message } => {
write!(f, "Beeble API: Video has too many frames ({} detected, max {}): {}", detected_frames, max_frames, message)
}
}
}
}
@@ -28,16 +28,6 @@ pub fn handle_error_response<T>(
webhook_url: context.maybe_callback_url.unwrap_or_default().to_string(),
}.into())
}
400 if response_body.contains("VIDEO_TOO_MANY_FRAMES") => {
let message = extract_error_message(response_body)
.unwrap_or_else(|| "Video has too many frames".to_string());
let (detected, max) = extract_frame_counts(&message);
Err(BeebleSpecificApiError::VideoHasTooManyFrames {
max_frames: max,
detected_frames: detected,
message,
}.into())
}
409 => Err(BeebleSpecificApiError::IdempotencyConflict.into()),
429 => Err(BeebleSpecificApiError::RateLimited.into()),
_ => {
@@ -55,18 +45,3 @@ fn extract_error_message(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
}
/// Parse frame counts from a message like "Video has 419 frames, which exceeds the maximum of 240."
fn extract_frame_counts(message: &str) -> (u64, u64) {
let numbers: Vec<u64> = message
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
match numbers.as_slice() {
[detected, max, ..] => (*detected, *max),
[detected] => (*detected, 0),
_ => (0, 0),
}
}
@@ -69,9 +69,6 @@ pub enum MediaFileOriginProductCategory {
#[serde(rename = "video_gen")]
VideoGeneration,
#[serde(rename = "video_edit")]
VideoEdit,
#[serde(rename = "world_gen")]
WorldGeneration,
@@ -108,7 +105,6 @@ impl MediaFileOriginProductCategory {
Self::Mocap => "mocap",
Self::ImageGeneration => "image_gen",
Self::VideoGeneration => "video_gen",
Self::VideoEdit => "video_edit",
Self::WorldGeneration => "world_gen",
Self::VideoFilter => "video_filter",
Self::Workflow => "workflow",
@@ -130,7 +126,6 @@ impl MediaFileOriginProductCategory {
"mocap" => Ok(Self::Mocap),
"image_gen" => Ok(Self::ImageGeneration),
"video_gen" => Ok(Self::VideoGeneration),
"video_edit" => Ok(Self::VideoEdit),
"world_gen" => Ok(Self::WorldGeneration),
"video_filter" => Ok(Self::VideoFilter),
"workflow" => Ok(Self::Workflow),
@@ -155,7 +150,6 @@ impl MediaFileOriginProductCategory {
Self::Mocap,
Self::ImageGeneration,
Self::VideoGeneration,
Self::VideoEdit,
Self::WorldGeneration,
Self::VideoFilter,
Self::Workflow,
@@ -186,7 +180,6 @@ mod tests {
assert_serialization(MediaFileOriginProductCategory::Mocap, "mocap");
assert_serialization(MediaFileOriginProductCategory::ImageGeneration, "image_gen");
assert_serialization(MediaFileOriginProductCategory::VideoGeneration, "video_gen");
assert_serialization(MediaFileOriginProductCategory::VideoEdit, "video_edit");
assert_serialization(MediaFileOriginProductCategory::WorldGeneration, "world_gen");
assert_serialization(MediaFileOriginProductCategory::VideoFilter, "video_filter");
assert_serialization(MediaFileOriginProductCategory::Workflow, "workflow");
@@ -207,7 +200,6 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::Mocap.to_str(), "mocap");
assert_eq!(MediaFileOriginProductCategory::ImageGeneration.to_str(), "image_gen");
assert_eq!(MediaFileOriginProductCategory::VideoGeneration.to_str(), "video_gen");
assert_eq!(MediaFileOriginProductCategory::VideoEdit.to_str(), "video_edit");
assert_eq!(MediaFileOriginProductCategory::WorldGeneration.to_str(), "world_gen");
assert_eq!(MediaFileOriginProductCategory::VideoFilter.to_str(), "video_filter");
assert_eq!(MediaFileOriginProductCategory::Workflow.to_str(), "workflow");
@@ -228,7 +220,6 @@ mod tests {
assert_eq!(MediaFileOriginProductCategory::from_str("mocap").unwrap(), MediaFileOriginProductCategory::Mocap);
assert_eq!(MediaFileOriginProductCategory::from_str("image_gen").unwrap(),MediaFileOriginProductCategory::ImageGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_gen").unwrap(), MediaFileOriginProductCategory::VideoGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_edit").unwrap(), MediaFileOriginProductCategory::VideoEdit);
assert_eq!(MediaFileOriginProductCategory::from_str("world_gen").unwrap(), MediaFileOriginProductCategory::WorldGeneration);
assert_eq!(MediaFileOriginProductCategory::from_str("video_filter").unwrap(), MediaFileOriginProductCategory::VideoFilter);
assert_eq!(MediaFileOriginProductCategory::from_str("workflow").unwrap(), MediaFileOriginProductCategory::Workflow);
@@ -237,7 +228,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = MediaFileOriginProductCategory::all_variants();
assert_eq!(variants.len(), 17);
assert_eq!(variants.len(), 16);
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Unknown));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceAnimator));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::FaceFusion));
@@ -251,7 +242,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Mocap));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::ImageGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoEdit));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::WorldGeneration));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::VideoFilter));
assert_eq!(variants.pop_first(), Some(MediaFileOriginProductCategory::Workflow));
@@ -8,8 +8,6 @@ use sqlx::Acquire;
use artcraft_api_defs::generate::video::edit::beeble_switchx_edit_video::{
BeebleSwitchXEditVideoRequest, BeebleSwitchXEditVideoResponse,
};
use beeble_client::error::beeble_error::BeebleError;
use beeble_client::error::beeble_specific_api_error::BeebleSpecificApiError;
use beeble_client::requests::create_upload_url::upload_bytes::upload_bytes_to_beeble;
use beeble_client::requests::start_generation::start_generation::{
start_generation, BeebleAlphaMode, BeebleGenerationType, StartGenerationArgs,
@@ -220,22 +218,8 @@ pub async fn beeble_switchx_edit_video_gen_handler(
idempotency_key: Some(request.uuid_idempotency_token.clone()),
},
}).await.map_err(|err| {
warn!("Beeble start_generation failed: {:?}", err);
match err {
BeebleError::ApiSpecific(BeebleSpecificApiError::VideoHasTooManyFrames {
max_frames,
detected_frames,
ref message,
}) => {
AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Video has too many frames: {} (max: {}); {}",
detected_frames, max_frames, message
))
},
_ => {
AdvancedCommonWebError::from_error(err)
}
}
error!("Beeble start_generation failed: {:?}", err);
AdvancedCommonWebError::from_error(err)
})?;
let external_job_id = &beeble_result.id;
@@ -17,7 +17,6 @@ use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::path_to_string::path_to_string;
@@ -367,7 +366,6 @@ async fn download_and_upload_video(
.media_file_class(MediaFileClass::Video)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoEdit)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_frame_width(maybe_frame_width)
@@ -343,10 +343,10 @@
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
"markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
}
]
}
@@ -1,7 +1,3 @@
/background-change /index.html 200
/background-change/ /index.html 200
/background-change/* /index.html 200
/checkout_cancel /index.html 200
/checkout_cancel/ /index.html 200
/checkout_cancel/* /index.html 200
@@ -73,71 +73,6 @@ function isEntryActive(pathname: string, entry: NavEntry): boolean {
return isPathActive(pathname, entry.href);
}
function CreditsChip({
credits,
align,
onBuyCredits,
onUpgrade,
}: {
credits: number;
align: "center" | "end";
onBuyCredits: () => void;
onUpgrade: () => void;
}) {
return (
<PopoverMenu
position="bottom"
align={align}
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary text-[11px]" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
>
{(close) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
onBuyCredits();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<FontAwesomeIcon icon={faCoins} className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
onUpgrade();
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
);
}
async function fetchCredits(): Promise<number | null> {
try {
const api = new CreditsApi();
@@ -347,12 +282,62 @@ export default function Navbar() {
</Link>
{credits !== null && (
<CreditsChip
credits={credits}
<PopoverMenu
position="bottom"
align="center"
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
triggerIcon={
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[11px]"
/>
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
>
{(close) => (
<div className="w-72 p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
setCreditsModalOpen(true);
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-medium text-white tracking-tight">
<FontAwesomeIcon
icon={faCoins}
className="text-xl text-primary"
/>
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
navigate("/pricing");
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
)}
{hasPaidPlan === false && (
@@ -504,16 +489,8 @@ export default function Navbar() {
</div>
)}
{/* Mobile: credits + task queue + hamburger */}
<div className="flex items-center gap-1.5 lg:hidden">
{user && credits !== null && (
<CreditsChip
credits={credits}
align="end"
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{/* Mobile: hamburger + task queue */}
<div className="flex items-center gap-2 lg:hidden">
{user && <TaskQueue />}
<DisclosureButton className="flex h-8 w-8 items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors">
<span className="sr-only">Open main menu</span>
@@ -664,6 +641,15 @@ export default function Navbar() {
/>
Discord
</DisclosureButton>
{credits !== null && (
<span className="flex items-center gap-1.5 ml-auto text-[12px] font-medium text-white/80">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-[10px]"
/>
{credits.toLocaleString()}
</span>
)}
</div>
<div className="flex items-center gap-2">
<img
@@ -23,7 +23,7 @@ import { Button } from "@storyteller/ui-button";
import {
getProviderDisplayName,
getModelDisplayName,
findModelByKey,
ALL_MODELS_LIST,
} from "@storyteller/model-list";
import { CloseButton } from "@storyteller/ui-close-button";
import dayjs from "dayjs";
@@ -157,10 +157,10 @@ const PromptLine = ({
style={
marqueePlaying
? {
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
animation: "marquee 6.5s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
}
: undefined
}
>
@@ -468,7 +468,9 @@ function getPrompt(job: Job, promptsMap?: Map<string, Prompts>): string {
? (promptsMap?.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
return (
cached?.maybe_positive_prompt || job.request.maybe_raw_inference_text || ""
cached?.maybe_positive_prompt ||
job.request.maybe_raw_inference_text ||
""
);
}
@@ -481,7 +483,8 @@ function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
const taskTypeStr = job.request.inference_category?.toLowerCase() ?? "";
const modelTypeStr =
cachedPrompt?.maybe_model_type ?? job.request.maybe_model_type ?? "";
const providerKey = cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
const providerKey =
cachedPrompt?.maybe_generation_provider ?? modelTypeStr;
let kind: string | undefined;
if (taskTypeStr.includes("video")) {
@@ -510,6 +513,19 @@ function formatTitleParts(job: Job, promptsMap?: Map<string, Prompts>) {
// Cache per-task durations so model changes don't affect existing progress bars
const taskDurationCache = new Map<string, number>();
// Per-model duration overrides for jobs whose models aren't (yet) in
// ALL_MODELS_LIST. Each entry returns ms, or `undefined` if it doesn't apply.
const MODEL_DURATION_OVERRIDES: Array<
(normalizedKey: string) => number | undefined
> = [
// Beeble SwitchX (background change). Matches switch_x, switchx,
// beeble_switchx, beeble_switch_x, etc.
(key) =>
key.includes("switchx") || key.includes("switch_x")
? 5 * 60 * 1000
: undefined,
];
function jobsToInProgress(
jobs: Job[],
promptsMap: Map<string, Prompts>,
@@ -529,24 +545,35 @@ function jobsToInProgress(
const parts = formatTitleParts(j, promptsMap);
const isVideo =
j.request.inference_category?.toLowerCase().includes("video") ?? false;
const modelType = j.request.maybe_model_type;
const promptToken = j.request.maybe_prompt_token;
const cachedPrompt = promptToken
? (promptsMap.get(promptToken) ?? getCachedPrompt(promptToken))
: undefined;
const modelType =
cachedPrompt?.maybe_model_type ?? j.request.maybe_model_type;
// Look up per-model estimated duration from the model list, cache per
// task. Cache only once `modelType` is resolved so a transient
// "prompt not loaded yet" render doesn't lock in the wrong fallback.
// Look up per-model estimated duration, cache per task
let duration = taskDurationCache.get(j.job_token);
if (duration === undefined) {
const model = findModelByKey(modelType);
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
if (modelType) {
taskDurationCache.set(j.job_token, duration);
if (!duration) {
const normalizedKey = modelType
? modelType.toLowerCase().replace(/\./g, "_")
: "";
let override: number | undefined;
if (normalizedKey) {
for (const matcher of MODEL_DURATION_OVERRIDES) {
const ms = matcher(normalizedKey);
if (ms !== undefined) {
override = ms;
break;
}
}
}
if (override !== undefined) {
duration = override;
} else {
const model = modelType
? ALL_MODELS_LIST.find(
(m) => m.tauriId === modelType || m.id === modelType,
)
: undefined;
duration = model?.progressBarTime ?? (isVideo ? 900000 : 30000);
}
taskDurationCache.set(j.job_token, duration);
}
// Always use time-based fake progress (matching desktop app behavior)
@@ -666,8 +693,7 @@ export const TaskQueue = () => {
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const j of jobs) {
if (j.request.maybe_prompt_token)
tokens.push(j.request.maybe_prompt_token);
if (j.request.maybe_prompt_token) tokens.push(j.request.maybe_prompt_token);
}
return tokens;
}, [jobs]);
@@ -714,7 +740,7 @@ export const TaskQueue = () => {
primaryActionText: "",
primaryActionIcon: faTrashAlt,
primaryActionBtnClassName: "",
onConfirm: async () => {},
onConfirm: async () => { },
});
// ── Confirmation handlers ──────────────────────────────────────────
@@ -818,9 +844,7 @@ export const TaskQueue = () => {
const failedJobs = fetchedJobs.filter((j) =>
FAILED_STATUSES.has(j.status.status),
);
const newCompletedIdSet = new Set(
completedJobs.map((j) => j.job_token),
);
const newCompletedIdSet = new Set(completedJobs.map((j) => j.job_token));
const newFailedIdSet = new Set(failedJobs.map((j) => j.job_token));
if (!initialLoadDoneRef.current) {
@@ -376,7 +376,6 @@ export default function CreateVFX() {
uploadVideo={uploadVideo}
uploadImage={uploadImage}
onError={(msg) => toast.error(msg)}
hideResolution
/>
</div>
</div>
@@ -1,6 +1,6 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { BetaKey } from "~/models";
import type { Pagination } from "@storyteller/ui-pagescene";
import { Pagination } from "~/pages/PageEnigma/models";
export class BetaKeysApi extends ApiManager {
public CreateBetaKey({
@@ -1,9 +1,9 @@
import type {
import {
MediaFile,
MediaInfo,
Pagination,
PaginationInfinite,
} from "@storyteller/ui-pagescene";
} from "~/pages/PageEnigma/models";
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import type { Property } from "csstype";
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import type { Prompts } from "@storyteller/ui-pagescene";
import { Prompts } from "~/pages/PageEnigma/models";
export class PromptsApi extends ApiManager {
public GetPromptsByToken({
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { GenerateTtsAudioErrorType } from "@storyteller/ui-pagescene";
import { GenerateTtsAudioErrorType } from "~/pages/PageEnigma/enums";
import { Visibility } from "~/enums";
export interface GenerateTtsAudioRequest {
@@ -1,10 +1,10 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import type {
import {
Pagination,
UserBookmarkBatch,
UserBookmarkByEntity,
UserBookmarkByUser,
} from "@storyteller/ui-pagescene";
} from "~/pages/PageEnigma/models";
import { authentication } from "~/signals";
export enum ScopedEntityTypes {
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import type { Pagination } from "@storyteller/ui-pagescene";
import { Pagination } from "~/pages/PageEnigma/models";
import { authentication } from "~/signals";
import { ZsDataset } from "~/models/Dataset";
@@ -1,7 +1,7 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import { Weight } from "~/models";
import type { Pagination, PaginationInfinite } from "@storyteller/ui-pagescene";
import { Pagination, PaginationInfinite } from "~/pages/PageEnigma/models";
import {
FilterEngineCategories,
FilterMediaClasses,
@@ -25,9 +25,10 @@ import {
import { toast } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { useTabStore } from "../../pages/Stores/TabState";
import { addObject, getActiveEditor } from "@storyteller/ui-pagescene";
import type { MediaItem } from "@storyteller/ui-pagescene";
import { addObject } from "../../pages/PageEnigma/signals/objectGroup/addObject";
import { set3DPageMounted } from "../../pages/PageEnigma/Editor/editor";
import { AssetType } from "~/enums";
import type { MediaItem } from "../../pages/PageEnigma/models";
import { SPLAT_MODELS } from "@storyteller/model-list";
import {
ClassyModelSelector,
@@ -973,6 +974,7 @@ export const ImageTo3DExperience = ({
variant="primary"
className="min-w-[120px]"
onClick={() => {
set3DPageMounted(true);
useTabStore.getState().setActiveTab("3D");
setTimeout(() => {
const mediaItem = {
@@ -984,13 +986,8 @@ export const ImageTo3DExperience = ({
} as MediaItem & {
position: { x: number; y: number; z: number };
};
const editor = getActiveEditor();
if (editor) {
void addObject(editor, mediaItem);
toast.success("Added to 3D scene");
} else {
toast.error("3D editor isn't ready yet");
}
addObject(mediaItem);
toast.success("Added to 3D scene");
}, 500);
}}
>
@@ -73,6 +73,17 @@ export function UploadModal3D(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
@@ -119,12 +130,8 @@ export function UploadModal3D(props: Props) {
<UploadSuccess
title="3D model"
onOk={() => {
// Open the gallery so the user can drag the freshly
// uploaded model onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
onSuccess(selectedCategory);
}}
/>
);
@@ -165,8 +172,7 @@ export function UploadModal3D(props: Props) {
className="max-w-xl"
showClose={true}
>
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
<UploaderModalContent />
</Modal>
);
}
@@ -48,6 +48,17 @@ export function UploadModalImage(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
@@ -83,12 +94,8 @@ export function UploadModalImage(props: Props) {
<UploadSuccess
title="Image"
onOk={() => {
// Open the gallery so the user can drag the freshly
// uploaded image onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
onSuccess(selectedCategory);
}}
/>
);
@@ -129,8 +136,7 @@ export function UploadModalImage(props: Props) {
className="max-w-xl"
showClose={true}
>
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
<UploaderModalContent />
</Modal>
);
}
@@ -113,8 +113,7 @@ export function UploadImageMediaModal({
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
<UploaderModalContent />
</Modal>
);
}
@@ -100,8 +100,7 @@ export function UploadModalMedia({
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
<UploaderModalContent />
</Modal>
);
}
@@ -40,6 +40,7 @@ interface Props {
};
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
onLocalBytes: (bytes: ArrayBuffer, shouldFlip: boolean) => void;
}
export const UploadFilesSplat = ({
@@ -18,7 +18,7 @@ import { UploadFilesSplat } from "./UploadFilesSplat";
interface Props {
onClose: () => void;
onSuccess: (category: FilterEngineCategories) => void;
onSuccess: (splatArrayBuffer: ArrayBuffer, shouldFlip: boolean) => void;
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
@@ -52,6 +52,17 @@ export function UploadModalSplat(props: Props) {
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
// galleryModalVisibleViewMode.value = true;
// galleryModalVisibleDuringDrag.value = true;
// onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
@@ -68,6 +79,7 @@ export function UploadModalSplat(props: Props) {
}}
onClose={onClose}
onUploadProgress={updateUploaderState}
onLocalBytes={(buffer, shouldFlip) => { onSuccess(buffer, shouldFlip); }}
/>
</div>
);
@@ -91,12 +103,8 @@ export function UploadModalSplat(props: Props) {
<UploadSuccess
title="Splat"
onOk={() => {
// Open the gallery so the user can drag the freshly
// uploaded splat onto the canvas.
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
onSuccess(selectedCategory);
onClose();
// onSuccess(selectedCategory);
}}
/>
);
@@ -137,8 +145,7 @@ export function UploadModalSplat(props: Props) {
className="max-w-xl"
showClose={true}
>
{/* Inline call — `<Comp />` would be a fresh component reference each render, remounting the dropzone mid-click and breaking the file picker. */}
{UploaderModalContent()}
<UploaderModalContent />
</Modal>
);
}
@@ -1,36 +1,32 @@
import { useSignals } from "@preact/signals-react/runtime";
import { faTriangleExclamation } from "@fortawesome/pro-solid-svg-icons";
import { useShallow } from "zustand/shallow";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
import {
showErrorDialog,
errorDialogMessage,
errorDialogTitle,
} from "~/pages/PageEnigma/signals";
export function ErrorDialog() {
const { showErrorDialog, errorDialogTitle, errorDialogMessage, setShowErrorDialog } =
usePageSceneStore(
useShallow((s) => ({
showErrorDialog: s.showErrorDialog,
errorDialogTitle: s.errorDialogTitle,
errorDialogMessage: s.errorDialogMessage,
setShowErrorDialog: s.setShowErrorDialog,
})),
);
useSignals();
return (
<Modal
title={errorDialogTitle}
title={errorDialogTitle.value}
titleIcon={faTriangleExclamation}
titleIconClassName="text-brand-primary"
isOpen={showErrorDialog}
onClose={() => setShowErrorDialog(false)}
isOpen={showErrorDialog.value}
onClose={() => (showErrorDialog.value = false)}
showClose={false}
>
<div>
{errorDialogMessage}
{errorDialogMessage.value}
<div className="flex justify-end">
<Button
type="button"
onClick={() => setShowErrorDialog(false)}
onClick={() => (showErrorDialog.value = false)}
variant="secondary"
>
Close
@@ -0,0 +1,63 @@
import { useSignals } from "@preact/signals-react/runtime";
import { twMerge } from "tailwind-merge";
import { faFilm } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { generateMovieId, viewMyMovies } from "~/pages/PageEnigma/signals";
import { activeWorkflowJobs } from "~/signals";
export const MyMoviesButton = () => {
useSignals();
const activeCount = activeWorkflowJobs.value
? activeWorkflowJobs.value.length
: 0;
return (
<div className="relative">
<Button
variant="action"
onClick={() => {
generateMovieId.value = "";
viewMyMovies.value = true;
}}
>
<div className="relative flex items-center gap-2">
{activeCount > 0 ? (
<svg
className="h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<FontAwesomeIcon icon={faFilm} />
)}
<div>My Movies</div>
</div>
</Button>
<div
className={twMerge(
"invisible absolute right-[-7px] top-[-7px] flex h-[20px] w-[20px] items-center justify-center rounded-full bg-brand-primary text-[13px] font-medium text-white",
activeCount > 0 && "visible",
)}
>
{activeCount}
</div>
</div>
);
};
@@ -4,7 +4,11 @@ import { useSignals } from "@preact/signals-react/runtime";
import { faPencil, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { scene, signalScene, authentication } from "~/signals";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
import {
showErrorDialog,
errorDialogMessage,
errorDialogTitle,
} from "~/pages/PageEnigma/signals";
import { Input } from "@storyteller/ui-input";
import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
@@ -32,7 +36,9 @@ export const SceneTitleInput = ({ pageName }: Props) => {
};
const handleShowErrorDialog = (errorMessage: string) => {
usePageSceneStore.getState().setErrorDialog("Error", errorMessage);
errorDialogTitle.value = "Error";
errorDialogMessage.value = errorMessage;
showErrorDialog.value = true;
};
const handleChangeSceneTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -82,7 +82,11 @@ import toast from "react-hot-toast";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import { useStoryboardStore } from "~/pages/PageStoryboard";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
import {
is3DEditorInitialized,
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
import { useImageTo3DStore } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useImageTo3DWorldStore } from "~/pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { useRemoveBackgroundStore } from "~/pages/PageRemoveBackground/RemoveBackgroundStore";
@@ -239,8 +243,8 @@ export const TopBar = ({ pageName }: Props) => {
const tabStore = useTabStore();
const is3DSceneReady = usePageSceneStore((s) => s.is3DSceneLoaded);
const is3DEditorReady = usePageSceneStore((s) => s.is3DEditorInitialized);
const is3DSceneReady = is3DSceneLoaded.value;
const is3DEditorReady = is3DEditorInitialized.value;
const [disableSwitcher, setDisableSwitcher] = useState(false);
const switcherThrottle = useRef(false);
@@ -659,6 +663,7 @@ export const TopBar = ({ pageName }: Props) => {
setDisableSwitcher(true);
if (tabId === "APPS") {
set3DPageMounted(false);
useTabStore.getState().setActiveTab("APPS");
setTimeout(() => {
switcherThrottle.current = false;
@@ -667,8 +672,12 @@ export const TopBar = ({ pageName }: Props) => {
return;
}
// PageScene's mount/unmount in MainApp drives the
// engine lifecycle now — no manual flag flips needed.
// Disable 3d engine to prevent memory leak.
if (tabId === "3D") {
set3DPageMounted(true);
} else {
set3DPageMounted(false);
}
useTabStore.getState().setActiveTab(tabId as TabId);
setTimeout(() => {
// Clear the throttle
@@ -18,6 +18,7 @@ import {
useStoryboardPageEnabled,
} from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor";
export type AppId =
| "IMAGE"
@@ -289,6 +290,11 @@ export const goToApp = (action?: string) => {
useExperimentalStore.getState();
if (!enabled || !storyboardPageEnabled) return;
}
if (action === "3D") {
set3DPageMounted(true);
} else {
set3DPageMounted(false);
}
useTabStore.getState().setActiveTab(action as TabId);
}
};
@@ -1 +1,6 @@
export { AssetFilterOption } from "@storyteller/ui-pagescene";
export enum AssetFilterOption {
ALL,
FEATURED,
MINE,
BOOKMARKED,
}
@@ -1,4 +1,11 @@
// Single source of truth lives in @storyteller/ui-pagescene; this
// re-export keeps the artcraft `~/enums` surface stable while the
// 3D scene lib owns the canonical enum identity.
export { AssetType } from "@storyteller/ui-pagescene";
export enum AssetType {
ANIMATION = "animation",
AUDIO = "audio",
CAMERA = "camera",
CHARACTER = "character",
EXPRESSION = "expression",
OBJECT = "object",
SKYBOX = "skybox",
SHAPE = "shape",
SPLAT = "splat",
}
@@ -1 +1,7 @@
export { ClipGroup } from "@storyteller/ui-pagescene";
export enum ClipGroup {
CHARACTER = "character",
CAMERA = "camera",
GLOBAL_AUDIO = "global_audio",
OBJECT = "object",
PROMPT_TRAVEL = "prompt",
}
@@ -1,7 +1,31 @@
// Single source of truth lives in @storyteller/ui-pagescene; this
// re-export keeps the artcraft `~/enums` surface stable.
export {
FilterEngineCategories,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/ui-pagescene";
export enum FilterMediaClasses {
AUDIO = "audio",
IMAGE = "image",
VIDEO = "video",
DIMENSIONAL = "dimensional",
}
export enum FilterMediaType {
SCENE_JSON = "scene_json",
GLB = "glb",
GLTF = "gltf",
PMX = "pmx",
PMD = "pmd",
VMD = "vmd",
}
export enum FilterEngineCategories {
ANIMATION = "animation",
AUDIO = "audio",
CHARACTER = "character",
CREATURE = "creature",
EXPRESSION = "expression",
IMAGE_PLANE = "image_plane",
LOCATION = "location",
OBJECT = "object",
SPLAT = "splat",
SCENE = "scene",
SET_DRESSING = "set_dressing",
SKYBOX = "skybox",
VIDEO_PLANE = "video_plane",
}
@@ -1,2 +1,5 @@
// Single source of truth lives in @storyteller/ui-pagescene.
export { ToastTypes } from "@storyteller/ui-pagescene";
export enum ToastTypes {
SUCCESS,
WARNING,
ERROR,
}
+2 -7
View File
@@ -1,7 +1,7 @@
import { StrictMode, useEffect } from "react";
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import { BrowserRouter } from "react-router-dom";
import { MainApp } from "./pages/MainApp";
import { PageEnigma } from "./pages/PageEnigma/PageEnigma";
import { GlobalFileDropHandler } from "./components/GlobalFileDropHandler/GlobalFileDropHandler";
import { createRoot } from "react-dom/client";
import "./styles/normalize.css";
@@ -13,7 +13,6 @@ import EnvironmentVariables from "~/Classes/EnvironmentVariables";
import { pageHeight, pageWidth, persistLogin } from "~/signals";
import { SyncStorytellerApiConfig } from "./api/SyncStorytellerApiConfig";
import { posthog } from "posthog-js";
import { SoundManager } from "@storyteller/soundboard";
config.autoAddCss = false; /* eslint-disable import/first */
@@ -85,10 +84,6 @@ const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
};
}, []);
useEffect(() => {
SoundManager.install();
}, []);
return null;
};
@@ -99,7 +94,7 @@ createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<GlobalSettingsManager env={ENV} />
<div className="topbar-spacer" data-tauri-drag-region={true} />
<MainApp />
<PageEnigma />
<GlobalFileDropHandler />
</BrowserRouter>
</StrictMode>
@@ -1,297 +0,0 @@
// Top-level shell for the artcraft app. Always-mounted chrome
// (TopBar, login + pricing modals, toaster, Tauri event listeners,
// background refresh hooks) lives here, and a single tab-driven
// switch picks the active page below it.
//
// The 3D editor (PageScene) is one of the 13 tab branches — it
// genuinely mounts/unmounts as the user moves on/off the "3D" tab,
// so the Three.js engine constructs on entry and tears down on exit
// via the EngineProvider's React lifecycle. Other pages render as
// siblings, never wrapped by EngineProvider, so they have no
// dependency on the 3D engine being present.
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
import { ErrorDialog } from "~/components";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import { toast, Toaster } from "@storyteller/ui-toaster";
import {
GalleryDragComponent,
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
PricingModal,
CreditsModal,
useCreditsModalStore,
} from "@storyteller/ui-pricing-modal";
import {
isActionReminderOpen,
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import {
useFlashFileDownloadErrorEvent,
useFlashUserInputErrorEvent,
useGenerationCompleteEvent,
useGenerationEnqueueFailureEvent,
useGenerationEnqueueSuccessEvent,
useGenerationFailedEvent,
useMediaFileDeletedEvent,
useTextToImageGenerationCompleteEvent,
} from "@storyteller/tauri-events";
import { SoundManager } from "@storyteller/soundboard";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
DomLevels,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { UsersApi } from "~/Classes/ApiManager";
import { authentication } from "~/signals";
import { AUTH_STATUS } from "~/enums";
import { useTabStore } from "./Stores/TabState";
import { useTextToImageStore } from "./PageImage/TextToImageStore";
import { AppsIndexPage } from "./PageApps/AppsIndexPage";
import PageDraw from "./PageDraw/PageDraw";
import TextToImage from "./PageImage/TextToImage";
import ImageToVideo from "./PageVideo/ImageToVideo";
import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { RemoveBackground } from "./PageRemoveBackground";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
interface Props {
sceneToken?: string;
}
export const MainApp = ({ sceneToken }: Props) => {
useSignals();
// Background plumbing — should keep running regardless of which tab
// is active.
useActiveJobs();
useBackgroundLoadingMedia();
// Tauri event listeners. Must always be mounted so generation/upload
// completions are surfaced no matter which tab the user is on.
useGenerationEnqueueSuccessEvent();
useGenerationEnqueueFailureEvent();
useGenerationCompleteEvent();
useGenerationFailedEvent();
const completeBatch = useTextToImageStore((s) => s.completeBatch);
useTextToImageGenerationCompleteEvent(async (event) => {
completeBatch(
event.generated_images || [],
event.maybe_frontend_subscriber_id,
);
});
useFlashUserInputErrorEvent(async (event) => {
console.log("Flash user input error event received:", event);
toast.error(event.message);
});
useFlashFileDownloadErrorEvent(async (event) => {
console.log("Flash file download error event received:", event);
toast.error(event.message || "File download failed");
});
useMediaFileDeletedEvent(async (event) => {
console.log("Media file deleted event received:", event);
await SoundManager.playFileDeleted();
toast.error("File deleted.");
});
// Session probe (runs once per shell mount) and GPU detection.
// Both are app-wide concerns, not 3D-only.
useEffect(() => {
const usersApi = new UsersApi();
usersApi.GetSession().then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
});
}, []);
const [, setValidGpu] = useState("unknown");
useEffect(() => {
const { getGPUTier } = gpu;
getGPUTier().then((gpuTier) => {
console.log("GPU tier", gpuTier);
let isValid = false;
const fps = gpuTier.fps || 0;
if (gpuTier.tier > 1) isValid = true;
if (fps > 15) isValid = true;
if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true;
setValidGpu(isValid ? "valid" : "error");
});
}, []);
const { triggerRecheck } = useLoginModalStore();
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
const disableHotkeyInput = usePageSceneStore((s) => s.disableHotkeyInput);
const enableHotkeyInput = usePageSceneStore((s) => s.enableHotkeyInput);
const currentReminderModalProps = actionReminderProps.value;
return (
<div className="w-screen">
<TopBar
loginSignUpPressed={() => {
console.log("PRESSED");
triggerRecheck();
}}
pageName="Edit Scene"
/>
<LoginModal
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
<TabBody sceneToken={sceneToken} />
<GalleryDragComponent />
<ErrorDialog />
<Toaster offsetTop={70} offsetRight={12} zIndex={9999} />
{currentReminderModalProps && (
<ActionReminderModal
isOpen={isActionReminderOpen.value}
onClose={currentReminderModalProps.onClose}
reminderType={currentReminderModalProps.reminderType}
onPrimaryAction={currentReminderModalProps.onPrimaryAction}
title={currentReminderModalProps.title}
message={currentReminderModalProps.message}
primaryActionText={currentReminderModalProps.primaryActionText}
secondaryActionText={currentReminderModalProps.secondaryActionText}
onSecondaryAction={currentReminderModalProps.onSecondaryAction}
isLoading={currentReminderModalProps.isLoading}
openAiLogo={currentReminderModalProps.openAiLogo}
primaryActionIcon={currentReminderModalProps.primaryActionIcon}
primaryActionBtnClassName={
currentReminderModalProps.primaryActionBtnClassName
}
/>
)}
<PricingModal />
<CreditsModal isOpen={isCreditsOpen} onClose={closeCreditsModal} />
</div>
);
};
const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
// The 3D case stays unwrapped because Stage3DBody (lib) already
// returns a <div> wrapper. Every other page is a fragment whose
// top-level children may use position: fixed (e.g. PageDraw); the
// wrapping <div> scopes them so they don't stack as siblings of
// the TopBar at the MainApp root.
switch (tabStore.activeTabId) {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":
return <div><AppsIndexPage /></div>;
case "2D":
return <div><PageDrawWithGalleryDrop /></div>;
case "IMAGE":
return (
<div>
<TextToImage
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
</div>
);
case "VIDEO":
return <div><ImageToVideo /></div>;
case "VIDEO_FRAME_EXTRACTOR":
return <div><VideoFrameExtractor /></div>;
case "VIDEO_WATERMARK_REMOVAL":
return <div><VideoWatermarkRemover /></div>;
case "IMAGE_WATERMARK_REMOVAL":
return <div><ImageWatermarkRemover /></div>;
case "IMAGE_TO_3D_OBJECT":
return <div><ImageTo3DObject /></div>;
case "IMAGE_TO_3D_WORLD":
return <div><ImageTo3DWorld /></div>;
case "REMOVE_BACKGROUND":
return <div><RemoveBackground /></div>;
case "ANGLES":
return <div><Angles /></div>;
case "STORYBOARD":
return storyboardPageEnabled ? <div><Storyboard /></div> : null;
case "BACKGROUND_CHANGE":
return <div><PageBackgroundChange /></div>;
default:
return null;
}
};
// Bridges gallery-modal's onImageDrop into PageDraw's existing
// `gallery-2d-drop` window-event listener. Lives here (rather than
// inside the pagedraw lib) so we don't have to add a gallery-modal
// dep there. Mounted only when the 2D tab is active, so it doesn't
// race the 3D-tab gallery handler.
const PageDrawWithGalleryDrop = () => {
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
const canvasElement = document.querySelectorAll("canvas")[0];
if (!canvasElement) return;
const rect = canvasElement.getBoundingClientRect();
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
if (
canvasX < 0 ||
canvasY < 0 ||
canvasX > rect.width ||
canvasY > rect.height
) {
return;
}
window.dispatchEvent(
new CustomEvent("gallery-2d-drop", {
detail: { item, canvasPosition: { x: canvasX, y: canvasY } },
}),
);
},
);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
}, []);
return <PageDraw />;
};
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import React, { useMemo } from "react";
import {
PageDraw as PageDrawLib,
useSceneStore,
@@ -97,7 +97,7 @@ const useTauriAdapter = (): PageDrawAdapter => {
enqueueEditImage: async (req) => {
const request: GenerateImageRequest = {
model: req.model,
canvas_image_media_token: req.canvasImageMediaToken,
scene_image_media_token: req.sceneImageMediaToken,
image_media_tokens: req.imageMediaTokens,
prompt: req.prompt,
enable_system_prompt:
@@ -0,0 +1,188 @@
import React from "react";
import { MediaItem } from "~/pages/PageEnigma/models";
import {
addCharacter,
addCharacterAnimation,
addCharacterAudio,
addCharacterExpression,
addGlobalAudio,
addObject,
canDrop,
currPosition,
dragItem,
overTimeline,
timelineHeight,
assetModalVisibleDuringDrag,
reopenAfterDragSignal,
} from "~/pages/PageEnigma/signals";
import { pageHeight, pageWidth } from "~/signals";
import { addShape } from "~/pages/PageEnigma/signals/shape";
import { AssetType } from "~/enums";
class DndAsset {
public dropId: string = "";
public overElement: DOMRect | null = null;
public dropOffset = 0;
public initX = 0;
public initY = 0;
public notDropText = "";
public isDragging: boolean = false;
public dragThreshold: number = 5;
constructor() {
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
}
onPointerDown(event: React.PointerEvent<HTMLDivElement>, item: MediaItem) {
if (event.button === 0) {
dragItem.value = item;
currPosition.value = {
currX: event.pageX,
currY: event.pageY,
};
this.initX = event.pageX;
this.initY = event.pageY;
this.isDragging = false;
canDrop.value = false;
this.notDropText = "";
assetModalVisibleDuringDrag.value = false;
window.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("pointermove", this.onPointerMove);
}
}
endDrag() {
if (dragItem.value) {
dragItem.value = null;
canDrop.value = false;
this.overElement = null;
overTimeline.value = false;
this.notDropText = "";
assetModalVisibleDuringDrag.value = reopenAfterDragSignal.value;
}
}
overCanvas(positionX: number, positionY: number) {
if (positionY < 69) {
return false;
}
if (positionY > pageHeight.value) {
return false;
}
return positionX <= pageWidth.value;
}
onPointerUp(event: PointerEvent) {
window.removeEventListener("pointerup", this.onPointerUp);
window.removeEventListener("pointermove", this.onPointerMove);
if (!this.isDragging) {
// It's a click, not a drag
assetModalVisibleDuringDrag.value = true;
dragItem.value = null;
currPosition.value = { currX: 0, currY: 0 };
return;
}
if (dragItem.value) {
const positionX = event.pageX;
const positionY = event.pageY;
if (this.overCanvas(positionX, positionY)) {
const mediaItem = dragItem.value;
if (mediaItem.type === AssetType.CHARACTER) {
addCharacter(dragItem.value);
}
// if (dragItem.value.type === AssetType.CAMERA) {
// console.log("Dragged In Camera Type")
// }
/*
FIXME:
THIS IS A TEMPARARY SOLUTION TO A LONG PROBLEM WITH SKYBOXES
UPDATE THIS WHEN UPLOADING SKYBOXES ARE FULLY IMPLEMENTED.
THIS IS JUST TEMPARARY!!!
*/
if (mediaItem.type === AssetType.OBJECT || mediaItem.type === AssetType.SPLAT || mediaItem.type === AssetType.SKYBOX) {
addObject(mediaItem);
}
if (mediaItem.type === AssetType.SHAPE) {
addShape(mediaItem);
}
this.endDrag();
return;
}
}
if (canDrop.value && dragItem.value) {
if (dragItem.value.type === AssetType.ANIMATION) {
addCharacterAnimation({
dragItem: dragItem.value,
characterId: this.dropId,
offset: this.dropOffset,
});
}
if (dragItem.value.type === AssetType.EXPRESSION) {
addCharacterExpression({
dragItem: dragItem.value,
characterId: this.dropId,
offset: this.dropOffset,
});
}
if (dragItem.value.type === AssetType.AUDIO) {
addCharacterAudio({
dragItem: dragItem.value,
characterId: this.dropId,
offset: this.dropOffset,
});
addGlobalAudio({
dragItem: dragItem.value,
audioId: this.dropId,
offset: this.dropOffset,
});
}
}
this.endDrag();
}
onPointerMove(event: MouseEvent) {
if (dragItem.value) {
event.stopPropagation();
event.preventDefault();
const deltaX = event.pageX - this.initX;
const deltaY = event.pageY - this.initY;
if (
Math.abs(deltaX) > this.dragThreshold ||
Math.abs(deltaY) > this.dragThreshold
) {
this.isDragging = true;
}
currPosition.value = {
currX: this.initX + deltaX,
currY: this.initY + deltaY,
};
overTimeline.value =
event.pageY > pageHeight.value - timelineHeight.value;
if (this.overElement) {
const pos = this.overElement;
const eventY = event.pageY;
const inHeight = eventY >= pos.top && eventY <= pos.top + pos.height;
const eventX = event.pageX;
const inWidth = eventX >= pos.left && eventX <= pos.left + pos.width;
if (inHeight && inWidth) {
return;
}
canDrop.value = false;
this.dropId = "";
this.overElement = null;
this.notDropText = "";
}
}
}
}
const dragAndDrop = new DndAsset();
export default dragAndDrop;
@@ -0,0 +1,12 @@
/**
* This is designed to surface user customer facing messages as errors.
* Errors shouldn't be 404 or something confusing should be
*/
class APIManagerResponseError extends Error {
constructor(message?: string) {
super(message);
this.name = "APIManagerResponseError";
}
}
export { APIManagerResponseError };
@@ -0,0 +1,14 @@
/**
* This is designed to surface user customer facing messages as errors.
*/
type Data = { [key: string]: any };
class APIManagerResponseSuccess {
public user_message: string;
public data: Data | null;
constructor(user_message: string = "", data: Data | null = null) {
this.data = data;
this.user_message = user_message;
}
}
export { APIManagerResponseSuccess };
@@ -0,0 +1,169 @@
import { SceneManager } from "./scene_manager_api";
import * as THREE from "three";
export interface ICommand<T> {
undo(): Promise<void>;
redo(): Promise<void>;
}
export interface TransformData {
object_uuid: string;
position: THREE.Vector3;
rotation: THREE.Euler;
scale: THREE.Vector3;
}
export interface UserData {
object_uuid: string;
color: string;
visible: boolean;
locked: boolean;
}
export interface CreationData {
object_uuid: string;
name: string;
position: THREE.Vector3;
userdata: Record<string, any>;
}
export interface DeletionData {
object_uuid: string;
name: string;
position: THREE.Vector3;
userdata: Record<string, any>;
}
export interface SceneState {
scene_objects: string[];
scene_names: string[];
scene_userdata: string[];
scene_positions: string[];
scene_rotations: string[];
scene_scales: string[];
}
export type CommandInputTypes = TransformData | CreationData | DeletionData;
export class CreationSceneItem implements ICommand<string> {
private component: SceneManager;
private creationData: CreationData;
public constructor(sceneManager: SceneManager, creationData: CreationData) {
this.component = sceneManager;
this.creationData = creationData;
}
public async undo(): Promise<void> {
this.component.delete(this.creationData.object_uuid);
}
public async redo(): Promise<void> {
const object = await this.component.create(
this.creationData.userdata["media_id"],
this.creationData.name,
this.creationData.position,
);
object.position.copy(this.creationData.position);
object.uuid = this.creationData.object_uuid;
if (this.creationData.userdata["visible"] !== undefined) {
object.visible = this.creationData.userdata["visible"];
}
this.component.scene.setColor(
object.uuid,
this.creationData.userdata["color"],
);
}
}
export class DeletionSceneItem implements ICommand<string> {
private component: SceneManager;
private deletionData: DeletionData;
public constructor(sceneManager: SceneManager, creationData: DeletionData) {
this.component = sceneManager;
this.deletionData = creationData;
}
public async undo(): Promise<void> {
const object = await this.component.create(
this.deletionData.userdata["media_id"],
this.deletionData.name,
this.deletionData.position,
);
object.uuid = this.deletionData.object_uuid;
object.visible = this.deletionData.userdata["visible"];
this.component.scene.setColor(
object.uuid,
this.deletionData.userdata["color"],
);
}
public async redo(): Promise<void> {
this.component.delete(this.deletionData.object_uuid);
}
}
export class TransformSceneItem implements ICommand<string> {
private component: SceneManager;
private transformDataStart: TransformData;
private transformDataEnd: TransformData;
public constructor(
sceneManager: SceneManager,
transformDataStart: TransformData,
transformDataEnd: TransformData,
) {
this.component = sceneManager;
this.transformDataStart = transformDataStart;
this.transformDataEnd = transformDataEnd;
}
public async undo(): Promise<void> {
this.component.update(
this.transformDataStart.object_uuid,
this.transformDataStart.position,
this.transformDataStart.rotation,
this.transformDataStart.scale,
);
}
public async redo(): Promise<void> {
this.component.update(
this.transformDataEnd.object_uuid,
this.transformDataEnd.position,
this.transformDataEnd.rotation,
this.transformDataEnd.scale,
);
}
}
export class UserDataSceneItem implements ICommand<string> {
private component: SceneManager;
private startUserData: UserData;
private endUserData: UserData;
public constructor(
sceneManager: SceneManager,
startUserData: UserData,
endUserData: UserData,
) {
this.component = sceneManager;
this.startUserData = startUserData;
this.endUserData = endUserData;
}
public async undo(): Promise<void> {
this.component.scene.setColor(
this.startUserData.object_uuid,
this.startUserData.color,
);
}
public async redo(): Promise<void> {
this.component.scene.setColor(
this.endUserData.object_uuid,
this.endUserData.color,
);
}
}
@@ -1,9 +1,11 @@
import { SceneGenereationMetaData } from "../../../models/sceneGenerationMetadata";
import { SceneStateJson } from "./scene_state_json";
import { TimelineStateJson } from "./timeline_state_json";
export interface EditorStateJson {
version: number;
sceneGenerationMetaData: SceneGenereationMetaData;
sceneStateJson: SceneStateJson;
timelineStateJson: TimelineStateJson;
snapshotTime: string; // make one via `new Date().toISOString()`
}
@@ -0,0 +1,9 @@
export interface TimelineStateJson {
version: number;
timelineItemsJson: string;
transformJson: string;
animationJson: string;
audioJson: string;
lipsyncJson: string;
emotionJson: string;
}
@@ -0,0 +1,66 @@
import { deepCopySerializableObjects as deepCopy } from "~/utilities";
import { ClipUI } from "../../clips/clip_ui";
import AudioEngine from "../Engines/audio_engine";
import EmotionEngine from "../Engines/emotion_engine";
import LipSyncEngine from "../Engines/lip_sync_engine";
import { TimeLine } from "../timeline";
import TransformEngine from "../Engines/transform_engine";
import { TimelineStateJson } from "./EditorStateJSON/timeline_state_json";
import { CharacterAnimationEngine } from "../Engines/CharacterAnimationEngine";
export class TimelineState {
version: number;
timeline_items: ClipUI[] | undefined;
transform_engine: TransformEngine | undefined;
animation_engine: CharacterAnimationEngine | undefined;
audio_engine: AudioEngine | undefined;
lipsync_engine: LipSyncEngine | undefined;
emotion_engine: EmotionEngine | undefined;
constructor({ editorVersion }: { editorVersion: number; }) {
this.version = editorVersion;
}
public async initializeFromTimeline(timeline: TimeLine): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.timeline_items = deepCopy(timeline.timeline_items);
this.transform_engine = deepCopy(timeline.transform_engine);
this.animation_engine = deepCopy(timeline.animation_engine);
this.audio_engine = deepCopy(timeline.audio_engine);
this.lipsync_engine = deepCopy(timeline.lipSync_engine);
this.emotion_engine = deepCopy(timeline.emotion_engine);
resolve();
} catch (e) {
reject(e);
}
});
}
public async initializeFromTimelineStateJson(
jsonObject: TimelineStateJson
): Promise<void> {
return new Promise((resolve, reject) => {
try {
console.log(jsonObject);
//TODO: parse json object back into each engine object
resolve();
} catch (e) {
reject(e);
}
});
}
public async toJSON() {
const result: TimelineStateJson = {
version: this.version,
timelineItemsJson: JSON.stringify(this.timeline_items),
transformJson: JSON.stringify(this.transform_engine),
animationJson: JSON.stringify(this.animation_engine),
audioJson: JSON.stringify(this.audio_engine),
lipsyncJson: JSON.stringify(this.lipsync_engine),
emotionJson: JSON.stringify(this.emotion_engine),
};
return result;
}
}
@@ -1,24 +1,33 @@
import { SceneGenereationMetaData } from "../../models/sceneGenerationMetadata";
import { EditorStateJson } from "./EditorStateJSON";
// import Scene from "../scene";
// import { TimeLine } from "../timeline";
import { SceneState } from "./scene_state";
import { TimelineState } from "./TimelineState";
export class EditorState {
version: number;
sceneGenerationMetaData: SceneGenereationMetaData | undefined;
sceneState: SceneState | undefined;
timelineState: TimelineState | undefined;
constructor({ editorVersion }: { editorVersion: number }) {
this.version = editorVersion;
}
public async toJSON() {
if (!this.sceneGenerationMetaData || !this.sceneState) {
if (
!this.sceneGenerationMetaData ||
!this.sceneState ||
!this.timelineState
) {
throw "Error in EditorState.toJSON: data undefined";
}
const result: EditorStateJson = {
version: this.version,
sceneGenerationMetaData: this.sceneGenerationMetaData,
sceneStateJson: await this.sceneState.toJSON(),
timelineStateJson: await this.timelineState.toJSON(),
snapshotTime: new Date().toISOString(),
};
return result;
@@ -1,7 +1,7 @@
// import * as THREE from "three";
import { Object3D } from "./object_3d";
import { Object3DJSON } from "./EditorStateJSON/object_3d_json";
import Scene from "../../engine/scene";
import Scene from "../../Editor/scene";
import { SceneStateJson } from "./EditorStateJSON/scene_state_json";
const DEFAULT_SCENE_VER = 1.0;
@@ -0,0 +1,308 @@
import * as THREE from "three";
import { Retarget } from "../retargeting";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { get_media_url } from "~/Classes/ApiHelpers";
import { MMDLoader } from "three/examples/jsm/loaders/MMDLoader.js";
import { ClipUI } from "../../clips/clip_ui";
import Ijson from "~/interfaces/Ijson";
export const START_FRAME_LABEL = "CharacterStartFrameLabel";
export class CharacterAnimationEngine implements Ijson {
version: number;
characterAnimations: Map<THREE.Object3D<THREE.Object3DEventMap>, ClipUI[]> = new Map();
characterMixers = new Map<THREE.Object3D<THREE.Object3DEventMap>, THREE.AnimationMixer>();
constructor(version: number) {
this.version = version;
}
toJSON(): unknown {
// Return just an array of character object IDs
const characterIds = Array.from(this.characterAnimations.keys()).map((character) => character.uuid);
return { characters: characterIds };
}
/**
* A single character model is capable of tracking multiple animations, in its own track
* These tracks can be blended with weights or additively
* The weight change can be done via custom interpolation logic
*
* @param objectUUID The UUID of the object.
* @param clip The 3JS animation clip
* @param clipUI The clip info
*/
addCharacterAnimation(character: THREE.Object3D, clip: THREE.AnimationClip, clipUI: ClipUI) {
// If character doesn't exist, create a clip object
// If it does exist, add a new animation to the existing character by extracting it
// TODO: FIXME: Duplicate prevention?
clip.name = clipUI.media_id;
character.animations.push(clip);
console.log("Adding animation track:")
console.log(clip);
if (!this.characterAnimations.has(character)) {
this.characterAnimations.set(character, []);
this.#ensureMixerExists(character);
}
// Add the clip info to the character map and sort the clips
const characterClips = this.characterAnimations.get(character)!;
characterClips.push(clipUI);
characterClips.sort((a, b) => a.offset - b.offset);
}
/**
* @param objectUUID The UUID of the object.
* @param mediaId The media ID of the animation.
* @param clipUI The clip info
*/
async addCharacterAnimationMedia(character: THREE.Object3D, mediaId: string, clipUI: ClipUI) {
// If character doesn't exist, create a clip object
// If it does exist, add a new animation to the existing character by extracting it
// TODO: FIXME: Duplicate prevention?
const animationTrack = await this.#load_animation(character, mediaId);
this.addCharacterAnimation(character, animationTrack, clipUI);
}
#ensureMixerExists(character: THREE.Object3D) {
if (!this.characterMixers.has(character)) {
this.characterMixers.set(character, new THREE.AnimationMixer(character));
}
}
getMixer(character: THREE.Object3D) {
this.#ensureMixerExists(character);
return this.characterMixers.get(character)!;
}
#interpolateClips(character: THREE.Object3D, timestamp: number, maxTime: number) {
const clips = this.characterAnimations.get(character);
const mixer = this.getMixer(character);
// Find the two clips we're interpolating between
let prevClip: ClipUI | null = null;
let nextClip: ClipUI | null = null;
const firstClip = clips ? clips[0] : null;
const lastClip = clips ? clips[clips.length - 1] : null;
// If we're before the first clip, interpolate it in from base pose
// TODO: Replace with starting frame
if (firstClip && firstClip.offset > timestamp) {
prevClip = null;
nextClip = firstClip;
} else if (lastClip && (lastClip.offset + lastClip.length) < timestamp) { // After last clip, interpolate it out
prevClip = lastClip;
nextClip = null;
} else if (clips) {
let prevClipIndex = 0;
// Loop until the next clip is after the timestamp
while (prevClipIndex < clips.length - 1 && clips[prevClipIndex + 1].offset < timestamp) {
prevClipIndex++;
}
prevClip = clips[prevClipIndex];
nextClip = clips[prevClipIndex + 1];
}
// If the clips exist, fetch their actions. If not, evaluation is either at the start or the end.
// If evalution is on either sides, check the start/end frame tracks
// Otherwise just mark it null
const prevAnimationTrack = prevClip ?
this.getCharacterAnimationTrack(character, prevClip.media_id) :
this.getCharacterAnimationTrack(character, START_FRAME_LABEL);
// TODO: Replace with ending frame
const nextAnimationTrack = nextClip ? this.getCharacterAnimationTrack(character, nextClip.media_id) : null;
const prevAction = prevAnimationTrack ? mixer.clipAction(prevAnimationTrack) : null;
const nextAction = nextAnimationTrack ? mixer.clipAction(nextAnimationTrack) : null;
// Calculate the progress of timestamp from end of prev action to start of next action
const left = (prevClip?.offset ?? 0) + (prevClip?.length ?? 0);
const right = nextClip?.offset ?? maxTime;
// Simple Linear interpolation for now
// TODO: Take an interpolation dependency, or better yet, write a transition engine
const progress = (timestamp - left) / (right - left);
prevAction?.setEffectiveWeight(1 - progress);
nextAction?.setEffectiveWeight(progress);
// Make sure we hold that last frame for the previous action
if (prevAction) {
prevAction.clampWhenFinished = true;
}
// The next action should stay at the first frame
if (nextAction) {
nextAction.paused = true;
}
// Necessary to ensure the actions are active - the default is inactive, mixer won't do anything
prevAction?.play();
nextAction?.play();
// The clip time would still be relative to the previous clip
const clipTime = timestamp - (prevClip?.offset ?? 0);
mixer.setTime(clipTime / 1000);
}
evaluateCharacter(character: THREE.Object3D, timestamp: number, maxTime: number) {
const mixer = this.getMixer(character);
const clips = this.characterAnimations.get(character);
// Find the clip we're in right now
const currentClip = clips?.find((clip) => {
return clip.offset <= timestamp && clip.offset + clip.length >= timestamp;
});
// If timestamp not in any clip, do nothing.
// If timestamp in clip, set mixer to the timestamp inside the clip
if (!currentClip) {
// Let the interpolation function handle this actions
this.#interpolateClips(character, timestamp, maxTime)
return;
}
const clipTime = timestamp - currentClip.offset;
const animationTrack = this.getCharacterAnimationTrack(character, currentClip.media_id);
// NOTE: This shouldn't really happen unless the UI was desynced at some point from the engine
if (!animationTrack) {
return;
}
// Play only this action
const animationAction = mixer.clipAction(animationTrack);
// Since it's the only clip in this timestamp, make it full weight and make sure it's not paused (from interpolation or otherwise)
animationAction.setEffectiveWeight(1);
animationAction.paused = false;
// Necessary to ensure the actions are active - the default is inactive, mixer won't do anything
animationAction.play();
mixer.setTime(clipTime / 1000);
}
getCharacterAnimationTrack(character: THREE.Object3D, mediaId: string) {
return character.animations.find((clip) => clip.name === mediaId);
}
/** Evaluate all character animations at a given timestamp (milliseconds) */
evaluate(timestamp: number, maxTime: number) {
this.characterMixers.forEach((_, character) => {
this.evaluateCharacter(character, timestamp, maxTime);
})
}
stopCharacter(character: THREE.Object3D) {
const mixer = this.characterMixers.get(character);
console.debug("Stopping all actions for character", character);
console.debug(mixer);
mixer?.stopAllAction();
console.debug(mixer);
}
stop() {
this.characterMixers.forEach((mixer) => {
mixer.stopAllAction();
});
}
async #load_animation(character: THREE.Object3D, mediaId: string): Promise<THREE.AnimationClip> {
// Get the file URL and extract the (first) animation track from it
// TODO: Support for multiple animations in a single file?
const url = await get_media_url(mediaId);
return await new Promise((resolve) => {
if (url.includes(".glb")) {
const glbLoader = new GLTFLoader();
glbLoader.load(url, (gltf) => {
const animationClip = gltf.animations[0];
resolve(animationClip);
});
} else if (url.includes(".fbx")) {
const fbxLoader = new FBXLoader();
fbxLoader.load(url, (fbx) => {
const animationClip_1 = fbx.animations[0];
animationClip_1.tracks.forEach((track) => {
const retarget = new Retarget();
const retarget_value = retarget.retarget(track.name);
track.name = retarget_value.bone;
console.log(track);
if (retarget_value.is_special) {
// TODO: Revisit special properties later
// this.special_properties.push(retarget_value);
}
});
resolve(animationClip_1);
});
} else if (url.includes(".vmd")) {
const mmdLoader = new MMDLoader();
mmdLoader.loadAnimation(url, character as THREE.SkinnedMesh, (mmd) => {
mmd.name = url;
resolve(mmd as THREE.AnimationClip);
});
}
});
}
removeCharacter(character: THREE.Object3D) {
console.debug("Removing character from animation engine", character);
this.characterAnimations.delete(character);
this.characterMixers.delete(character);
}
removeAnimation(character: THREE.Object3D, clip: ClipUI) {
console.debug("Removing animation from character", character, clip);
const clips = this.characterAnimations.get(character)!;
const clipIndex = clips.findIndex((c) => c.clip_uuid === clip.clip_uuid);
if (clipIndex < 0) {
return;
}
// Remove the clip from character's tracked clips
clips.splice(clipIndex, 1);
// Stop all character animations to cleanly reset
this.stopCharacter(character);
}
clearStartFrame(character: THREE.Object3D, resetTime?: number, maxTime = 1) {
// Stop all character animations
this.stopCharacter(character);
// Check the animations and remove any labelled START_FRAME_LABEL
character.animations = character.animations.filter((clip) => clip.name !== START_FRAME_LABEL);
// If resetTime is passed, evaluate the character to that time
if (resetTime) {
this.evaluateCharacter(character, resetTime, maxTime);
}
}
/** resetTime: The time to reset the character to. This should ideally be the current timeline time. */
async createStartFrameAnimation(character: THREE.Object3D, poseHelper: any, url: string, resetTime: number = 0, maxTime: number = 1) {
//// Clear any existing start frame
//// This will also reset the animation to default pose
//this.clearStartFrame(character);
//// We must create the track data now after resetting to default
//const poseData = await poseHelper.extractPoseData(url);
//const tracks = poseHelper.inflatePoseDataToTracks(character, poseData);
//// Create the animation track from the rotation values and hip position
//// then add it to the character
//const startFrameClip = new THREE.AnimationClip(START_FRAME_LABEL, 0, tracks);
//character.animations.push(startFrameClip);
//// Reset the character evaluation to what was passed in
//this.evaluateCharacter(character, resetTime, maxTime);
}
}
@@ -0,0 +1,48 @@
// A mapping between the model bones names and the Mediapipe output index
export const MixamoPoseMap: Record<string, number> = {
"mixamorigLeftArm": 11,
"mixamorigRightArm": 12,
"mixamorigLeftForeArm": 13,
"mixamorigRightForeArm": 14,
"mixamorigLeftHand": 15,
"mixamorigRightHand": 16,
"mixamorigLeftUpLeg": 23,
"mixamorigRightUpLeg": 24,
"mixamorigLeftLeg": 25,
"mixamorigRightLeg": 26,
"mixamorigLeftFoot": 27,
"mixamorigRightFoot": 28,
"mixamorigLeftHandThumb2": 21,
"mixamorigRightHandThumb2": 22,
}
export const MixamoInterpolationBoneNames = {
Spine: "mixamorigSpine",
Spine1: "mixamorigSpine1",
Spine2: "mixamorigSpine2",
Neck: "mixamorigNeck",
LeftShoulderBlade: "mixamorigLeftShoulder",
DefLeftUpperArm: "DEF-upper_armL001",
DefLeftForeArm: "DEF-forearmL001",
RightShoulderBlade: "mixamorigRightShoulder",
DefRightUpperArm: "DEF-upper_armR001",
DefRightForeArm: "DEF-forearmR001",
DefLeftThigh: "DEF-thighL001",
DefRightThigh: "DEF-thighR001",
}
export const PoseMap = {
NOSE: 0,
LEFT_SHOULDER: 11,
RIGHT_SHOULDER: 12,
LEFT_ELBOW: 13,
RIGHT_ELBOW: 14,
LEFT_WRIST: 15,
RIGHT_WRIST: 16,
LEFT_HIP: 23,
RIGHT_HIP: 24,
LEFT_KNEE: 25,
RIGHT_KNEE: 26,
LEFT_ANKLE: 27,
RIGHT_ANKLE: 28,
}
@@ -0,0 +1,27 @@
import { AnimationClip } from "../../clips/animation_clip";
export class AnimationEngine {
clips: { [key: string]: AnimationClip } = {};
version: number;
constructor(version: number) {
this.clips = {};
this.version = version;
}
load_object(object_uuid: string, media_id: string, clip_name: string) {
console.debug("load_object", object_uuid, media_id, clip_name);
this.clips[object_uuid + media_id] = new AnimationClip(
this.version,
media_id,
"glb",
object_uuid,
1.0,
1.0,
clip_name,
0.0,
0.0,
);
//this.clips[object_uuid + media_id]._load_animation();
}
}
@@ -0,0 +1,81 @@
import { AudioClip } from "../../clips/audio_clip";
class AudioEngine {
clips: { [key: string]: AudioClip } = {};
audio_sources: { [key: string]: AudioBufferSourceNode } = {};
version: number;
playing: string[];
last_frame: number;
constructor() {
this.clips = {};
this.playing = [];
this.audio_sources = {};
this.version = 1.0;
this.last_frame = 0;
}
// loads clips into the engine to cache
loadClip(audio_media_id: string) {
if (this.clips[audio_media_id] != null) {
return;
}
this.clips[audio_media_id] = new AudioClip(
this.version,
audio_media_id,
1.0,
);
}
// plays from the timeline.
playClip(audio_media_id: string) {
if (this.clips[audio_media_id] == null) {
this.loadClip(audio_media_id);
}
}
async step(audio_media_id: string, frame: number, offset: number) {
const frameBuffer = 12;
const pos = frame - offset;
let doPlay = true;
if (
Math.abs(this.last_frame - frame) < frameBuffer - 1 &&
this.last_frame !== 0
) {
doPlay = false;
}
if (pos <= 1) {
doPlay = true;
}
const clip = this.clips[audio_media_id];
if (doPlay && clip.audio_data?.audioContext) {
const startTime = pos / 60.0;
const endTime = frameBuffer / 60.0;
clip.audio_data.source =
clip.audio_data.audioContext.createBufferSource();
clip.audio_data.source.buffer = clip.audio_data.audioBuffer;
clip.audio_data.source.connect(clip.audio_data.audioContext.destination);
clip.audio_data.source.start(0, startTime, endTime);
this.last_frame = frame;
}
}
// stops the clips
stopClip(audio_media_id: string) {
const clip = this.clips[audio_media_id];
if (clip.audio_data?.source) {
clip.audio_data.source.stop();
clip.audio_data.source.disconnect();
} else {
console.warn(
`AudioManager: AudioClip with id "${audio_media_id}" not found.`,
);
}
}
}
export default AudioEngine;
@@ -0,0 +1,24 @@
import { EmotionClip } from "../../clips/emotion_clip";
class EmotionEngine {
clips: { [key: string]: EmotionClip } = {};
version: number;
constructor(version: number) {
this.clips = {};
this.version = version;
}
// loads clips into the engine to cache
loadClip(object_uuid: string, csv_media_id: string) {
if (this.clips[object_uuid + csv_media_id] != null) {
return;
}
this.clips[object_uuid + csv_media_id] = new EmotionClip(
this.version,
csv_media_id,
);
}
}
export default EmotionEngine;
@@ -0,0 +1,22 @@
import { LipSyncClip } from "../../clips/lipsync_clip";
// needs to be apart of the editor window.
export default class LipSyncEngine {
clips: { [key: string]: LipSyncClip } = {};
audio_sources: { [key: string]: AudioBufferSourceNode } = {};
version: number;
constructor() {
this.clips = {};
this.audio_sources = {};
this.version = 1.0;
}
load_object(object_uuid: string, audio_media_id: string) {
this.clips[object_uuid + audio_media_id] = new LipSyncClip(
this.version,
audio_media_id,
1.0,
);
}
}
@@ -0,0 +1,58 @@
import { TransformClip } from "../../clips/transform_clip";
import * as THREE from "three";
class TransformEngine {
clips: { [key: string]: TransformClip } = {};
version: number;
constructor(version: number) {
this.version = version;
this.clips = {};
}
loadObject(object_uuid: string, clip_length: number = 2) {
this.clips[object_uuid] = new TransformClip(
this.version,
object_uuid,
clip_length,
object_uuid,
); // replace the last object_uuid with the media ID when its ready.
return this.clips[object_uuid];
}
addFrame(
object_uuid: string,
clip_length: number = 2,
position: THREE.Vector3,
rotation: THREE.Vector3,
scale: THREE.Vector3,
offset: number,
keyframe_uuid: string,
): boolean {
if (this.clips[object_uuid] == null) {
this.clips[object_uuid] = new TransformClip(
this.version,
object_uuid,
clip_length,
);
this.clips[object_uuid].add_frame(
position,
rotation,
scale,
offset,
keyframe_uuid,
);
return false;
}
this.clips[object_uuid].add_frame(
position,
rotation,
scale,
offset,
keyframe_uuid,
);
return true;
}
}
export default TransformEngine;
@@ -91,8 +91,6 @@ export class FKHelper {
}
onMouseClick(mouse: THREE.Vector2) {
// @ts-expect-error — TransformControls.camera is set by the local copy
// under engine/TransformControls.js but isn't declared in three's types.
this.raycaster.setFromCamera(mouse, this.transformControls.camera);
const intersections = this.raycaster.intersectObjects(this.targetBoneSpheres, false);
@@ -0,0 +1,32 @@
import { getArtStyle } from "~/enums/ArtStyle";
import {
cameraAspectRatio,
cinematic,
enginePreProcessing,
faceDetail,
globalIPAMediaToken,
lipSync,
styleStrength,
upscale,
} from "~/pages/PageEnigma/signals";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import Editor from "./editor";
export const getSceneGenerationMetaData = (
editorEngine: Editor,
): SceneGenerationMetaData => {
// when this is called, editor engine is guarunteed by it's caller
return {
positivePrompt: editorEngine.positive_prompt,
negativePrompt: editorEngine.negative_prompt,
artisticStyle: getArtStyle(editorEngine.art_style.toString()),
cameraAspectRatio: cameraAspectRatio.value,
globalIPAMediaToken: globalIPAMediaToken.value || undefined,
upscale: upscale.value,
faceDetail: faceDetail.value,
styleStrength: styleStrength.value,
lipSync: lipSync.value,
cinematic: cinematic.value,
enginePreProcessing: enginePreProcessing.value,
};
};
@@ -0,0 +1,64 @@
import { v4 as uuidv4 } from "uuid";
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
import { StorytellerApiHostStore } from "@storyteller/api";
export const uploadNewScene = async (file: File, sceneTitle: string) => {
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const endpoint = `${apiSchemeAndHost}/v1/media_files/upload/new_scene`;
const formData = new FormData();
formData.append("uuid_idempotency_token", uuidv4());
formData.append("file", file);
formData.append("maybe_title", sceneTitle);
formData.append("maybe_visibility", "public");
formData.append("engine_category", "scene");
return await fetch(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
},
credentials: "include",
body: formData,
})
.then((res) => res.json())
.then((res) => {
if (res && res.success) {
return res;
} else {
return { success: false };
}
})
.catch(() => {
return { success: false };
});
};
export const updateExistingScene = async (file: File, sceneToken: string) => {
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const endpoint = `${apiSchemeAndHost}/v1/media_files/upload/saved_scene/${sceneToken}`;
const formData = new FormData();
formData.append("uuid_idempotency_token", uuidv4());
formData.append("file", file);
return await fetch(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
},
credentials: "include",
body: formData,
})
.then((res) => res.json())
.then((res) => {
if (res && res.success) {
return res;
} else {
return { success: false };
}
})
.catch(() => {
return { success: false };
});
};
@@ -0,0 +1,460 @@
import { v4 as uuidv4 } from "uuid";
import * as THREE from "three";
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
import { signalScene, startPollingActiveJobs } from "~/signals";
import { updateExistingScene, uploadNewScene } from "./api_fetchers";
import environmentVariables from "~/Classes/EnvironmentVariables";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import { StorytellerApiHostStore } from "@storyteller/api";
interface StylizeVideoParams {
media_token: string;
style: ArtStyle;
positive_prompt: string;
negative_prompt: string;
visibility: Visibility;
use_face_detailer?: boolean;
use_upscaler?: boolean;
use_strength?: number;
use_lipsync?: boolean;
use_cinematic?: boolean;
use_global_ipa_media_token?: string | null;
input_depth_file?: string | null;
input_normal_file?: string | null;
input_outline_file?: string | null;
}
/**
* Storyteller Studio API Manager
* The source of truth of all these media items is the database in the cloud
*/
export class MediaFile {
public_bucket_path: string;
media_type: string;
media_token: string;
constructor(
public_bucket_path: string,
media_type: string,
media_token: string,
) {
this.media_token = media_token;
this.public_bucket_path = public_bucket_path;
this.media_type = media_type;
}
}
export enum ArtStyle {
Anime2_5D = "anime_2_5d",
Anime2DFlat = "anime_2d_flat",
Cartoon3D = "cartoon_3d",
ComicBook = "comic_book",
AnimeGhibli = "anime_ghibli",
InkPunk = "ink_punk",
InkSplash = "ink_splash",
InkBWStyle = "ink_bw_style",
JojoStyle = "jojo_style",
PaperOrigami = "paper_origami",
PixelArt = "pixel_art",
PopArt = "pop_art",
Realistic1 = "realistic_1",
Realistic2 = "realistic_2",
AnimeRetroNeon = "anime_retro_neon",
AnimeStandard = "anime_standard",
HRGiger = "hr_giger",
Simpsons = "simpsons",
Carnage = "carnage",
PastelCuteAnime = "pastel_cute_anime",
Bloom = "bloom_lighting",
Horror2_5D = "25d_horror",
Creepy = "creepy",
CreepyVHS = "creepy_vhs",
TrailCamFootage = "trail_cam_footage",
OldBWMovie = "old_black_white_movie",
HorrorNoirBW = "horror_noir_black_white",
TechnoNoirBW = "techno_noir_black_white",
BW20s = "black_white_20s",
AnimeCyberpunk = "cyberpunk_anime",
Dragonball = "dragonball",
RealisticMatrix = "realistic_matrix",
RealisticCyberpunk = "realistic_cyberpunk",
}
export enum Visibility {
Public = "public",
Hidden = "hidden",
Private = "private",
}
/**
* This is designed to surface user customer facing messages as errors.
*/
// type Data = { [key: string]: any };
// class APIManagerResponseSuccess {
// public user_message: string;
// public data: Data | null;
// constructor(user_message: string = "", data: Data | null = null) {
// this.data = data;
// this.user_message = user_message;
// }
// }
/**
* This is designed to surface user customer facing messages as errors.
* Errors shouldn't be 404 or something confusing should be
*/
class APIManagerResponseError extends Error {
constructor(message?: string) {
super(message);
this.name = "APIManagerResponseError";
}
}
export class APIManager {
constructor() {
// NB(bt,2025-07-06): Cleaning this up. The code was duplicated a half dozen times.
// I think all of the code in this file is legacy, but to be safe I'm continuing to update it.
}
protected getApiSchemeAndHost() {
return StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
}
/**
* @param scene The 3JS Scene we want to save.
* @param scene_name The Scene name we want to display
* @param scene_media_file_token If null we will we will create a new save or copy the scene, if provided we will overwrite the scene.
* @returns APIManagerResponseMessage
*/
public async saveSceneState({
saveJson,
sceneTitle,
sceneToken,
sceneThumbnail,
}: {
saveJson: string;
sceneTitle: string;
sceneToken?: string;
sceneThumbnail: Blob | undefined;
}): Promise<string> {
const file = new File([saveJson], `${sceneTitle}.glb`, {
type: "application/json",
});
const uploadSceneResponse = sceneToken
? await updateExistingScene(file, sceneToken)
: await uploadNewScene(file, sceneTitle);
// failed so catch this.
if (uploadSceneResponse["success"] == false) {
return "";
}
if (sceneThumbnail) {
const image_resp = await this.uploadMediaSceneThumbnail(
sceneThumbnail,
"render.png",
);
if (image_resp["success"] == false) {
return "";
}
if (image_resp["media_file_token"]) {
const image_token = image_resp["media_file_token"];
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/cover_image/${uploadSceneResponse["media_file_token"]}`;
await fetch(endpoint, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ cover_image_media_file_token: image_token }),
});
}
}
console.log(uploadSceneResponse);
return uploadSceneResponse["media_file_token"];
}
public async loadSceneState(
scene_media_file_token: string | null,
): Promise<any> {
const url = `${this.getApiSchemeAndHost()}/v1/media_files/file/${scene_media_file_token}`;
const response = await fetch(url);
if (response.status > 200) {
throw new APIManagerResponseError("Failed to load scene");
}
const json = await response.json();
if (json && json.media_file) {
if (json.media_file.maybe_title === null) {
console.warn(`Scene /w Token: ${scene_media_file_token} has no title`);
}
signalScene({
title: json.media_file.maybe_title || "Untitled Scene",
token: scene_media_file_token || undefined,
ownerToken: json.media_file.maybe_creator_user.user_token,
isModified: false,
});
}
const bucket_path = json["media_file"]["public_bucket_path"];
const media_base_url = GetCdnOrigin();
//const media_url = `${media_base_url}/vocodes-public${bucket_path}`; // gets you a bucket path
const media_url = `${media_base_url}${bucket_path}`; // gets you a bucket path
const file_response = await fetch(media_url);
if (!file_response.ok) {
throw new APIManagerResponseError("Failed to download file");
}
// Convert the response from a blob to json text
const blob = await file_response.blob();
const json_result: string = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(JSON.parse(reader.result as string));
reader.onerror = reject;
reader.readAsText(blob);
});
return json_result;
}
/**
* This recieves the bucket path of a media file
* @param media_file_token
* @returns
*/
public async getMediaFile(media_file_token: string): Promise<string> {
const url = `${this.getApiSchemeAndHost()}/v1/media_files/file/${media_file_token}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_base_url = GetCdnOrigin();
const media_url = `${media_base_url}${bucketPath}`; // gets you a bucket path
return media_url;
}
/**
This will save the scene to keep ids positions.
It will also give the file a name which will be a uuidv4()
@param scene The 3JS Scene we want to make a file to be uploaded as multipart form.
*/
private async gltfExport(scene: THREE.Scene): Promise<File> {
const gltfExporter = new GLTFExporter();
const uuid = uuidv4();
const result = await gltfExporter.parseAsync(scene);
const file = new File([JSON.stringify(result)], `${uuid}.glb`, {
type: "application/json",
});
return file;
}
public async uploadMediaSceneThumbnail(blob: Blob | File, fileName: string) {
const url = `${this.getApiSchemeAndHost()}/v1/media_files/upload/image`;
const uuid = uuidv4();
const formData = new FormData();
formData.append("uuid_idempotency_token", uuid);
formData.append("is_intermediate_system_file", "true");
formData.append("maybe_title", "Screenshot");
formData.append("file", blob, fileName);
//formData.append("source", "file");
//formData.append("type", "video");
//formData.append("source", "file");
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
throw new APIManagerResponseError("Upload Media Failed to send file");
} else {
const json_data = await response.json();
//console.log(`uploadMedia: ${JSON.stringify(json_data)}`);
return json_data;
}
}
public async uploadMedia({
blob,
fileName,
title,
styleName,
maybe_scene_source_media_file_token,
}: {
blob: Blob;
fileName: string;
title: string;
styleName?: string;
maybe_scene_source_media_file_token: string | undefined;
}) {
// Promise<APIManagerResponseSuccess>
//TODO: UPDATE ENDPOINT!!!!
const url = `${this.getApiSchemeAndHost()}/v1/media_files/upload/new_video`;
const uuid = uuidv4();
const formData = new FormData();
formData.append("uuid_idempotency_token", uuid);
formData.append("file", blob, fileName);
// formData.append("source", "file");
// formData.append("type", "video");
if (styleName) formData.append("maybe_style_name", styleName);
formData.append("maybe_title", title);
// This signals to the backend to hide the video from view
formData.append("is_intermediate_system_file", "true");
if (maybe_scene_source_media_file_token !== undefined) {
formData.append(
"maybe_scene_source_media_file_token",
maybe_scene_source_media_file_token,
);
}
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
throw new APIManagerResponseError("Upload Media Failed to send file");
} else {
const json_data = await response.json();
//console.log(`uploadMedia: ${JSON.stringify(json_data)}`);
return json_data;
}
}
public async uploadMediaFrameGeneration(
blob: Blob | File,
fileName: string,
style: string = "comic_book",
positive_prompt: string,
negative_prompt: string,
): Promise<string> {
const url = `${environmentVariables.values.FUNNEL_API}/preview/`;
const payload = {
style: style,
positive_prompt: positive_prompt,
negative_prompt: negative_prompt,
};
const formData = new FormData();
formData.append("input_file", blob, fileName);
formData.append("request", JSON.stringify(payload));
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
body: formData,
});
if (!response.ok) {
throw new APIManagerResponseError("Upload Media Failed to send file");
} else {
return URL.createObjectURL(await response.blob());
}
}
public async getMediaBatch(media_tokens: string[]): Promise<MediaFile[]> {
const tokens = media_tokens;
const url = new URL(`${this.getApiSchemeAndHost()}/v1/media_files/batch`);
tokens.forEach((token) => url.searchParams.append("tokens", token));
const result = await fetch(url)
.then((response) => response.json())
.then((data) => {
const result = data["media_files"].map((element: any) => {
return new MediaFile(
element["public_bucket_path"],
element["media_type"],
element["token"],
);
});
return result;
})
.catch((error) => console.error("Error:", error));
return result;
}
public async stylizeVideo({
media_token,
style,
positive_prompt,
negative_prompt,
visibility,
use_face_detailer = false,
use_upscaler = false,
use_strength = 1.0,
use_lipsync = false,
use_cinematic = false,
use_global_ipa_media_token = null,
input_depth_file = null,
input_normal_file = null,
input_outline_file = null,
}: StylizeVideoParams) {
const uuid = uuidv4();
const data = {
uuid_idempotency_token: uuid,
style: style,
input_file: media_token,
prompt: positive_prompt,
negative_prompt: negative_prompt,
trim_start_millis: 0,
trim_end_millis: 7000,
enable_lipsync: use_lipsync,
creator_set_visibility: visibility,
use_face_detailer: use_face_detailer,
use_upscaler: use_upscaler,
use_strength: use_strength,
use_lipsync: use_lipsync,
global_ipa_media_token: use_global_ipa_media_token,
use_cinematic: use_cinematic,
input_depth_file: input_depth_file,
input_normal_file: input_normal_file,
input_outline_file: input_outline_file,
};
const json_data = JSON.stringify(data);
const response = await fetch(
`${this.getApiSchemeAndHost()}/v1/workflows/enqueue_studio`,
{
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: json_data,
},
);
startPollingActiveJobs();
if (!response.ok) {
// Handle HTTP error responses
const errorData = await response.json();
throw new Error(`API Error: ${response.status} ${errorData.message}`);
}
// Assuming the response is JSON and matches the EnqueueVideoStyleTransferSuccessResponse interface
return await response.json();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,445 @@
import {
EventDispatcher,
Quaternion,
Vector3,
Vector2,
MathUtils,
} from "three";
import * as THREE from "three";
import { cameras, selectedCameraId } from "~/pages/PageEnigma/signals/camera";
import { isPromptBoxFocused } from "~/pages/PageEnigma/signals/promptBox";
export const isSafariOrWebKit = () => {
const ua = navigator.userAgent;
const isWebKit = /AppleWebKit/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS|FxiOS/i.test(ua);
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua);
return isSafari || isWebKit;
};
export const hasApplePay = () => {
// @ts-expect-error Apple Pay is not defined in TypeScript
return !!window.ApplePaySession;
}
export const isSafari = () => {
return isSafariOrWebKit() || hasApplePay();
}
export const isPointerLockSupported = () => {
return !isSafari();
}
class FreeCam extends EventDispatcher {
object: THREE.PerspectiveCamera;
domElement: HTMLElement | Document;
enabled = true;
movementSpeed = 1.0;
rollSpeed = 0.005;
dragToLook = false;
autoForward = false;
dragging = false;
xOffset = 0;
yOffset = 0;
// Smoothing properties
smoothingFactor = 0.2;
currentVelocity = new Vector3();
lastPosition = new Vector3();
lastMousePosition = new Vector2();
mouseVelocity = new Vector2();
tmpQuaternion = new Quaternion();
status = 0;
moveState = {
up: 0,
down: 0,
left: 0,
right: 0,
forward: 0,
back: 0,
pitchUp: 0,
pitchDown: 0,
yawLeft: 0,
yawRight: 0,
rollLeft: 0,
rollRight: 0,
};
moveVector = new Vector3(0, 0, 0);
rotationVector = new Vector3(0, 0, 0);
constructor(
object: THREE.PerspectiveCamera,
domElement: HTMLElement | Document,
) {
super();
this.object = object;
this.domElement = domElement;
this.lastPosition.copy(object.position);
this.keydown = this.keydown.bind(this);
this.keyup = this.keyup.bind(this);
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mousewheel = this.mousewheel.bind(this);
this.reset = this.reset.bind(this);
this.update = this.update.bind(this);
this.updateMovementVector = this.updateMovementVector.bind(this);
this.updateRotationVector = this.updateRotationVector.bind(this);
this.getContainerDimensions = this.getContainerDimensions.bind(this);
this.dispose = this.dispose.bind(this);
this.domElement.addEventListener("contextmenu", function (event) {
event.preventDefault();
});
this.updateMovementVector();
this.updateRotationVector();
}
attachEventListeners() {
window.addEventListener("keydown", this.keydown);
window.addEventListener("keyup", this.keyup);
window.addEventListener("mousedown", this.mousedown);
window.addEventListener("mouseup", this.mouseup);
window.addEventListener("mousemove", this.mousemove);
window.addEventListener("wheel", this.mousewheel);
}
detachEventListeners() {
window.removeEventListener("keydown", this.keydown);
window.removeEventListener("keyup", this.keyup);
window.removeEventListener("mousedown", this.mousedown);
window.removeEventListener("mouseup", this.mouseup);
window.removeEventListener("mousemove", this.mousemove);
window.removeEventListener("wheel", this.mousewheel);
}
keydown(event: KeyboardEvent) {
if (!this.enabled || isPromptBoxFocused.value) {
return;
}
switch (event.code) {
case "KeyW":
this.moveState.forward = 1;
break;
case "KeyS":
this.moveState.back = 1;
break;
case "KeyA":
this.moveState.left = 1;
break;
case "KeyD":
this.moveState.right = 1;
break;
case "KeyQ":
this.moveState.down = 1;
break;
case "KeyE":
this.moveState.up = 1;
break;
case "ArrowUp":
this.moveState.pitchUp = 1;
break;
case "ArrowDown":
this.moveState.pitchDown = 1;
break;
case "ArrowLeft":
this.moveState.yawLeft = 1;
break;
case "ArrowRight":
this.moveState.yawRight = 1;
break;
}
this.updateMovementVector();
this.updateRotationVector();
}
keyup(event: KeyboardEvent) {
if (!this.enabled || isPromptBoxFocused.value) {
return;
}
switch (event.code) {
case "KeyW":
this.moveState.forward = 0;
break;
case "KeyS":
this.moveState.back = 0;
break;
case "KeyA":
this.moveState.left = 0;
break;
case "KeyD":
this.moveState.right = 0;
break;
case "ArrowUp":
this.moveState.pitchUp = 0;
break;
case "ArrowDown":
this.moveState.pitchDown = 0;
break;
case "ArrowLeft":
this.moveState.yawLeft = 0;
break;
case "ArrowRight":
this.moveState.yawRight = 0;
break;
case "KeyQ":
this.moveState.down = 0;
break;
case "KeyE":
this.moveState.up = 0;
break;
}
this.updateMovementVector();
this.updateRotationVector();
}
mousedown(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (event.button === 2) {
this.dragging = true;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
}
mouseup(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (event.button === 2) {
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
this.dragging = false;
}
mousemove(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (this.dragging) {
const mouseX = event.clientX - this.xOffset;
const mouseY = event.clientY - this.yOffset;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
if (Math.abs(mouseX + mouseY) > 0) {
// Store last position before movement
this.lastPosition.copy(this.object.position);
// Apply movement with smoothing
const translationX = -mouseX * (this.movementSpeed * 0.01);
const translationY = mouseY * (this.movementSpeed * 0.01);
this.currentVelocity.x = MathUtils.lerp(
this.currentVelocity.x,
translationX,
this.smoothingFactor,
);
this.currentVelocity.y = MathUtils.lerp(
this.currentVelocity.y,
translationY,
this.smoothingFactor,
);
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
}
}
}
mousewheel(event: WheelEvent) {
if (!this.enabled) {
return;
}
// Store last position before zoom
this.lastPosition.copy(this.object.position);
const zoomDelta = event.deltaY / 120;
this.currentVelocity.z = MathUtils.lerp(
this.currentVelocity.z,
zoomDelta,
this.smoothingFactor,
);
this.object.translateZ(this.currentVelocity.z);
}
resetVelocity() {
this.currentVelocity.set(0, 0, 0);
this.lastPosition.copy(this.object.position);
}
reset() {
this.moveState.forward = 0;
this.moveState.back = 0;
this.moveState.left = 0;
this.moveState.right = 0;
this.moveState.pitchUp = 0;
this.moveState.pitchDown = 0;
this.moveState.yawLeft = 0;
this.moveState.yawRight = 0;
this.moveState.down = 0;
this.moveState.up = 0;
this.resetVelocity();
this.updateMovementVector();
this.updateRotationVector();
}
isStationary() {
return (
this.moveVector.distanceTo(new Vector3(0, 0, 0)) === 0 &&
this.currentVelocity.distanceTo(new Vector3(0, 0, 0)) < 0.0001
);
}
isRotating() {
return (
this.moveState.pitchUp ||
this.moveState.pitchDown ||
this.moveState.yawLeft ||
this.moveState.yawRight
);
}
update(delta: number) {
if (!this.enabled) {
return;
}
// Skip update if there's no movement, velocity, or rotation
if (this.isStationary() && !this.isRotating()) {
return;
}
const moveMulti = delta * this.movementSpeed;
// Apply WASD movement with smoothing
const targetMovement = new Vector3(
this.moveVector.x * moveMulti,
this.moveVector.y * moveMulti,
this.moveVector.z * moveMulti,
);
// Smooth out the movement
this.currentVelocity.lerp(targetMovement, this.smoothingFactor);
// Apply the smoothed movement
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
this.object.translateZ(this.currentVelocity.z);
// Store last position
this.lastPosition.copy(this.object.position);
// Done with positions
// Now handle rotations
this.object.rotateX(this.rotationVector.x);
this.object.rotateY(this.rotationVector.y);
this.object.rotateZ(this.rotationVector.z);
// Update camera signals
if (selectedCameraId.value) {
const pos = this.object.position;
const rot = this.object.rotation;
// Calculate lookAt point based on camera's forward direction
const lookAtPoint = new Vector3(0, 0, -1);
lookAtPoint.applyQuaternion(this.object.quaternion);
lookAtPoint.add(pos);
cameras.value = cameras.value.map((cam) =>
cam.id === selectedCameraId.value
? {
...cam,
position: { x: pos.x, y: pos.y, z: pos.z },
rotation: { x: rot.x, y: rot.y, z: rot.z },
lookAt: { x: lookAtPoint.x, y: lookAtPoint.y, z: lookAtPoint.z },
}
: cam,
);
}
}
updateMovementVector() {
const forward =
this.moveState.forward || (this.autoForward && !this.moveState.back)
? 1
: 0;
this.moveVector.x = -this.moveState.left + this.moveState.right;
this.moveVector.y = -this.moveState.down + this.moveState.up;
this.moveVector.z = -forward + this.moveState.back;
}
updateRotationVector() {
this.rotationVector.x = -this.moveState.pitchDown + this.moveState.pitchUp;
this.rotationVector.y = -this.moveState.yawRight + this.moveState.yawLeft;
this.rotationVector.z = -this.moveState.rollRight + this.moveState.rollLeft;
this.rotationVector.multiplyScalar(this.rollSpeed);
}
getContainerDimensions() {
if (this.domElement !== document) {
return {
size: [
(this.domElement as HTMLElement).offsetWidth,
(this.domElement as HTMLElement).offsetHeight,
],
offset: [
(this.domElement as HTMLElement).offsetLeft,
(this.domElement as HTMLElement).offsetTop,
],
};
} else {
return {
size: [window.innerWidth, window.innerHeight],
offset: [0, 0],
};
}
}
dispose() {
// window.addEventListener('contextmenu', function(event) {
// event.preventDefault();
// });
// this.domElement.removeEventListener("contextmenu", this.contextmenu);
// this.domElement.removeEventListener("pointerdown", this.pointerdown);
// this.domElement.removeEventListener("pointermove", this.pointermove);
// this.domElement.removeEventListener("pointerup", this.pointerup);
// this.domElement.removeEventListener("pointercancel", this.pointercancel);
//
window.removeEventListener("keydown", this.keydown);
window.removeEventListener("keyup", this.keyup);
}
}
export { FreeCam };
@@ -0,0 +1,299 @@
import Scene from "./scene";
import Editor from "./editor";
import * as THREE from "three";
import { XYZ } from "../datastructures/common";
import { editorState } from "../signals/engine";
import { EditorStates } from "../enums";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { MediaItem } from "~/pages/PageEnigma/models";
import { AssetType } from "~/enums";
import { hideObjectPanel } from "../signals";
export class SceneUtils {
scene: Scene;
editor: Editor;
constructor(editor: Editor, scene: Scene) {
this.scene = scene;
this.editor = editor;
}
// If string is empty.
isEmpty(value: string): boolean {
return (
value == null || (typeof value === "string" && value.trim().length === 0)
);
}
// For react to see if an object has lipsync capability or not.
isObjectLipsync(object_uuid: string) {
const object = this.scene.get_object_by_uuid(object_uuid);
let hasLipsync = false;
if (object) {
object.traverse((c: THREE.Object3D) => {
if (c instanceof THREE.Mesh) {
if (c.morphTargetInfluences && c.morphTargetDictionary) {
const blendShapeIndexE = c.morphTargetDictionary["E"];
// console.log(c.morphTargetDictionary, blendShapeIndexE)
if (blendShapeIndexE !== null) {
hasLipsync = true;
}
}
}
});
}
return hasLipsync;
}
// Returns if the object is locked or unlocked.
isObjectLocked(object_uuid: string): boolean {
const object = this.scene.get_object_by_uuid(object_uuid);
if (object) {
if (object.userData["locked"] == undefined) {
object.userData["locked"] = false;
}
return object.userData["locked"];
}
//console.log("No object found.");
return false;
}
// Locks or unlocks and object and returns its new state,
lockUnlockObject(object_uuid: string): boolean {
const object = this.scene.get_object_by_uuid(object_uuid);
if (object) {
if (object.userData["locked"] == undefined) {
object.userData["locked"] = false;
}
object.userData["locked"] = !object.userData["locked"];
if (object.userData["locked"]) {
this.removeTransformControls(false);
} else if (this.editor.control) {
this.scene.scene.add(this.editor.control);
if (this.editor.sceneManager?.selected_objects)
this.editor.control.attach(
this.editor.sceneManager?.selected_objects[0],
);
}
return object.userData["locked"];
}
//console.log("No object found.");
return false;
}
// Removes transform controls and publishes selected.
removeTransformControls(remove_outline: boolean = true) {
if (this.editor.control == undefined) {
return;
}
if (this.editor.outlinePass == undefined) {
return;
}
if (remove_outline) {
this.editor.last_selected = this.editor.selected;
this.editor.outlinePass.selectedObjects = [];
this.editor.publishSelect();
}
this.editor.control.detach();
this.editor.activeScene.scene.remove(this.editor.control);
if (remove_outline) this.editor.outlinePass.selectedObjects = [];
}
// TO UPDATE selected objects in the scene might want to add to the scene ...
async setSelectedObject(position: XYZ, rotation: XYZ, scale: XYZ) {
if (this.editor.sceneManager?.selected_objects) {
const object = this.editor.sceneManager?.selected_objects[0];
if (object != undefined || object != null) {
object.position.x = position.x;
object.position.y = position.y;
object.position.z = position.z;
object.rotation.x = THREE.MathUtils.degToRad(rotation.x);
object.rotation.y = THREE.MathUtils.degToRad(rotation.y);
object.rotation.z = THREE.MathUtils.degToRad(rotation.z);
object.scale.x = scale.x;
object.scale.y = scale.y;
object.scale.z = scale.z;
}
}
}
switchCameraView() {
this.editor.camera_person_mode = !this.editor.camera_person_mode;
this.editor.cameraViewControls?.reset();
if (this.editor.cam_obj) {
if (this.editor.camera_person_mode && this.editor.camera) {
this.editor.last_cam_pos.copy(this.editor.camera.position);
this.editor.last_cam_rot.copy(this.editor.camera.rotation);
this.editor.camera.position.copy(this.editor.cam_obj.position);
this.editor.camera.rotation.copy(this.editor.cam_obj.rotation);
if (this.editor.lockControls) {
this.editor.activeScene.scene.add(
this.editor.lockControls.getObject(),
);
}
if (this.editor.cameraViewControls) {
this.editor.cameraViewControls.enabled = true;
}
this.editor.cam_obj.scale.set(0, 0, 0);
this.removeTransformControls();
this.editor.selected = this.editor.cam_obj;
this.editor.publishSelect();
this.editor.updateSelectedUI();
editorState.value = EditorStates.CAMERA_VIEW;
if (this.editor.activeScene.hot_items) {
this.editor.activeScene.hot_items.forEach((element) => {
element.visible = false;
});
}
// Make a better solution later but for now this is so that in camera mode when you right click it does not bring up the context menu and allows you to pan.
setTimeout(
() =>
document
.getElementById("letterbox")
?.addEventListener("contextmenu", function (event) {
event.preventDefault();
}),
250,
);
} else if (this.editor.camera) {
this.editor.camera.position.copy(this.editor.last_cam_pos);
this.editor.camera.rotation.copy(this.editor.last_cam_rot);
if (this.editor.lockControls) {
this.editor.activeScene.scene.remove(
this.editor.lockControls.getObject(),
);
}
this.editor.cam_obj.scale.set(1, 1, 1);
if (this.editor.activeScene.hot_items) {
this.editor.activeScene.hot_items.forEach((element) => {
element.visible = true;
});
}
hideObjectPanel();
editorState.value = EditorStates.EDIT;
}
}
}
// Returns the "check sum" of the editors selected object.
getSelectedSum(): number {
if (this.editor.sceneManager?.selected_objects === undefined) {
return 0;
}
if (this.editor.sceneManager?.selected_objects.length <= 0) {
return 0;
}
const posCombo =
this.editor.sceneManager?.selected_objects[0].position.x +
this.editor.sceneManager?.selected_objects[0].position.y +
this.editor.sceneManager?.selected_objects[0].position.z;
const rotCombo =
this.editor.sceneManager?.selected_objects[0].rotation.x +
this.editor.sceneManager?.selected_objects[0].rotation.y +
this.editor.sceneManager?.selected_objects[0].rotation.z;
const sclCombo =
this.editor.sceneManager?.selected_objects[0].scale.x +
this.editor.sceneManager?.selected_objects[0].scale.y +
this.editor.sceneManager?.selected_objects[0].scale.z;
return posCombo + rotCombo + sclCombo;
}
/* Will add in the future
A good practice to remove 3D objects from Three.js scenes
function removeObject3D(object3D) {
if (!(object3D instanceof THREE.Object3D)) return false;
// for better memory management and performance
if (object3D.geometry) object3D.geometry.dispose();
if (object3D.material) {
if (object3D.material instanceof Array) {
// for better memory management and performance
object3D.material.forEach(material => material.dispose());
} else {
// for better memory management and performance
object3D.material.dispose();
}
}
object3D.removeFromParent(); // the parent might be the scene or another Object3D, but it is sure to be removed this way
return true;
}
*/
deleteObject(uuid: string) {
const obj = this.scene.get_object_by_uuid(uuid);
if (!obj) {
return
}
this.removeTransformControls();
if (obj.name === this.editor.camera_name) {
return;
}
// Finally remove the object from the scene
this.scene.scene.remove(obj);
obj.traverse(child => {
(child as THREE.Mesh)?.geometry?.dispose()
if (Array.isArray((child as THREE.Mesh).texture)) {
(child as THREE.Mesh).texture.forEach(mat => mat.dispose());
} else if ((child as THREE.Mesh).texture) {
(child as THREE.Mesh).texture.dispose();
}
if (Array.isArray((child as THREE.Mesh).material)) {
(child as THREE.Mesh).material.forEach(mat => mat.dispose());
} else if ((child as THREE.Mesh).material) {
(child as THREE.Mesh).material.dispose();
}
})
if (Array.isArray((obj as THREE.Mesh).texture)) {
(obj as THREE.Mesh).texture.forEach(mat => mat.dispose());
} else if ((obj as THREE.Mesh).texture) {
(obj as THREE.Mesh).texture.dispose();
}
if (Array.isArray((obj as THREE.Mesh).material)) {
(obj as THREE.Mesh).material.forEach(mat => mat.dispose());
} else if ((obj as THREE.Mesh).material) {
(obj as THREE.Mesh).material.dispose();
}
if ((obj as THREE.Mesh).geometry) {
(obj as THREE.Mesh).geometry.dispose()
}
// TODO
// FIXME: Timeline deletion is called twice, as well as a queue event.
// Why is this necessary?
this.editor.timeline.deleteObject(obj);
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.DELETE_OBJECT,
data: {
version: 1,
type: AssetType.OBJECT,
media_id: "",
object_uuid: uuid,
name: "",
} as MediaItem,
});
this.editor.selected = undefined;
this.editor.publishSelect();
hideObjectPanel();
this.editor.timeline.deleteObject(obj);
}
}
@@ -1,4 +1,12 @@
import * as THREE from "three";
import {
hideObjectPanel,
hotkeysStatus,
outlinerState,
showObjectPanel,
assetModalVisible,
assetModalVisibleDuringDrag,
} from "../signals";
import {
OrbitControls,
OutlinePass,
@@ -6,21 +14,15 @@ import {
} from "three/examples/jsm/Addons.js";
import { TransformControls } from "./TransformControls";
import { SceneManager, SceneObject } from "./scene_manager_api";
import { isPointerLockSupported } from "./browserChecks";
import type { FreeCamControlState } from "./cameraMath";
import { FreeCam, isPointerLockSupported } from "./free_cam";
import { FKHelper } from "./KinHelpers/FKHelper";
import { Euler } from "three";
import type { EngineEventBus } from "./events/EngineEventBus";
import {
AssetModalVisibilityChangedEvent,
InspectorPanelChangedEvent,
OutlinerSelectedItemChangedEvent,
PoseControlsVisibilityChangedEvent,
PoseModeChangedEvent,
SelectedModeChangedEvent,
TransformSpaceChangedEvent,
} from "./events/EngineEvent";
import type { PoseMode, TransformSpace } from "../PageSceneStore";
selectedMode,
poseMode,
showPoseControls,
transformSpace,
} from "../signals/selectedMode";
import { Euler } from "three";
const EDITABLE_INPUT_TYPES = new Set([
"text",
@@ -60,43 +62,6 @@ export enum KinMode {
NONE,
}
// Capabilities MouseControls needs from outside its own state. Editor
// wires these in inline at construction (Phase 2 idiom — same shape as
// CameraControllerDeps, SaveManagerDeps, etc.). Replaces the prior
// 20-positional-arg constructor; new fields go here, not in a 21st
// positional slot.
export type MouseControlsDeps = {
camera: THREE.PerspectiveCamera | null;
camera_person_mode: boolean;
cameraViewControls: FreeCamControlState | null;
lockControls: PointerLockControls | undefined;
camera_last_pos: THREE.Vector3;
deleteObject: Function;
canvReference: HTMLCanvasElement | null;
mouse: THREE.Vector2 | undefined;
timeline_mouse: THREE.Vector2 | undefined;
raycaster: THREE.Raycaster | undefined;
control: TransformControls | undefined;
outlinePass: OutlinePass | undefined;
scene: THREE.Scene;
publishSelect: Function;
updateSelectedUI: Function;
transform_interaction: boolean;
last_selected: THREE.Object3D | undefined;
getAssetType: Function;
setSelected: Function;
isMovable: Function;
enable_stats: Function;
// Typed event bus — every engine→store write goes through here.
bus: EngineEventBus;
// Reads from the store happen at the boundary (Editor wires these up).
// The class itself stays store-agnostic.
getPoseMode: () => PoseMode;
isHotkeyDisabled: () => boolean;
getTransformSpace: () => TransformSpace;
};
export class MouseControls {
camera: THREE.PerspectiveCamera | null;
camera_person_mode: boolean;
@@ -104,6 +69,10 @@ export class MouseControls {
camera_last_pos: THREE.Vector3;
selected: THREE.Object3D[] | undefined;
orbitControls: OrbitControls | undefined;
selectedCanvas: boolean;
switchPreviewToggle: boolean;
rendering: boolean;
togglePlayback: Function;
deleteObject: Function;
canvReference: HTMLCanvasElement | null = null;
mouse: THREE.Vector2 | undefined;
@@ -120,7 +89,7 @@ export class MouseControls {
setSelected: Function;
sceneManager: SceneManager | undefined;
private isProcessing: boolean = false;
private cameraViewControls: FreeCamControlState | null;
private cameraViewControls: FreeCam;
private isMouseClicked: boolean = false;
private isMovable: Function;
enable_stats: Function;
@@ -129,51 +98,72 @@ export class MouseControls {
private fkHelper: FKHelper;
private isBoneDragged: boolean = false;
private ignoreNextClick: boolean = false;
private manualMouseLock = false;
// Snapshot of the camera quaternion taken in onMouseUp right before
// lockControls.unlock(). The unlock listener in editor.ts restores
// this value to neutralize the synthetic mousemove Chromium fires
// while pointer-lock is releasing — that synthetic move would
// otherwise be processed by PointerLockControls and lurch the camera
// back by the cursor's "trip home" delta, producing a visible snap.
quatAtUnlockRequest: THREE.Quaternion | null = null;
// Deps held for read callbacks + bus emissions throughout the class.
// Constructor still copies primitive fields onto `this.x` for the
// existing internals; new code reads through `this.deps` directly.
private deps: MouseControlsDeps;
constructor(deps: MouseControlsDeps) {
this.deps = deps;
this.camera = deps.camera;
this.camera_person_mode = deps.camera_person_mode;
this.cameraViewControls = deps.cameraViewControls;
this.lockControls = deps.lockControls;
this.camera_last_pos = deps.camera_last_pos;
constructor(
camera: THREE.PerspectiveCamera,
camera_person_mode: boolean,
cameraViewControls: FreeCam,
lockControls: PointerLockControls | undefined,
camera_last_pos: THREE.Vector3,
selectedCanvas: boolean,
switchPreviewToggle: boolean,
rendering: boolean,
togglePlayback: Function,
deleteObject: Function,
canvReference: HTMLCanvasElement | null,
mouse: THREE.Vector2 | undefined,
timeline_mouse: THREE.Vector2 | undefined,
raycaster: THREE.Raycaster | undefined,
control: TransformControls,
outlinePass: OutlinePass | undefined,
scene: THREE.Scene,
publishSelect: Function,
updateSelectedUI: Function,
transform_interaction: boolean,
last_selected: THREE.Object3D | undefined,
getAssetType: Function,
setSelected: Function,
isMovable: Function,
enable_stats: Function,
) {
this.camera = camera;
this.camera_person_mode = camera_person_mode;
this.cameraViewControls = cameraViewControls;
this.lockControls = lockControls;
this.camera_last_pos = camera_last_pos;
this.selected = [];
this.deleteObject = deps.deleteObject;
this.canvReference = deps.canvReference;
this.mouse = deps.mouse;
this.timeline_mouse = deps.timeline_mouse;
this.raycaster = deps.raycaster;
this.control = deps.control;
this.outlinePass = deps.outlinePass;
this.scene = deps.scene;
this.publishSelect = deps.publishSelect;
this.updateSelectedUI = deps.updateSelectedUI;
this.transform_interaction = deps.transform_interaction;
this.selectedCanvas = selectedCanvas;
this.switchPreviewToggle = switchPreviewToggle;
this.rendering = rendering;
this.togglePlayback = togglePlayback;
this.deleteObject = deleteObject;
this.canvReference = canvReference;
this.mouse = mouse;
this.timeline_mouse = timeline_mouse;
this.raycaster = raycaster;
this.control = control;
this.outlinePass = outlinePass;
this.scene = scene;
this.publishSelect = publishSelect;
this.updateSelectedUI = updateSelectedUI;
this.transform_interaction = transform_interaction;
this.last_selected = [];
this.getAssetType = deps.getAssetType;
this.setSelected = deps.setSelected;
this.getAssetType = getAssetType;
this.setSelected = setSelected;
this.sceneManager = undefined;
this.isMovable = deps.isMovable;
this.enable_stats = deps.enable_stats;
this.isMovable = isMovable;
this.enable_stats = enable_stats;
this.fkHelper = new FKHelper({
camera: this.camera!,
domElement: this.control!.domElement,
camera: this.camera,
domElement: this.control.domElement,
scene: this.scene,
onDragChange: this.onFKControlsDragging.bind(this),
});
// Expose handlePoseModeChange to window
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__mouseControls = this;
}
onFKControlsDragging(dragging: boolean) {
@@ -238,7 +228,7 @@ export class MouseControls {
this.publishSelect();
if (currentObject.userData.isCharacter) {
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(true));
showPoseControls.value = true;
}
// Normal selection behavior
@@ -251,9 +241,8 @@ export class MouseControls {
this.outlinePass.selectedObjects = this.selected;
}
this.transform_interaction = true;
// Contact react land — updateSelectedUI emits InspectorPanelChangedEvent
// with the panel data, which the bridge translates into both
// updateObjectPanel + showObjectPanel store calls.
// Contact react land
showObjectPanel();
this.updateSelectedUI();
}
@@ -264,22 +253,11 @@ export class MouseControls {
!this.isBoneDragged
) {
this.isMouseClicked = true;
// Pointer lock is requested from `handleMousePointerLock()` once
// we see a real mousemove with the button held — locking eagerly
// on mousedown intercepts the browser's click-event pipeline and
// suppresses static-click selection.
}
}
onMouseUp(event: any) {
if (event.button === 0 || event.button === 1) {
// Snapshot the quaternion the user actually ended on. The unlock
// listener in editor.ts will restore this once Chromium's
// synthetic-mousemove window has closed, so the camera doesn't
// lurch by the cursor's trip-home delta.
if (this.camera) {
this.quatAtUnlockRequest = this.camera.quaternion.clone();
}
this.lockControls?.unlock();
this.isMouseClicked = false;
}
@@ -303,8 +281,8 @@ export class MouseControls {
this.fkHelper.clear();
this.kinMode = KinMode.NONE;
this.reattachTransformControls();
if (this.deps.getPoseMode() === "pose") {
this.deps.bus.emit(new PoseModeChangedEvent("select"));
if (poseMode.value === "pose") {
poseMode.value = "select";
}
console.log("FK mode off");
return;
@@ -326,8 +304,8 @@ export class MouseControls {
this.hideTransformControls();
this.kinMode = KinMode.FK;
this.fkHelper.setTarget(this.selected[0]);
if (this.deps.getPoseMode() === "select") {
this.deps.bus.emit(new PoseModeChangedEvent("pose"));
if (poseMode.value === "select") {
poseMode.value = "pose";
}
console.log("FK mode on");
return;
@@ -338,11 +316,16 @@ export class MouseControls {
return;
}
if (this.deps.isHotkeyDisabled()) {
if (hotkeysStatus.value.disabled) {
return;
} else if (event.key === "f" && this.selected && this.lockControls) {
this.focus();
return;
} else if (event.key === " ") {
if (!this.rendering && !this.switchPreviewToggle && this.selectedCanvas) {
this.togglePlayback();
}
return;
} else if (event.key === "Backspace" || event.key === "Delete") {
if (this.selected) {
this.selected.forEach((selected) => {
@@ -352,43 +335,41 @@ export class MouseControls {
this.toggleFKMode();
}
this.removeTransformControls();
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
showPoseControls.value = false;
});
}
return;
} else if (event.key === "t") {
// transform
this.control?.setMode("translate");
const ts = this.deps.getTransformSpace();
if (this.control) this.control.space = ts;
this.deps.bus.emit(new SelectedModeChangedEvent("move"));
if (this.control) this.control.space = transformSpace.value;
selectedMode.value = "move";
return;
} else if (event.key === "x") {
// toggle world/local space (blocked in scale mode)
if (this.control?.mode === "scale") return;
const next: TransformSpace =
this.deps.getTransformSpace() === "world" ? "local" : "world";
this.deps.bus.emit(new TransformSpaceChangedEvent(next));
if (this.control) this.control.space = next;
transformSpace.value = transformSpace.value === "world" ? "local" : "world";
if (this.control) this.control.space = transformSpace.value;
return;
} else if (event.key === "r" && !event.ctrlKey) {
// rotate
this.control?.setMode("rotate");
if (this.control) this.control.space = this.deps.getTransformSpace();
this.deps.bus.emit(new SelectedModeChangedEvent("rotate"));
if (this.control) this.control.space = transformSpace.value;
selectedMode.value = "rotate";
return;
} else if (event.key === "g") {
// scale
this.control?.setMode("scale");
if (this.control) this.control.space = this.deps.getTransformSpace();
this.deps.bus.emit(new SelectedModeChangedEvent("scale"));
if (this.control) this.control.space = transformSpace.value;
selectedMode.value = "scale";
return;
} else if (event.key === "k") {
this.toggleFKMode();
return;
} else if (event.key === "b") {
// Open asset modal
this.deps.bus.emit(new AssetModalVisibilityChangedEvent(true, true));
assetModalVisible.value = true;
assetModalVisibleDuringDrag.value = true;
return;
}
@@ -456,24 +437,22 @@ export class MouseControls {
event.stopPropagation();
}
if (this.cameraViewControls) {
if (event.shiftKey) {
this.cameraViewControls.movementSpeed = 3;
} else if (event.altKey) {
this.cameraViewControls.movementSpeed = 0.1;
} else {
this.cameraViewControls.movementSpeed = 0.75;
}
if (event.shiftKey) {
this.cameraViewControls.movementSpeed = 3;
} else if (event.altKey) {
this.cameraViewControls.movementSpeed = 0.1;
} else {
this.cameraViewControls.movementSpeed = 0.75;
}
if (event.key === "Escape") {
if (this.deps.getPoseMode() === "pose") {
if (poseMode.value === "pose") {
this.toggleFKMode();
return;
} else if (this.selected && this.selected.length > 0) {
this.removeTransformControls();
this.deps.bus.emit(new InspectorPanelChangedEvent(null));
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
hideObjectPanel();
showPoseControls.value = false;
}
}
}
@@ -632,13 +611,13 @@ export class MouseControls {
this.selected = [];
this.setSelected(this.selected);
this.removeTransformControls();
this.deps.bus.emit(new InspectorPanelChangedEvent(null));
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
hideObjectPanel();
showPoseControls.value = false;
}
if (this.sceneManager) {
const selected: SceneObject | null = this.sceneManager.selected();
this.deps.bus.emit(new OutlinerSelectedItemChangedEvent(selected));
outlinerState.selectedItem.value = selected;
}
}
}
@@ -0,0 +1,239 @@
const BoundingFrequencyMasc = [0, 400, 560, 2400, 4800];
const BoundingFrequencyFem = [0, 500, 700, 3000, 6000];
const IndicesFrequencyFemale = [];
const IndicesFrequencyMale = [];
const FFT_SIZE = 1024;
const samplingFrequency = 44100;
for (let m = 0; m < BoundingFrequencyMasc.length; m++) {
IndicesFrequencyMale[m] = Math.round(
((2 * FFT_SIZE) / samplingFrequency) * BoundingFrequencyMasc[m],
);
}
for (let m = 0; m < BoundingFrequencyFem.length; m++) {
IndicesFrequencyFemale[m] = Math.round(
((2 * FFT_SIZE) / samplingFrequency) * BoundingFrequencyFem[m],
);
}
export class LipSync {
constructor(face) {
this.face = face;
this.buffer = null;
this.last_frame = 0;
// WE NEED THIS TO be run at RUNTIME
// we have to modify this for our
// this.ee = this.face.morphTargetDictionary["E"];
// this.oh = this.face.morphTargetDictionary["O"];
// this.ah = this.face.morphTargetDictionary["aa"];
}
// for streaming sound?
start(stream) {
this.audioContext = new AudioContext();
this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
this.meter = LipSync.createAudioMeter(this.audioContext);
this.mediaStreamSource.connect(this.meter);
}
// testing only
startFromAudioFile(file) {
if (!this.audioContext) this.audioContext = new AudioContext();
if (!this.userSpeechAnalyzer)
this.userSpeechAnalyzer = this.audioContext.createAnalyser();
this.userSpeechAnalyzer.smoothingTimeConstant = 0.5;
this.userSpeechAnalyzer.fftSize = FFT_SIZE;
if (this.mediaStreamSource) this.mediaStreamSource.stop();
this.audioContext.decodeAudioData(file).then((buffer) => {
this.mediaStreamSource = this.audioContext.createBufferSource();
this.mediaStreamSource.buffer = buffer;
this.meter = LipSync.createAudioMeter(this.audioContext);
this.mediaStreamSource.connect(this.meter);
this.mediaStreamSource.connect(this.audioContext.destination);
this.mediaStreamSource.start();
// connect the output of mediaStreamSource to the input of userSpeechAnalyzer
this.mediaStreamSource.connect(this.userSpeechAnalyzer);
});
}
startLipSyncFromAudioBuffer(buffer) {
if (!this.audioContext) this.audioContext = new AudioContext();
if (!this.userSpeechAnalyzer)
this.userSpeechAnalyzer = this.audioContext.createAnalyser();
this.userSpeechAnalyzer.smoothingTimeConstant = 0.5;
this.userSpeechAnalyzer.fftSize = FFT_SIZE;
if (this.mediaStreamSource) this.mediaStreamSource.stop();
this.buffer = buffer;
}
destroy() {
this.meter?.shutdown();
this.meter = null;
this.mediaStreamSource?.disconnect();
return this.audioContext?.close().catch(() => {}) || Promise.resolve();
}
update(frame, offset, rendering) {
const frameBuffer = 12;
let pos = frame - offset;
if (Math.floor(pos) <= 1){
this.last_frame = 0;
}
let doPlay = true;
if (
Math.abs(this.last_frame - frame) < frameBuffer - 1 &&
this.last_frame !== 0
) {
doPlay = false;
}
if (doPlay) {
const startTime = pos / 60.0;
const endTime = frameBuffer / 60.0;
this.mediaStreamSource = this.audioContext.createBufferSource();
this.mediaStreamSource.buffer = this.buffer;
this.meter = LipSync.createAudioMeter(this.audioContext);
this.mediaStreamSource.connect(this.meter);
this.mediaStreamSource.connect(this.audioContext.destination);
this.mediaStreamSource.start(0, startTime, endTime);
this.mediaStreamSource.connect(this.userSpeechAnalyzer);
this.last_frame = frame;
}
if (this.meter) {
const { volume } = this.meter;
let { ah, oh, ee } = this.update2();
if (volume < 0.01) {
ah = 0;
oh = 0;
ee = 0;
}
return { ah: ah, oh: oh, ee: ee };
}
return { ah: 0, oh: 0, ee: 0 };
}
update2() {
function getSensitivityMap(spectrum) {
const sensitivity_threshold = 0.5;
const stPSD = new Float32Array(spectrum.length);
for (let i = 0; i < spectrum.length; i++) {
stPSD[i] = sensitivity_threshold + (spectrum[i] + 20) / 140;
}
return stPSD;
}
const spectrum = new Float32Array(
this.userSpeechAnalyzer.frequencyBinCount,
);
// Populate frequency data for computing frequency intensities
this.userSpeechAnalyzer.getFloatFrequencyData(spectrum); // getByteTimeDomainData gets volumes over the sample time
// Populate time domain for calculating RMS
// userSpeechAnalyzer.getFloatTimeDomainData(spectrum);
// RMS (root mean square) is a better approximation of current input level than peak (just sampling this frame)
// spectrumRMS = getRMS(spectrum);
const sensitivityPerPole = getSensitivityMap(spectrum);
// Lower and higher voices have different frequency domains, so we'll separate and max them
const EnergyBinMasc = new Float32Array(BoundingFrequencyMasc.length);
const EnergyBinFem = new Float32Array(BoundingFrequencyFem.length);
// Masc energy bins (groups of frequency-depending energy)
for (let m = 0; m < BoundingFrequencyMasc.length - 1; m++) {
for (
let j = IndicesFrequencyMale[m];
j <= IndicesFrequencyMale[m + 1];
j++
)
if (sensitivityPerPole[j] > 0)
EnergyBinMasc[m] += sensitivityPerPole[j];
EnergyBinMasc[m] /= IndicesFrequencyMale[m + 1] - IndicesFrequencyMale[m];
}
// Fem energy bin
for (let m = 0; m < BoundingFrequencyFem.length - 1; m++) {
for (
let j = IndicesFrequencyMale[m];
j <= IndicesFrequencyMale[m + 1];
j++
)
if (sensitivityPerPole[j] > 0) EnergyBinFem[m] += sensitivityPerPole[j];
EnergyBinMasc[m] /= IndicesFrequencyMale[m + 1] - IndicesFrequencyMale[m];
EnergyBinFem[m] =
EnergyBinFem[m] /
(IndicesFrequencyFemale[m + 1] - IndicesFrequencyFemale[m]);
}
const oh =
Math.max(EnergyBinFem[1], EnergyBinMasc[1]) > 0.2
? 1 - 2 * Math.max(EnergyBinMasc[2], EnergyBinFem[2])
: (1 - 2 * Math.max(EnergyBinMasc[2], EnergyBinFem[2])) *
5 *
Math.max(EnergyBinMasc[1], EnergyBinFem[1]);
const ah = 3 * Math.max(EnergyBinMasc[3], EnergyBinFem[3]);
const ee =
0.8 *
(Math.max(EnergyBinMasc[1], EnergyBinFem[1]) -
Math.max(EnergyBinMasc[3], EnergyBinFem[3]));
return { oh, ee, ah };
}
static createAudioMeter(audioContext) {
const processor = audioContext.createScriptProcessor(512);
processor.onaudioprocess = (event) => {
const buf = event.inputBuffer.getChannelData(0);
const bufLength = buf.length;
let sum = 0;
let x;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < bufLength; i++) {
x = buf[i];
if (Math.abs(x) >= processor.clipLevel) {
processor.clipping = true;
processor.lastClip = window.performance.now();
}
sum += x * x;
}
const rms = Math.sqrt(sum / bufLength);
processor.volume = Math.max(rms, processor.volume * processor.averaging);
};
processor.clipping = false;
processor.lastClip = 0;
processor.volume = 0;
processor.clipLevel = 0.98;
processor.averaging = 0.95;
processor.clipLag = 750;
processor.connect(audioContext.destination);
processor.checkClipping = () => {
if (!processor.clipping) {
return false;
}
if (processor.lastClip + processor.clipLag < window.performance.now()) {
processor.clipping = false;
}
return processor.clipping;
};
processor.shutdown = () => {
processor.disconnect();
processor.onaudioprocess = null;
};
return processor;
}
}
@@ -0,0 +1,41 @@
import { MediaUploadApi, VideoApi } from "~/Classes/ApiManager";
import {
ProcessStatus,
StreamingProgressResponse,
VideoAudioPreProcessor,
} from "./VideoProcessor/video_audio_preprocessor";
class PreviewVideoGeneration {
mediaUploadAPI: MediaUploadApi;
videoAPI: VideoApi;
videoAndAudioPreProcessor: VideoAudioPreProcessor;
onProgress: (response: StreamingProgressResponse<ProcessStatus>) => void;
constructor(
onProgress: (response: StreamingProgressResponse<ProcessStatus>) => void,
) {
this.onProgress = onProgress;
this.videoAndAudioPreProcessor = new VideoAudioPreProcessor(onProgress);
this.mediaUploadAPI = new MediaUploadApi();
this.videoAPI = new VideoApi();
}
// Cancel any part of the processing
public async cancel() {}
private async stageOneProcessing() {}
private async stageTwoProcessing() {}
private async processVideo() {
// check if video is cached or not if not jump to 50%
// collect frames and run preprocessing in the background.
// start polling for percentage change and convert and remap to remainint %
}
async clearFrameBuffers() {}
}
export { PreviewVideoGeneration };
@@ -0,0 +1,97 @@
export class MoveAIResult {
bone: string;
is_special: boolean;
x: number;
y: number;
z: number;
flip: boolean;
only_y: boolean;
constructor(bone: string, is_special: boolean = false, x: number = 0, y: number = 0, z: number = 0,
flip:boolean=false, only_y:boolean=false) {
this.bone = bone;
this.is_special = is_special;
this.x = x;
this.y = y;
this.z = z;
this.flip = flip;
this.only_y = only_y;
}
}
export class Retarget {
move_ai_options: { [Key: string]: MoveAIResult }
constructor() {
this.move_ai_options = {
"_1Head.quaternion": new MoveAIResult("mixamorigHead.quaternion"),
"_1Neck.quaternion": new MoveAIResult("mixamorigNeck.quaternion"),
"_Spine.quaternion": new MoveAIResult("mixamorigSpine.quaternion"),
"_1Spine.quaternion": new MoveAIResult("mixamorigSpine1.quaternion"),
"_3Spine.quaternion": new MoveAIResult("mixamorigSpine2.quaternion"),
"_1Hips.quaternion": new MoveAIResult("mixamorigHips.quaternion", true, -90, 0, 0, false, true),
"_1Hips.position": new MoveAIResult("mixamorigHips.position", true, 1, 0.05, 1),
"_1LeftFoot.quaternion": new MoveAIResult("mixamorigLeftFoot.quaternion", true, 0, 0, 0, true),
"_1RightFoot.quaternion": new MoveAIResult("mixamorigRightFoot.quaternion", true, 0, 0, 0, true),
"_1RightShoulder.quaternion": new MoveAIResult("mixamorigRightShoulder.quaternion", true, 0, 0, 0, false),
"_1LeftShoulder.quaternion": new MoveAIResult("mixamorigLeftShoulder.quaternion", true, 0, 0, 0, false),
"_1LeftArm.quaternion": new MoveAIResult("mixamorigLeftArm.quaternion", true, 0, 0, 0, false),
"_1RightArm.quaternion": new MoveAIResult("mixamorigRightArm.quaternion", true, 0, 0, 0, false),
"_1LeftForeArm.quaternion": new MoveAIResult("mixamorigLeftForeArm.quaternion"),
"_1RightForeArm.quaternion": new MoveAIResult("mixamorigRightForeArm.quaternion"),
"_1LeftLeg.quaternion": new MoveAIResult("mixamorigLeftLeg.quaternion", true, 0, 0, 0, true),
"_1RightLeg.quaternion": new MoveAIResult("mixamorigRightLeg.quaternion", true, 0, 0, 0, true),
"_1LeftUpLeg.quaternion": new MoveAIResult("mixamorigLeftUpLeg.quaternion", true, 0, 180, 0),
"_1RightUpLeg.quaternion": new MoveAIResult("mixamorigRightUpLeg.quaternion", true, 0, 180, 0),
"_1LeftHand.quaternion": new MoveAIResult("mixamorigLeftHand.quaternion"),
"_1RightHand.quaternion": new MoveAIResult("mixamorigRightHand.quaternion"),
"_1LeftHandThumb1.quaternion": new MoveAIResult("mixamorigLeftHandThumb1.quaternion"),
"_1LeftHandThumb2.quaternion": new MoveAIResult("mixamorigLeftHandThumb2.quaternion"),
"_1LeftHandThumb3.quaternion": new MoveAIResult("mixamorigLeftHandThumb3.quaternion"),
"_1LeftHandIndex1.quaternion": new MoveAIResult("mixamorigLeftHandIndex1.quaternion"),
"_1LeftHandIndex2.quaternion": new MoveAIResult("mixamorigLeftHandIndex2.quaternion"),
"_1LeftHandIndex3.quaternion": new MoveAIResult("mixamorigLeftHandIndex3.quaternion"),
"_1LeftHandMiddle1.quaternion": new MoveAIResult("mixamorigLeftHandMiddle1.quaternion"),
"_1LeftHandMiddle2.quaternion": new MoveAIResult("mixamorigLeftHandMiddle2.quaternion"),
"_1LeftHandMiddle3.quaternion": new MoveAIResult("mixamorigLeftHandMiddle3.quaternion"),
"_1LeftHandRing1.quaternion": new MoveAIResult("mixamorigLeftHandRing1.quaternion"),
"_1LeftHandRing2.quaternion": new MoveAIResult("mixamorigLeftHandRing2.quaternion"),
"_1LeftHandRing3.quaternion": new MoveAIResult("mixamorigLeftHandRing3.quaternion"),
"_1LeftHandPinky1.quaternion": new MoveAIResult("mixamorigLeftHandPinky1.quaternion"),
"_1LeftHandPinky2.quaternion": new MoveAIResult("mixamorigLeftHandPinky2.quaternion"),
"_1LeftHandPinky3.quaternion": new MoveAIResult("mixamorigLeftHandPinky3.quaternion"),
"_1RightHandThumb1.quaternion": new MoveAIResult("mixamorigRightHandThumb1.quaternion"),
"_1RightHandThumb2.quaternion": new MoveAIResult("mixamorigRightHandThumb2.quaternion"),
"_1RightHandThumb3.quaternion": new MoveAIResult("mixamorigRightHandThumb3.quaternion"),
"_1RightHandIndex1.quaternion": new MoveAIResult("mixamorigRightHandIndex1.quaternion"),
"_1RightHandIndex2.quaternion": new MoveAIResult("mixamorigRightHandIndex2.quaternion"),
"_1RightHandIndex3.quaternion": new MoveAIResult("mixamorigRightHandIndex3.quaternion"),
"_1RightHandMiddle1.quaternion": new MoveAIResult("mixamorigRightHandMiddle1.quaternion"),
"_1RightHandMiddle2.quaternion": new MoveAIResult("mixamorigRightHandMiddle2.quaternion"),
"_1RightHandMiddle3.quaternion": new MoveAIResult("mixamorigRightHandMiddle3.quaternion"),
"_1RightHandRing1.quaternion": new MoveAIResult("mixamorigRightHandRing1.quaternion"),
"_1RightHandRing2.quaternion": new MoveAIResult("mixamorigRightHandRing2.quaternion"),
"_1RightHandRing3.quaternion": new MoveAIResult("mixamorigRightHandRing3.quaternion"),
"_1RightHandPinky1.quaternion": new MoveAIResult("mixamorigRightHandPinky1.quaternion"),
"_1RightHandPinky2.quaternion": new MoveAIResult("mixamorigRightHandPinky2.quaternion"),
"_1RightHandPinky3.quaternion": new MoveAIResult("mixamorigRightHandPinky3.quaternion"),
}
}
retarget(boneName: string): MoveAIResult {
for (const [key, value] of Object.entries(this.move_ai_options)) {
if (boneName == key) {
return value;
}
}
return new MoveAIResult(boneName);
}
}
@@ -0,0 +1,303 @@
import * as THREE from "three";
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene";
import {
setCameraAspectRatio,
hideEditorLoader,
restoreSceneGenerationMetadata,
showEditorLoader,
globalIPAMediaToken,
} from "../signals";
import Editor from "./editor";
import { getArtStyle } from "~/enums";
import { cameras, selectedCameraId, Camera } from "../signals/camera";
export type EditorInitializeConfig = {
sceneToken: string;
};
// need to move this into the
export class SaveManager {
editor: Editor;
constructor(editor: Editor) {
this.editor = editor;
}
async computeHashForBrowser(data: string): Promise<string> {
// Encode the string data to a Uint8Array
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
// Compute the hash using the SubtleCrypto.digest method
const hashBuffer = await crypto.subtle.digest("SHA-256", encodedData);
// Convert the ArrayBuffer to a hexadecimal string
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
public async computeSceneChecksum(): Promise<string> {
const jsonString = await this.checkSumData();
return this.computeHashForBrowser(jsonString);
}
private async checkSumData(): Promise<string> {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
const check_sum_data = {
scene: scene_json,
timeline: "",
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
};
const jsonString = JSON.stringify(check_sum_data);
return jsonString;
}
// JSON structure should and can return snapshot
public async saveData({
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken?: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}): Promise<string> {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = await proxyScene.saveToScene(this.editor.version);
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
const save_data = {
version: this.editor.version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: "",
skybox: this.editor.activeScene.skybox,
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
// Add cameras array and selected camera
cameras: camerasData,
selectedCameraId: selectedCameraId.value,
};
// take json scene and figure out checksum
const jsonString = JSON.stringify(save_data);
return jsonString;
}
public getSceneJson({
sceneGenerationMetadata,
}: {
sceneGenerationMetadata: SceneGenereationMetaData;
}) {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
const scene_json = proxyScene.saveToScene(this.editor.version);
console.log(scene_json);
// Save all cameras data
const camerasData = cameras.value.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
const save_data = {
version: this.editor.version,
scene: scene_json,
...sceneGenerationMetadata,
timeline: "",
skybox: this.editor.activeScene.skybox,
camera_data: {
position: this.editor.camera?.position,
rotation: this.editor.camera?.rotation,
},
// Add cameras array and selected camera
cameras: camerasData,
selectedCameraId: selectedCameraId.value,
};
return save_data;
}
// TODO Move this function into scene manager.
public async saveScene({
sceneTitle,
sceneToken,
sceneGenerationMetadata,
}: {
sceneTitle: string;
sceneToken?: string;
sceneGenerationMetadata: SceneGenereationMetaData;
}): Promise<string> {
this.editor.generating_preview = true; // Set this to true to stop control panel from flipping out.
// remove controls when saving scene.
this.editor.utils.removeTransformControls();
showEditorLoader();
const sceneJson = this.getSceneJson({
sceneGenerationMetadata,
});
// TODO turn scene information into and object ...
let sceneThumbnail = undefined;
if (this.editor.renderer) {
const imgData = this.editor.renderer.domElement.toDataURL();
const response = await fetch(imgData); // Fetch the data URL
sceneThumbnail = await response.blob(); // Convert to Blob
}
const result = await this.editor.api_manager.saveSceneState({
saveJson: JSON.stringify(sceneJson),
sceneTitle,
sceneToken,
sceneThumbnail,
});
// this means error going to have to fix the plumbing on this because not using
// the api manager
hideEditorLoader();
this.editor.generating_preview = false; // FIX THIS LATER WITH VICCCCCCCCCCCCCCCTORRRRRRRR
console.debug("Save Scene Result: ", result);
return result; // if this is an empty string it is an error. need to migrate to api manager.
}
private async loadFromJson(scene_json: any) {
const proxyScene = new StoryTellerProxyScene(
this.editor.version,
this.editor.activeScene,
);
await proxyScene.loadFromSceneJson(
scene_json["scene"],
scene_json["skybox"],
scene_json["version"],
);
const camera_data = scene_json["camera_data"];
if (camera_data && this.editor.camera) {
const camera_position: THREE.Vector3 = camera_data["position"];
const camera_rotation: THREE.Euler = camera_data["rotation"];
this.editor.camera.position.copy(camera_position);
this.editor.camera.rotation.copy(camera_rotation);
}
// Restore cameras data
if (scene_json.cameras) {
cameras.value = scene_json.cameras.map((cam: Camera) => ({
id: cam.id,
label: cam.label,
focalLength: cam.focalLength,
position: cam.position,
rotation: cam.rotation,
lookAt: cam.lookAt,
}));
}
// Restore selected camera
if (scene_json.selectedCameraId) {
selectedCameraId.value = scene_json.selectedCameraId;
}
// For Remixing Scenes.
// this calls the signal function to propagate the data to the UI
restoreSceneGenerationMetadata(scene_json);
// these propogate the values into the editor
if (scene_json.globalIpAdapterImageMediaToken) {
// this should be populated right after
globalIPAMediaToken.value = scene_json.globalIpAdapterImageMediaToken;
}
if (scene_json.positivePrompt) {
this.editor.positive_prompt = scene_json.positivePrompt;
}
if (scene_json.negativePrompt) {
this.editor.negative_prompt = scene_json.negativePrompt;
}
if (scene_json.artisticStyle) {
this.editor.art_style = getArtStyle(scene_json["artisticStyle"]);
}
if (scene_json.cameraAspectRatio) {
//editor propagation
this.editor.changeRenderCameraAspectRatio(scene_json.cameraAspectRatio);
//signal propagation
setCameraAspectRatio(scene_json.cameraAspectRatio);
}
this.editor.version = scene_json["version"];
this.editor.cam_obj = this.editor.activeScene.get_object_by_name(
this.editor.camera_name,
);
this.editor.cam_obj?.layers.set(1);
this.editor.cam_obj?.children.forEach((child) => {
child.layers.set(1);
});
console.debug("Loading Timeline", scene_json["timeline"]);
this.editor.timeline.checkEditorCanPlay();
}
public async loadCache(cacheJson: string) {
showEditorLoader();
const scene_json = JSON.parse(cacheJson);
await this.loadFromJson(scene_json);
hideEditorLoader();
this.editor.timeline.scrub({ currentTime: 0 });
}
// TODO Refactor remove editor.
public async loadScene(scene_media_token: string) {
showEditorLoader();
this.editor.current_scene_media_token = scene_media_token;
const scene_json = await this.editor.api_manager
.loadSceneState(this.editor.current_scene_media_token)
.catch((err) => {
hideEditorLoader();
throw err;
});
await this.loadFromJson(scene_json);
hideEditorLoader();
// TODO figure out if this is a bug.
this.editor.timeline.scrub({ currentTime: 0 });
}
}
@@ -8,24 +8,17 @@ import { LoadingPlaceHolderManager } from "./placeholder_manager";
import { MMDAnimationHelper, Water } from "three/examples/jsm/Addons.js";
import { MediaFileType } from "../enums";
import { ChromaKeyMaterial } from "./chromakey";
import { TimeLine } from "./timeline";
import { ClipGroup, ClipType } from "~/enums";
import { ClipUI } from "../clips/clip_ui";
import { LoadWithoutCors } from "@storyteller/tauri-api";
import { gridVisibility } from "../signals/engine";
import { InfiniteGridHelper } from "./InfiniteGridHelper";
import type { Camera } from "@storyteller/common";
import { cameras, selectedCameraId } from "../signals/camera";
import { MediaFilesApi } from "@storyteller/api";
import toast from "react-hot-toast";
import { SplatMesh } from "@sparkjsdev/spark";
// Capabilities Scene needs from outside its own state. Editor wires
// these in inline at construction (Phase 2 idiom — same shape as
// SceneUtilsDeps, SaveManagerDeps, etc.). Scene does NOT import the
// Zustand store directly and has no Tauri imports; the host (artcraft
// wrapper) supplies fetchAsset (CORS-bypassed binary fetch) and
// getMediaUrlByToken (resolves a media_file_token to its CDN URL).
export type SceneDeps = {
getCameras: () => Camera[];
getSelectedCameraId: () => string;
fetchAsset: (url: string) => Promise<Response>;
getMediaUrlByToken: (token: string) => Promise<string>;
};
class Scene {
name: string;
gridHelper: InfiniteGridHelper | undefined;
@@ -49,24 +42,25 @@ class Scene {
// loading indicator manager
placeholder_manager: LoadingPlaceHolderManager | undefined;
mediaFilesApi: MediaFilesApi;
updateSurfaceIdAttributeToMesh: Function;
helper: MMDAnimationHelper;
ambientLight: THREE.AmbientLight | undefined;
hemisphereLight: THREE.HemisphereLight | undefined;
directional_light: THREE.DirectionalLight | undefined;
timeline: TimeLine | undefined;
version: number;
// This is used to ensure we do not rerender or process the video if we already have done so.
// This allows us to reprompt things quickly. This is only written when a snap shot is taken.
// Which guarentees that the scene is availible.
current_scene_checksum: string;
private deps: SceneDeps;
constructor(
name: string,
camera_name: string,
updateSurfaceIdAttributeToMesh: Function,
version: number,
deps: SceneDeps,
) {
this.version = version;
this.name = name;
@@ -82,8 +76,8 @@ class Scene {
// global names
this.camera_name = camera_name;
this.placeholder_manager = undefined;
this.mediaFilesApi = new MediaFilesApi();
this.updateSurfaceIdAttributeToMesh = updateSurfaceIdAttributeToMesh;
this.deps = deps;
this.current_scene_checksum = "";
}
@@ -100,20 +94,19 @@ class Scene {
this.helper = new MMDAnimationHelper({ afterglow: 0.0 });
this.scene.userData["helper"] = this.helper;
}
// Apply a grid visibility change. Pure THREE-side mutation — the
// store subscription that drives this lives at the engine boundary
// (editor.ts), not inside Scene itself.
applyGridVisibility(visible: boolean) {
if (!this.gridHelper) return;
if (visible) {
if (!this.scene.children.includes(this.gridHelper)) {
this.scene.add(this.gridHelper);
// Subscribe to grid visibility changes
gridVisibility.subscribe((isVisible) => {
if (this.gridHelper) {
if (isVisible) {
if (!this.scene.children.includes(this.gridHelper)) {
this.scene.add(this.gridHelper);
}
} else {
this.scene.remove(this.gridHelper);
}
}
} else {
this.scene.remove(this.gridHelper);
}
});
}
clear() {
@@ -389,9 +382,7 @@ class Scene {
}
async _create_camera_obj() {
const cameras = this.deps.getCameras();
const selectedCameraId = this.deps.getSelectedCameraId();
cameras.forEach((cameraConfig) => {
cameras.value.forEach((cameraConfig) => {
const camera_position = new THREE.Vector3(
cameraConfig.position.x,
cameraConfig.position.y,
@@ -419,7 +410,7 @@ class Scene {
});
this.scene.add(camera_obj);
if (cameraConfig.id === selectedCameraId) {
if (cameraConfig.id === selectedCameraId.value) {
camera_obj.visible = false;
camera_obj.layers.disableAll(); // Make the active camera not touchable
} else {
@@ -442,9 +433,14 @@ class Scene {
}
}
// Resolve a media_file_token to its CDN URL via the host adapter.
// TODO: REPLACE
async getMediaURL(media_id: string) {
return this.deps.getMediaUrlByToken(media_id);
console.log("getMediaID!!!!!!!!!!!!!", media_id);
const response = await this.mediaFilesApi.GetMediaFileByToken({
mediaFileToken: media_id,
});
console.log("response!!!!!!!!!!!!!!", response);
return response.data!.media_links.cdn_url;
}
private floatToPercent(value: number) {
@@ -544,7 +540,6 @@ class Scene {
mmd.userData["media_file_type"] = MediaFileType.MMD;
mmd.layers.enable(0);
mmd.layers.enable(1);
mmd.position.copy(position);
this.scene.add(mmd);
this.updateSurfaceIdAttributeToMesh(this.scene);
return mmd;
@@ -595,7 +590,6 @@ class Scene {
url.includes(".mp4")
) {
const obj = await this.instantiate("Image::" + url);
obj.position.copy(position);
// Preserve original token to enable copy/paste and timeline mapping
obj.userData["media_id"] = media_id;
if (url.includes(".mp4")) {
@@ -675,7 +669,6 @@ class Scene {
});
splat.rotation.z = Math.PI;
splat.position.copy(position);
splat.userData["media_id"] = media_id;
splat.userData["media_file_type"] = MediaFileType.SPZ;
@@ -801,12 +794,30 @@ class Scene {
throw Error("GLB Did not contain an object or children.");
}
child_result.position.copy(position);
if (auto_add) {
this.scene.add(child_result);
if (glb.animations.length > 0) {
console.log(glb.animations);
const animation = glb.animations[0];
child_result.animations = glb.animations;
// load first animation
this.timeline?.addSelfAnimationClip(
new ClipUI(
this.version,
ClipType.ANIMATION,
ClipGroup.CHARACTER,
animation.name,
"SelfClip",
animation.uuid,
child_result.uuid,
name,
0,
100,
0,
MediaFileType.GLB,
),
animation,
);
}
}
this.updateSurfaceIdAttributeToMesh(this.scene);
@@ -826,9 +837,7 @@ class Scene {
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)).arrayBuffer();
buffer = await LoadWithoutCors(media_url); // Load via Tauri!
} catch (error) {
console.error("load GLB from Tauri error:", error);
reject(error);
@@ -863,9 +872,7 @@ class Scene {
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)).arrayBuffer();
buffer = await LoadWithoutCors(media_url); // Load via Tauri!
} catch (error) {
console.error("load Splat from Tauri error:", error);
reject(error);
@@ -1110,6 +1117,17 @@ class Scene {
this.scene.add(this.groundPlane);
}
// Update grid visibility based on the signal
updateGridVisibility() {
if (gridVisibility.value) {
if (!this.scene.children.includes(this.gridHelper)) {
this.scene.add(this.gridHelper);
}
} else {
this.scene.remove(this.gridHelper);
}
// Keep the ground plane regardless of grid visibility
}
}
export default Scene;
@@ -0,0 +1,577 @@
import * as THREE from "three";
import {
IconDefinition,
faCamera,
faCube,
faPerson,
} from "@fortawesome/pro-solid-svg-icons";
import Scene from "./scene";
import { MouseControls } from "./keybinds_controls";
import { ClipGroup } from "~/enums";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { MediaItem } from "~/pages/PageEnigma/models";
import { AssetType } from "~/enums";
import {
CommandInputTypes,
CreationSceneItem,
DeletionData,
DeletionSceneItem,
ICommand,
SceneState,
TransformData,
TransformSceneItem,
UserData,
UserDataSceneItem,
} from "./Commands";
export type SceneObject = {
id: string;
icon: IconDefinition;
name: string;
type: string;
visible: boolean;
locked: boolean;
};
export interface SceneManagerAPI {
create(media_token: string, name: string, position: THREE.Vector3): void;
retrieve(object_uuid: string): void;
delete(object_uuid: string): void;
update(
object_uuid: string,
position: THREE.Vector3,
rotation: THREE.Euler,
scale: THREE.Vector3,
): void;
undo(): void;
redo(): void;
selected(): void;
render_outliner(timeline_characters: { [key: string]: ClipGroup }): void;
onMouseMove(event: MouseEvent): void;
onMouseClick(): void;
onKeyDown(event: KeyboardEvent): void;
onMouseDown(event: MouseEvent): void;
onMouseUp(event: MouseEvent): void;
select_object(id: string): void;
}
type UpdateOutliner = () => void;
export class SceneManager implements SceneManagerAPI {
scene: Scene;
mouse_controls: MouseControls;
version: number;
selected_objects: THREE.Object3D[] | undefined;
private updateOutliner: UpdateOutliner;
public undoStack: ICommand<CommandInputTypes>[] = [];
private undoIndex: number = 0;
private lastSceneState: SceneState;
private copiedObject: THREE.Object3D | undefined;
private is_character: Function;
private devMode: boolean;
constructor(
version: number,
mouse_controls: MouseControls,
scene: Scene,
devMode: boolean = false,
updateOutliner: UpdateOutliner,
is_character: Function,
) {
this.mouse_controls = mouse_controls;
this.scene = scene;
this.version = version;
this.lastSceneState = this.getSceneState();
this.updateOutliner = updateOutliner;
this.is_character = is_character;
this.devMode = devMode;
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseClick = this.onMouseClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
}
public attachEventListeners() {
if (!this.devMode) {
return;
}
window.addEventListener("mousemove", this.onMouseMove, false);
window.addEventListener("click", this.onMouseClick, false);
window.addEventListener("keydown", this.onKeyDown, false);
window.addEventListener("mousedown", this.onMouseDown, false);
window.addEventListener("mouseup", this.onMouseUp, false);
}
public detachEventListeners() {
if (!this.devMode) {
return;
}
window.removeEventListener("mousemove", this.onMouseMove, false);
window.removeEventListener("click", this.onMouseClick, false);
window.removeEventListener("keydown", this.onKeyDown, false);
window.removeEventListener("mousedown", this.onMouseDown, false);
window.removeEventListener("mouseup", this.onMouseUp, false);
}
public async undo() {
if (this.undoIndex >= this.undoStack.length) {
return;
}
this.undoIndex += 1;
const undoCommand = this.undoStack.at(
this.undoStack.length - this.undoIndex,
);
await undoCommand?.undo();
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
this.lastSceneState = this.getSceneState();
}
public async redo() {
if (this.undoIndex <= 0) {
return;
}
const undoCommand = this.undoStack.at(
this.undoStack.length - this.undoIndex,
);
await undoCommand?.redo();
this.undoIndex -= 1;
this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class.
this.lastSceneState = this.getSceneState();
}
// Drop any redo-future before pushing a fresh command so the new action
// replaces the branch of history the user had undone past.
private dropRedoHistory() {
if (this.undoIndex > 0) {
this.undoStack = this.undoStack.slice(
0,
this.undoStack.length - this.undoIndex,
);
this.undoIndex = 0;
}
}
public async create(
media_token: string,
name: string,
position: THREE.Vector3,
): Promise<THREE.Object3D<THREE.Object3DEventMap> | undefined> {
if (media_token.includes("SKY::")) {
const token = media_token.replace("SKY::", "");
this.scene.updateSkybox(token);
} else if (media_token !== "Parim") {
// Support direct image/video URL pastes via the Image::<url> scheme
if (media_token.startsWith("Image::")) {
const url = media_token.replace("Image::", "");
const obj = await this.scene.loadObjectFromUrl(url, position);
if (obj) {
obj.name = name;
obj.userData["name"] = name;
}
return obj as THREE.Object3D<THREE.Object3DEventMap>;
}
const obj = await this.scene.loadObject(
media_token,
name,
true,
position,
this.version,
);
if (obj) {
obj.name = name;
obj.userData["name"] = name;
}
return obj as THREE.Object3D<THREE.Object3DEventMap> | undefined;
} else {
return this.scene.instantiate(name, position);
}
}
updateSkybox(media_id: string) {
this.scene.updateSkybox(media_id);
}
/* NEVER CALL THIS INTERNALLY */
public render_outliner(timeline_characters: { [key: string]: ClipGroup }) {
// needs timeline_characters to render favicons.
// Not permanent just in place until we have multi object select ability.
const selected_item = this.selected();
const signal_items: SceneObject[] = [];
this.scene.scene.children.forEach((child) => {
const converted = this.convert_object(child, timeline_characters);
if (converted.name !== "") {
signal_items.push(converted);
}
});
const outlinerState = {
selectedItem: selected_item,
items: signal_items,
};
return outlinerState;
}
public async retrieve(object_uuid: string) {
return this.scene.get_object_by_uuid(object_uuid);
}
public async update(
object_uuid: string,
position: THREE.Vector3,
rotation: THREE.Euler,
scale: THREE.Vector3,
) {
const object = await this.retrieve(object_uuid);
if (object) {
object.position.copy(position);
object.rotation.copy(rotation);
object.scale.copy(scale);
}
}
public async delete(object_uuid: string) {
// Deletes an object.
this.mouse_controls.deleteObject(object_uuid);
}
public async double_click() {
this.mouse_controls.focus();
}
public async hideObject(object_uuid: string) {
const object = await this.retrieve(object_uuid);
if (object?.visible !== undefined) {
object.visible = !object.visible;
object.userData["visible"] = object.visible;
}
}
public selected() {
let selected_item = null;
if (this.selected_objects && this.selected_objects.length > 0) {
selected_item = this.selected_objects[0];
}
if (selected_item) {
return this.convert_object(selected_item, {});
}
return null;
}
public select_object(id: string) {
const object = this.scene.get_object_by_uuid(id);
if (object) {
this.mouse_controls.selected = [object];
this.mouse_controls.selectObject(object);
}
}
// Converts a 3d object to signal item format.
private convert_object(
object: THREE.Object3D,
timeline_characters: { [key: string]: ClipGroup },
) {
let faicon = faCube;
let name = object.name;
if (object.name == "::CAM::") {
faicon = faCamera;
name = "Camera";
} else if (object.uuid in timeline_characters) {
faicon = faPerson;
}
let locked = object.userData["locked"];
if (locked == undefined) {
locked = false;
}
return {
id: object.uuid,
icon: faicon,
name: name.charAt(0).toUpperCase() + name.slice(1),
type: object.type,
visible: object.visible,
locked: object.userData["locked"],
};
}
public async copy() {
const object = this.mouse_controls.selected?.at(0);
if (object !== undefined && object.name !== "::CAM::") {
this.copiedObject = object;
}
}
public async paste() {
if (this.copiedObject && this.copiedObject.name != "::CAM::") {
const userdata = this.copiedObject.userData;
const position = this.copiedObject.position.clone();
const rotation = this.copiedObject.rotation.clone();
const scale = this.copiedObject.scale.clone();
const media_id = userdata["media_id"];
const color = userdata["color"];
const name = this.copiedObject.name;
const wasCharacter: boolean = !!this.copiedObject.userData.isCharacter;
const obj = await this.create(media_id, name, position);
if (!obj) {
return;
}
this.scene.setColor(obj.uuid, color);
obj.position.copy(position.add(new THREE.Vector3(0.5, 0.0, 0.5)));
obj.rotation.copy(rotation);
obj.scale.copy(scale);
// Preserve character stuff on pasted object
if (wasCharacter) {
obj.userData.isCharacter = true;
}
this.mouse_controls.selectObject(obj);
this.updateOutliner();
if (wasCharacter) {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_CHARACTER_ID,
data: {
version: 1,
type: AssetType.CHARACTER,
media_id: media_id,
object_uuid: obj.uuid,
name: name,
} as MediaItem,
});
} else {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.ADD_OBJECT,
data: {
version: 1,
type: AssetType.OBJECT,
media_id: media_id,
object_uuid: obj.uuid,
name: name,
} as MediaItem,
});
}
await this.copy();
await this.add_creation_undostack(obj);
}
}
public onMouseMove(event: MouseEvent) {
this.mouse_controls.onMouseMove(event);
}
public onMouseClick() {
this.mouse_controls.onMouseClick();
}
public onKeyDown(event: KeyboardEvent) {
this.mouse_controls.onkeydown(event);
}
public onMouseDown(event: MouseEvent) {
this.mouse_controls.onMouseDown(event);
}
public onMouseUp(event: MouseEvent) {
this.mouse_controls.onMouseUp(event);
this.check_scene_for_updates();
}
private getSceneState(): SceneState {
const scene_objects: string[] = [];
const scene_names: string[] = [];
const scene_positions: string[] = [];
const scene_userdata: string[] = [];
const scene_rotations: string[] = [];
const scene_scales: string[] = [];
this.scene.scene.children.forEach((child) => {
if (child.name != "") {
scene_objects.push(child.uuid);
scene_names.push(child.name);
scene_userdata.push(JSON.stringify(child.userData));
scene_positions.push(JSON.stringify(child.position.clone()));
scene_rotations.push(JSON.stringify(child.rotation.clone()));
scene_scales.push(JSON.stringify(child.scale.clone()));
}
});
return {
scene_objects: scene_objects,
scene_names: scene_names,
scene_userdata: scene_userdata,
scene_positions: scene_positions,
scene_rotations: scene_rotations,
scene_scales: scene_scales,
};
}
private is_creation(sceneState: SceneState): string {
let resp = "";
sceneState.scene_objects.forEach((object) => {
if (!this.lastSceneState.scene_objects.includes(object)) {
resp = object;
return resp;
}
});
return resp;
}
private is_deletion(sceneState: SceneState): string {
let resp = "";
this.lastSceneState.scene_objects.forEach((object) => {
if (sceneState.scene_objects.includes(object) == false) {
resp = object;
return resp;
}
});
return resp;
}
private is_userdata(sceneState: SceneState): string {
let resp = "";
this.lastSceneState.scene_userdata.forEach((object) => {
if (sceneState.scene_userdata.includes(object) == false) {
const respIdx = this.lastSceneState.scene_userdata.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
return resp;
}
private is_transformation(sceneState: SceneState): string {
let resp = "";
if (resp === "") {
sceneState.scene_positions.forEach((object) => {
if (this.lastSceneState.scene_positions.includes(object) == false) {
const respIdx = sceneState.scene_positions.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
}
if (resp === "") {
sceneState.scene_rotations.forEach((object) => {
if (this.lastSceneState.scene_rotations.includes(object) == false) {
const respIdx = sceneState.scene_rotations.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
}
if (resp === "") {
sceneState.scene_scales.forEach((object) => {
if (this.lastSceneState.scene_scales.includes(object) == false) {
const respIdx = sceneState.scene_scales.indexOf(object);
resp = sceneState.scene_objects[respIdx];
return sceneState.scene_objects[respIdx];
}
});
}
return resp;
}
public async add_creation_undostack(object: THREE.Object3D) {
this.dropRedoHistory();
this.undoStack.push(
new CreationSceneItem(this, {
object_uuid: object.uuid,
name: object.name,
position: object.position.clone(),
userdata: object.userData,
}),
);
this.lastSceneState = this.getSceneState();
}
/* Can be called Internally or Externally for undo redo stack */
// This function allows you to capture transform states and occurs on mouse up.
public async check_scene_for_updates() {
const sceneState = this.getSceneState();
if (JSON.stringify(this.lastSceneState) !== JSON.stringify(sceneState)) {
// Add Scene state for undo redo here.
const is_deleted = this.is_deletion(sceneState);
const is_transform = this.is_transformation(sceneState);
const is_userdata = this.is_userdata(sceneState);
this.dropRedoHistory();
this.undoStack = this.undoStack.slice(-16);
if (is_deleted != "") {
const objectIndex =
this.lastSceneState.scene_objects.indexOf(is_deleted);
const name = this.lastSceneState.scene_names[objectIndex];
const position = this.lastSceneState.scene_positions[objectIndex];
const userdata = JSON.parse(
this.lastSceneState.scene_userdata[objectIndex],
);
const undoPush: DeletionData = {
object_uuid: is_deleted,
name: name,
position: JSON.parse(position),
userdata: userdata,
};
this.undoStack.push(new DeletionSceneItem(this, undoPush));
} else if (is_userdata != "") {
const objectIndex = sceneState.scene_objects.indexOf(is_userdata);
const startUserdata = JSON.parse(
this.lastSceneState.scene_userdata[objectIndex],
);
const userdata = JSON.parse(sceneState.scene_userdata[objectIndex]);
const endPush: UserData = {
object_uuid: is_userdata,
color: userdata["color"],
locked: userdata["locked"],
visible: userdata["visible"],
};
const startPush: UserData = {
object_uuid: is_userdata,
color: startUserdata["color"],
locked: startUserdata["locked"],
visible: startUserdata["visible"],
};
this.undoStack.push(new UserDataSceneItem(this, startPush, endPush));
} else if (is_transform != "") {
const objectIndexStart =
this.lastSceneState.scene_objects.indexOf(is_transform);
if (
this.lastSceneState.scene_positions[objectIndexStart] !== undefined
) {
const objectIndexEnd = sceneState.scene_objects.indexOf(is_transform);
const startPush: TransformData = {
object_uuid: is_transform,
position: JSON.parse(
this.lastSceneState.scene_positions[objectIndexStart],
),
rotation: JSON.parse(
this.lastSceneState.scene_rotations[objectIndexStart],
),
scale: JSON.parse(
this.lastSceneState.scene_scales[objectIndexStart],
),
};
const endPush: TransformData = {
object_uuid: is_transform,
position: JSON.parse(sceneState.scene_positions[objectIndexEnd]),
rotation: JSON.parse(sceneState.scene_rotations[objectIndexEnd]),
scale: JSON.parse(sceneState.scene_scales[objectIndexEnd]),
};
this.undoStack.push(new TransformSceneItem(this, startPush, endPush));
}
}
}
this.lastSceneState = sceneState;
}
}
@@ -0,0 +1,950 @@
import * as THREE from "three";
import { ClipUI } from "../clips/clip_ui";
import Scene from "./scene.js";
import AudioEngine from "./Engines/audio_engine";
import TransformEngine from "./Engines/transform_engine";
import LipSyncEngine from "./Engines/lip_sync_engine";
import Queue, {
UnionedActionTypes,
UnionedDataTypes,
} from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "../Queue/QueueNames";
import { toEngineActions } from "../Queue/toEngineActions";
import { fromEngineActions } from "../Queue/fromEngineActions";
import { ClipGroup, ClipType, AssetType } from "~/enums";
import { CameraAspectRatio, MediaFileType } from "~/pages/PageEnigma/enums";
import { Keyframe, MediaItem, UpdateTime } from "~/pages/PageEnigma/models";
import Editor from "~/pages/PageEnigma/Editor/editor";
import EmotionEngine from "./Engines/emotion_engine";
import { IGenerationOptions } from "~/pages/PageEnigma/models/generationOptions";
import { Vector3 } from "three";
import { filmLength, outlinerState } from "../signals";
import { CharacterAnimationEngine } from "./Engines/CharacterAnimationEngine";
import { SplatMesh } from "@sparkjsdev/spark";
export class TimeLine {
editorEngine: Editor;
timeline_items: ClipUI[];
timeline_limit: number;
absolute_end: number;
scrubber_frame_position: number;
is_playing: boolean;
is_repeating: boolean = true;
// plays audio
audio_engine: AudioEngine;
// key framing
transform_engine: TransformEngine;
// animation engine
animation_engine: CharacterAnimationEngine;
// lip sync engine
lipSync_engine: LipSyncEngine;
// emotion engine
emotion_engine: EmotionEngine;
// characters
characters: { [key: string]: ClipGroup };
scene: Scene;
camera: THREE.Camera | null;
mouse: THREE.Vector2 | undefined;
current_time: number;
camera_name: string;
// ensure that the elements are loaded first.
private debounce_generate_video: boolean;
constructor(
editor: Editor,
audio_engine: AudioEngine,
transform_engine: TransformEngine,
lipsync_engine: LipSyncEngine,
animation_engine: CharacterAnimationEngine,
emotion_engine: EmotionEngine,
scene: Scene,
camera: THREE.Camera | null,
mouse: THREE.Vector2 | undefined,
camera_name: string,
) {
this.editorEngine = editor;
this.timeline_items = [];
this.characters = {};
this.absolute_end = 60 * 12;
this.timeline_limit = this.getTimelineEnd();
this.camera = camera;
this.mouse = mouse;
this.is_playing = false;
this.scrubber_frame_position = 0; // in frames into the tl
// this will be used to play the audio clips
this.audio_engine = audio_engine;
this.transform_engine = transform_engine;
this.lipSync_engine = lipsync_engine;
this.animation_engine = animation_engine;
this.emotion_engine = emotion_engine;
this.scene = scene;
this.debounce_generate_video = false;
Queue.subscribe(
QueueNames.TO_ENGINE,
"engine",
this.handleTimelineActions.bind(this),
);
this.current_time = 0;
this.camera_name = camera_name;
}
public async updateUI() {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_TIME_LINE,
data: this.timeline_items,
});
}
public async pushEvent(action: fromEngineActions, data: UnionedDataTypes) {
//this.current_time += 0.75;
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_TIME,
data: data,
});
}
public isCharacter(uuid: string): boolean {
this.timeline_items.forEach((clip) => {
if (clip.group == ClipGroup.CHARACTER)
this.characters[clip.object_uuid] = ClipGroup.CHARACTER;
});
let result: boolean = false;
for (const key in this.characters) {
if (key === uuid) {
result = true;
return true;
}
}
return result;
}
public async handleTimelineActions(data: {
action: UnionedActionTypes;
data: UnionedDataTypes;
}) {
const action = data.action;
switch (action) {
case toEngineActions.ADD_KEYFRAME:
await this.addKeyFrame(data.data as Keyframe);
break;
case toEngineActions.UPDATE_KEYFRAME:
await this.updateKeyFrame(data.data as Keyframe);
break;
case toEngineActions.DELETE_KEYFRAME:
await this.deleteKeyFrame(data.data as Keyframe);
break;
case toEngineActions.ADD_CLIP:
await this.addClip(data.data as ClipUI);
break;
case toEngineActions.DELETE_CLIP:
await this.deleteClip(data.data as ClipUI);
break;
case toEngineActions.UPDATE_CLIP:
await this.updateClip(data.data as ClipUI);
break;
case toEngineActions.UPDATE_TIME:
await this.scrub(data.data as UpdateTime);
break;
case toEngineActions.MUTE:
await this.mute(data.data as ClipUI, false);
break;
case toEngineActions.UNMUTE:
await this.mute(data.data as ClipUI, true);
break;
// Create operations
case toEngineActions.ADD_CHARACTER: {
const newObject = await this.addCharacter(data.data as MediaItem);
if (newObject)
this.queueNewObjectMessage(
newObject,
data.data as MediaItem,
AssetType.CHARACTER,
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
);
if (result) outlinerState.items.value = result.items;
break;
}
case toEngineActions.ADD_OBJECT: {
const newObject = await this.addObject(data.data as MediaItem);
if (newObject)
this.queueNewObjectMessage(
newObject,
data.data as MediaItem,
newObject.type as AssetType
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
);
if (result) outlinerState.items.value = result.items;
break;
}
case toEngineActions.ADD_SHAPE: {
const newShape = await this.addShape(data.data as MediaItem);
this.queueNewObjectMessage(
newShape,
data.data as MediaItem,
AssetType.SHAPE,
);
const result = this.editorEngine.sceneManager?.render_outliner(
this.characters,
);
if (result) outlinerState.items.value = result.items;
break;
}
case toEngineActions.ENTER_PREVIEW_STATE:
await this.editorEngine.switchPreview();
break;
case toEngineActions.ENTER_EDIT_STATE:
this.editorEngine.switchEdit();
break;
case toEngineActions.TOGGLE_CAMERA_STATE:
this.editorEngine.switchCameraView();
break;
case toEngineActions.TOGGLE_REPEATING:
this.is_repeating = !this.is_repeating;
break;
case toEngineActions.REFRESH_PREVIEW:
if (this.editorEngine.switchPreviewToggle) {
await this.editorEngine.generateFrame();
}
break;
case toEngineActions.GENERATE_VIDEO: {
console.log("Calling Generate Video");
// debounce generate video ...
if (this.debounce_generate_video == false) {
this.debounce_generate_video = true;
const options = data.data; // super overloaded talk to the devs about this. TODO... refactor
// pass this in ... rather than doing it implicitly ...
this.editorEngine.generation_options = options as IGenerationOptions;
await this.editorEngine.generateVideo();
this.debounce_generate_video = false;
}
console.log("Generate Video Press Event");
break;
}
case toEngineActions.CHANGE_CAMERA_ASPECT_RATIO: {
this.editorEngine.changeRenderCameraAspectRatio(
data.data as CameraAspectRatio,
);
break;
}
default:
console.log("Action Not Wired", action);
}
}
public async addCharacter(data: MediaItem) {
const media_id = data.media_id;
const name = data.name;
const pos = this.getPos();
const new_data = { ...data };
console.debug('Adding to engine scene manager: ', media_id, name, pos);
const obj = await this.editorEngine.sceneManager?.create(
media_id,
name,
pos,
);
if (obj) {
obj.userData["name"] = name;
obj.name = name;
obj.position.copy(pos);
obj.userData.isCharacter = true;
console.debug("Added character object to timeline; object: ", obj);
// TODO(bt,2025-01-28): Hack for debugging only. Remove soon.
(window as any).lastCharacter = obj;
// TODO(bt,2025-01-28): Hack for debugging only. Remove soon.
const lastRig = obj.children[0];
if (!!lastRig && lastRig.name.includes("gamerig")) {
(window as any).lastRig = lastRig;
}
const object_uuid = obj.uuid;
this.characters[object_uuid] = ClipGroup.CHARACTER; // TODO: Create a class to make the idea of a character.
new_data["object_uuid"] = object_uuid;
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.UPDATE_CHARACTER_ID,
data: new_data,
});
this.addPlayableClip(
new ClipUI(
data.version,
ClipType.FAKE,
ClipGroup.CHARACTER,
"Default",
media_id,
obj.uuid,
obj.uuid,
name,
0,
0,
0,
obj.userData["media_file_type"],
),
);
await this.editorEngine.sceneManager?.add_creation_undostack(obj);
}
return obj;
}
queueNewObjectMessage(
item: THREE.Object3D<THREE.Object3DEventMap>,
data: MediaItem,
asset_type: AssetType.OBJECT | AssetType.CHARACTER | AssetType.SHAPE | AssetType.SPLAT,
) {
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.ADD_OBJECT,
data: {
media_id: data.media_id,
type: asset_type,
name: item.name,
object_uuid: item.uuid,
version: 1,
} as MediaItem,
});
// this.addPlayableClip(
// new ClipUI(
// data["version"],
// ClipType.FAKE,
// ClipGroup.OBJECT,
// "Default",
// data.media_id,
// item.uuid,
// item.uuid,
// item.name,
// 0,
// 0,
// 0,
// this.scene.get_object_by_uuid(item.uuid)?.userData[
// "media_file_type"
// ],
// ),
// );
}
public getPos() {
this.editorEngine.utils.removeTransformControls(true);
const raycaster = new THREE.Raycaster();
raycaster.layers.enable(0);
raycaster.layers.enable(1);
if (this.editorEngine.mouse && this.camera) {
raycaster.setFromCamera(this.editorEngine.mouse, this.camera);
const intersects = raycaster.intersectObjects(
this.scene.scene.children,
true,
);
if (intersects.length > 0) {
return intersects[0].point;
}
}
return new THREE.Vector3(0, 0, 0);
}
public async addObject(data: MediaItem & { position?: { x: number; y: number; z: number } }) {
const dataPos = data.position;
const pos = dataPos
? new THREE.Vector3(dataPos.x, dataPos.y, dataPos.z)
: this.getPos();
const media_id = data.media_id;
const name = data.name;
const obj = await this.editorEngine.sceneManager?.create(
media_id,
name,
pos,
);
if (obj) {
obj.userData["name"] = name;
obj.name = name;
obj.position.copy(pos);
await this.editorEngine.sceneManager?.add_creation_undostack(obj);
}
return obj;
}
// This method is for local dev testing of objects
// and bypasses the queue system, that's kinda redundant anyway
public addLocalSplat(fileBytes: ArrayBuffer | Uint8Array, flipVertical: boolean = false) {
const splatMesh = new SplatMesh({ fileBytes: fileBytes });
splatMesh.name = `{SplatMesh-${Date.now()}}`;
if (flipVertical) {
splatMesh.rotateX(Math.PI);
}
this.editorEngine.sceneManager?.scene.scene.add(splatMesh);
}
public async addShape(data: MediaItem) {
const pos = this.getPos();
const parim = await this.editorEngine.create_parim(data.media_id, pos);
await this.editorEngine.sceneManager?.add_creation_undostack(parim);
return parim;
}
public async addKeyFrame(data: Keyframe) {
// KeyFrame Object
// version: number;
// clip_uuid: string;
// group: ClipGroup;
// object_uuid?: string;
// offset: number;
// position: XYZ
// rotation: XYZ;
// scale: XYZ;
// selected?: boolean;
const data_json = data;
const uuid = data_json.object_uuid;
const keyframe_uuid = data_json.keyframe_uuid;
let object_name = this.scene.get_object_by_uuid(uuid)?.name;
if (object_name === undefined) {
object_name = "undefined";
}
this.transform_engine.addFrame(
uuid,
data_json.offset,
data_json.position as Vector3,
data_json.rotation as Vector3,
data_json.scale as Vector3,
data_json.offset,
data_json.keyframe_uuid,
);
await this.addPlayableClip(
new ClipUI(
data_json["version"],
ClipType.TRANSFORM,
data_json["group"],
object_name,
"",
keyframe_uuid,
uuid,
object_name,
0,
data_json["offset"],
data_json["offset"],
MediaFileType.None,
),
);
const point = this.scene.createPoint(
data_json.position as Vector3,
data_json.rotation as Vector3,
data_json.scale as Vector3,
data_json.keyframe_uuid,
);
if (this.editorEngine.camera_person_mode) {
point.visible = false;
}
this.checkEditorCanPlay();
}
public checkEditorCanPlay() {
this.editorEngine.can_playback = this.getLastClipEnd() > 1;
this.editorEngine.updateSelectedUI();
}
// This method is NOT responsible for deletion of the object from scene or other editor parts
// It is only responsible for deleting the object from the timeline and its corresponding elements
public deleteObject(object: THREE.Object3D<THREE.Object3DEventMap>) {
if (!object) {
return
}
if (object.name === this.camera_name) {
return;
}
const object_uuid = object.uuid;
this.timeline_items.forEach((element) => {
if (
element.type == ClipType.TRANSFORM &&
element.object_uuid == object_uuid
) {
this.scene.deletePoint(element.clip_uuid);
}
});
// TODO: In the future object should have relations that can be deleted inside the engines
// and the engines handle the deletion of objects.
// Character Animation Engine already does this.
this.timeline_items = this.timeline_items.filter(
(element) => element.object_uuid !== object_uuid,
);
this.animation_engine.removeCharacter(object);
// Update react land here.
}
public async addClip(data: ClipUI) {
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const name = data.name;
const group = data.group;
const version = 1;
const type = data.type;
const offset = data.offset;
const end_offset = data.length + offset;
const object = this.scene.get_object_by_uuid(object_uuid);
const object_name =
object?.name ?? "undefined";
const clip_uuid = data.clip_uuid;
switch (type) {
case "animation":
await this.animation_engine.addCharacterAnimationMedia(object!, media_id, data);
this.animation_engine.evaluate(this.current_time, this.timeline_limit);
break;
case "transform":
this.transform_engine.loadObject(object_uuid, data.length);
break;
case "expression":
this.emotion_engine.loadClip(object_uuid, media_id);
break;
case "audio":
if (group === "character") {
this.lipSync_engine.load_object(object_uuid, media_id);
// media id for this as well it can be downloaded
this.addPlayableClip(
new ClipUI(
version,
ClipType.AUDIO,
ClipGroup.CHARACTER,
name,
media_id,
clip_uuid,
object_uuid,
object_name,
offset,
end_offset,
0, // length
this.scene.get_object_by_uuid(object_uuid)?.userData[
"media_file_type"
],
),
);
return;
} else {
this.audio_engine.loadClip(media_id);
}
break;
}
// media id for this as well it can be downloaded
this.addPlayableClip(
new ClipUI(
version,
type,
group,
name,
media_id,
clip_uuid,
object_uuid,
object_name,
offset,
end_offset, // length
0,
this.scene.get_object_by_uuid(object_uuid)?.userData["media_file_type"],
),
);
this.checkEditorCanPlay();
}
public async addSelfAnimationClip(
data: ClipUI,
animation_clip: THREE.AnimationClip,
) {
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const name = data.name;
const group = data.group;
const version = 1;
const type = data.type;
const offset = data.offset;
const end_offset = data.length + offset;
const object = this.scene.get_object_by_uuid(object_uuid);
const object_name =
object?.name ?? "undefined";
const clip_uuid = data.clip_uuid;
this.animation_engine.addCharacterAnimation(object!, animation_clip, data);
// media id for this as well it can be downloaded
this.addPlayableClip(
new ClipUI(
version,
type,
group,
name,
media_id,
clip_uuid,
object_uuid,
object_name,
offset,
end_offset, // length
0,
this.scene.get_object_by_uuid(object_uuid)?.userData["media_file_type"],
),
);
this.checkEditorCanPlay();
this.updateUI();
}
public async deleteKeyFrame(data: Keyframe) {
const keyframe_uuid = data.keyframe_uuid;
const object_uuid = data.object_uuid;
this.transform_engine.clips[object_uuid].removeKeyframe(keyframe_uuid);
this.scene.deletePoint(keyframe_uuid);
for (const element of this.timeline_items) {
if (
element.clip_uuid === keyframe_uuid &&
element.object_uuid === object_uuid
) {
this.timeline_items = this.timeline_items.filter(
(element) =>
!(
element.clip_uuid === keyframe_uuid &&
element.object_uuid === object_uuid
),
);
break;
}
}
this.checkEditorCanPlay();
}
public async updateKeyFrame(data: Keyframe) {
const keyframe_uuid = data.keyframe_uuid;
const keyframe_offset = data.offset;
const object_uuid = data.object_uuid;
const keyframe_pos = data.position;
const keyframe_rot = data.rotation;
const keyframe_scl = data.scale;
this.transform_engine.clips[object_uuid].setOffset(
keyframe_uuid,
keyframe_offset,
);
this.transform_engine.clips[object_uuid].setTransform(
keyframe_uuid,
keyframe_pos as Vector3,
keyframe_rot as Vector3,
keyframe_scl as Vector3,
);
this.scene.updatePoint(
keyframe_uuid,
keyframe_pos as Vector3,
keyframe_rot as Vector3,
);
this.checkEditorCanPlay();
}
public async updateClip(data: ClipUI) {
// only length and offset changes here.
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const offset = data.offset;
const length = data.length + offset;
const clip_uuid = data.clip_uuid;
for (const element of this.timeline_items) {
if (
element.media_id === media_id &&
element.object_uuid === object_uuid &&
element.clip_uuid == clip_uuid
) {
element.length = length;
element.offset = offset;
}
}
this.checkEditorCanPlay();
}
public async deleteClip(data: ClipUI) {
const object_uuid = data.object_uuid;
const media_id = data.media_id;
const clip_uuid = data.clip_uuid;
// Remove the clip from the items array
for (let i = 0; i < this.timeline_items.length; i++) {
const element = this.timeline_items[i];
if (
element.media_id === media_id &&
element.object_uuid === object_uuid &&
element.clip_uuid == clip_uuid
) {
this.timeline_items.splice(i, 1);
break;
}
}
// Once the clip is removed, we need to make sure the engines aren't tracking it either
// TODO: Ideally the engines should just handle this individually and we check them based on clip type/group
// This way we won't need to iterate over all timeline items
if (data.type === ClipType.ANIMATION) {
const object = this.scene.get_object_by_uuid(object_uuid)!;
this.animation_engine.removeAnimation(object, data)
}
this.checkEditorCanPlay();
}
public async mute(data: ClipUI, isMute: boolean) {
this.timeline_items.forEach((element) => {
if (element.group === data.group) {
element.should_play = isMute;
}
});
}
public async addPlayableClip(clip: ClipUI): Promise<void> {
this.timeline_items.push(clip);
}
public async scrub(data: UpdateTime): Promise<void> {
if (this.is_playing) {
return;
}
const value = Math.floor(data.currentTime);
await this.setScrubberPosition(value);
this.current_time = value;
await this.update();
if (this.editorEngine.switchPreviewToggle) {
await this.editorEngine.generateFrame();
}
}
// public streaming events into the timeline from
public async setScrubberPosition(offset: number) {
this.scrubber_frame_position = offset; // in ms
}
// should play from the clip that is closest to the to scrubber
public async play(): Promise<void> {
console.log(`Play - Starting Timeline`);
this.is_playing = true;
}
public async resetScene() {
for (const element of this.timeline_items) {
if (element.type === ClipType.TRANSFORM) {
const object = this.scene.get_object_by_uuid(element.object_uuid);
if (object && this.transform_engine.clips[element.object_uuid]) {
this.transform_engine.clips[element.object_uuid].reset(object);
}
} else if (
element.type === ClipType.AUDIO &&
element.group !== ClipGroup.CHARACTER
) {
this.audio_engine.loadClip(element.media_id);
this.audio_engine.stopClip(element.media_id);
} else if (element.type === ClipType.ANIMATION) {
this.animation_engine.stop();
} else if (
element.type === ClipType.AUDIO &&
element.group === ClipGroup.CHARACTER
) {
this.lipSync_engine.clips[
element.object_uuid + element.media_id
].stop();
this.lipSync_engine.clips[
element.object_uuid + element.media_id
].reset();
} else if (element.type === ClipType.EXPRESSION) {
const object = this.scene.get_object_by_uuid(element.object_uuid);
if (object)
this.emotion_engine.clips[
element.object_uuid + element.media_id
].reset(object);
}
}
}
public getLastClipEnd(): number {
let longest = 0;
for (const element of this.timeline_items) {
if (longest < element.length) {
longest = element.length;
}
}
return longest;
}
public getTimelineEnd(): number {
return filmLength.peek() * 1000;
}
// called by the editor update loop on each frame
public async update(
isRendering = false,
delta_time: number = 0,
): Promise<boolean> {
//if (this.is_playing === false) return; // start and stop
this.timeline_limit = this.getTimelineEnd();
if (this.is_playing) {
this.current_time += delta_time * 1000;
this.pushEvent(fromEngineActions.UPDATE_TIME, {
currentTime: this.current_time,
});
this.scrubber_frame_position = this.current_time;
}
if (this.scrubber_frame_position <= 0) {
for (const video_plane of this.scene.video_planes) {
video_plane.currentTime = 0;
}
await this.resetScene();
}
for (const video_plane of this.scene.video_planes) {
// Caps to 10fps so that the buffering issue is solved and it plays i am not sure how to fix this.
// TODO: Fix buffering and make 30 fps.
const fixedPoint = 1;
let pb = parseFloat(
(this.scrubber_frame_position / this.editorEngine.cap_fps).toFixed(
fixedPoint,
),
);
pb = parseFloat((pb % video_plane.duration).toFixed(fixedPoint));
if (video_plane.currentTime !== pb) {
video_plane.playbackRate = 6;
video_plane.currentTime = pb;
}
}
//this.scrubber_frame_position += 1;
//2. allow stopping.
//3. smallest unit is a frame and it is set by the scene and is in fps, our videos will be 60fps but we can reprocess them using the pipeline.
// Since the animation engine is newer we can just call an evaluation on it instead of having the timeline process it
this.animation_engine.evaluate(this.current_time, this.timeline_limit);
// Iterate over the clips and play them as fit
for (const element of this.timeline_items) {
if (
element.offset <= this.scrubber_frame_position &&
this.scrubber_frame_position <= element.length &&
element.should_play
) {
// run async
// element.play()
// remove the element from the list
const object = this.scene.get_object_by_uuid(element.object_uuid);
if (element.type === ClipType.TRANSFORM) {
if (object && this.transform_engine.clips[element.object_uuid]) {
this.transform_engine.clips[element.object_uuid].step(
object,
element.offset,
this.scrubber_frame_position,
this.scene,
);
element.length =
this.transform_engine.clips[element.object_uuid].length;
}
} else if (
element.type === ClipType.AUDIO &&
element.group !== ClipGroup.CHARACTER &&
this.is_playing &&
!isRendering
) {
// if (this.scrubber_frame_position + 1 >= element.length) {
// this.audio_engine.playClip(element.media_id);
// } else {
// await this.audio_engine.step(
// element.object_uuid + element.media_id,
// this.scrubber_frame_position, element.offset);
// }
this.audio_engine.playClip(element.media_id);
await this.audio_engine.step(
element.media_id,
this.scrubber_frame_position,
element.offset,
);
} else if (
element.type === ClipType.AUDIO &&
element.group === ClipGroup.CHARACTER &&
this.is_playing
) {
if (object) {
await this.lipSync_engine.clips[
element.object_uuid + element.media_id
].play(object);
this.lipSync_engine.clips[
element.object_uuid + element.media_id
].step(this.scrubber_frame_position, element.offset);
}
} else if (element.type === ClipType.EXPRESSION) {
if (object) {
await this.emotion_engine.clips[
object.uuid + element.media_id
].step(this.scrubber_frame_position - element.offset, object);
}
}
}
}
if (
this.scrubber_frame_position >= this.timeline_limit &&
this.is_playing
) {
await this.stop();
return true;
}
return false;
}
private async stop(): Promise<void> {
await this.resetScene();
this.is_playing = false;
console.log(`Stop - Stopping Timeline`);
this.current_time = 0;
this.update();
this.pushEvent(fromEngineActions.UPDATE_TIME, {
currentTime: this.current_time,
});
}
}
@@ -0,0 +1,474 @@
import Editor from "./editor";
import { EditorStates } from "~/pages/PageEnigma/enums";
import { editorState, previewSrc } from "../signals/engine";
import Queue from "~/pages/PageEnigma/Queue/Queue";
import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames";
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { ToastTypes, ClipType } from "~/enums";
import { Visibility } from "./api_manager.js";
import * as THREE from "three";
import { getSceneSignals, addToast, startPollingActiveJobs } from "~/signals";
import { v4 as uuidv4 } from "uuid";
import { SceneGenereationMetaData as SceneGenerationMetaData } from "~/pages/PageEnigma/models/sceneGenerationMetadata";
import { MediaUploadApi, StudioGen2Api, VideoApi } from "~/Classes/ApiManager";
import { globalIPAMediaToken } from "../signals";
// TODO THIS CLASS MAKES NO SENSE
// Refactor so we generate all the frames first.
// then pass it through this pipeline as a data structure process it.
// through this class.
interface MediaTokens {
// NB(bt,2025-01-27): This media token was likely called "color" because we were originally
// going to send a shaded "color" render, a depth map render, and an edge detect render to
// the backend for inference.
color: string;
}
export class VideoGeneration {
editor: Editor;
mediaUploadAPI: MediaUploadApi;
videoAPI: VideoApi;
studioGen2Api: StudioGen2Api;
// For cached style Re-Generation
private last_scene_check_sum: string;
// Last Media token IDs
private last_media_tokens: MediaTokens;
// Last toggle position for Render
public last_position_of_preprocessing: boolean;
constructor(editor: Editor) {
this.editor = editor;
this.mediaUploadAPI = new MediaUploadApi();
this.videoAPI = new VideoApi();
this.studioGen2Api = new StudioGen2Api();
this.last_scene_check_sum = "";
this.last_media_tokens = { color: "" };
this.last_position_of_preprocessing = false;
}
sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async videoProcessingPipeline(title: string): Promise<MediaTokens> {
const mediaTokens: MediaTokens = {
color: "",
};
try {
// TODO send the audio a later time.
// const audioClips = this.editor.timeline.timeline_items.filter((clip) => {
// return clip.type == ClipType.AUDIO;
// });
if (!this.editor.engineFrameBuffers.payloadZip) {
await this.handleError(
`Video Processing Failed Please Try Again (zip process failed)`,
3000,
);
}
const zipBlob = this.editor.engineFrameBuffers.payloadZip;
if (!zipBlob) {
await this.handleError(`"Payload blob is null."`, 3000);
}
const video_upload_response = await this.mediaUploadAPI.UploadStudioShot({
maybe_title: title,
uuid_idempotency_token: uuidv4(),
blob: zipBlob,
fileName: `${title}.zip`,
maybe_visibility: Visibility.Public,
});
if (!video_upload_response.data) {
console.log("Missing media_tokens.color Token");
const error = {
message: "Missing Data From Video Upload Response",
code: 1,
};
throw error;
}
mediaTokens.color = video_upload_response.data;
console.log(`Studio recording: https://storyteller.ai/media/${mediaTokens.color}`);
} finally {
}
return mediaTokens;
}
// this is to generate previews
async generateFrame() {
if (!this.editor.generating_preview && this.editor.rawRenderer) {
this.editor.generating_preview = true;
this.editor.utils.removeTransformControls();
this.editor.activeScene.renderMode(true);
if (this.editor.activeScene.hot_items) {
this.editor.activeScene.hot_items.forEach((element) => {
element.visible = false;
});
}
previewSrc.value = "";
this.editor.rawRenderer.setSize(
this.editor.render_width,
this.editor.render_height,
);
this.editor.render_camera.aspect =
this.editor.render_width / this.editor.render_height;
this.editor.render_camera.updateProjectionMatrix();
this.editor.rawRenderer.render(
this.editor.activeScene.scene,
this.editor.render_camera,
);
const imgData = this.editor.rawRenderer.domElement.toDataURL();
const response = await fetch(imgData); // Fetch the data URL
const blob = await response.blob(); // Convert to Blob
if (!this.editor.camera_person_mode) {
this.editor.switchCameraView();
editorState.value = EditorStates.PREVIEW;
}
this.editor.generating_preview = false;
try {
const url = await this.editor.api_manager.uploadMediaFrameGeneration(
blob,
"render.png",
this.editor.art_style,
this.editor.positive_prompt,
this.editor.negative_prompt,
);
previewSrc.value = url;
return Promise.resolve(url);
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: "Unknown Error in Generate Frame";
Queue.publish({
queueName: QueueNames.FROM_ENGINE,
action: fromEngineActions.POP_A_TOAST,
data: {
type: ToastTypes.ERROR,
message: errorMessage,
},
});
}
}
}
// to determine if we should capture frames again or not.
async shouldRenderScenesAgain(checkSumData: string): Promise<boolean> {
// Should skip to rendering the scene if already processed the frames inputs for running a new style.
if (this.last_scene_check_sum === "") {
// no token means input was not processed so lets go do the video re-render process
this.last_scene_check_sum = checkSumData;
return false;
} else {
if (this.last_scene_check_sum === checkSumData) {
return true;
} else {
this.last_scene_check_sum = checkSumData;
return false;
}
}
}
async stopPlaybackAndUploadVideo() {
this.editor.rendering = false; // not sure about this variable ... this has so many state issues.
// precondition checks, if we have no frames then we shouldn't try to do generations or snapshots
// This means no frames so error out
this.editor.generating_preview = true;
await this.editor.updateLoad({
progress: 50,
message:
"Please stay on this screen and do not switch tabs! while your video is being processed.",
});
let media_tokens: MediaTokens = {
color: "",
};
// properties used by the external scope to the for loop.
const title = getSceneSignals().title || "Untitled";
const style_name = this.editor.art_style.toString();
const media_token = this.editor.current_scene_media_token || undefined;
// TAKE A snap shot of the scene then use this token across all videos
// convert the ip adapter image and upload as a media token
const image_uuid = uuidv4();
let ipa_image_token = undefined;
if (this.editor.globalIpAdapterImage != undefined) {
const response = await this.mediaUploadAPI.UploadImage({
fileName: `${image_uuid}.ipa`,
blob: this.editor.globalIpAdapterImage,
uuid: image_uuid,
});
if (!response.success) {
// ip adapter upload failed
await this.handleError(
"Reference Image Upload Failed Try Generating Movie Again.",
3000,
);
return;
}
if (!response.data) {
console.log("IPA Image Token Missing");
}
ipa_image_token = response.data;
}
// update the signal with this information
if (ipa_image_token) {
globalIPAMediaToken.value = ipa_image_token;
}
// TODO Remove so many of these around wtf. SceneGenereationMetaData should only be one place
const metaData: SceneGenerationMetaData = {
artisticStyle: this.editor.art_style,
positivePrompt: this.editor.positive_prompt,
negativePrompt: this.editor.negative_prompt,
cameraAspectRatio: this.editor.render_camera_aspect_ratio,
upscale: this.editor.generation_options.upscale,
faceDetail: this.editor.generation_options.faceDetail,
styleStrength: this.editor.generation_options.styleStrength,
lipSync: this.editor.generation_options.lipSync,
cinematic: this.editor.generation_options.cinematic,
globalIPAMediaToken: ipa_image_token,
enginePreProcessing: this.editor.engine_preprocessing, // the only thing that will invalidate the cache
};
// for the one case where the engine preprecessing is turned we need to cache this.
this.last_position_of_preprocessing = this.editor.engine_preprocessing;
// This is to save the snapshot of the scene for remixing
const uuid_snapshot = uuidv4();
// Save the scene
const saveData = await this.editor.save_manager.saveData({
sceneTitle: title,
sceneToken: media_token,
sceneGenerationMetadata: metaData,
});
const file = new File([saveData], `${title}.glb`, {
type: "application/json",
});
const response =
await this.editor.media_upload.UploadSceneSnapshotMediaFileForm({
maybe_title: title,
maybe_scene_source_media_file_token: media_token, // can be undefined or null
uuid: uuid_snapshot,
blob: file,
});
let immutable_media_token = undefined;
if (response.success) {
if (response.data) {
immutable_media_token = response.data;
}
} else {
await this.handleError(
"Scene Snapshot Failed Try Generating Movie Again.",
3000,
);
return;
}
console.log(`Immutable Snapshot Token: ${immutable_media_token}`);
await this.editor.updateLoad({
progress: 50,
message:
"Please stay on this screen and do not switch tabs! while your video is being processed.",
});
// write the end point to upload the video
// upload the color frames with end point.
try {
media_tokens = await this.videoProcessingPipeline(title);
} catch (error) {
await this.handleError(`Video Processing Failed Please Try Again`, 3000);
throw error;
}
this.editor.onWindowResize();
this.editor.setColorMap();
await this.editor.updateLoad({
progress: 100,
message: "Done Check Your Movies Tab On Profile.",
label: "Success",
});
// this is so that its a check point just encase enqueue fails, if it does we can still restylize
this.last_media_tokens = media_tokens;
// Legacy studio:
//await this.handleEnqueue(media_tokens, ipa_image_token ?? "");
// NB(bt,2025-01-27): We're using a hack to set the first frame (in CharacterFrameButton)
const firstFrameMediaToken = (window as any).firstFrameMediaToken;
const videoMediaToken = media_tokens.color;
console.log(`Enqueue Studio Gen2 Render; Video: ${videoMediaToken} Image: ${firstFrameMediaToken}`);
await this.handleEnqueueStudioGen2(videoMediaToken, firstFrameMediaToken);
await this.EndLoadingState();
}
async handleCachedEnqueue() {
const enqueue_studio_response = await this.videoAPI.EnqueueStudio({
enqueueVideo: {
disable_lcm: false,
enable_lipsync: this.editor.generation_options.lipSync,
input_file: this.last_media_tokens.color,
negative_prompt: this.editor.negative_prompt,
prompt: this.editor.positive_prompt,
remove_watermark: false,
style: this.editor.art_style.toString(),
frame_skip: 2,
travel_prompt: "",
trim_end_millis: 7000,
trim_start_millis: 0,
use_cinematic: this.editor.generation_options.cinematic,
use_face_detailer: this.editor.generation_options.faceDetail,
use_strength: this.editor.generation_options.styleStrength,
use_upscaler: this.editor.generation_options.upscale,
uuid_idempotency_token: uuidv4(),
global_ipa_media_token: globalIPAMediaToken.value ?? "",
input_depth_file: "",
input_normal_file: "",
input_outline_file: "",
creator_set_visibility: Visibility.Public,
},
});
if (enqueue_studio_response.success) {
console.log("Start Polling Active Jobs");
startPollingActiveJobs();
addToast(
ToastTypes.SUCCESS,
"Done Check Your Movies Tab On Profile.",
3000,
);
} else {
addToast(ToastTypes.ERROR, "Failed To Process Movie Try Again", 3000);
}
}
async handleEnqueue(upload_tokens: MediaTokens, ipa_image_token: string) {
const enqueue_studio_response = await this.videoAPI.EnqueueStudio({
enqueueVideo: {
disable_lcm: false,
enable_lipsync: this.editor.generation_options.lipSync,
input_file: upload_tokens.color,
negative_prompt: this.editor.negative_prompt,
prompt: this.editor.positive_prompt,
remove_watermark: false,
style: this.editor.art_style.toString(),
frame_skip: 2,
travel_prompt: "",
trim_end_millis: 7000,
trim_start_millis: 0,
use_cinematic: this.editor.generation_options.cinematic,
use_face_detailer: this.editor.generation_options.faceDetail,
use_strength: this.editor.generation_options.styleStrength,
use_upscaler: this.editor.generation_options.upscale,
uuid_idempotency_token: uuidv4(),
global_ipa_media_token: ipa_image_token,
input_depth_file: "",
input_normal_file: "",
input_outline_file: "",
creator_set_visibility: Visibility.Public,
},
});
if (enqueue_studio_response.success) {
startPollingActiveJobs();
} else {
await this.handleError("Failed To Process Movie Try Again", 3000);
return;
}
this.handleSuccess("Done Check Your Movies Tab On Profile.", 3000);
}
async handleEnqueueStudioGen2(videoMediaToken: string, firstFrameImageToken: string) {
const response = await this.studioGen2Api.EnqueueStudioGen2({
enqueueVideo: {
uuid_idempotency_token: uuidv4(),
image_file: firstFrameImageToken,
video_file: videoMediaToken,
creator_set_visibility: Visibility.Public,
},
});
if (response.success) {
startPollingActiveJobs();
} else {
await this.handleError("Failed To Process Animation Try Again", 3000);
return;
}
this.handleSuccess("Done Check Your Movies Tab On Profile.", 3000);
}
async handleSuccess(message: string, timeout: number) {
addToast(ToastTypes.SUCCESS, message, timeout);
}
async handleError(message: string, timeout: number) {
addToast(ToastTypes.ERROR, message, timeout);
await this.EndLoadingState();
}
async EndLoadingState() {
this.editor.generating_preview = false;
this.editor.endLoading();
this.editor.onWindowResize();
this.editor.recorder = undefined;
if (this.editor.rawRenderer) {
this.editor.rawRenderer.setSize(
this.editor.startRenderWidth,
this.editor.startRenderHeight,
);
}
this.editor.camViewCanvasMayReset();
this.editor.rawRenderer = new THREE.WebGLRenderer({
antialias: true,
canvas: this.editor.canvasRenderCamReference || undefined,
preserveDrawingBuffer: true,
});
this.editor._configurePostProcessingRaw();
this.editor.activeScene.renderMode(false);
this.editor.switchEdit();
}
}
@@ -0,0 +1,47 @@
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
import { persistLogin } from "~/signals";
import { pageHeight, pageWidth } from "~/signals";
import { useEffect } from "react";
export const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
useSignals();
useSignalEffect(() => {
persistLogin();
});
/// Initizations that depends on ENV vars ///
function PostHogInit() {
const data = EnvironmentVariables.values;
const apiKey = data.REACT_APP_PUBLIC_POSTHOG_KEY as string;
//posthog.init(apiKey, {
// //HACK: This is the default host from Netlify, but need to figure out why it isn't working on prod.
// // api_host: data.DEPLOY_PRIME_URL + "/ingest" as string,
// api_host: "https://studio.storyteller.ai/ingest" as string,
// ui_host: data.REACT_APP_PUBLIC_POSTHOG_UI as string,
//});
}
useEffect(() => {
EnvironmentVariables.initialize(env);
PostHogInit();
}, [env]);
/// Initizations that run only once on 1ST mount ///
function setPage() {
// TODO address this issue with zooming
pageHeight.value = window.innerHeight;
pageWidth.value = window.innerWidth;
}
useEffect(() => {
setPage();
window.addEventListener("resize", setPage);
return () => {
window.removeEventListener("resize", setPage);
};
}, []);
return null;
};
@@ -0,0 +1,58 @@
import { SoundRegistry, SoundEffect } from "@storyteller/soundboard";
// TODO: This is maintained in two places. Here and MiscSettingsPane.
export const InstallSounds = () => {
const r = SoundRegistry.getInstance();
if (r.hasSound("decline_normal")) {
return;
}
// Good for simple menu choices
r.setSoundOnce("click", new SoundEffect("resources/sound/smrpg_click.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("scifi_menu_beep_1", new SoundEffect("resources/sound/metroidprime_UI_15.wav", { defaultVolume: 0.3 }));
r.setSoundOnce("scifi_menu_beep_2", new SoundEffect("resources/sound/metroidprime_UI_14.wav", { defaultVolume: 0.3 }));
r.setSoundOnce("scifi_menu_select", new SoundEffect("resources/sound/metroidprime_UI_18.wav", { defaultVolume: 0.3 }));
// Good for simple immediate enqueue success
r.setSoundOnce("done", new SoundEffect("resources/sound/oot_dialogue_done.wav", { defaultVolume: 0.4 })); // DEFAULT
// Good for simple immediate failure
r.setSoundOnce("error_chirp", new SoundEffect("resources/sound/goldensun_135.wav", { defaultVolume: 0.4 }));
r.setSoundOnce("spike_throw", new SoundEffect("resources/sound/smrpg_enemy_spikethrow.wav", { defaultVolume: 0.1 })); // DEFAULT
r.setSoundOnce("giant_shell_kick", new SoundEffect("resources/sound/smrpg_mario_giantshellkick.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("wrong", new SoundEffect("resources/sound/smrpg_wrong.wav", { defaultVolume: 0.4 }));
// Good for simple async success
r.setSoundOnce("special_flower", new SoundEffect("resources/sound/smrpg_specialflower.wav", { defaultVolume: 0.2 })); // DEFAULT
r.setSoundOnce("extra_power", new SoundEffect("resources/sound/smrpg_character_extrapower.wav", { defaultVolume: 0.2 })); // DEFAULT
// Good for async errors
r.setSoundOnce("crumble", new SoundEffect("resources/sound/smrpg_drybones_crumble.wav", { defaultVolume: 0.1 })); // DEFAULT
r.setSoundOnce("ghost", new SoundEffect("resources/sound/smrpg_ghost.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("special_alert", new SoundEffect("resources/sound/goldensun_214.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("scifi_alert", new SoundEffect("resources/sound/metroidprime_UI_52.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("scifi_shrill_alert", new SoundEffect("resources/sound/metroidprime_UI_51.wav", { defaultVolume: 0.2 }));
// Good for menus
r.setSoundOnce("next", new SoundEffect("resources/sound/oot_dialogue_next.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("select", new SoundEffect("resources/sound/goldensun_111.wav", { defaultVolume: 0.4 }));
r.setSoundOnce("scifi_menu_open", new SoundEffect("resources/sound/metroidprime_UI_12.wav", { defaultVolume: 0.3 }));
r.setSoundOnce("scifi_menu_close", new SoundEffect("resources/sound/metroidprime_UI_13.wav", { defaultVolume: 0.3 }));
// Good for special reward / celebration
r.setSoundOnce("correct", new SoundEffect("resources/sound/smrpg_correct.wav", { defaultVolume: 0.1 }));
r.setSoundOnce("flower", new SoundEffect("resources/sound/smrpg_flower.wav", { defaultVolume: 0.1 }));
// File delete, trash, etc.
r.setSoundOnce("trash", new SoundEffect("resources/sound/oot_scrub_crumble.wav", { defaultVolume: 0.4 }));
// Misc / ungrouped
r.setSoundOnce("accept_chirp", new SoundEffect("resources/sound/goldensun_101.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("accept_normal_level_1", new SoundEffect("resources/sound/goldensun_173.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("accept_normal_level_2", new SoundEffect("resources/sound/goldensun_174.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("accept_normal_level_3", new SoundEffect("resources/sound/goldensun_175.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("decline_chirp", new SoundEffect("resources/sound/goldensun_102.wav", { defaultVolume: 0.2 }));
r.setSoundOnce("decline_normal", new SoundEffect("resources/sound/goldensun_113.wav", { defaultVolume: 0.2 }));
(window as any).sounds = r;
}
@@ -0,0 +1,662 @@
import React, { useContext, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
import { Controls3D } from "./comps/Controls3D";
import { ControlsTopButtons } from "./comps/ControlsTopButtons";
import { ControlPanelSceneObject } from "./comps/ControlPanelSceneObject";
import { PreviewEngineCamera } from "./comps/PreviewEngineCamera";
import { PreviewFrameImage } from "./comps/PreviewFrameImage";
import { authentication, pageHeight, pageWidth } from "~/signals";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import ImageToVideo from "../PageVideo/ImageToVideo";
import TextToImage from "../PageImage/TextToImage";
import { AppsIndexPage } from "../PageApps/AppsIndexPage";
import { VideoFrameExtractor } from "../PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "../PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "../PageImageWatermarkRemover";
import { ImageTo3DObject } from "../PageImageTo3DObject";
import { ImageTo3DWorld } from "../PageImageTo3DWorld";
import { RemoveBackground } from "../PageRemoveBackground";
import { Angles } from "../PageAngles";
import { Storyboard } from "../PageStoryboard";
import { PageBackgroundChange } from "../PageBackgroundChange";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import {
timelineHeight,
sidePanelWidth,
dndTimelineHeight,
editorLoader,
cameraAspectRatio,
outlinerIsShowing,
disableHotkeyInput,
enableHotkeyInput,
gridVisibility,
setGridVisibility,
DomLevels,
} from "~/pages/PageEnigma/signals";
import { EditorCanvas } from "./comps/EngineCanvases";
import { SceneContainer } from "./comps/SceneContainer";
import { Outliner } from "./comps/Outliner";
import { CameraAspectRatio } from "./enums";
import { PromptBox3D } from "@storyteller/ui-promptbox";
import { PopoverItem } from "@storyteller/ui-popover";
import { LoadingDots } from "@storyteller/ui-loading";
import { OnboardingHelper } from "./comps/OnboardingHelper";
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
import {
addCamera,
cameras,
deleteCamera,
focalLengthDragging,
selectedCameraId,
updateCamera,
} from "./signals/camera";
import { isPromptBoxFocused } from "./signals/promptBox";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { EngineContext } from "./contexts/EngineContext";
import Queue, { QueueNames } from "./Queue";
import { toEngineActions } from "./Queue/toEngineActions";
import { UnionedDataTypes } from "./Queue/Queue";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
import { uploadPlaneFromMediaToken } from "~/components/reusable/UploadModalMedia/uploadPlane";
import { addObject } from "./signals/objectGroup/addObject";
import { addCharacter } from "./signals/characterGroups/addCharacter";
import { AssetType, AUTH_STATUS } from "~/enums";
import { v4 as uuidv4 } from "uuid";
import { MediaItem } from "~/pages/PageEnigma/models";
import { UploaderState } from "~/models";
import * as THREE from "three";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import {
STAGE_3D_PAGE_MODEL_LIST,
ModelPage,
useSelectedImageModel,
useSelectedProviderForModel,
ClassyModelSelector,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
import { ImageModel } from "@storyteller/model-list";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
export const PageEditor = () => {
useSignals();
const { triggerRecheck } = useLoginModalStore();
const { status } = authentication;
useEffect(() => {
if (status.value === AUTH_STATUS.LOGGED_OUT) {
triggerRecheck();
}
}, [status.value, triggerRecheck]);
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
useEffect(() => {
timelineHeight.value = 0; //timelineHeight.value = 208;
sidePanelWidth.value = 340;
window.onbeforeunload = () => {
return "You may have unsaved changes.";
};
}, []);
const selectedImageModel: ImageModel | undefined =
useSelectedImageModel(PAGE_ID);
const selectedProvider: GenerationProvider | undefined =
useSelectedProviderForModel(PAGE_ID, selectedImageModel?.id);
const imageCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[PAGE_ID],
);
const height =
dndTimelineHeight.value > -1
? pageHeight.value - dndTimelineHeight.value - 56
: pageHeight.value - timelineHeight.value - 56;
const getScale = () => {
const height = pageHeight.value - timelineHeight.value - 56;
const scaleHeight = height < 610 ? height / 610 : 1;
if (
cameraAspectRatio.value === CameraAspectRatio.VERTICAL_9_16 &&
outlinerIsShowing.value &&
height < 900
) {
if (pageWidth.value > 2000) {
return scaleHeight;
}
return scaleHeight * 0.78;
}
if (
cameraAspectRatio.value === CameraAspectRatio.SQUARE_1_1 &&
pageWidth.value < 2000
) {
return scaleHeight * 0.85;
}
return scaleHeight;
};
// These are callbacks required by promptbox
const editorEngine = useContext(EngineContext);
const handleCameraSelect = (selectedItem: PopoverItem) => {
const selectedCamera = cameras.value.find(
(cam) => cam.label === selectedItem.label,
);
if (selectedCamera && editorEngine) {
selectedCameraId.value = selectedCamera.id;
// Show focal length display temporarily
// TODO: Rename dragging to visible - BFlat
focalLengthDragging.value = {
isDragging: true,
focalLength: selectedCamera.focalLength,
};
setTimeout(() => {
focalLengthDragging.value = {
isDragging: false,
focalLength: selectedCamera.focalLength,
};
}, 1500);
// Update the main camera to match the selected camera's properties
if (editorEngine.camera) {
// First update position and lookAt
editorEngine.camera.position.set(
selectedCamera.position.x,
selectedCamera.position.y,
selectedCamera.position.z,
);
editorEngine.camera.lookAt(
selectedCamera.lookAt.x,
selectedCamera.lookAt.y,
selectedCamera.lookAt.z,
);
// Update FOV
editorEngine.camera.fov = editorEngine.focalLengthToFov(
selectedCamera.focalLength,
);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
}
// Force update camera properties in the state
updateCamera(selectedCamera.id, {
focalLength: selectedCamera.focalLength,
position: selectedCamera.position,
rotation: selectedCamera.rotation,
lookAt: selectedCamera.lookAt,
});
}
};
const handleAddCamera = () => {
// Check if we've reached the maximum number of cameras
if (cameras.value.length >= 6) {
console.warn("Maximum number of cameras (6) reached");
return;
}
const newIndex = cameras.value.length + 1;
const newId = `cam${newIndex}`;
// This is for generating random orbital position for the new camera using spherical coordinates
const radius = Math.random() * 5 + 7; // Distance from center: 7 to 12 units
const theta = Math.random() * Math.PI * 2; // Azimuthal angle: 0 to 2π
const phi = Math.PI / 3 + (Math.random() * Math.PI) / 6; // Polar angle: π/3 to π/2 (60° to 90° from horizontal)
// Convert spherical coordinates to Cartesian coordinates
const randomX = radius * Math.sin(phi) * Math.cos(theta);
const randomY = Math.abs(radius * Math.cos(phi)) + 2; // Ensure Y is positive and at least 2 units up
const randomZ = radius * Math.sin(phi) * Math.sin(theta);
addCamera({
id: newId,
label: `Camera ${newIndex}`,
focalLength: 24,
position: {
x: randomX,
y: randomY,
z: randomZ,
},
rotation: { x: 0, y: 0, z: 0 },
lookAt: { x: 0, y: 0, z: 0 },
});
// Switch to the newly created camera
selectedCameraId.value = newId;
// Update the engine camera to match the new camera's properties
if (editorEngine && editorEngine.camera) {
editorEngine.camera.position.set(randomX, randomY, randomZ);
editorEngine.camera.lookAt(0, 0, 0);
editorEngine.camera.fov = editorEngine.focalLengthToFov(24);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
}
// Force a render to update the view
editorEngine.renderScene();
}
};
const handleCameraNameChange = (id: string, newName: string) => {
updateCamera(id, { label: newName });
};
const handleCameraFocalLengthChange = (id: string, value: number) => {
const camera = cameras.value.find((cam) => cam.id === id);
if (camera) {
updateCamera(id, { focalLength: value });
}
};
const onAspectRatioSelect = (newRatio: UnionedDataTypes) => {
// Publish the change to the engine
Queue.publish({
queueName: QueueNames.TO_ENGINE,
action: toEngineActions.CHANGE_CAMERA_ASPECT_RATIO,
data: newRatio,
});
};
// MOVE THIS don't throw this in here
// Image drop from gallery/library modal logic
useEffect(() => {
let handler: unknown;
// 3D Drag and Drop Logic
if (tabStore.activeTabId === "3D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("3D Drop debug (event):", {
editorEngine,
camera: editorEngine?.camera,
renderer: editorEngine?.renderer,
position,
});
// Calculate world position from cursor immediately on drop
let worldPosition = undefined;
if (editorEngine?.camera && editorEngine?.renderer && position) {
const rect =
editorEngine.renderer.domElement.getBoundingClientRect();
// Convert client coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
const ndcX = (canvasX / rect.width) * 2 - 1;
const ndcY = -(canvasY / rect.height) * 2 + 1;
const vector = new THREE.Vector3(ndcX, ndcY, 0.5);
vector.unproject(editorEngine.camera);
worldPosition = vector;
}
(async () => {
try {
if (item.mediaClass === "dimensional") {
// Handle 3D models
const isCharacter = item.assetType === "character";
const mediaItem: MediaItem = {
version: 1,
type: isCharacter ? AssetType.CHARACTER : AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || (isCharacter ? "Character" : "3D Object"),
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
if (isCharacter) {
addCharacter(mediaItem);
} else {
addObject(mediaItem);
}
} else {
// Handle images (create image planes)
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "Image Plane",
...(worldPosition && {
position: {
x: worldPosition.x,
y: worldPosition.y,
z: worldPosition.z,
},
}),
};
addObject(mediaItem);
await uploadPlaneFromMediaToken({
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state: UploaderState) => {
if (state.status)
console.log("Upload status:", state.status);
},
});
}
} catch (err) {
console.error("Failed to add object to scene:", err);
}
})();
},
);
// 2D Drag and Drop Logic
} else if (tabStore.activeTabId === "2D") {
handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
console.log("2D Drop debug (event):", {
item,
position,
});
// Find the main Konva canvas element - get the first canvas (left panel)
const canvasElements = document.querySelectorAll("canvas");
const canvasElement = canvasElements[0]; // Get the main drawing canvas (left panel)
if (!canvasElement) {
console.error("Could not find canvas element for 2D drop");
return;
}
const rect = canvasElement.getBoundingClientRect();
// Convert screen coordinates to canvas coordinates
const canvasX = position.x - rect.left;
const canvasY = position.y - rect.top;
// Ensure the drop position is within canvas bounds
if (
canvasX < 0 ||
canvasY < 0 ||
canvasX > rect.width ||
canvasY > rect.height
) {
console.log("Drop position outside canvas bounds");
return;
}
console.log("Canvas drop position:", { canvasX, canvasY });
(async () => {
try {
// event that PageDraw listens for
const dropEvent = new CustomEvent("gallery-2d-drop", {
detail: {
item,
canvasPosition: { x: canvasX, y: canvasY },
},
});
window.dispatchEvent(dropEvent);
} catch (err) {
console.error("Failed to add image to 2D canvas:", err);
}
})();
},
);
}
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabStore.activeTabId, editorEngine]);
return (
<div className="w-screen">
<TopBar
loginSignUpPressed={() => {
console.log("PRESSED");
triggerRecheck();
}}
pageName="Edit Scene"
/>
<LoginModal
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
{tabStore.activeTabId == "3D" && (
<div>
<OnboardingHelper />
<div
className="relative flex w-screen"
style={{ height: "calc(100vh - 68px)" }}
>
{/* Engine section/side panel */}
<div
id="engine-n-panels-wrapper"
className="flex"
style={{
height,
}}
>
<div className="relative w-full overflow-hidden bg-transparent">
<SceneContainer>
<EditorCanvas />
<PreviewFrameImage />
</SceneContainer>
{/* Focal Length Display */}
<FocalLengthDisplay />
{/* Pose Mode Selector */}
<PoseModeSelector />
{/* Top controls */}
<div
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D />
</div>
</div>
{/* Bottom controls */}
<div
className="absolute bottom-0 left-0"
style={{
width: pageWidth.value,
}}
onClick={handleOverlayClick}
>
<div
className="absolute bottom-20 mb-4 ml-4 flex origin-bottom-left flex-col gap-2"
style={{ transform: `scale(${getScale()})` }}
>
<Outliner />
<PreviewEngineCamera />
</div>
<ControlPanelSceneObject />
</div>
<PromptBox3D
cameras={cameras}
cameraAspectRatio={cameraAspectRatio}
disableHotkeyInput={disableHotkeyInput}
enableHotkeyInput={enableHotkeyInput}
gridVisibility={gridVisibility}
setGridVisibility={setGridVisibility}
selectedCameraId={selectedCameraId}
deleteCamera={deleteCamera}
focalLengthDragging={focalLengthDragging}
isPromptBoxFocused={isPromptBoxFocused}
uploadImage={uploadImage}
handleCameraSelect={handleCameraSelect}
handleAddCamera={handleAddCamera}
handleCameraNameChange={handleCameraNameChange}
handleCameraFocalLengthChange={handleCameraFocalLengthChange}
onAspectRatioSelect={onAspectRatioSelect}
selectedImageModel={selectedImageModel}
selectedProvider={selectedProvider}
credits={imageCredits}
setEnginePrompt={(prompt) => {
console.log("setEnginePrompt", prompt);
if (!editorEngine) {
console.log("editorEngine is not available");
return;
}
editorEngine!.positive_prompt = prompt;
}}
snapshotCurrentFrame={editorEngine?.snapShotOfCurrentFrame.bind(
editorEngine,
)}
/>
<LoadingDots
className="absolute left-0 top-0 z-50"
isShowing={editorLoader.value.isShowing}
type="bricks"
message={editorLoader.value.message}
/>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</div>
</div>
</div>
</div>
</div>
)}
{tabStore.activeTabId == "APPS" && (
<div>
<AppsIndexPage />
</div>
)}
{tabStore.activeTabId == "2D" && (
<div>
<PageDraw />
</div>
)}
{tabStore.activeTabId == "IMAGE" && (
<div>
<TextToImage
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
</div>
)}
{tabStore.activeTabId == "VIDEO" && (
<div>
<ImageToVideo />
</div>
)}
{tabStore.activeTabId == "VIDEO_FRAME_EXTRACTOR" && (
<div>
<VideoFrameExtractor />
</div>
)}
{tabStore.activeTabId == "VIDEO_WATERMARK_REMOVAL" && (
<div>
<VideoWatermarkRemover />
</div>
)}
{tabStore.activeTabId == "IMAGE_WATERMARK_REMOVAL" && (
<div>
<ImageWatermarkRemover />
</div>
)}
{tabStore.activeTabId == "IMAGE_TO_3D_OBJECT" && (
<div>
<ImageTo3DObject />
</div>
)}
{tabStore.activeTabId == "IMAGE_TO_3D_WORLD" && (
<div>
<ImageTo3DWorld />
</div>
)}
{tabStore.activeTabId == "REMOVE_BACKGROUND" && (
<div>
<RemoveBackground />
</div>
)}
{tabStore.activeTabId == "ANGLES" && (
<div>
<Angles />
</div>
)}
{tabStore.activeTabId == "STORYBOARD" && storyboardPageEnabled && (
<div>
<Storyboard />
</div>
)}
{tabStore.activeTabId == "BACKGROUND_CHANGE" && (
<div>
<PageBackgroundChange />
</div>
)}
</div>
);
};
@@ -0,0 +1,179 @@
import { DragComponent } from "~/pages/PageEnigma/comps/DragComponent/DragComponent";
import { EngineProvider } from "./contexts/EngineContext";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { useQueueHandler } from "./hooks/useQueueHandler";
import { ErrorDialog } from "~/components";
import { GenerateModals } from "~/pages/PageEnigma/comps/GenerateModals/GenerateModals";
import { toast, Toaster } from "@storyteller/ui-toaster";
import { EditorLoadingBar } from "./comps/EditorLoadingBar";
import { useSignals } from "@preact/signals-react/runtime";
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { UsersApi } from "~/Classes/ApiManager";
import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector";
import {
precisionSelectedValue,
precisionSelectorMenuCoords,
precisionSelectorValues,
showPrecisionSelector,
} from "./signals/precisionSelectorMenu";
import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import {
PricingModal,
CreditsModal,
useCreditsModalStore,
} from "@storyteller/ui-pricing-modal";
import {
isActionReminderOpen,
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import { useFlashFileDownloadErrorEvent, useFlashUserInputErrorEvent, useMediaFileDeletedEvent } from "@storyteller/tauri-events";
import { useGenerationCompleteEvent } from "@storyteller/tauri-events";
import { useGenerationEnqueueFailureEvent } from "@storyteller/tauri-events";
import { useGenerationEnqueueSuccessEvent } from "@storyteller/tauri-events";
import { useGenerationFailedEvent } from "@storyteller/tauri-events";
import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
import { useTextToImageStore } from "~/pages/PageImage/TextToImageStore";
import { GetAppPreferences } from "@storyteller/tauri-api";
import { SoundRegistry } from "@storyteller/soundboard";
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useSignals();
useActiveJobs();
useBackgroundLoadingMedia();
useQueueHandler();
// Credits modal state (must be before any early returns)
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
const [validGpu, setValidGpu] = useState("unknown");
useEffect(() => {
const usersApi = new UsersApi();
const sessionResponse = usersApi.GetSession();
sessionResponse.then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
});
});
useEffect(() => {
const { getGPUTier } = gpu;
getGPUTier().then((gpuTier) => {
console.log("GPU tier", gpuTier);
let isValid = false;
const fps = gpuTier.fps || 0;
if (gpuTier.tier > 1) {
isValid = true;
}
if (fps > 15) {
isValid = true;
}
switch (gpuTier.gpu) {
case "apple gpu (Apple GPU)":
isValid = true;
break;
default:
break;
}
setValidGpu(isValid ? "valid" : "error");
});
});
useGenerationEnqueueSuccessEvent();
useGenerationEnqueueFailureEvent();
useGenerationCompleteEvent();
useGenerationFailedEvent();
const completeBatch = useTextToImageStore((s) => s.completeBatch);
useTextToImageGenerationCompleteEvent(async (event) => {
completeBatch(
event.generated_images || [],
event.maybe_frontend_subscriber_id,
);
});
useFlashUserInputErrorEvent(async (event) => {
console.log("Flash user input error event received:", event);
toast.error(event.message);
});
useFlashFileDownloadErrorEvent(async (event) => {
console.log("Flash file download error event received:", event);
toast.error(event.message || "File download failed");
});
useMediaFileDeletedEvent(async (event) => {
console.log("Media file deleted event received:", event);
const prefs = await GetAppPreferences();
const soundName = prefs.preferences?.delete_file_sound;
if (soundName !== undefined) {
const registry = SoundRegistry.getInstance();
registry.playSound(soundName);
}
toast.error("File deleted.");
});
useEffect(() => {
console.log("installing event listeners");
InstallSounds();
});
const currentReminderModalProps = actionReminderProps.value;
return (
<EngineProvider sceneToken={sceneToken}>
<PageEditor />
<DragComponent />
<GalleryDragComponent />
<PrecisionSelector
showSignal={showPrecisionSelector}
coordSignal={precisionSelectorMenuCoords}
valuesSignal={precisionSelectorValues}
selectedValueSignal={precisionSelectedValue}
/>
<GenerateModals />
<ErrorDialog />
<EditorLoadingBar />
<Toaster offsetTop={70} offsetRight={12} zIndex={9999} />
{currentReminderModalProps && (
<ActionReminderModal
isOpen={isActionReminderOpen.value}
onClose={currentReminderModalProps.onClose}
reminderType={currentReminderModalProps.reminderType}
onPrimaryAction={currentReminderModalProps.onPrimaryAction}
title={currentReminderModalProps.title}
message={currentReminderModalProps.message}
primaryActionText={currentReminderModalProps.primaryActionText}
secondaryActionText={currentReminderModalProps.secondaryActionText}
onSecondaryAction={currentReminderModalProps.onSecondaryAction}
isLoading={currentReminderModalProps.isLoading}
openAiLogo={currentReminderModalProps.openAiLogo}
primaryActionIcon={currentReminderModalProps.primaryActionIcon}
primaryActionBtnClassName={
currentReminderModalProps.primaryActionBtnClassName
}
/>
)}
<PricingModal />
<CreditsModal isOpen={isCreditsOpen} onClose={closeCreditsModal} />
</EngineProvider>
);
};
@@ -0,0 +1,127 @@
import { fromEngineActions } from "~/pages/PageEnigma/Queue/fromEngineActions";
import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions";
import { CameraAspectRatio } from "../enums";
import {
QueueClip,
QueueKeyframe,
UpdateTime,
MediaItem,
} from "~/pages/PageEnigma/models";
import { toTimelineActions } from "./toTimelineActions";
import { ClipUI } from "../clips/clip_ui";
import { IGenerationOptions } from "../models/generationOptions";
import { ToastDataType } from "~/components";
import { QueueNames } from "./QueueNames";
export type UnionedActionTypes =
| fromEngineActions
| toEngineActions
| toTimelineActions;
export type UnionedDataTypes =
| QueueClip
| UpdateTime
| QueueKeyframe
| ClipUI[]
| MediaItem
| ToastDataType
| IGenerationOptions
| CameraAspectRatio
| null;
export type QueueSubscribeType = {
action: UnionedActionTypes;
data: UnionedDataTypes;
};
export type SubscribeHandler = (entry: {
action: UnionedActionTypes;
data: UnionedDataTypes;
}) => void;
class Queue {
private _queues: Record<
QueueNames,
Array<{
action: UnionedActionTypes;
data: UnionedDataTypes;
}>
> = {
[QueueNames.FROM_ENGINE]: [],
[QueueNames.TO_ENGINE]: [],
[QueueNames.TO_TIMELINE]: []
};
private _subscribers: Record<
QueueNames,
Map<
string,
SubscribeHandler
>
> = {
[QueueNames.FROM_ENGINE]: new Map<string, SubscribeHandler>(),
[QueueNames.TO_ENGINE]: new Map<string, SubscribeHandler>(),
[QueueNames.TO_TIMELINE]: new Map<string, SubscribeHandler>(),
};
// Enqueues actions to a specified queue
public publish({
queueName,
action,
data,
}: {
queueName: QueueNames;
action: UnionedActionTypes;
data: UnionedDataTypes;
}) {
// Queues for all QueueNames should be initialized above but this is a safeguard for future enum additions
// Make sure the queue exists
if (!this._queues[queueName]) {
this._queues[queueName] = [];
}
// Queue the task in the right queue
this._queues[queueName].push({ action, data });
// TODO: Ideally the publisher shouldn't deal at all with the subscribers
// It should only publish the events to the right queue
// It also doesn't make sense to fire an event on publish because the operations are then dispersed
// leading to potential race conditions
// FIXME: Add some sort of queue loop using internal event and add queue locks using promises to serialize queue publishes
if (this._subscribers[queueName]) {
this._subscribers[queueName].forEach((subcribeHandler) =>
subcribeHandler(this._queues[queueName][0]),
);
this._queues[queueName].shift();
}
}
public subscribe(
queueName: QueueNames,
id: string,
onMessage: (entry: QueueSubscribeType) => void,
) {
// Queues for all QueueNames should be initialized above but this is a safeguard for future enum additions
// Make sure the queue exists
if (!this._subscribers[queueName]) {
this._subscribers[queueName] = new Map<string, SubscribeHandler>();
}
// Set the handler mapped to the ID
this._subscribers[queueName].set(id, onMessage);
while (this._queues[queueName]?.length) {
this._subscribers[queueName].forEach((subscribeHandler) =>
subscribeHandler(this._queues[queueName][0]),
);
this._queues[queueName].shift();
}
}
}
const queue = new Queue();
export default queue;
@@ -0,0 +1,5 @@
export enum QueueNames {
FROM_ENGINE = "FROM_ENGINE",
TO_ENGINE = "TO_ENGINE",
TO_TIMELINE = "TO_TIMELINE",
}
@@ -0,0 +1,14 @@
export enum fromEngineActions {
ADD_OBJECT = "add_object",
CAMERA_ASPECT_RATIO_CHANGED = "camera_aspect_ratio_changed",
// data = CameraAspectRatio
DELETE_OBJECT = "delete_object",
DESELECT_OBJECT = "deselect_object",
RESET_TIMELINE = "reset_timeline",
SELECT_OBJECT = "select_object",
UPDATE_CHARACTER_ID = "update_character_id",
UPDATE_TIME = "update_time",
UPDATE_TIME_LINE = "update_timeline",
POP_A_TOAST = "pop_a_toast",
// data={currentTime: number}
}
@@ -0,0 +1,3 @@
import Queue from "./Queue";
export default Queue;
export * from "./QueueNames";
@@ -0,0 +1,47 @@
export enum toEngineActions {
ADD_CLIP = "add_clip",
// data=QueueClip
ADD_KEYFRAME = "add_keyframe",
ADD_CHARACTER = "add_character",
ADD_OBJECT = "add_object",
ADD_SHAPE = "add_shape",
// data= QueueKeyframe
DELETE_CLIP = "delete_clip",
// data=QueueClip
DELETE_KEYFRAME = "delete_keyframe",
// data=QueueKeyframe
ENTER_EDIT_STATE = "enter_edit",
// data=null
ENTER_PREVIEW_STATE = "enter_preview",
// data=null
GENERATE_VIDEO = "generate_video",
// data=null
MUTE = "mute",
// data={version, type, group, object_uuid} if lipsync
// data={version, type, group} if global_audio
REFRESH_PREVIEW = "refresh_preview",
// data=QueueClip
TOGGLE_CAMERA_STATE = "toggle_camera",
// data=null
TOGGLE_REPEATING = "toggle_repeating",
// data=null
CHANGE_CAMERA_ASPECT_RATIO = "change_camera_aspect_ratio",
// data=CamperaAspectRatio
CAMERA_CHANGED = "camera_changed",
UNMUTE = "unmute",
// data={version, type, group, object_uuid} if lipsync
// data={version, type, group} if global_audio
UPDATE_CLIP = "update_clip",
// data=QueueClip
UPDATE_KEYFRAME = "update_keyframe",
// data=QueueKeyframe
UPDATE_TIME = "update_time",
// data={currentTime: number}
}
@@ -0,0 +1,3 @@
export enum toInferenceActions {
ADD_TTS_JOB = "add_tts_job"
}
@@ -0,0 +1,4 @@
export enum toTimelineActions {
ADD_KEYFRAME = "add_keyframe",
GENERATE_PROGRESS = "generate_progress",
}
@@ -0,0 +1,7 @@
export default interface IAnimationClip {
speed: number;
duration: number;
startTime: number;
endTime: number;
evaluate: (time: number) => void;
}
@@ -0,0 +1,348 @@
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import {
FBXLoader,
MMDAnimationHelper,
MMDAnimationHelperMixer,
} from "three/examples/jsm/Addons.js";
import { MMDLoader } from "three/addons/loaders/MMDLoader.js";
import { MoveAIResult, Retarget } from "../Editor/retargeting";
import IAnimationClip from "./ClipInterfaces/IAnimationClip";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import { StorytellerApiHostStore } from "@storyteller/api";
export class AnimationClip implements IAnimationClip {
version: number;
media_id: string; // comes from the server
object_uuid: string;
type: "animation";
location: "glb" | "remote";
speed: number;
duration: number;
startTime: number;
length: number;
clip_name: string;
mixer: THREE.AnimationMixer | undefined;
animation_clip: THREE.AnimationClip | undefined;
clip_action: THREE.AnimationAction | undefined;
special_properties: MoveAIResult[];
retargeted: boolean;
last_frame: number;
private isMMD: boolean = false;
private obj: MMDAnimationHelperMixer | undefined;
private helper: MMDAnimationHelper | undefined;
private mmd_anim_clip: THREE.AnimationClip | undefined;
private pose: Object | undefined;
private processingClip: boolean = false;
private mmd_url: string = "";
constructor(
version: number,
media_id: string,
location: "glb" | "remote",
object_uuid: string,
speed: number,
length: number,
clip_name: string,
startTime: number,
duration: number,
) {
this.version = version;
this.media_id = media_id;
this.type = "animation";
this.object_uuid = object_uuid;
this.location = location;
this.speed = speed;
this.length = length;
this.startTime = startTime;
this.duration = duration;
this.clip_name = clip_name;
this.animation_clip;
this.mixer;
this.clip_action;
this.special_properties = [];
this.retargeted = false;
this.last_frame = 0;
}
get endTime() {
return this.startTime + this.duration
}
evaluate: (time: number) => void;
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
_load_animation(): Promise<THREE.AnimationClip> {
// Return the promise chain starting from `this.get_media_url()`
return this.get_media_url().then((url) => {
// Return a new Promise that resolves with the animation clip
return new Promise((resolve) => {
if (url.includes(".glb")) {
const glbLoader = new GLTFLoader();
glbLoader.load(url, (gltf) => {
// Assuming the animation is the first one in the animations array
const animationClip = gltf.animations[0];
resolve(animationClip);
});
} else if (url.includes(".fbx")) {
const fbxLoader = new FBXLoader();
fbxLoader.load(url, (fbx) => {
const animationClip = fbx.animations[0];
this.retargeted = true;
animationClip.tracks.forEach((track) => {
const retarget = new Retarget();
const retarget_value = retarget.retarget(track.name);
track.name = retarget_value.bone;
console.log(track);
if (retarget_value.is_special) {
this.special_properties.push(retarget_value);
}
});
resolve(animationClip);
});
} else if (url.includes(".vmd")) {
console.log("VMD Loader");
const root = this.mixer?.getRoot();
this.isMMD = true;
if (root) {
const mmdLoader = new MMDLoader();
this.mmd_url = url;
mmdLoader.loadAnimation(url, root as THREE.SkinnedMesh, (mmd) => {
mmd.name = this.mmd_url;
resolve(mmd as THREE.AnimationClip);
});
console.log("Loaded");
}
console.log("Dont");
}
});
});
}
_create_mixer(object: THREE.Object3D) {
this.mixer = new THREE.AnimationMixer(object);
return this.mixer;
}
async _get_clip() {
if (this.animation_clip == null && this.mixer !== null) {
console.log(this.media_id);
if (this.media_id === "SelfClip") {
const rootObject = this.mixer?.getRoot();
if (rootObject)
this.animation_clip = (
rootObject as THREE.Object3D<THREE.Object3DEventMap>
).animations[0];
} else {
this.animation_clip = await this._load_animation();
}
}
return this.animation_clip;
}
async play(object: THREE.Object3D) {
if (this.mixer == null) {
this.mixer = this._create_mixer(object);
const anim_clip = await this._get_clip();
if (anim_clip == undefined) {
return;
}
this.clip_action = this.mixer?.clipAction(anim_clip);
if (this.clip_action && this.isMMD === false) {
if (this.clip_action?.isRunning() == false) {
this.clip_action.play();
}
}
}
}
update_bones() {
if (this.retargeted === false) {
return;
}
const rootObject = this.mixer?.getRoot();
if (rootObject)
for (
let index_ = 0;
index_ <
(rootObject as THREE.Object3D<THREE.Object3DEventMap>).children.length;
index_++
) {
const child_holder = (
rootObject as THREE.Object3D<THREE.Object3DEventMap>
).children[index_];
if (child_holder.type == "Bone") {
child_holder.traverse(
(bone: THREE.Object3D<THREE.Object3DEventMap>) => {
for (
let index__ = 0;
index__ < this.special_properties.length;
index__++
) {
const property = this.special_properties[index__];
if (property.bone == bone.name + ".quaternion") {
const quat_y = THREE.MathUtils.degToRad(property.y);
bone.rotateY(quat_y);
if (property.only_y == false) {
const quat_x = THREE.MathUtils.degToRad(property.x);
bone.rotateX(quat_x);
const quat_z = THREE.MathUtils.degToRad(property.z);
bone.rotateZ(quat_z);
} else {
if (child_holder.parent) {
child_holder.parent.rotation.x =
THREE.MathUtils.degToRad(-90);
}
}
if (property.flip) {
bone.rotateX(bone.rotation.x * 2 * -1);
}
} else if (property.bone == bone.name + ".position") {
bone.position.set(
bone.position.x * property.x,
bone.position.y * property.y,
bone.position.z * property.z,
);
}
}
},
);
}
}
}
private async load_vpd_wrapped(): Promise<object> {
return new Promise((resolve) => {
const poseLoader = new MMDLoader();
poseLoader.loadVPD("/resources/pose/7.vpd", false, (vpd) => {
resolve(vpd);
}); // End of loadVPD call
}); // End of new Promise
}
async animate(deltatime: number) {
// Sets the frame of the animation mixer.
this.mixer?.setTime(deltatime);
// Regtargeting for Move.AI
this.update_bones();
// Next
if (this.isMMD && this.mixer) {
if (this.obj === undefined) {
// Sets the ik helper and object for IK and animation.
this.helper = (this.mixer.getRoot() as THREE.Object3D).parent?.userData[
"helper"
];
}
this.obj = this.helper?.objects.get(
this.mixer?.getRoot() as THREE.SkinnedMesh,
);
const mesh = this.mixer?.getRoot() as THREE.SkinnedMesh;
if (this.helper) {
this.helper._restoreBones(mesh); // Privateish js function for resetting bones.
this.obj?.mixer?.setTime(deltatime); // Sets the time.
this.helper._saveBones(mesh); // Saves the bones location for restoration later.
mesh.updateMatrixWorld(true); // Updates mesh.
this.obj?.ikSolver.update(); // Updates IK Solver.
this.obj?.grantSolver.update(); // Updates Grant which moves the bones.
}
// Changing of animation.
if (
this.obj &&
this.helper &&
this.processingClip === false &&
this.obj.mixer &&
this.mixer._actions[0]._clip.name !==
this.obj.mixer._actions[0]._clip.name &&
this.mixer !== undefined
) {
// On animation change.
this.processingClip = true;
const helper_mmd = this.helper.objects.get(mesh);
if (helper_mmd?.mixer !== undefined) {
const clips = helper_mmd.mixer._actions;
clips.forEach((clip) => {
this.helper?.objects.delete(clip._clip);
});
}
this.helper.remove(mesh);
mesh.pose();
const loader = new MMDLoader();
loader.loadAnimation(this.mmd_url, mesh, (mmd) => {
mmd.name = this.mmd_url;
this.helper?.add(mesh, {
animation: mmd,
physics: false,
});
this.processingClip = false;
if (helper_mmd?.mixer !== undefined) {
const clips = helper_mmd.mixer._actions;
clips.forEach((clip) => {
this.helper?.objects.delete(clip._clip);
});
}
this.obj.mixer = this.mixer;
});
}
}
}
async step(deltatime: number, isPlaying: boolean, frame: number) {
if (this.mixer == null) {
return;
}
if (this.retargeted) {
if (isPlaying || Math.floor(frame) != this.last_frame) {
if (Math.floor(frame) != 0) {
await this.animate(deltatime);
}
}
} else {
await this.animate(deltatime);
}
this.last_frame = frame;
}
stop() {
this.mixer?.setTime(0);
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
object_uuid: this.object_uuid,
type: this.type,
speed: this.speed,
length: this.length,
clip_name: this.clip_name,
};
}
}
@@ -0,0 +1,69 @@
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import Ijson from "~/interfaces/Ijson";
import { StorytellerApiHostStore } from "@storyteller/api";
interface AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
}
class AudioData implements AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
source: AudioBufferSourceNode | undefined;
constructor(audioContext: AudioContext, audioBuffer: AudioBuffer) {
this.audioContext = audioContext;
this.audioBuffer = audioBuffer;
}
}
export class AudioClip implements Ijson {
version: number;
media_id: string;
type: "audio";
volume: number;
audio_data: AudioData | undefined;
constructor(version: number, media_id: string, volume: number) {
this.version = version;
this.media_id = media_id;
this.type = "audio";
this.volume = volume;
this.download_audio().then((data) => {
this.audio_data = data;
});
}
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
async download_audio() {
const url = await this.get_media_url();
const audioContext = new AudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return new AudioData(audioContext, audioBuffer);
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
type: this.type,
volume: this.volume,
};
}
}
@@ -0,0 +1,71 @@
// Clip offsets represent the state of the clip on the timeline as well as what type of clip it is.
// it is created from a media id.
import { MediaFileType } from "~/pages/PageEnigma/enums";
import { ClipGroup, ClipType } from "~/enums";
import Ijson from "~/interfaces/Ijson";
export class ClipUI implements Ijson {
version: number;
type: ClipType;
group: ClipGroup;
name: string;
media_id: string;
object_uuid: string;
object_name: string;
offset: number; // in frames
length: number; // in frames
should_play: boolean;
clip_uuid: string;
keyframe_offset: number;
media_file_type: MediaFileType;
constructor(
version: number,
type: ClipType,
group: ClipGroup,
name: string,
media_id: string,
clip_uuid: string,
object_uuid: string,
object_name: string,
offset: number,
length: number,
keyframe_offset: number = 0,
media_file_type: MediaFileType,
) {
this.version = version;
this.group = group; // Only needed for UI
this.clip_uuid = clip_uuid;
this.name = name; // UI
this.type = type; // UI and Animation / Audio / Lipsync / : Engine
this.object_uuid = object_uuid; // Animation / Audio / Lipsync / : Engine
this.object_name = object_name;
this.media_id = media_id; // Animation / Audio / Lipsync / : Engine
this.offset = offset;
this.length = length;
this.should_play = true;
this.keyframe_offset = keyframe_offset;
this.media_file_type = media_file_type;
}
toJSON() {
return {
version: this.version,
group: this.group,
name: this.name,
type: this.type,
clip_uuid: this.clip_uuid,
object_uuid: this.object_uuid,
object_name: this.object_name,
media_id: this.media_id,
start_offset: this.offset,
ending_offset: this.length,
keyframe_offset: this.keyframe_offset,
media_file_type: this.media_file_type,
};
}
}
@@ -0,0 +1,143 @@
import * as THREE from "three";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import Ijson from "~/interfaces/Ijson";
import { StorytellerApiHostStore } from "@storyteller/api";
interface CsvJson {
[key: string]: string[];
}
export class EmotionClip implements Ijson {
version: number;
media_id: string;
type: "expression";
emotion_json: any;
faces: THREE.Mesh[];
constructor(version: number, media_id: string) {
this.version = version;
this.media_id = media_id;
this.type = "expression";
this.faces = [];
this.download_csv().then((data) => {
this.emotion_json = data;
});
}
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
async download_json() {
const url = await this.get_media_url();
const response = await fetch(url);
return await response.json();
}
async download_csv(): Promise<CsvJson> {
const url = await this.get_media_url();
const response = await fetch(url);
const csvText = await response.text();
// Parsing the CSV text
const rows = csvText.trim().split("\n");
const header = rows[0].split(",");
const jsonData: CsvJson = {};
header.forEach((key) => (jsonData[key] = []));
for (let i = 1; i < rows.length; i++) {
const values = rows[i].split(",");
values.forEach((value, index) => {
jsonData[header[index]].push(value);
});
}
return jsonData;
}
async _detect_face(object: THREE.Object3D): Promise<THREE.Mesh> {
this.faces = [];
return new Promise((resolve) => {
object.traverse((c: THREE.Object3D) => {
if (c instanceof THREE.Mesh) {
if (c.morphTargetInfluences && c.morphTargetDictionary) {
const blendShapeIndexE = c.morphTargetDictionary["jawOpen"];
if (blendShapeIndexE !== undefined) {
this.faces.push(c);
resolve(c);
}
}
}
});
});
}
setBlends(shapes: { [key: string]: number }) {
this.faces.forEach((element: THREE.Mesh) => {
if (element.morphTargetInfluences && shapes) {
Object.keys(shapes).forEach((key) => {
let index = element.morphTargetDictionary?.[key];
if (index === undefined) {
index =
element.morphTargetDictionary?.[
key.charAt(0).toLowerCase() + key.slice(1)
];
}
if (
typeof index === "number" &&
element.morphTargetInfluences !== undefined
) {
element.morphTargetInfluences[index] = shapes[key];
}
});
}
});
}
async reset(object: THREE.Object3D) {
await this._detect_face(object);
const keys: { [key: string]: number } = {};
if (this.emotion_json === undefined || this.emotion_json === null) {
return;
}
Object.keys(this.emotion_json).forEach((key) => {
keys[key] = 0;
});
this.setBlends(keys);
}
async step(frame: number, object: THREE.Object3D) {
if (this.faces.length <= 0) {
await this._detect_face(object);
}
if (this.emotion_json === undefined || this.emotion_json === null) {
return;
}
const keys: { [key: string]: number } = {};
Object.keys(this.emotion_json).forEach((key) => {
if (Math.floor(frame) > this.emotion_json[key].length) {
return;
}
keys[key] = this.emotion_json[key][Math.floor(frame)];
});
this.setBlends(keys);
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
type: this.type,
};
}
}
@@ -0,0 +1,171 @@
import { LipSync } from "../Editor/lipsync";
import * as THREE from "three";
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
import Ijson from "~/interfaces/Ijson";
import { StorytellerApiHostStore } from "@storyteller/api";
interface AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
}
class AudioData implements AudioDataInterface {
audioContext: AudioContext;
audioBuffer: AudioBuffer;
source: AudioBufferSourceNode | undefined;
constructor(audioContext: AudioContext, audioBuffer: AudioBuffer) {
this.audioContext = audioContext;
this.audioBuffer = audioBuffer;
}
}
class BlendShapeHelper {
ah: number;
ee: number;
oh: number;
constructor(ah: number, ee: number, oh: number) {
this.ah = ah;
this.ee = ee;
this.oh = oh;
}
}
export class LipSyncClip implements Ijson {
version: number;
media_id: string;
type: "lipsync";
volume: number;
audio_data: AudioData | undefined;
lipsync: LipSync;
blendshape_helper: BlendShapeHelper;
faces: THREE.Mesh[];
constructor(version: number, media_id: string, volume: number) {
this.version = version;
this.media_id = media_id;
this.type = "lipsync";
this.volume = volume;
this.download_audio().then((data) => {
this.audio_data = data;
});
this.blendshape_helper = new BlendShapeHelper(0, 0, 0);
// we might need 3 of these one for each character ...
this.lipsync = new LipSync();
this.faces = [];
}
// lip sync will be generated through TTS
async get_media_url() {
//This is for prod when we have the proper info on the url.
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
const url = `${apiSchemeAndHost}/v1/media_files/file/${this.media_id}`;
console.log(`API BASE URL? ${apiSchemeAndHost}`);
console.log(`CALLED URL? ${url}`);
const response = await fetch(url);
const json = await JSON.parse(await response.text());
const bucketPath = json["media_file"]["public_bucket_path"];
const media_api_base_url = GetCdnOrigin();
//const media_base_url = `${media_api_base_url}/vocodes-public`;
//const media_url = `${media_base_url}${bucketPath}`;
const media_url = `${media_api_base_url}${bucketPath}`;
return media_url;
}
async download_audio() {
const url = await this.get_media_url();
const audioContext = new AudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return new AudioData(audioContext, audioBuffer);
}
async _detect_face(object: THREE.Object3D): Promise<THREE.Mesh> {
return new Promise((resolve) => {
object.traverse((c: THREE.Object3D) => {
if (c instanceof THREE.Mesh) {
if (c.morphTargetInfluences && c.morphTargetDictionary) {
let blendShapeIndexE = c.morphTargetDictionary["E"];
let blendShapeIndexO = c.morphTargetDictionary["O"];
let blendShapeIndexA = c.morphTargetDictionary["aa"];
if (blendShapeIndexE === undefined) {
blendShapeIndexE = c.morphTargetDictionary["お"]; // MMD OH
blendShapeIndexO = c.morphTargetDictionary["ω"]; // MMD O
blendShapeIndexA = c.morphTargetDictionary["あ"]; // MMD A
}
if (blendShapeIndexE != null) {
this.blendshape_helper = new BlendShapeHelper(
blendShapeIndexA,
blendShapeIndexE,
blendShapeIndexO,
);
this.faces.push(c);
resolve(c);
}
}
}
});
});
}
async play(object: THREE.Object3D) {
if (this.audio_data?.audioBuffer == null) {
await this.download_audio();
}
if (this.lipsync.face == null) {
this.lipsync = new LipSync(await this._detect_face(object));
this.lipsync.startLipSyncFromAudioBuffer(this.audio_data?.audioBuffer);
}
}
stop() {
if (this.lipsync == null) {
return;
}
this.lipsync.destroy();
}
setBlends(ah: number, ee: number, oh: number) {
this.faces.forEach((element: THREE.Mesh) => {
if (element.morphTargetInfluences) {
element.morphTargetInfluences[this.blendshape_helper.ee] = ee;
element.morphTargetInfluences[this.blendshape_helper.ah] = ah;
element.morphTargetInfluences[this.blendshape_helper.oh] = oh;
}
});
}
step(
frame: number,
offset: number,
// rendering: boolean
) {
if (this.lipsync == null) {
return;
}
const positions = this.lipsync.update(frame, offset, true);
if (positions)
this.setBlends(positions["ee"], positions["ah"], positions["oh"]);
}
reset() {
if (this.lipsync.face != undefined) {
this.setBlends(0, 0, 0);
}
this.lipsync = new LipSync();
}
toJSON() {
return {
version: this.version,
media_id: this.media_id,
type: this.type,
volume: this.volume,
};
}
}

Some files were not shown because too many files have changed in this diff Show More