Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
BFlatandBrandon Thomas authored and GitHub committed 2026-03-09 21:49:17 -04:00
1 parent 9e9156cd32
commit f97def83e1
17 files changed
+1774 -6

No files matched your search

@@ -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,
}
}
@@ -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()?;
@@ -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;
}