mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 23:47:51 +00:00
1 parent
9e9156cd32
commit
f97def83e1
17 files changed
+1774
-6
No files matched your search
+2
@@ -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,
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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()?;
|
||||
|
||||
+18
@@ -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<EditImageResolution>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for image angle edit models.
|
||||
pub horizontal_angle: Option<f64>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for image angle edit models.
|
||||
pub vertical_angle: Option<f64>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Only for image angle edit models.
|
||||
pub zoom: Option<f64>,
|
||||
|
||||
/// OPTIONAL.
|
||||
/// Name of the frontend caller.
|
||||
/// We'll use this to selectively trigger events.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string> => {
|
||||
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<string[]>(
|
||||
[],
|
||||
);
|
||||
const [sourceMediaToken, setSourceMediaToken] = useState<string | null>(null);
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<typeof setTimeout>;
|
||||
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: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
|
||||
action: "upload",
|
||||
},
|
||||
{
|
||||
label: "Choose from Library",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
|
||||
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<void>((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<void>((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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-56px)] w-full flex-col overflow-hidden bg-ui-background text-base-fg">
|
||||
{showUploadScreen ? (
|
||||
/* ──── Upload screen ──── */
|
||||
<div className="flex flex-1 items-center justify-center p-8 lg:p-16">
|
||||
<div className="w-full max-w-5xl">
|
||||
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-panel shadow-lg">
|
||||
<UploadEntryCard
|
||||
icon={faCrosshairs}
|
||||
title="Angles"
|
||||
description="Generate new camera angles from a single photo. Upload an image to get started."
|
||||
accentBackgroundClass="bg-lime-500/20"
|
||||
accentBorderClass="border-lime-500/40"
|
||||
accept="image/*"
|
||||
onFilesSelected={handleLocalImageSelect}
|
||||
primaryLabel="Upload media"
|
||||
secondaryLabel="Pick from Library"
|
||||
secondaryIcon={faImages}
|
||||
onSecondaryClick={handleOpenGallery}
|
||||
disabled={isLoadingImage}
|
||||
/>
|
||||
{isLoadingImage && (
|
||||
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
|
||||
<LoadingSpinner className="h-12 w-12" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ──── Editor layout — floating panels over full-bleed canvas ──── */
|
||||
<div className="relative h-full w-full">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
{/* Full-bleed image display — pb-44 reserves space for floating bottom controls */}
|
||||
<div className="flex h-full w-full items-center justify-center px-16 pb-56 pt-16">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl shadow-lg"
|
||||
style={imageContainerStyle}
|
||||
>
|
||||
{activeAngle ? (
|
||||
<img
|
||||
src={activeAngle.imageUrl}
|
||||
alt="Generated Angle"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
) : sourceImageUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={sourceImageUrl}
|
||||
alt="Source"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="relative z-10 flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-16 w-16 animate-spin rounded-full border-4 border-primary-500/30 border-t-primary-500" />
|
||||
<FontAwesomeIcon
|
||||
icon={faCrosshairs}
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-primary-400"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white">
|
||||
Generating Angle...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-ui-background">
|
||||
<LoadingSpinner className="h-12 w-12" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Floating top toolbar ── */}
|
||||
<div className="absolute left-1/2 top-4 z-10 -translate-x-1/2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="action"
|
||||
onClick={handleChangeImage}
|
||||
className="select-none px-4 py-1.5 text-sm font-medium transition-all"
|
||||
>
|
||||
Change Image
|
||||
</Button>
|
||||
{activeAngle && (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faDownload}
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={twMerge(
|
||||
"select-none px-4 py-1.5 text-sm font-medium transition-all",
|
||||
isProcessing && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Floating left thumbnail strip ── */}
|
||||
<div className="absolute left-4 top-1/2 z-10 -translate-y-1/2">
|
||||
<div className="glass flex max-h-[calc(100%-120px)] flex-col items-center gap-2 rounded-xl p-2">
|
||||
<PopoverMenu
|
||||
items={addMenuItems}
|
||||
onSelect={handleAddMenuSelect}
|
||||
mode="button"
|
||||
position="bottom"
|
||||
showIconsInList
|
||||
buttonClassName={twMerge(
|
||||
"h-12 w-12 rounded-lg border-2 border-dashed border-base-fg/20 bg-ui-controls/30 transition-colors hover:border-base-fg/40",
|
||||
isProcessing && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faPlus} className="text-base" />
|
||||
}
|
||||
/>
|
||||
|
||||
{generatedAngles.length > 0 && (
|
||||
<div className="flex flex-col items-center gap-2 overflow-y-auto">
|
||||
{generatedAngles.map((angle) => (
|
||||
<button
|
||||
key={angle.id}
|
||||
onClick={() => handleThumbnailClick(angle)}
|
||||
className={twMerge(
|
||||
"relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border-2 transition-all",
|
||||
angle.id === activeAngleId
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-transparent hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={angle.imageUrl}
|
||||
alt={`Angle ${angle.rotation}°`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/70 px-0.5 py-px text-center text-[8px] text-base-fg/80">
|
||||
{angle.rotation}°
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Floating bottom angle controls ── */}
|
||||
<div className="absolute bottom-4 left-1/2 z-10 w-[calc(100%-32px)] max-w-[860px] -translate-x-1/2">
|
||||
<div className="glass flex items-center gap-4 rounded-xl px-4 lg:gap-5 lg:px-5">
|
||||
{/* Orbit sphere — compact, with equal arrow spacing on all sides */}
|
||||
<div className="relative shrink-0 px-5 py-5">
|
||||
<button
|
||||
onClick={() => handleTiltStep(1)}
|
||||
className="absolute left-1/2 top-0 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronUp}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTiltStep(-1)}
|
||||
className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRotationStep(-1)}
|
||||
className="absolute left-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronLeft}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRotationStep(1)}
|
||||
className="absolute right-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
</button>
|
||||
<OrbitSphere
|
||||
rotation={angleConfig.rotation}
|
||||
tilt={angleConfig.tilt}
|
||||
zoom={angleConfig.zoom}
|
||||
onDragEnd={handleSphereDragEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-20 w-px shrink-0 bg-base-fg/10" />
|
||||
|
||||
{/* Sliders group */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
|
||||
Rotation
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SliderV2
|
||||
min={0}
|
||||
max={315}
|
||||
step={45}
|
||||
value={angleConfig.rotation}
|
||||
onChange={handleRotationSlider}
|
||||
suffix="°"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
|
||||
{angleConfig.rotation}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
|
||||
Tilt
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SliderV2
|
||||
min={-30}
|
||||
max={60}
|
||||
step={30}
|
||||
value={angleConfig.tilt}
|
||||
onChange={handleTiltSlider}
|
||||
suffix="°"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
|
||||
{angleConfig.tilt}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
|
||||
Zoom
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SliderV2
|
||||
min={0}
|
||||
max={10}
|
||||
step={5}
|
||||
value={angleConfig.zoom}
|
||||
onChange={handleZoomSlider}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
|
||||
{angleConfig.zoom}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-20 w-px shrink-0 bg-base-fg/10" />
|
||||
|
||||
{/* Toggle + Generate */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-2.5">
|
||||
<Switch.Group>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch.Label className="cursor-pointer whitespace-nowrap text-xs text-base-fg/50">
|
||||
Best 12 angles
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={generateFromBestAngles}
|
||||
onChange={setGenerateFromBestAngles}
|
||||
className={twMerge(
|
||||
"group inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
generateFromBestAngles ? "bg-primary" : "bg-action",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"size-3.5 rounded-full bg-white transition",
|
||||
generateFromBestAngles
|
||||
? "translate-x-[18px]"
|
||||
: "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</Switch.Group>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={faCrosshairs}
|
||||
onClick={handleGenerate}
|
||||
disabled={isProcessing || !sourceMediaToken}
|
||||
loading={isProcessing}
|
||||
className="whitespace-nowrap px-5 py-1.5 text-sm"
|
||||
>
|
||||
{isProcessing ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GalleryModal
|
||||
isOpen={isGalleryModalOpen}
|
||||
onClose={handleCloseGallery}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
onSelectItem={handleImageSelect}
|
||||
maxSelections={1}
|
||||
onUseSelected={handleGallerySelect}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<AnglesState>((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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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<HTMLCanvasElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const lastPos = useRef({ x: 0, y: 0 });
|
||||
const liveRotation = useRef(rotation);
|
||||
const liveTilt = useRef(tilt);
|
||||
const rafId = useRef<number | null>(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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-[160px] w-[160px] cursor-grab active:cursor-grabbing"
|
||||
style={{ width: "160px", height: "160px" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
OrbitSphere.displayName = "OrbitSphere";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Angles } from "./Angles";
|
||||
export { useAnglesStore } from "./AnglesStore";
|
||||
@@ -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 = () => {
|
||||
<RemoveBackground />
|
||||
</div>
|
||||
)}
|
||||
{tabStore.activeTabId == "ANGLES" && (
|
||||
<div>
|
||||
<Angles />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user