mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
2D: Add blank canvas option with preset sizes (#1275)
This commit is contained in:
@@ -19,6 +19,9 @@ interface UploadEntryCardProps {
|
||||
secondaryLabel?: string;
|
||||
secondaryIcon?: IconDefinition;
|
||||
onSecondaryClick?: () => void;
|
||||
tertiaryLabel?: string;
|
||||
tertiaryIcon?: IconDefinition;
|
||||
onTertiaryClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +39,9 @@ export const UploadEntryCard = ({
|
||||
secondaryLabel,
|
||||
secondaryIcon,
|
||||
onSecondaryClick,
|
||||
tertiaryLabel,
|
||||
tertiaryIcon,
|
||||
onTertiaryClick,
|
||||
disabled,
|
||||
}: UploadEntryCardProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -162,6 +168,17 @@ export const UploadEntryCard = ({
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
)}
|
||||
{tertiaryLabel && onTertiaryClick && (
|
||||
<Button
|
||||
variant="action"
|
||||
icon={tertiaryIcon}
|
||||
onClick={onTertiaryClick}
|
||||
className="border-2 px-8 py-3 text-base font-semibold"
|
||||
disabled={disabled}
|
||||
>
|
||||
{tertiaryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { faExpand, faImages, faPencil } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
|
||||
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
|
||||
import { UploaderStates } from "~/enums";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
|
||||
import { ModelPage } from "@storyteller/ui-model-selector";
|
||||
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
|
||||
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
|
||||
const MAX_GALLERY_SELECTIONS = 1;
|
||||
|
||||
type BaseImageSelectorProps = {
|
||||
onImageSelect: (image: BaseSelectorImage) => void;
|
||||
showLoading?: boolean;
|
||||
};
|
||||
|
||||
export const BaseImageSelector = ({
|
||||
onImageSelect,
|
||||
showLoading = false,
|
||||
}: BaseImageSelectorProps) => {
|
||||
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
|
||||
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isBlankCanvasModalOpen, setIsBlankCanvasModalOpen] = useState(false);
|
||||
|
||||
const handleGalleryClick = () => setIsGalleryModalOpen(true);
|
||||
|
||||
const handleGalleryClose = () => {
|
||||
setIsGalleryModalOpen(false);
|
||||
setSelectedGalleryImages([]);
|
||||
};
|
||||
|
||||
const handleImageSelect = (mediaToken: string) => {
|
||||
setSelectedGalleryImages((prev) => {
|
||||
if (prev.includes(mediaToken)) return prev.filter((x) => x !== mediaToken);
|
||||
if (prev.length >= MAX_GALLERY_SELECTIONS) {
|
||||
return MAX_GALLERY_SELECTIONS === 1 ? [mediaToken] : prev;
|
||||
}
|
||||
return [...prev, mediaToken];
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseGalleryImages = (selectedItems: GalleryItem[]) => {
|
||||
const item = selectedItems[0];
|
||||
if (!item || !item.fullImage) {
|
||||
toast.error("No image selected");
|
||||
return;
|
||||
}
|
||||
const referenceImage: BaseSelectorImage = {
|
||||
url: item.fullImage,
|
||||
mediaToken: item.id,
|
||||
thumbnailUrlTemplate: item.thumbnailUrlTemplate,
|
||||
};
|
||||
handleGalleryClose();
|
||||
onImageSelect(referenceImage);
|
||||
};
|
||||
|
||||
const handleBlankCanvasConfirm = (width: number, height: number) => {
|
||||
setIsBlankCanvasModalOpen(false);
|
||||
const blankImage: BaseSelectorImage = {
|
||||
url: "",
|
||||
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
isBlankCanvas: true,
|
||||
blankCanvasWidth: width,
|
||||
blankCanvasHeight: height,
|
||||
};
|
||||
onImageSelect(blankImage);
|
||||
};
|
||||
|
||||
const handleFileUpload = (files: FileList) => {
|
||||
setIsLoading(true);
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
uploadImage({
|
||||
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
|
||||
assetFile: file,
|
||||
progressCallback: (newState) => {
|
||||
if (newState.status === UploaderStates.success && newState.data) {
|
||||
const mediaToken = newState.data || "";
|
||||
(async () => {
|
||||
let finalUrl = reader.result as string;
|
||||
let thumbnailUrlTemplate = undefined;
|
||||
try {
|
||||
const api = new MediaFilesApi();
|
||||
const result = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
|
||||
if (result.success && result.data) {
|
||||
finalUrl =
|
||||
result.data.media_links?.cdn_url ||
|
||||
result.data.public_bucket_url ||
|
||||
finalUrl;
|
||||
const mediaLinks = result.data.media_links as
|
||||
| { thumbnail_template?: string; maybe_thumbnail_template?: string }
|
||||
| undefined;
|
||||
thumbnailUrlTemplate =
|
||||
mediaLinks?.thumbnail_template || mediaLinks?.maybe_thumbnail_template;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Falling back to data URL for uploaded image", e);
|
||||
}
|
||||
const referenceImage: BaseSelectorImage = {
|
||||
mediaToken,
|
||||
url: finalUrl,
|
||||
fullImageUrl: finalUrl,
|
||||
thumbnailUrlTemplate,
|
||||
};
|
||||
toast.success("Image uploaded successfully!");
|
||||
onImageSelect(referenceImage);
|
||||
setIsLoading(false);
|
||||
})();
|
||||
} else if (
|
||||
newState.status === UploaderStates.assetError ||
|
||||
newState.status === UploaderStates.imageCreateError
|
||||
) {
|
||||
toast.error("Upload failed. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-ui-panel text-base-fg">
|
||||
<div className="aspect-video w-full max-w-5xl bg-ui-background">
|
||||
<UploadEntryCard
|
||||
icon={faPencil}
|
||||
title="Edit Image"
|
||||
description="Click to upload or drag and drop an image here to edit"
|
||||
accentBackgroundClass="bg-blue-500/40"
|
||||
accentBorderClass="border-blue-400/30"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onFilesSelected={handleFileUpload}
|
||||
primaryLabel="Select Image"
|
||||
secondaryLabel="Pick from Library"
|
||||
secondaryIcon={faImages}
|
||||
onSecondaryClick={handleGalleryClick}
|
||||
tertiaryLabel="Blank Canvas"
|
||||
tertiaryIcon={faExpand}
|
||||
onTertiaryClick={() => setIsBlankCanvasModalOpen(true)}
|
||||
disabled={isLoading || showLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={ModelPage.ImageEditor} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
<GalleryModal
|
||||
isOpen={!!isGalleryModalOpen}
|
||||
onClose={handleGalleryClose}
|
||||
mode="select"
|
||||
selectedItemIds={selectedGalleryImages}
|
||||
onSelectItem={handleImageSelect}
|
||||
maxSelections={MAX_GALLERY_SELECTIONS}
|
||||
onUseSelected={handleUseGalleryImages}
|
||||
onDownloadClicked={downloadFileFromUrl}
|
||||
forceFilter="image"
|
||||
/>
|
||||
<BlankCanvasModal
|
||||
isOpen={isBlankCanvasModalOpen}
|
||||
onClose={() => setIsBlankCanvasModalOpen(false)}
|
||||
onConfirm={handleBlankCanvasConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -18,9 +18,7 @@ import {
|
||||
} from "@storyteller/tauri-api";
|
||||
import { useImageEditCompleteEvent } from "@storyteller/tauri-events";
|
||||
import { UploadImageMedia } from "@storyteller/api";
|
||||
import {
|
||||
BaseImageSelector,
|
||||
} from "../PageEdit/BaseImageSelector";
|
||||
import { BaseImageSelector } from "./BaseImageSelector";
|
||||
|
||||
// ─── Aspect ratio / resolution mappers ────────────────────────────────────────
|
||||
const mapAspectRatio = (ratio?: string): EnqueueEditImageSize | undefined => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
// Main component
|
||||
export { default as PageDraw } from "./lib/PageDraw";
|
||||
export { BlankCanvasModal } from "./lib/BlankCanvasModal";
|
||||
|
||||
// Store
|
||||
export { useSceneStore, generateId } from "./lib/stores/SceneState";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface BlankCanvasModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
interface ResolutionPreset {
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const PRESETS: { group: string; items: ResolutionPreset[] }[] = [
|
||||
{
|
||||
group: "Square",
|
||||
items: [
|
||||
{ label: "512 × 512", width: 512, height: 512 },
|
||||
{ label: "768 × 768", width: 768, height: 768 },
|
||||
{ label: "1024 × 1024", width: 1024, height: 1024 },
|
||||
{ label: "1536 × 1536", width: 1536, height: 1536 },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Landscape",
|
||||
items: [
|
||||
{ label: "HD 1280 × 720", width: 1280, height: 720 },
|
||||
{ label: "Full HD 1920 × 1080", width: 1920, height: 1080 },
|
||||
{ label: "QHD 2560 × 1440", width: 2560, height: 1440 },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Portrait",
|
||||
items: [
|
||||
{ label: "720 × 1280", width: 720, height: 1280 },
|
||||
{ label: "1080 × 1920", width: 1080, height: 1920 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const BlankCanvasModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: BlankCanvasModalProps) => {
|
||||
const [selected, setSelected] = useState<ResolutionPreset | null>(null);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selected) return;
|
||||
onConfirm(selected.width, selected.height);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelected(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title="Choose Canvas Size"
|
||||
onClose={handleClose}
|
||||
width={560}
|
||||
>
|
||||
<div className="flex flex-col gap-5 p-1">
|
||||
{PRESETS.map((group) => (
|
||||
<div key={group.group}>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-base-fg/50">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.items.map((preset) => {
|
||||
const isSelected =
|
||||
selected?.width === preset.width &&
|
||||
selected?.height === preset.height;
|
||||
const maxW = 56;
|
||||
const maxH = 36;
|
||||
const scale = Math.min(maxW / preset.width, maxH / preset.height);
|
||||
const previewW = Math.round(preset.width * scale);
|
||||
const previewH = Math.round(preset.height * scale);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${preset.width}x${preset.height}`}
|
||||
onClick={() => setSelected(preset)}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center gap-2 rounded-xl border-2 bg-ui-panel px-4 py-3 text-sm font-medium text-base-fg transition-colors hover:border-primary/60",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-ui-panel-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm border border-base-fg/20 bg-white"
|
||||
style={{ width: previewW, height: previewH }}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<Button variant="action" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm} disabled={!selected}>
|
||||
Create Canvas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -193,11 +193,15 @@ export const HistoryStack = ({
|
||||
)}
|
||||
onClick={() => handleSelectWithPreload(image)}
|
||||
>
|
||||
<img
|
||||
src={getImageThumbnailSource(image)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
{image.isBlankCanvas ? (
|
||||
<div className="absolute inset-0 h-full w-full bg-white" />
|
||||
) : (
|
||||
<img
|
||||
src={getImageThumbnailSource(image)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="absolute -right-0 -top-0 flex h-5 w-5 items-center justify-center rounded-bl-lg bg-red/50 opacity-0 transition-opacity hover:bg-red/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -563,9 +563,8 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
}, [getAspectRatioDimensions, createImageFromFile]);
|
||||
|
||||
const getCompositeCanvasFile = useCallback(async (): Promise<File | null> => {
|
||||
if (!stageRef.current || !baseImageKonvaRef.current || !baseImageBitmap) {
|
||||
return null;
|
||||
}
|
||||
if (!stageRef.current || !baseImageKonvaRef.current) return null;
|
||||
if (!baseImageInfo?.isBlankCanvas && !baseImageBitmap) return null;
|
||||
|
||||
const editsLayer = stageRef.current
|
||||
.getLayers()
|
||||
@@ -584,7 +583,12 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
|
||||
if (baseImageBitmap) {
|
||||
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
|
||||
} else {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
const markerLayerCanvas = editsLayer.toCanvas({
|
||||
x: stageRef.current.x(),
|
||||
@@ -603,7 +607,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
const blob = await canvas.convertToBlob({ type: "image/png" });
|
||||
const uuid = crypto.randomUUID();
|
||||
return new File([blob], `${uuid}.png`, { type: "image/png" });
|
||||
}, [baseImageBitmap]);
|
||||
}, [baseImageBitmap, baseImageInfo]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (
|
||||
@@ -778,7 +782,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
}, [activeTool, supportsMaskedInpainting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseImageBitmap) {
|
||||
if (!baseImageBitmap && !baseImageInfo?.isBlankCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -807,7 +811,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
|
||||
autoFitCanvas();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseImageBitmap]);
|
||||
}, [baseImageBitmap, baseImageInfo?.isBlankCanvas]);
|
||||
|
||||
const handleSelectTool = useCallback(() => setActiveTool("select"), [setActiveTool]);
|
||||
|
||||
@@ -930,8 +934,9 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
[editing3DNodeId, drawNodes],
|
||||
);
|
||||
|
||||
// Display image selector on launch, otherwise hide it
|
||||
if (!baseImageInfo || !baseImageBitmap) {
|
||||
// Display image selector on launch, otherwise hide it.
|
||||
// Also show the selector when a non-blank-canvas image is set but the bitmap is still loading.
|
||||
if (!baseImageInfo || (!baseImageBitmap && !baseImageInfo.isBlankCanvas)) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -945,7 +950,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
|
||||
addHistoryImageBundle({ images: [image] });
|
||||
setBaseImageInfo(image);
|
||||
},
|
||||
showLoading: baseImageInfo !== null && baseImageBitmap === null,
|
||||
showLoading: baseImageInfo !== null && baseImageBitmap === null && !baseImageInfo.isBlankCanvas,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1684,6 +1684,16 @@ export const PaintSurface = ({
|
||||
zIndex={-1}
|
||||
/>
|
||||
|
||||
{!baseImageBitmap && (
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={getAspectRatioDimensions().width}
|
||||
height={getAspectRatioDimensions().height}
|
||||
fill="white"
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
ref={baseImageRef}
|
||||
x={0}
|
||||
|
||||
@@ -1343,10 +1343,15 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
},
|
||||
|
||||
getAspectRatioDimensions: () => {
|
||||
const baseImageInfo = get().baseImageBitmap;
|
||||
const bitmap = get().baseImageBitmap;
|
||||
|
||||
if (baseImageInfo) {
|
||||
return { width: baseImageInfo.width, height: baseImageInfo.height };
|
||||
if (bitmap) {
|
||||
return { width: bitmap.width, height: bitmap.height };
|
||||
}
|
||||
|
||||
const info = get().baseImageInfo;
|
||||
if (info?.isBlankCanvas && info.blankCanvasWidth && info.blankCanvasHeight) {
|
||||
return { width: info.blankCanvasWidth, height: info.blankCanvasHeight };
|
||||
}
|
||||
|
||||
const { aspectRatioType } = get();
|
||||
@@ -1403,13 +1408,9 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
});
|
||||
}
|
||||
|
||||
set({ baseImageInfo: image });
|
||||
|
||||
const imgBitmap = new Image();
|
||||
imgBitmap.onload = () => {
|
||||
const applyImageNodes = (bitmap: HTMLImageElement | null) => {
|
||||
const previousNodeData = get().historyImageNodeMap.get(image);
|
||||
if (previousNodeData) {
|
||||
// Restore draw nodes, re-instantiating Node objects
|
||||
const restoredDrawNodes = previousNodeData.drawNodes.map((nodeData) => {
|
||||
if (nodeData.type === "line") return nodeData as LineNode;
|
||||
const node = new Node(nodeData as SerializedNodeData);
|
||||
@@ -1421,16 +1422,23 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
set({
|
||||
drawNodes: restoredDrawNodes,
|
||||
inpaintLineNodes: previousNodeData.inpaintLineNodes,
|
||||
});
|
||||
set({ drawNodes: restoredDrawNodes, inpaintLineNodes: previousNodeData.inpaintLineNodes, baseImageBitmap: bitmap });
|
||||
} else {
|
||||
set({ drawNodes: [], inpaintLineNodes: [] });
|
||||
set({ drawNodes: [], inpaintLineNodes: [], baseImageBitmap: bitmap });
|
||||
}
|
||||
};
|
||||
|
||||
set({ baseImageBitmap: imgBitmap });
|
||||
if (image.isBlankCanvas) {
|
||||
set({ baseImageInfo: image });
|
||||
applyImageNodes(null);
|
||||
return;
|
||||
}
|
||||
|
||||
set({ baseImageInfo: image });
|
||||
|
||||
const imgBitmap = new Image();
|
||||
imgBitmap.onload = () => {
|
||||
applyImageNodes(imgBitmap);
|
||||
};
|
||||
imgBitmap.onerror = (event) => {
|
||||
console.error("Failed to load base image, discarding", event);
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface BaseSelectorImage {
|
||||
mediaToken: string;
|
||||
thumbnailUrlTemplate?: string;
|
||||
fullImageUrl?: string;
|
||||
isBlankCanvas?: boolean;
|
||||
blankCanvasWidth?: number;
|
||||
blankCanvasHeight?: number;
|
||||
}
|
||||
|
||||
export interface ImageBundle {
|
||||
|
||||
Reference in New Issue
Block a user