diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs index 64944316d3..794ecc9dbc 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft.rs @@ -32,5 +32,7 @@ pub async fn handle_image_edit_artcraft( ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await, ImageEditModel::Seedream4p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4p5, GenerationModel::Seedream4p5).await, ImageEditModel::Seedream5Lite => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream5Lite, GenerationModel::Seedream5Lite).await, + ImageEditModel::QwenEdit2511Angles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::QwenEdit2511Angles, GenerationModel::QwenEdit2511Angles).await, + ImageEditModel::Flux2LoraAngles => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Flux2LoraAngles, GenerationModel::Flux2LoraAngles).await, } } diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs index b97a14fa99..9d3572ae11 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/artcraft/handle_image_edit_artcraft_via_router.rs @@ -76,9 +76,9 @@ pub(super) async fn handle_image_edit_artcraft_via_router( request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, generation_mode_mismatch_strategy: None, idempotency_token: None, - horizontal_angle: None, - vertical_angle: None, - zoom: None, + horizontal_angle: request.horizontal_angle, + vertical_angle: request.vertical_angle, + zoom: request.zoom, }; let plan = router_request.build()?; diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/enqueue_edit_image_command.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/enqueue_edit_image_command.rs index 84041fba3d..32a2c40705 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/enqueue_edit_image_command.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/enqueue_edit_image_command.rs @@ -81,6 +81,12 @@ pub enum ImageEditModel { #[serde(rename = "seedream_5_lite")] Seedream5Lite, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + // #[serde(rename = "qwen")] // Qwen, // @@ -130,6 +136,18 @@ pub struct EnqueueEditImageCommand { /// Image resolution pub image_resolution: Option, + /// OPTIONAL. + /// Only for image angle edit models. + pub horizontal_angle: Option, + + /// OPTIONAL. + /// Only for image angle edit models. + pub vertical_angle: Option, + + /// OPTIONAL. + /// Only for image angle edit models. + pub zoom: Option, + /// OPTIONAL. /// Name of the frontend caller. /// We'll use this to selectively trigger events. diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/image_edit_models.rs b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/image_edit_models.rs index bf29484528..124f566402 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/image_edit_models.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/image_edit/image_edit_models.rs @@ -13,5 +13,7 @@ pub fn image_edit_model_to_model_type(model: ImageEditModel) -> ModelType { ImageEditModel::Seedream4 => ModelType::Seedream4, ImageEditModel::Seedream4p5 => ModelType::Seedream4p5, ImageEditModel::Seedream5Lite => ModelType::Seedream5Lite, + ImageEditModel::QwenEdit2511Angles => ModelType::QwenEdit2511Angles, + ImageEditModel::Flux2LoraAngles => ModelType::Flux2LoraAngles, } } diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs index 16fd5fc885..b147360e9a 100644 --- a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs +++ b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs @@ -76,6 +76,8 @@ impl TaskEnqueueSuccess{ Some(GenerationModel::Seedream4) => Some(TaskModelType::Seedream4), Some(GenerationModel::Seedream4p5) => Some(TaskModelType::Seedream4p5), Some(GenerationModel::Seedream5Lite) => Some(TaskModelType::Seedream5Lite), + Some(GenerationModel::QwenEdit2511Angles) => Some(TaskModelType::QwenEdit2511Angles), + Some(GenerationModel::Flux2LoraAngles) => Some(TaskModelType::Flux2LoraAngles), Some(GenerationModel::GrokImage) => Some(TaskModelType::GrokImage), Some(GenerationModel::Recraft3) => Some(TaskModelType::Recraft3), Some(GenerationModel::GrokVideo) => Some(TaskModelType::GrokVideo), diff --git a/crates/desktop/artcraft/src/core/events/generation_events/common.rs b/crates/desktop/artcraft/src/core/events/generation_events/common.rs index 76b6454a7c..1999a39c1c 100644 --- a/crates/desktop/artcraft/src/core/events/generation_events/common.rs +++ b/crates/desktop/artcraft/src/core/events/generation_events/common.rs @@ -50,6 +50,11 @@ pub enum GenerationModel { #[serde(rename = "seedream_5_lite")] Seedream5Lite, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, + #[serde(rename = "hunyuan_3d_2_0")] Hunyuan3d2_0, #[serde(rename = "hunyuan_3d_2_1")] diff --git a/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs b/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs index 3974839fc3..dcd182fecc 100644 --- a/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs +++ b/crates/schema/public/enums/src/tauri/tasks/task_model_type.rs @@ -45,6 +45,10 @@ pub enum TaskModelType { Seedream4p5, #[serde(rename = "seedream_5_lite")] Seedream5Lite, + #[serde(rename = "qwen_edit_2511_angles")] + QwenEdit2511Angles, + #[serde(rename = "flux_2_lora_angles")] + Flux2LoraAngles, #[serde(rename = "grok_image")] GrokImage, #[serde(rename = "recraft_3")] @@ -133,6 +137,8 @@ impl TaskModelType { Self::Seedream4 => "seedream_4", Self::Seedream4p5 => "seedream_4p5", Self::Seedream5Lite => "seedream_5_lite", + Self::QwenEdit2511Angles => "qwen_edit_2511_angles", + Self::Flux2LoraAngles => "flux_2_lora_angles", Self::GrokImage => "grok_image", Self::Recraft3 => "recraft_3", Self::Midjourney => "midjourney", @@ -184,6 +190,8 @@ impl TaskModelType { "seedream_4" => Ok(Self::Seedream4), "seedream_4p5" => Ok(Self::Seedream4p5), "seedream_5_lite" => Ok(Self::Seedream5Lite), + "qwen_edit_2511_angles" => Ok(Self::QwenEdit2511Angles), + "flux_2_lora_angles" => Ok(Self::Flux2LoraAngles), "grok_image" => Ok(Self::GrokImage), "recraft_3" => Ok(Self::Recraft3), "midjourney" => Ok(Self::Midjourney), @@ -238,6 +246,8 @@ impl TaskModelType { Self::Seedream4, Self::Seedream4p5, Self::Seedream5Lite, + Self::QwenEdit2511Angles, + Self::Flux2LoraAngles, Self::GrokImage, Self::Recraft3, Self::Midjourney, @@ -299,6 +309,8 @@ mod tests { assert_serialization(TaskModelType::Seedream4, "seedream_4"); assert_serialization(TaskModelType::Seedream4p5, "seedream_4p5"); assert_serialization(TaskModelType::Seedream5Lite, "seedream_5_lite"); + assert_serialization(TaskModelType::QwenEdit2511Angles, "qwen_edit_2511_angles"); + assert_serialization(TaskModelType::Flux2LoraAngles, "flux_2_lora_angles"); assert_serialization(TaskModelType::GrokImage, "grok_image"); assert_serialization(TaskModelType::Recraft3, "recraft_3"); assert_serialization(TaskModelType::Midjourney, "midjourney"); @@ -349,6 +361,8 @@ mod tests { assert_eq!(TaskModelType::Seedream4.to_str(), "seedream_4"); assert_eq!(TaskModelType::Seedream4p5.to_str(), "seedream_4p5"); assert_eq!(TaskModelType::Seedream5Lite.to_str(), "seedream_5_lite"); + assert_eq!(TaskModelType::QwenEdit2511Angles.to_str(), "qwen_edit_2511_angles"); + assert_eq!(TaskModelType::Flux2LoraAngles.to_str(), "flux_2_lora_angles"); assert_eq!(TaskModelType::GrokImage.to_str(), "grok_image"); assert_eq!(TaskModelType::Recraft3.to_str(), "recraft_3"); assert_eq!(TaskModelType::Midjourney.to_str(), "midjourney"); @@ -399,6 +413,8 @@ mod tests { assert_eq!(TaskModelType::from_str("seedream_4").unwrap(), TaskModelType::Seedream4); assert_eq!(TaskModelType::from_str("seedream_4p5").unwrap(), TaskModelType::Seedream4p5); assert_eq!(TaskModelType::from_str("seedream_5_lite").unwrap(), TaskModelType::Seedream5Lite); + assert_eq!(TaskModelType::from_str("qwen_edit_2511_angles").unwrap(), TaskModelType::QwenEdit2511Angles); + assert_eq!(TaskModelType::from_str("flux_2_lora_angles").unwrap(), TaskModelType::Flux2LoraAngles); assert_eq!(TaskModelType::from_str("grok_image").unwrap(), TaskModelType::GrokImage); assert_eq!(TaskModelType::from_str("recraft_3").unwrap(), TaskModelType::Recraft3); assert_eq!(TaskModelType::from_str("midjourney").unwrap(), TaskModelType::Midjourney); @@ -444,7 +460,7 @@ mod tests { #[test] fn all_variants() { let mut variants = TaskModelType::all_variants(); - assert_eq!(variants.len(), 43); + assert_eq!(variants.len(), 45); // Image models assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev)); assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell)); @@ -462,6 +478,8 @@ mod tests { assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4)); assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream4p5)); assert_eq!(variants.pop_first(), Some(TaskModelType::Seedream5Lite)); + assert_eq!(variants.pop_first(), Some(TaskModelType::QwenEdit2511Angles)); + assert_eq!(variants.pop_first(), Some(TaskModelType::Flux2LoraAngles)); assert_eq!(variants.pop_first(), Some(TaskModelType::GrokImage)); assert_eq!(variants.pop_first(), Some(TaskModelType::Recraft3)); assert_eq!(variants.pop_first(), Some(TaskModelType::Midjourney)); diff --git a/frontend/apps/artcraft/app/src/config/appMenu.tsx b/frontend/apps/artcraft/app/src/config/appMenu.tsx index ac495dd9c6..39add01b60 100644 --- a/frontend/apps/artcraft/app/src/config/appMenu.tsx +++ b/frontend/apps/artcraft/app/src/config/appMenu.tsx @@ -9,6 +9,7 @@ import { faPencil, faWandMagicSparkles, faPenNib, + faCrosshairs, } from "@fortawesome/pro-solid-svg-icons"; import { useTabStore, TabId } from "~/pages/Stores/TabState"; import { set3DPageMounted } from "~/pages/PageEnigma/Editor/editor"; @@ -24,7 +25,8 @@ export type AppId = | "IMAGE_WATERMARK_REMOVAL" | "IMAGE_TO_3D_OBJECT" | "IMAGE_TO_3D_WORLD" - | "REMOVE_BACKGROUND"; + | "REMOVE_BACKGROUND" + | "ANGLES"; export interface AppDescriptor { id: AppId; @@ -157,6 +159,16 @@ export const ALL_APPS: FullAppItem[] = [ action: "REMOVE_BACKGROUND", color: "bg-violet-500/40", }, + { + id: "angles", + label: "Angles", + description: "Generate new camera angles from a single photo", + icon: faCrosshairs, + category: "generate", + action: "ANGLES", + color: "bg-lime-500/40", + badge: "NEW", + }, { id: "2d-canvas", @@ -210,6 +222,7 @@ export const goToApp = (action?: string) => { "IMAGE_TO_3D_OBJECT", "IMAGE_TO_3D_WORLD", "REMOVE_BACKGROUND", + "ANGLES", ].includes(action) ) { if (action === "3D") { diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx new file mode 100644 index 0000000000..88fb31422a --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx @@ -0,0 +1,715 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faImages, + faPlus, + faDownload, + faUpload, + faCrosshairs, + faChevronUp, + faChevronDown, + faChevronLeft, + faChevronRight, +} from "@fortawesome/pro-solid-svg-icons"; +import { Button } from "@storyteller/ui-button"; +import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal"; +import { downloadFileFromUrl } from "@storyteller/api"; +import toast from "react-hot-toast"; + +import { UploadEntryCard } from "../../components/media/UploadEntryCard"; +import { + useAnglesStore, + GeneratedAngle, + ROTATION_VALUES, + TILT_VALUES, + ZOOM_VALUES, +} from "./AnglesStore"; +import { OrbitSphere, snapToNearest } from "./OrbitSphere"; +import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover"; +import { twMerge } from "tailwind-merge"; +import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; +import { SliderV2 } from "@storyteller/ui-sliderv2"; +import { Switch } from "@headlessui/react"; +import { EnqueueEditImage, EnqueueEditImageRequest } from "@storyteller/tauri-api"; + +// ─── Utility ────────────────────────────────────────────────────────────────── + +const convertFileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + if (reader.result) { + resolve(reader.result as string); + } else { + reject(new Error("Failed to convert file to base64.")); + } + }; + reader.onerror = () => reject(new Error("Error reading file.")); + reader.readAsDataURL(file); + }); +}; + +// ─── Main Angles Component ───────────────────────────────────────────────────── + +export const Angles = () => { + const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false); + const [selectedGalleryImages, setSelectedGalleryImages] = useState( + [], + ); + const [sourceMediaToken, setSourceMediaToken] = useState(null); + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + + const fileInputRef = useRef(null); + + // State selectors (only re-render when specific values change) + const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl); + const imageDimensions = useAnglesStore((s) => s.imageDimensions); + const angleConfig = useAnglesStore((s) => s.angleConfig); + const generateFromBestAngles = useAnglesStore( + (s) => s.generateFromBestAngles, + ); + const generatedAngles = useAnglesStore((s) => s.generatedAngles); + const activeAngleId = useAnglesStore((s) => s.activeAngleId); + const isProcessing = useAnglesStore((s) => s.isProcessing); + const isLoadingImage = useAnglesStore((s) => s.isLoadingImage); + + // Actions (stable references — never cause re-renders) + const setSourceImage = useAnglesStore((s) => s.setSourceImage); + const setImageDimensions = useAnglesStore((s) => s.setImageDimensions); + const setRotation = useAnglesStore((s) => s.setRotation); + const setTilt = useAnglesStore((s) => s.setTilt); + const setZoom = useAnglesStore((s) => s.setZoom); + const setGenerateFromBestAngles = useAnglesStore( + (s) => s.setGenerateFromBestAngles, + ); + const addGeneratedAngle = useAnglesStore((s) => s.addGeneratedAngle); + const setActiveAngle = useAnglesStore((s) => s.setActiveAngle); + const setIsProcessing = useAnglesStore((s) => s.setIsProcessing); + const setIsLoadingImage = useAnglesStore((s) => s.setIsLoadingImage); + const resetSource = useAnglesStore((s) => s.resetSource); + + const activeAngle = useMemo( + () => generatedAngles.find((a) => a.id === activeAngleId) ?? null, + [generatedAngles, activeAngleId], + ); + + // Window resize handler (debounced to avoid excessive re-renders) + useEffect(() => { + let timeoutId: ReturnType; + const handleResize = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + }, 150); + }; + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + clearTimeout(timeoutId); + }; + }, []); + + // Popover "add" items + const addMenuItems: PopoverItem[] = useMemo( + () => [ + { + label: "Upload Image", + selected: false, + icon: , + action: "upload", + }, + { + label: "Choose from Library", + selected: false, + icon: , + action: "library", + }, + ], + [], + ); + + const handleAddMenuSelect = useCallback((item: PopoverItem) => { + if (item.action === "upload") { + fileInputRef.current?.click(); + } else if (item.action === "library") { + setIsGalleryModalOpen(true); + } + }, []); + + const handleLocalImageSelect = useCallback( + async (files: FileList) => { + const file = files[0]; + if (!file || !file.type.startsWith("image/")) return; + + setIsLoadingImage(true); + + try { + const base64Image = await convertFileToBase64(file); + const objectUrl = URL.createObjectURL(file); + + await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + resolve(); + }; + img.onerror = () => reject(new Error("Failed to load image")); + img.src = objectUrl; + }); + + setSourceImage(objectUrl, base64Image); + setIsLoadingImage(false); + } catch (error) { + console.error("Error processing image:", error); + toast.error("Failed to process image"); + setIsLoadingImage(false); + } + }, + [setSourceImage, setImageDimensions, setIsLoadingImage], + ); + + const handleImageSelect = useCallback((id: string) => { + setSelectedGalleryImages((prev) => { + if (prev.includes(id)) return prev.filter((x) => x !== id); + return [id]; + }); + }, []); + + const handleGallerySelect = useCallback( + async (selectedItems: GalleryItem[]) => { + const item = selectedItems[0]; + if (!item || !item.fullImage) { + toast.error("No image selected"); + return; + } + + let mediaToken = item.id; // NB: `id` is a media token. + + const imageUrl = item.fullImage; + setIsGalleryModalOpen(false); + setSelectedGalleryImages([]); + setSourceMediaToken(mediaToken); + setIsLoadingImage(true); + + try { + await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + resolve(); + }; + img.onerror = () => reject(new Error("Failed to load image")); + img.src = imageUrl; + }); + + setSourceImage(imageUrl, ""); + setIsLoadingImage(false); + } catch (error) { + console.error("Error processing gallery image:", error); + toast.error("Failed to process image"); + setIsLoadingImage(false); + } + }, + [setSourceImage, setImageDimensions, setIsLoadingImage], + ); + + const handleGenerate = useCallback(async () => { + if (!sourceMediaToken || isProcessing) return; + + setIsProcessing(true); + + try { + await EnqueueEditImage({ + model: "flux_2_lora_angles" as EnqueueEditImageRequest["model"], + image_media_tokens: [sourceMediaToken], + prompt: "", + horizontal_angle: angleConfig.rotation, + vertical_angle: angleConfig.tilt, + zoom: angleConfig.zoom, + }); + + toast.success("Angle generation enqueued"); + } catch (error) { + console.error("Error generating angle:", error); + toast.error("Failed to generate angle"); + } finally { + setIsProcessing(false); + } + }, [ + sourceMediaToken, + isProcessing, + angleConfig, + setIsProcessing, + ]); + + const handleDownload = useCallback(async () => { + if (!activeAngle) { + toast.error("No image to download"); + return; + } + try { + await downloadFileFromUrl(activeAngle.imageUrl); + toast.success("Image saved to Downloads folder"); + } catch (error) { + console.error("Download failed:", error); + toast.error("Failed to download image"); + } + }, [activeAngle]); + + const handleThumbnailClick = useCallback( + (angle: GeneratedAngle) => { + setActiveAngle(angle.id); + }, + [setActiveAngle], + ); + + // Called when user releases the sphere drag — values are already snapped + const handleSphereDragEnd = useCallback( + (snappedRotation: number, snappedTilt: number) => { + setRotation(snappedRotation); + setTilt(snappedTilt); + }, + [setRotation, setTilt], + ); + + const showUploadScreen = !sourceImageUrl && !isLoadingImage; + + const imageContainerStyle = useMemo(() => { + if (!imageDimensions) { + return { width: "600px", height: "450px" }; + } + + // Floating panels: ~80px left thumbnails, ~16px margins, p-16 (128px total horizontal) + // Top toolbar ~56px + top bar ~56px, bottom controls ~160px floating + margins + const horizontalPadding = 128 + 80; + const verticalPadding = 56 + 64 + 160; + + const availableWidth = windowSize.width - horizontalPadding; + const availableHeight = windowSize.height - verticalPadding; + + const maxWidth = Math.min(availableWidth, 1200); + const maxHeight = Math.max(availableHeight, 200); + const imageAspect = imageDimensions.width / imageDimensions.height; + + let width = maxWidth; + let height = width / imageAspect; + + if (height > maxHeight) { + height = maxHeight; + width = height * imageAspect; + } + + width = Math.max(width, 200); + height = Math.max(height, 150); + + return { width: `${width}px`, height: `${height}px` }; + }, [imageDimensions, windowSize.width, windowSize.height]); + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files) { + handleLocalImageSelect(e.target.files); + e.target.value = ""; + } + }, + [handleLocalImageSelect], + ); + + const handleOpenGallery = useCallback(() => { + setIsGalleryModalOpen(true); + }, []); + + const handleCloseGallery = useCallback(() => { + setIsGalleryModalOpen(false); + setSelectedGalleryImages([]); + }, []); + + const handleChangeImage = useCallback(() => { + resetSource(); + setSourceMediaToken(null); + }, [resetSource]); + + // Slider handlers that snap to allowed values + const handleRotationSlider = useCallback( + (value: number) => { + setRotation(snapToNearest(value, ROTATION_VALUES)); + }, + [setRotation], + ); + + const handleTiltSlider = useCallback( + (value: number) => { + setTilt(snapToNearest(value, TILT_VALUES)); + }, + [setTilt], + ); + + const handleZoomSlider = useCallback( + (value: number) => { + setZoom(snapToNearest(value, ZOOM_VALUES)); + }, + [setZoom], + ); + + // Arrow step handlers for orbit sphere + const handleRotationStep = useCallback( + (direction: 1 | -1) => { + const idx = ROTATION_VALUES.indexOf(angleConfig.rotation); + const next = + (idx + direction + ROTATION_VALUES.length) % ROTATION_VALUES.length; + setRotation(ROTATION_VALUES[next]); + }, + [angleConfig.rotation, setRotation], + ); + + const handleTiltStep = useCallback( + (direction: 1 | -1) => { + const idx = TILT_VALUES.indexOf(angleConfig.tilt); + const next = Math.max( + 0, + Math.min(TILT_VALUES.length - 1, idx + direction), + ); + setTilt(TILT_VALUES[next]); + }, + [angleConfig.tilt, setTilt], + ); + + return ( + <> +
+ {showUploadScreen ? ( + /* ──── Upload screen ──── */ +
+
+
+ + {isLoadingImage && ( +
+ +
+ )} +
+
+
+ ) : ( + /* ──── Editor layout — floating panels over full-bleed canvas ──── */ +
+ + + {/* Full-bleed image display — pb-44 reserves space for floating bottom controls */} +
+
+ {activeAngle ? ( + Generated Angle + ) : sourceImageUrl ? ( + <> + Source + {isProcessing && ( +
+
+
+
+ +
+ + Generating Angle... + +
+
+ )} + + ) : ( +
+ +
+ )} +
+
+ + {/* ── Floating top toolbar ── */} +
+
+ + {activeAngle && ( + + )} +
+
+ + {/* ── Floating left thumbnail strip ── */} +
+
+ + } + /> + + {generatedAngles.length > 0 && ( +
+ {generatedAngles.map((angle) => ( + + ))} +
+ )} +
+
+ + {/* ── Floating bottom angle controls ── */} +
+
+ {/* Orbit sphere — compact, with equal arrow spacing on all sides */} +
+ + + + + +
+ + {/* Divider */} +
+ + {/* Sliders group */} +
+
+ + Rotation + +
+ +
+ + {angleConfig.rotation}° + +
+ +
+ + Tilt + +
+ +
+ + {angleConfig.tilt}° + +
+ +
+ + Zoom + +
+ +
+ + {angleConfig.zoom} + +
+
+ + {/* Divider */} +
+ + {/* Toggle + Generate */} +
+ +
+ + Best 12 angles + + + + +
+
+ + +
+
+
+
+ )} +
+ + + + ); +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts b/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts new file mode 100644 index 0000000000..e4b1f90141 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts @@ -0,0 +1,169 @@ +import { create } from "zustand"; + +export interface GeneratedAngle { + id: string; + imageUrl: string; + rotation: number; + tilt: number; + zoom: number; + timestamp: number; +} + +export interface ImageDimensions { + width: number; + height: number; +} + +export interface AngleConfig { + rotation: number; // 0, 45, 90, 135, 180, 225, 270, 315 + tilt: number; // -30, 0, 30, 60 + zoom: number; // 0, 5, 10 +} + +// Snapped value options +export const ROTATION_VALUES = [0, 45, 90, 135, 180, 225, 270, 315]; +export const TILT_VALUES = [-30, 0, 30, 60]; +export const ZOOM_VALUES = [0, 5, 10]; + +interface AnglesState { + // Source image + sourceImageUrl: string | null; + sourceBase64: string | null; + imageDimensions: ImageDimensions | null; + + // Angle config + angleConfig: AngleConfig; + generateFromBestAngles: boolean; + + // Generated results + generatedAngles: GeneratedAngle[]; + activeAngleId: string | null; + + // Processing + isProcessing: boolean; + pendingJobId: string | null; + isLoadingImage: boolean; + + // Actions + setSourceImage: (url: string, base64: string) => void; + setImageDimensions: (dims: ImageDimensions | null) => void; + setRotation: (value: number) => void; + setTilt: (value: number) => void; + setZoom: (value: number) => void; + setGenerateFromBestAngles: (value: boolean) => void; + addGeneratedAngle: (angle: GeneratedAngle) => void; + setActiveAngle: (id: string | null) => void; + getActiveAngle: () => GeneratedAngle | null; + setIsProcessing: (value: boolean) => void; + setPendingJobId: (id: string | null) => void; + setIsLoadingImage: (value: boolean) => void; + resetSource: () => void; + clearAll: () => void; +} + +const DEFAULT_CONFIG: AngleConfig = { + rotation: 0, + tilt: 0, + zoom: 0, +}; + +export const useAnglesStore = create((set, get) => ({ + sourceImageUrl: null, + sourceBase64: null, + imageDimensions: null, + angleConfig: { ...DEFAULT_CONFIG }, + generateFromBestAngles: false, + generatedAngles: [], + activeAngleId: null, + isProcessing: false, + pendingJobId: null, + isLoadingImage: false, + + setSourceImage: (url, base64) => { + set({ sourceImageUrl: url, sourceBase64: base64 }); + }, + + setImageDimensions: (dims) => { + set({ imageDimensions: dims }); + }, + + setRotation: (value) => { + set((state) => ({ + angleConfig: { ...state.angleConfig, rotation: value }, + })); + }, + + setTilt: (value) => { + set((state) => ({ + angleConfig: { ...state.angleConfig, tilt: value }, + })); + }, + + setZoom: (value) => { + set((state) => ({ + angleConfig: { ...state.angleConfig, zoom: value }, + })); + }, + + setGenerateFromBestAngles: (value) => { + set({ generateFromBestAngles: value }); + }, + + addGeneratedAngle: (angle) => { + set((state) => ({ + generatedAngles: [...state.generatedAngles, angle], + activeAngleId: angle.id, + })); + }, + + setActiveAngle: (id) => { + set({ activeAngleId: id }); + }, + + getActiveAngle: () => { + const state = get(); + return ( + state.generatedAngles.find((a) => a.id === state.activeAngleId) ?? null + ); + }, + + setIsProcessing: (value) => { + set({ isProcessing: value }); + }, + + setPendingJobId: (id) => { + set({ pendingJobId: id }); + }, + + setIsLoadingImage: (value) => { + set({ isLoadingImage: value }); + }, + + resetSource: () => { + set({ + sourceImageUrl: null, + sourceBase64: null, + imageDimensions: null, + angleConfig: { ...DEFAULT_CONFIG }, + generateFromBestAngles: false, + isProcessing: false, + pendingJobId: null, + isLoadingImage: false, + }); + }, + + clearAll: () => { + set({ + sourceImageUrl: null, + sourceBase64: null, + imageDimensions: null, + angleConfig: { ...DEFAULT_CONFIG }, + generateFromBestAngles: false, + generatedAngles: [], + activeAngleId: null, + isProcessing: false, + pendingJobId: null, + isLoadingImage: false, + }); + }, +})); diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/OrbitSphere.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/OrbitSphere.tsx new file mode 100644 index 0000000000..f5723481e8 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/OrbitSphere.tsx @@ -0,0 +1,729 @@ +import { useRef, useEffect, useCallback, memo } from "react"; +import { ROTATION_VALUES, TILT_VALUES } from "./AnglesStore"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface OrbitSphereProps { + rotation: number; + tilt: number; + zoom: number; + onDragEnd: (rotation: number, tilt: number) => void; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Snap a raw value to the nearest element in an array of allowed values. */ +export const snapToNearest = ( + value: number, + allowedValues: number[], +): number => { + let closest = allowedValues[0]; + let minDist = Math.abs(value - closest); + for (const v of allowedValues) { + const dist = Math.abs(value - v); + if (dist < minDist) { + minDist = dist; + closest = v; + } + } + return closest; +}; + +// ─── Face descriptor used by the painter's-algorithm renderer ──────────────── + +interface Face { + pts: { x: number; y: number; depth: number }[]; + fillColor: string; + edgeColor: string; + edgeWidth: number; + /** Lens front cap — draws concentric glass rings. */ + isFront: boolean; + /** Camera body back face — draws the LCD screen inset. */ + isBack: boolean; + /** Camera body front face — draws the red recording dot. */ + isBodyFront: boolean; + avgDepth: number; + /** Targeting-laser line from lens tip to sphere centre. */ + isLine?: boolean; +} + +// ─── OrbitSphere Component ─────────────────────────────────────────────────── + +export const OrbitSphere = memo( + ({ rotation, tilt, onDragEnd }: OrbitSphereProps) => { + const canvasRef = useRef(null); + const isDragging = useRef(false); + const lastPos = useRef({ x: 0, y: 0 }); + const liveRotation = useRef(rotation); + const liveTilt = useRef(tilt); + const rafId = useRef(null); + + // Keep live refs in sync when props change (from sliders / snap) + useEffect(() => { + if (!isDragging.current) { + liveRotation.current = rotation; + liveTilt.current = tilt; + } + }, [rotation, tilt]); + + // ─── Core draw function ────────────────────────────────────────────────── + + const drawSphere = useCallback( + (renderRotation: number, renderTilt: number, dragging = false) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // HiDPI setup + const displayW = canvas.clientWidth; + const displayH = canvas.clientHeight; + const dpr = window.devicePixelRatio || 1; + canvas.width = displayW * dpr; + canvas.height = displayH * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const w = displayW; + const h = displayH; + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) * 0.4; + + ctx.clearRect(0, 0, w, h); + + // Subtle radial outer glow + const outerGlow = ctx.createRadialGradient( + cx, + cy, + radius * 0.8, + cx, + cy, + radius * 1.3, + ); + outerGlow.addColorStop(0, "rgba(255, 255, 255, 0.02)"); + outerGlow.addColorStop(1, "rgba(255, 255, 255, 0)"); + ctx.fillStyle = outerGlow; + ctx.fillRect(0, 0, w, h); + + const rotRad = (renderRotation * Math.PI) / 180; + const tiltRad = (renderTilt * Math.PI) / 180; + + // ── Perspective projection ────────────────────────────────────────── + const project = ( + x3d: number, + y3d: number, + z3d: number, + ): { x: number; y: number; depth: number } => { + const perspective = 6; + const scale = perspective / (perspective - z3d); + return { + x: cx + x3d * radius * scale, + y: cy + y3d * radius * scale, + depth: z3d, + }; + }; + + // ── Wireframe sphere ──────────────────────────────────────────────── + const wireAlpha = dragging ? 0.12 : 0.06; + ctx.strokeStyle = `rgba(255, 255, 255, ${wireAlpha})`; + ctx.lineWidth = 0.7; + + // Meridians + for (let i = 0; i < 12; i++) { + const angle = (i * Math.PI) / 6 + rotRad; + ctx.beginPath(); + for (let j = 0; j <= 40; j++) { + const phi = (j / 40) * Math.PI * 2; + const p = project( + Math.cos(angle) * Math.sin(phi), + Math.cos(phi), + Math.sin(angle) * Math.sin(phi), + ); + if (j === 0) ctx.moveTo(p.x, p.y); + else ctx.lineTo(p.x, p.y); + } + ctx.stroke(); + } + + // Parallels + for (let i = 1; i < 6; i++) { + const phi = (i * Math.PI) / 6; + ctx.beginPath(); + for (let j = 0; j <= 40; j++) { + const angle = (j / 40) * Math.PI * 2 + rotRad; + const p = project( + Math.cos(angle) * Math.sin(phi), + Math.cos(phi), + Math.sin(angle) * Math.sin(phi), + ); + if (j === 0) ctx.moveTo(p.x, p.y); + else ctx.lineTo(p.x, p.y); + } + ctx.stroke(); + } + + // ── 3D camera position & orientation ─────────────────────────────── + const camPosX = Math.sin(rotRad) * Math.cos(tiltRad); + const camPosY = -Math.sin(tiltRad); + const camPosZ = Math.cos(rotRad) * Math.cos(tiltRad); + + const centerScreen = project(0, 0, 0); + + // Direction vector pointing from camera towards origin + const dirX = -camPosX; + const dirY = -camPosY; + const dirZ = -camPosZ; + + // Camera-local right axis (world-up crossed with dir) + let rightX = dirZ; + const rightY = 0; + let rightZ = -dirX; + const rightLen = Math.sqrt(rightX * rightX + rightZ * rightZ) || 1; + rightX /= rightLen; + rightZ /= rightLen; + + // Camera-local up axis + let upX = rightY * dirZ - rightZ * dirY; + let upY = rightZ * dirX - rightX * dirZ; + let upZ = rightX * dirY - rightY * dirX; + const upLen = Math.sqrt(upX * upX + upY * upY + upZ * upZ) || 1; + upX /= upLen; + upY /= upLen; + upZ /= upLen; + + // ── Camera body dimensions ───────────────────────────────────────── + const bodyW = 0.28; + const bodyH = 0.19; + const bodyD = 0.22; + + // ── Geometry helpers ─────────────────────────────────────────────── + + /** Build 8 corners of an axis-aligned box in camera-local space. */ + const makeBox = ( + bx: number, + by: number, + bz: number, + hw: number, + hh: number, + hd: number, + ) => [ + { + x: bx + rightX * hw + upX * hh, + y: by + rightY * hw + upY * hh, + z: bz + rightZ * hw + upZ * hh, + }, + { + x: bx - rightX * hw + upX * hh, + y: by - rightY * hw + upY * hh, + z: bz - rightZ * hw + upZ * hh, + }, + { + x: bx - rightX * hw - upX * hh, + y: by - rightY * hw - upY * hh, + z: bz - rightZ * hw - upZ * hh, + }, + { + x: bx + rightX * hw - upX * hh, + y: by + rightY * hw - upY * hh, + z: bz + rightZ * hw - upZ * hh, + }, + { + x: bx + dirX * hd + rightX * hw + upX * hh, + y: by + dirY * hd + rightY * hw + upY * hh, + z: bz + dirZ * hd + rightZ * hw + upZ * hh, + }, + { + x: bx + dirX * hd - rightX * hw + upX * hh, + y: by + dirY * hd - rightY * hw + upY * hh, + z: bz + dirZ * hd - rightZ * hw + upZ * hh, + }, + { + x: bx + dirX * hd - rightX * hw - upX * hh, + y: by + dirY * hd - rightY * hw - upY * hh, + z: bz + dirZ * hd - rightZ * hw - upZ * hh, + }, + { + x: bx + dirX * hd + rightX * hw - upX * hh, + y: by + dirY * hd + rightY * hw - upY * hh, + z: bz + dirZ * hd + rightZ * hw - upZ * hh, + }, + ]; + + // ── Build camera geometry ────────────────────────────────────────── + const bodyCorners3D = makeBox( + camPosX, + camPosY, + camPosZ, + bodyW, + bodyH, + bodyD, + ); + + const lensLen = 0.14; + const lensR = 0.1; + const lensCx = camPosX + dirX * bodyD; + const lensCy = camPosY + dirY * bodyD; + const lensCz = camPosZ + dirZ * bodyD; + + const vfCorners3D = makeBox( + camPosX + dirX * -0.03 + upX * (bodyH + 0.05), + camPosY + dirY * -0.03 + upY * (bodyH + 0.05), + camPosZ + dirZ * -0.03 + upZ * (bodyH + 0.05), + 0.09, + 0.05, + 0.1, + ); + const gripCorners3D = makeBox( + camPosX + rightX * (bodyW + 0.03), + camPosY + rightY * (bodyW + 0.03), + camPosZ + rightZ * (bodyW + 0.03), + 0.04, + bodyH * 0.85, + bodyD * 0.7, + ); + + // Project box corners to screen + const bodyCorners = bodyCorners3D.map((c) => project(c.x, c.y, c.z)); + const vfCorners = vfCorners3D.map((c) => project(c.x, c.y, c.z)); + const gripCorners = gripCorners3D.map((c) => project(c.x, c.y, c.z)); + + // Box face winding: [back, front, top, bottom, left, right] + const BOX_FACES: [number, number, number, number][] = [ + [0, 1, 2, 3], + [4, 5, 6, 7], + [0, 1, 5, 4], + [3, 2, 6, 7], + [1, 2, 6, 5], + [0, 3, 7, 4], + ]; + + // Edge colours + const edgeGlow = "rgba(180, 200, 255, 0.35)"; + const edgeGlowBright = "rgba(180, 200, 255, 0.55)"; + + // ── Face collector ───────────────────────────────────────────────── + const allFaces: Face[] = []; + + const addBoxFaces = ( + corners: { x: number; y: number; depth: number }[], + fillBase: string, + edge: string, + edgeW: number, + markBack = false, + markBodyFront = false, + ) => { + for (let i = 0; i < 6; i++) { + const pts = BOX_FACES[i].map((idx) => corners[idx]); + allFaces.push({ + pts, + fillColor: fillBase, + edgeColor: edge, + edgeWidth: edgeW, + isFront: false, + isBack: markBack && i === 0, + isBodyFront: markBodyFront && i === 1, + avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length, + }); + } + }; + + const addCylinder = ( + bx: number, + by: number, + bz: number, + r: number, + len: number, + fillBase: string, + edge: string, + edgeW: number, + markFrontCap: boolean, + ) => { + const NUM_SEGS = 20; + const basePts: { x: number; y: number; z: number }[] = []; + const frontPts: { x: number; y: number; z: number }[] = []; + + for (let i = 0; i < NUM_SEGS; i++) { + const a = (i / NUM_SEGS) * Math.PI * 2; + const lx = Math.cos(a) * r; + const ly = Math.sin(a) * r; + basePts.push({ + x: bx + rightX * lx + upX * ly, + y: by + rightY * lx + upY * ly, + z: bz + rightZ * lx + upZ * ly, + }); + frontPts.push({ + x: bx + dirX * len + rightX * lx + upX * ly, + y: by + dirY * len + rightY * lx + upY * ly, + z: bz + dirZ * len + rightZ * lx + upZ * ly, + }); + } + + // Side quads + for (let i = 0; i < NUM_SEGS; i++) { + const ni = (i + 1) % NUM_SEGS; + const pts = [ + basePts[i], + basePts[ni], + frontPts[ni], + frontPts[i], + ].map((pt) => project(pt.x, pt.y, pt.z)); + allFaces.push({ + pts, + fillColor: fillBase, + edgeColor: edge, + edgeWidth: edgeW, + isFront: false, + isBack: false, + isBodyFront: false, + avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length, + }); + } + + // Front cap + const capPts = frontPts.map((p) => project(p.x, p.y, p.z)); + allFaces.push({ + pts: capPts, + fillColor: fillBase, + edgeColor: edge, + edgeWidth: edgeW, + isFront: markFrontCap, + isBack: false, + isBodyFront: false, + avgDepth: capPts.reduce((s, p) => s + p.depth, 0) / capPts.length, + }); + }; + + // ── Add all camera parts ─────────────────────────────────────────── + addBoxFaces( + bodyCorners, + "rgba(35, 38, 45, 0.92)", + edgeGlow, + 1.0, + true, + true, + ); + addBoxFaces( + gripCorners, + "rgba(30, 33, 40, 0.9)", + edgeGlow, + 0.7, + false, + false, + ); + addCylinder( + lensCx, + lensCy, + lensCz, + lensR, + lensLen, + "rgba(28, 30, 38, 0.95)", + edgeGlowBright, + 0.8, + true, + ); + addBoxFaces( + vfCorners, + "rgba(40, 43, 50, 0.9)", + edgeGlow, + 0.8, + false, + false, + ); + + // Targeting laser from lens tip to sphere centre + const lensTip = project( + lensCx + dirX * lensLen, + lensCy + dirY * lensLen, + lensCz + dirZ * lensLen, + ); + allFaces.push({ + pts: [lensTip, centerScreen], + fillColor: "", + edgeColor: "", + edgeWidth: 0, + isFront: false, + isBack: false, + isBodyFront: false, + avgDepth: (lensTip.depth + centerScreen.depth) / 2, + isLine: true, + }); + + // ── Painter's sort & draw ───────────────────────────────────────── + allFaces.sort((a, b) => a.avgDepth - b.avgDepth); + ctx.lineJoin = "round"; + + for (const face of allFaces) { + // ── Targeting laser ─────────────────────────────────────────── + if (face.isLine) { + ctx.save(); + ctx.strokeStyle = "rgba(180, 210, 255, 0.7)"; + ctx.lineWidth = 1.5; + ctx.shadowColor = "rgba(160, 200, 255, 0.9)"; + ctx.shadowBlur = 4; + ctx.beginPath(); + ctx.moveTo(face.pts[0].x, face.pts[0].y); + ctx.lineTo(face.pts[1].x, face.pts[1].y); + ctx.stroke(); + ctx.restore(); + continue; + } + + // ── Fill + glow edges ───────────────────────────────────────── + ctx.beginPath(); + ctx.moveTo(face.pts[0].x, face.pts[0].y); + for (let i = 1; i < face.pts.length; i++) + ctx.lineTo(face.pts[i].x, face.pts[i].y); + ctx.closePath(); + ctx.fillStyle = face.fillColor; + ctx.fill(); + + ctx.save(); + ctx.shadowColor = "rgba(160, 190, 255, 0.4)"; + ctx.shadowBlur = 3; + ctx.strokeStyle = face.edgeColor; + ctx.lineWidth = face.edgeWidth; + ctx.stroke(); + ctx.restore(); + + ctx.strokeStyle = face.edgeColor; + ctx.lineWidth = face.edgeWidth * 0.5; + ctx.stroke(); + + // ── Back LCD screen inset ───────────────────────────────────── + if (face.isBack) { + const scx = + (face.pts[0].x + face.pts[1].x + face.pts[2].x + face.pts[3].x) / + 4; + const scy = + (face.pts[0].y + face.pts[1].y + face.pts[2].y + face.pts[3].y) / + 4; + const screenPts = face.pts.map((p) => ({ + x: scx + (p.x - scx) * 0.82, + y: scy + (p.y - scy) * 0.75, + })); + + ctx.beginPath(); + ctx.moveTo(screenPts[0].x, screenPts[0].y); + for (let i = 1; i < screenPts.length; i++) + ctx.lineTo(screenPts[i].x, screenPts[i].y); + ctx.closePath(); + ctx.fillStyle = "rgba(18, 20, 26, 0.98)"; + ctx.fill(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.06)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Subtle specular highlight on top-left corner + ctx.beginPath(); + ctx.moveTo(screenPts[0].x, screenPts[0].y); + ctx.lineTo(screenPts[1].x, screenPts[1].y); + ctx.lineTo(screenPts[2].x, screenPts[2].y); + ctx.closePath(); + ctx.fillStyle = "rgba(255, 255, 255, 0.02)"; + ctx.fill(); + } + + // ── Lens glass rings & gradient ─────────────────────────────── + if (face.isFront) { + // Projects a concentric ring of lens-local radius rScale onto the screen. + const getCirclePts = (rScale: number) => { + const pts = []; + for (let i = 0; i < 20; i++) { + const a = (i / 20) * Math.PI * 2; + const lx = Math.cos(a) * lensR * rScale; + const ly = Math.sin(a) * lensR * rScale; + pts.push( + project( + lensCx + dirX * lensLen + rightX * lx + upX * ly, + lensCy + dirY * lensLen + rightY * lx + upY * ly, + lensCz + dirZ * lensLen + rightZ * lx + upZ * ly, + ), + ); + } + return pts; + }; + + const drawRing = (pts: { x: number; y: number }[]) => { + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) + ctx.lineTo(pts[i].x, pts[i].y); + ctx.closePath(); + }; + + // Mid ring + ctx.strokeStyle = "rgba(180, 200, 255, 0.45)"; + ctx.lineWidth = 1.0; + drawRing(getCirclePts(0.7)); + ctx.stroke(); + + // Inner ring + ctx.strokeStyle = "rgba(160, 190, 255, 0.3)"; + ctx.lineWidth = 0.6; + drawRing(getCirclePts(0.4)); + ctx.stroke(); + + // Glass gradient fill + const lfc = project( + lensCx + dirX * lensLen, + lensCy + dirY * lensLen, + lensCz + dirZ * lensLen, + ); + const lensGrad = ctx.createRadialGradient( + lfc.x, + lfc.y, + 0, + lfc.x, + lfc.y, + 8, + ); + lensGrad.addColorStop(0, "rgba(100, 160, 255, 0.4)"); + lensGrad.addColorStop(0.6, "rgba(60, 100, 200, 0.2)"); + lensGrad.addColorStop(1, "rgba(40, 70, 150, 0.05)"); + ctx.fillStyle = lensGrad; + drawRing(getCirclePts(0.85)); + ctx.fill(); + + // Outer glow ring + ctx.save(); + ctx.shadowColor = "rgba(160, 190, 255, 0.6)"; + ctx.shadowBlur = 6; + ctx.strokeStyle = "rgba(180, 200, 255, 0.4)"; + ctx.lineWidth = 1.2; + drawRing(getCirclePts(0.95)); + ctx.stroke(); + ctx.restore(); + } + + // ── Red recording dot on body front ─────────────────────────── + if (face.isBodyFront) { + const dot3D = { + x: + camPosX + + dirX * bodyD + + rightX * bodyW * 0.6 + + upX * bodyH * 0.6, + y: + camPosY + + dirY * bodyD + + rightY * bodyW * 0.6 + + upY * bodyH * 0.6, + z: + camPosZ + + dirZ * bodyD + + rightZ * bodyW * 0.6 + + upZ * bodyH * 0.6, + }; + const dot = project(dot3D.x, dot3D.y, dot3D.z); + + const glow = ctx.createRadialGradient( + dot.x, + dot.y, + 0, + dot.x, + dot.y, + 5, + ); + glow.addColorStop(0, "rgba(255, 40, 40, 0.8)"); + glow.addColorStop(0.5, "rgba(255, 40, 40, 0.2)"); + glow.addColorStop(1, "rgba(255, 40, 40, 0)"); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(dot.x, dot.y, 5, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "rgba(255, 60, 60, 1)"; + ctx.beginPath(); + ctx.arc(dot.x, dot.y, 1.8, 0, Math.PI * 2); + ctx.fill(); + } + } + + // ── Centre crosshair ───────────────────────────────────────────── + ctx.lineJoin = "miter"; + ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; + ctx.lineWidth = 0.8; + const crossSize = 5; + ctx.beginPath(); + ctx.moveTo(centerScreen.x - crossSize, centerScreen.y); + ctx.lineTo(centerScreen.x + crossSize, centerScreen.y); + ctx.moveTo(centerScreen.x, centerScreen.y - crossSize); + ctx.lineTo(centerScreen.x, centerScreen.y + crossSize); + ctx.stroke(); + }, + [], + ); + + // Redraw on prop changes (not during drag — drag has its own rAF loop) + useEffect(() => { + if (!isDragging.current) drawSphere(rotation, tilt); + }, [rotation, tilt, drawSphere]); + + // Keep stable refs so mouse handlers are never stale + const drawSphereRef = useRef(drawSphere); + drawSphereRef.current = drawSphere; + const onDragEndRef = useRef(onDragEnd); + onDragEndRef.current = onDragEnd; + + // Window-level mouse listeners so dragging continues outside the canvas + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!isDragging.current) return; + const dx = e.clientX - lastPos.current.x; + const dy = e.clientY - lastPos.current.y; + lastPos.current = { x: e.clientX, y: e.clientY }; + + liveRotation.current += dx * 0.8; + liveTilt.current = Math.max( + -30, + Math.min(60, liveTilt.current - dy * 0.8), + ); + + if (rafId.current !== null) cancelAnimationFrame(rafId.current); + rafId.current = requestAnimationFrame(() => { + drawSphereRef.current(liveRotation.current, liveTilt.current, true); + rafId.current = null; + }); + }; + + const onUp = () => { + if (!isDragging.current) return; + isDragging.current = false; + + let rawRot = liveRotation.current % 360; + if (rawRot < 0) rawRot += 360; + const snappedRot = snapToNearest(rawRot, ROTATION_VALUES); + const snappedTilt = snapToNearest(liveTilt.current, TILT_VALUES); + + liveRotation.current = snappedRot; + liveTilt.current = snappedTilt; + + drawSphereRef.current(snappedRot, snappedTilt); + onDragEndRef.current(snappedRot, snappedTilt); + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, []); + + const handleMouseDown = (e: React.MouseEvent) => { + isDragging.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + }; + + return ( + + ); + }, +); + +OrbitSphere.displayName = "OrbitSphere"; diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/index.ts b/frontend/apps/artcraft/app/src/pages/PageAngles/index.ts new file mode 100644 index 0000000000..13a7558c52 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/index.ts @@ -0,0 +1,2 @@ +export { Angles } from "./Angles"; +export { useAnglesStore } from "./AnglesStore"; diff --git a/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx index 3a89c368c4..039c691a15 100644 --- a/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageEnigma/PageEditor.tsx @@ -17,6 +17,7 @@ import { ImageWatermarkRemover } from "../PageImageWatermarkRemover"; import { ImageTo3DObject } from "../PageImageTo3DObject"; import { ImageTo3DWorld } from "../PageImageTo3DWorld"; import { RemoveBackground } from "../PageRemoveBackground"; +import { Angles } from "../PageAngles"; import { timelineHeight, @@ -638,6 +639,11 @@ export const PageEditor = () => {
)} + {tabStore.activeTabId == "ANGLES" && ( +
+ +
+ )}
); }; diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts index d5734bd3e0..4d346aff50 100644 --- a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts +++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts @@ -13,7 +13,8 @@ export type TabId = | "IMAGE_WATERMARK_REMOVAL" | "IMAGE_TO_3D_OBJECT" | "IMAGE_TO_3D_WORLD" - | "REMOVE_BACKGROUND"; + | "REMOVE_BACKGROUND" + | "ANGLES"; const DEFAULT_TAB: TabId = "IMAGE"; diff --git a/frontend/libs/model-list/src/lib/classes/ImageModel.ts b/frontend/libs/model-list/src/lib/classes/ImageModel.ts index 7da7ff921c..ba9ea7b48c 100644 --- a/frontend/libs/model-list/src/lib/classes/ImageModel.ts +++ b/frontend/libs/model-list/src/lib/classes/ImageModel.ts @@ -42,6 +42,10 @@ export class ImageModel extends Model { // If true, it'll be displayed on the text-to-image page. readonly canTextToImage: boolean; + // Whether the model can be used for angle editing + // If true, it'll be on the "angles" editing page. + readonly canEditAngles: boolean; + // Whether the model supports changing resolution (e.g. 1k, 2k, 4k) readonly canChangeResolution: boolean; @@ -80,6 +84,7 @@ export class ImageModel extends Model { canUseImagePrompt?: boolean; maxImagePromptCount?: number; canTextToImage?: boolean; + canEditAngles?: boolean; canChangeResolution?: boolean; canChangeAspectRatio?: boolean; tags?: ModelTag[]; @@ -111,6 +116,7 @@ export class ImageModel extends Model { this.canUseImagePrompt = args.canUseImagePrompt ?? false; this.maxImagePromptCount = Math.max(0, args.maxImagePromptCount ?? 1); this.canTextToImage = args.canTextToImage === false ? false : true; // Default to true ! + this.canEditAngles = args.canEditAngles ?? false; this.canChangeResolution = args.canChangeResolution ?? false; this.canChangeAspectRatio = args.canChangeAspectRatio ?? false; this.aspectRatios = args.aspectRatios ?? []; diff --git a/frontend/libs/model-list/src/lib/lists/ImageModels.ts b/frontend/libs/model-list/src/lib/lists/ImageModels.ts index 824eb5844a..a78926fcfe 100644 --- a/frontend/libs/model-list/src/lib/lists/ImageModels.ts +++ b/frontend/libs/model-list/src/lib/lists/ImageModels.ts @@ -403,6 +403,62 @@ export const IMAGE_MODELS: ImageModel[] = [ CommonAspectRatio.TallNineBySixteen, ] }), + new ImageModel({ + id: "qwen_edit_2511_angles", + tauriId: "qwen_edit_2511_angles", + fullName: "Qwen Edit 2511 Angles", + category: "image", + creator: ModelCreator.Alibaba, + selectorName: "Qwen Edit 2511 Angles", + selectorDescription: "Angle manipulation with optional prompt", + selectorBadges: ["30 sec."], + maxGenerationCount: 4, + defaultGenerationCount: 1, + canTextToImage: false, + canEditImages: false, + canEditAngles: true, + canUseImagePrompt: true, + maxImagePromptCount: 1, + progressBarTime: 30000, + canChangeAspectRatio: true, + defaultAspectRatio: CommonAspectRatio.SquareHd, + aspectRatios: [ + CommonAspectRatio.Square, + CommonAspectRatio.SquareHd, + CommonAspectRatio.WideFourByThree, + CommonAspectRatio.WideSixteenByNine, + CommonAspectRatio.TallThreeByFour, + CommonAspectRatio.TallNineBySixteen, + ] + }), + new ImageModel({ + id: "flux_2_lora_angles", + tauriId: "flux_2_lora_angles", + fullName: "Flux 2 LoRA Angles", + category: "image", + creator: ModelCreator.BlackForestLabs, + selectorName: "Flux 2 LoRA Angles", + selectorDescription: "Angle manipulation", + selectorBadges: ["30 sec."], + maxGenerationCount: 4, + defaultGenerationCount: 1, + canTextToImage: false, + canEditImages: false, + canEditAngles: true, + canUseImagePrompt: true, + maxImagePromptCount: 1, + progressBarTime: 30000, + canChangeAspectRatio: true, + defaultAspectRatio: CommonAspectRatio.SquareHd, + aspectRatios: [ + CommonAspectRatio.Square, + CommonAspectRatio.SquareHd, + CommonAspectRatio.WideFourByThree, + CommonAspectRatio.WideSixteenByNine, + CommonAspectRatio.TallThreeByFour, + CommonAspectRatio.TallNineBySixteen, + ] + }), new ImageModel({ id: "flux_pro_kontext_max", tauriId: "flux_pro_kontext_max", diff --git a/frontend/libs/tauri-api/src/lib/enqueue/EnqueueEditImage.ts b/frontend/libs/tauri-api/src/lib/enqueue/EnqueueEditImage.ts index ec1af86ec2..bf4f23af7c 100644 --- a/frontend/libs/tauri-api/src/lib/enqueue/EnqueueEditImage.ts +++ b/frontend/libs/tauri-api/src/lib/enqueue/EnqueueEditImage.ts @@ -40,6 +40,15 @@ export interface EnqueueEditImageRequest { /// Image resolution. Support: Nano Banana Pro image_resolution?: EnqueueEditImageResolution; + /// Horizontal angle (rotation) for angle edit models. + horizontal_angle?: number; + + /// Vertical angle (tilt) for angle edit models. + vertical_angle?: number; + + /// Zoom for angle edit models. + zoom?: number; + // TODO: Actual enum. frontend_caller?: string; @@ -61,6 +70,9 @@ interface RawEnqueueEditImageRequest { aspect_ratio?: EnqueueEditImageSize; common_aspect_ratio?: CommonAspectRatio; image_quality?: EnqueueEditImageQuality; + horizontal_angle?: number; + vertical_angle?: number; + zoom?: number; frontend_caller?: string; frontend_subscriber_id?: string; } @@ -168,6 +180,18 @@ export const EnqueueEditImage = async (request: EnqueueEditImageRequest) : Promi mutableRequest.disable_system_prompt = request.disable_system_prompt; } + if (request.horizontal_angle != null) { + mutableRequest.horizontal_angle = request.horizontal_angle; + } + + if (request.vertical_angle != null) { + mutableRequest.vertical_angle = request.vertical_angle; + } + + if (request.zoom != null) { + mutableRequest.zoom = request.zoom; + } + if (!!request.frontend_caller) { mutableRequest.frontend_caller = request.frontend_caller; }