Compare commits

...
Author SHA1 Message Date
Tanish Manku 53bd51c91f 2D: Big changes to prevent react renders whenever possible 2026-02-20 15:04:05 -08:00
Tanish Manku 4f2b63ac31 Map history stack and corresponding edits 2026-02-16 13:02:01 -08:00
BFlat 658b15d6aa long prompt bug fix + generic billing pop up (#1040)
- fixed the bug where long prompts overflow the lighbox modal causing
buttons to be pushed off the screen and non interactable.
- added a generic billing popup if user doesn't have a subscription plan
2026-02-16 16:27:38 +07:00
Brandon Thomas 60461e9208 db migration tools 2026-02-15 11:45:31 +07:00
bflatastic a8b4541000 verify reset end flow update 2 2026-02-14 13:12:46 +07:00
Brandon Thomas 7cc1c0209f database migration tool (primarily record deletion) (#1039)
We need to remove irrelevant FakeYou records that do not impact the
ArtCraft product

- 87M irrelevant media file records (no relation to ArtCraft)
- 105M jobs records (which we purge occasionally anyway)
- lots of other files: audit logs, old/irrelevant stripe event logs,
feed data, etc. etc.
2026-02-14 08:06:54 +07:00
bflatastic 43c82e0a82 verify reset end flow update 2026-02-13 11:49:28 +07:00
14 changed files with 1471 additions and 873 deletions
+3
View File
@@ -32,6 +32,9 @@ script/production_database_secret_backup.sh
script/production_database_secret_connect.sh
script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Scratch data
scratch/
test_data/temp/
@@ -11,7 +11,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { PasswordResetApi } from "@storyteller/api";
import { PasswordResetApi, BillingApi } from "@storyteller/api";
import Seo from "../../components/seo";
@@ -31,6 +31,8 @@ const VerifyReset = () => {
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [success, setSuccess] = useState(false);
const [redirectTo, setRedirectTo] = useState("/");
const [redirectLabel, setRedirectLabel] = useState("Back to Homepage");
const handleRedeemReset = async () => {
setError(null);
@@ -70,6 +72,28 @@ const VerifyReset = () => {
if (response.success) {
setSuccess(true);
window.dispatchEvent(new Event("auth-change"));
// Check if user has an active subscription to decide redirect
try {
const billingApi = new BillingApi();
const billingResponse = await billingApi.ListActiveSubscriptions();
if (
billingResponse.success &&
billingResponse.data &&
billingResponse.data.active_subscriptions.length > 0
) {
setRedirectTo("/");
setRedirectLabel("Back to Homepage");
} else {
setRedirectTo("/pricing");
setRedirectLabel("Continue");
}
} catch {
// Default to homepage if billing check fails
setRedirectTo("/");
setRedirectLabel("Back to Homepage");
}
} else {
setError(
response.errorMessage ||
@@ -256,15 +280,15 @@ const VerifyReset = () => {
Password Reset Successfully
</h3>
<p className="text-white/60 text-sm mb-8">
Your password has been changed. You can now log in with your
new password.
Your password has been changed and you've been logged in
successfully.
</p>
<Button
id="go-to-login-btn"
id="back-to-homepage-btn"
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12"
onClick={() => navigate("/login")}
onClick={() => navigate(redirectTo)}
>
Go to Login
{redirectLabel}
</Button>
</div>
</>
@@ -21,7 +21,10 @@ import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
import { useSubscriptionState } from "@storyteller/subscription";
import { DownloadUrl } from "@storyteller/tauri-api";
import { useCreditsBalanceChangedEvent, useSubscriptionPlanChangedEvent } from "@storyteller/tauri-events";
import {
useCreditsBalanceChangedEvent,
useSubscriptionPlanChangedEvent,
} from "@storyteller/tauri-events";
import {
useTauriPlatform,
useTauriWindowControls,
@@ -251,9 +254,7 @@ export const TopBar = ({ pageName }: Props) => {
) => {
try {
if (mediaId) {
useImageTo3DWorldStore
.getState()
.setPendingExternalImage(url, mediaId);
useImageTo3DWorldStore.getState().setPendingExternalImage(url, mediaId);
}
useTabStore.getState().setActiveTab("IMAGE_TO_3D_WORLD");
galleryModalVisibleViewMode.value = false;
@@ -569,7 +570,7 @@ export const TopBar = ({ pageName }: Props) => {
/>
<ProviderSetupModal />
<ProviderBillingModal />
<ProviderBillingModal isVideoPage={tabStore.activeTabId === "VIDEO"} />
</>
);
};
@@ -1,9 +1,14 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface";
import "./App.css";
import PromptEditor from "./PromptEditor/PromptEditor";
import SideToolbar from "./components/ui/SideToolbar";
import { AspectRatioType, useSceneStore } from "./stores/SceneState";
import {
AspectRatioType,
type SceneState,
useSceneStore,
} from "./stores/SceneState";
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys";
@@ -18,16 +23,26 @@ import {
useSelectedImageModel,
useSelectedProviderForModel,
} from "@storyteller/ui-model-selector";
import { EnqueueEditImage, EnqueueEditImageRequest, EnqueueEditImageResolution, EnqueueEditImageSize, EnqueueImageInpaint, EnqueueImageInpaintRequest, useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
import {
EnqueueEditImage,
EnqueueEditImageRequest,
EnqueueEditImageResolution,
EnqueueEditImageSize,
EnqueueImageInpaint,
EnqueueImageInpaintRequest,
useCanvasBgRemovedEvent,
} from "@storyteller/tauri-api";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { GenerationProvider } from "@storyteller/api-enums";
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
import { BaseImageSelector, BaseSelectorImage } from "../PageEdit/BaseImageSelector";
import {
BaseImageSelector,
BaseSelectorImage,
} from "../PageEdit/BaseImageSelector";
import { normalizeCanvas } from "~/Helpers/CanvasHelpers";
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox";
import { PromptsApi } from "@storyteller/api";
import toast from "react-hot-toast";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
@@ -62,26 +77,123 @@ const PageDraw = () => {
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
const store = useSceneStore();
/*
* Scene store: use a selector + useShallow to avoid re-rendering on every store change.
* Without this, useSceneStore() subscribes to the whole store (e.g. moveNode during
* drag updates state constantly) and PageDraw would re-render every frame.
*
* useShallow compares the selected object by top-level keys: we only re-render when
* one of these values actually changes. Do NOT add:
* - cursorPosition / cursorVisible: updated every mouse move → would re-render constantly.
* - historyImageNodeMap: mutated in place in the store (reference never changes),
* so shallow would never see updates; fix the store to replace the Map if you need it.
* When adding new store fields here, prefer primitive/array/object refs that the store
* replaces (e.g. new array from .map()), not in-place mutations.
*
* IMPORTANT: The selector function is memoized to prevent infinite loops. If you modify
* this selector, ensure the function reference stays stable (use useMemo if needed).
*/
const selector = useMemo(
() => (state: SceneState) => ({
baseImageInfo: state.baseImageInfo,
baseImageBitmap: state.baseImageBitmap,
nodes: state.nodes,
selectedNodeIds: state.selectedNodeIds,
lineNodes: state.lineNodes,
activeTool: state.activeTool,
currentShape: state.currentShape,
fillColor: state.fillColor,
brushColor: state.brushColor,
brushSize: state.brushSize,
historyImageBundles: state.historyImageBundles,
getAspectRatioDimensions: state.getAspectRatioDimensions,
finishRemoveBackground: state.finishRemoveBackground,
createImageFromUrl: state.createImageFromUrl,
createImageFromFile: state.createImageFromFile,
setBaseImageInfo: state.setBaseImageInfo,
RESET: state.RESET,
clearLineNodes: state.clearLineNodes,
setNodes: state.setNodes,
removeHistoryImage: state.removeHistoryImage,
addHistoryImageBundle: state.addHistoryImageBundle,
setAspectRatioType: state.setAspectRatioType,
setActiveTool: state.setActiveTool,
selectNode: state.selectNode,
setCurrentShape: state.setCurrentShape,
setBrushColor: state.setBrushColor,
setBrushSize: state.setBrushSize,
setBrushOpacity: state.setBrushOpacity,
setFillColor: state.setFillColor,
toggleLock: state.toggleLock,
beginRemoveBackground: state.beginRemoveBackground,
bringToFront: state.bringToFront,
bringForward: state.bringForward,
sendBackward: state.sendBackward,
sendToBack: state.sendToBack,
copySelectedItems: state.copySelectedItems,
pasteItems: state.pasteItems,
deleteSelectedItems: state.deleteSelectedItems,
undo: state.undo,
redo: state.redo,
}),
[],
);
const {
baseImageInfo,
baseImageBitmap,
nodes,
selectedNodeIds,
lineNodes,
activeTool,
currentShape,
fillColor,
brushColor,
brushSize,
historyImageBundles,
getAspectRatioDimensions,
finishRemoveBackground,
createImageFromUrl,
createImageFromFile,
setBaseImageInfo,
RESET,
clearLineNodes,
setNodes,
removeHistoryImage,
addHistoryImageBundle,
setAspectRatioType,
setActiveTool,
selectNode,
setCurrentShape,
setBrushColor,
setBrushSize,
setBrushOpacity,
setFillColor,
toggleLock,
beginRemoveBackground,
bringToFront,
bringForward,
sendBackward,
sendToBack,
copySelectedItems,
pasteItems,
deleteSelectedItems,
undo,
redo,
} = useSceneStore(useShallow(selector));
const promptStoreProvider = usePrompt2DStore;
const generationCount = promptStoreProvider((state) => state.generationCount);
const setGenerationCount = promptStoreProvider((state) => state.setGenerationCount);
const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt);
const referenceImages = promptStoreProvider((state) => state.referenceImages);
const prompt = promptStoreProvider((state) => state.prompt);
const setGenerationCount = promptStoreProvider(
(state) => state.setGenerationCount,
);
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const baseImageUrl = store.baseImageInfo?.url;
const baseImageUrl = baseImageInfo?.url;
const [pendingGenerations, setPendingGenerations] = useState<
{ id: string; count: number }[]
>([]);
const addHistoryImageBundle = useSceneStore(
(state) => state.addHistoryImageBundle,
);
const removeHistoryImage = useSceneStore((state) => state.removeHistoryImage);
const historyImageBundles = useSceneStore(
(state) => state.historyImageBundles,
);
const selectedImageModel: ImageModel | undefined =
useSelectedImageModel(PAGE_ID);
@@ -92,11 +204,11 @@ const PageDraw = () => {
const supportsMaskedInpainting =
selectedImageModel?.usesInpaintingMask ?? false;
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
useDeleteHotkeys({ onDelete: deleteSelectedItems });
useUndoRedoHotkeys({ undo, redo });
useCopyPasteHotkeys({
onCopy: store.copySelectedItems,
onPaste: store.pasteItems,
onCopy: copySelectedItems,
onPaste: pasteItems,
});
useCanvasBgRemovedEvent(async (event) => {
@@ -113,24 +225,19 @@ const PageDraw = () => {
// const file = new File([blob], "generated_image.png", {
// type: blob.type,
// });
store.finishRemoveBackground(
nodeId,
event.media_token,
event.image_cdn_url,
);
finishRemoveBackground(nodeId, event.media_token, event.image_cdn_url);
});
// Create a function to use the left layer ref and download the bitmap from it
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
if (
!stageRef.current ||
!baseImageKonvaRef.current
) {
if (!stageRef.current || !baseImageKonvaRef.current) {
console.error("Stage or left panel ref is not available");
throw new Error("Stage or left panel or base image ref is not available");
}
const layer = stageRef.current.getLayers().find((l) => l.id() === INPAINT_LAYER_ID)!;
const layer = stageRef.current
.getLayers()
.find((l) => l.id() === INPAINT_LAYER_ID)!;
// Get the canvas area that's covered by the image/rectangle
const rect = baseImageKonvaRef.current;
@@ -195,7 +302,7 @@ const PageDraw = () => {
console.log("Creating image from URL:", imageUrl);
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
console.log(
`Created image "${item.label}" at stage position:`,
@@ -214,12 +321,12 @@ const PageDraw = () => {
handleGallery2DDrop as EventListener,
);
};
}, [store]);
// Stable action ref; no need to depend on full store.
}, [createImageFromUrl]);
const handleImageUpload = async (files: File[]): Promise<void> => {
// Determine current canvas dimensions from the store (according to aspect-ratio)
const { width: canvasW, height: canvasH } =
store.getAspectRatioDimensions();
const { width: canvasW, height: canvasH } = getAspectRatioDimensions();
// Target maximum size – 85 % of the canvas in each direction
const maxW = canvasW * 0.85;
@@ -240,80 +347,20 @@ const PageDraw = () => {
const x = (canvasW - finalW) / 2;
const y = (canvasH - finalH) / 2;
store.createImageFromFile(x, y, file, finalW, finalH);
createImageFromFile(x, y, file, finalW, finalH);
};
img.src = URL.createObjectURL(file);
}
};
const handleTauriEnqueue = async (image: ImageBitmap, aspectRatio: EnqueueEditImageSize | undefined, resolution: EnqueueEditImageResolution | undefined, subscriberId: string) => {
if (image === undefined) {
console.log("image is undefined");
return;
}
const api = new PromptsApi();
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const byteString = atob(base64Bitmap);
const mimeString = "image/png";
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const uuid = crypto.randomUUID(); // Generate a new UUID
const file = new File([ab], `${uuid}.png`, { type: mimeString });
const snapshotMediaToken = await api.uploadSceneSnapshot({
screenshot: file,
});
if (snapshotMediaToken.data === undefined) {
toast.error("Error: Unable to upload scene snapshot Please try again.");
return;
}
console.log("useSystemPrompt", useSystemPrompt);
console.log("Snapshot media token:", snapshotMediaToken.data);
const request: EnqueueEditImageRequest = {
model: selectedImageModel,
scene_image_media_token: snapshotMediaToken.data!,
image_media_tokens: referenceImages
.map((image) => image.mediaToken)
.filter((t) => t.length > 0),
disable_system_prompt: !useSystemPrompt,
prompt: prompt,
image_count: generationCount,
aspect_ratio: aspectRatio,
image_resolution: resolution,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
};
if (selectedProvider) {
request.provider = selectedProvider;
}
const generateResponse = await EnqueueEditImage(request);
console.log("generateResponse", generateResponse);
return generateResponse;
}
const getCompositeCanvasFile = useCallback(async (): Promise<File | null> => {
if (
!stageRef.current ||
!baseImageKonvaRef.current ||
!store.baseImageBitmap
) {
if (!stageRef.current || !baseImageKonvaRef.current || !baseImageBitmap) {
return null;
}
const editsLayer = stageRef.current.getLayers().find((l) => l.id() === DRAW_LAYER_ID);
const editsLayer = stageRef.current
.getLayers()
.find((l) => l.id() === DRAW_LAYER_ID);
if (!editsLayer) {
console.error("Edits layer not found");
@@ -328,7 +375,7 @@ const PageDraw = () => {
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
const markerLayerCanvas = editsLayer.toCanvas({
x: stageRef.current.x(),
@@ -347,7 +394,7 @@ const PageDraw = () => {
const blob = await canvas.convertToBlob({ type: "image/png" });
const uuid = crypto.randomUUID();
return new File([blob], `${uuid}.png`, { type: "image/png" });
}, [store.baseImageBitmap]);
}, [baseImageBitmap]);
const handleGenerate = useCallback(
async (
@@ -359,7 +406,7 @@ const PageDraw = () => {
selectedProvider?: GenerationProvider;
},
) => {
const editedImageToken = store.baseImageInfo?.mediaToken;
const editedImageToken = baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
@@ -400,7 +447,7 @@ const PageDraw = () => {
}
};
const { width, height } = store.getAspectRatioDimensions();
const { width, height } = getAspectRatioDimensions();
const subscriberId: string =
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -518,7 +565,9 @@ const PageDraw = () => {
);
throw error;
}
}, [generationCount, getCompositeCanvasFile, selectedImageModel, store]);
},
[generationCount, getCompositeCanvasFile, selectedImageModel],
);
const onFitPressed = async () => {
// Get the stage and its container dimensions
@@ -530,8 +579,8 @@ const PageDraw = () => {
const containerHeight = stage.container().offsetHeight;
// Get canvas dimensions from store aspect ratio
const canvasW = store.getAspectRatioDimensions().width;
const canvasH = store.getAspectRatioDimensions().height;
const canvasW = getAspectRatioDimensions().width;
const canvasH = getAspectRatioDimensions().height;
// Add padding to ensure canvas doesn't touch the edges
const padding = 40;
@@ -560,15 +609,17 @@ const PageDraw = () => {
};
// When the model inpainting support changes, we need to auto-change the tool so it's not set to inpainting
// Note: setActiveTool is a stable Zustand action, so we don't need it in deps
useEffect(() => {
if (!supportsMaskedInpainting && store.activeTool === "inpaint") {
store.setActiveTool("select");
if (!supportsMaskedInpainting && activeTool === "inpaint") {
setActiveTool("select");
}
}, [store, supportsMaskedInpainting]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTool, supportsMaskedInpainting]);
// Auto-fit canvas to screen on initial load
useEffect(() => {
if (!store.baseImageBitmap) {
if (!baseImageBitmap) {
return;
}
@@ -597,11 +648,11 @@ const PageDraw = () => {
autoFitCanvas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store.baseImageBitmap]);
}, [baseImageBitmap]);
// Display image selector on launch, otherwise hide it
// Also show loading state if info is set but image is loading
if (!store.baseImageInfo || !store.baseImageBitmap) {
if (!baseImageInfo || !baseImageBitmap) {
return (
<div
className={
@@ -613,11 +664,9 @@ const PageDraw = () => {
<BaseImageSelector
onImageSelect={(image: BaseSelectorImage) => {
addHistoryImageBundle({ images: [image] });
store.setBaseImageInfo(image);
setBaseImageInfo(image);
}}
showLoading={
store.baseImageInfo !== null && store.baseImageInfo === null
}
showLoading={baseImageInfo !== null && baseImageBitmap === null}
/>
</div>
</div>
@@ -629,29 +678,30 @@ const PageDraw = () => {
<>
<div className="fixed inset-0 -z-10 bg-ui-background" />
<div
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<HistoryStack
onClear={() => {
store.RESET();
RESET();
setPendingGenerations([]);
}}
imageBundles={historyImageBundles}
pendingPlaceholders={pendingGenerations}
blurredBackgroundUrl={baseImageUrl}
onImageSelect={(baseImage) => {
store.clearLineNodes();
store.setNodes([]);
store.setBaseImageInfo(baseImage);
clearLineNodes();
setNodes([]);
setBaseImageInfo(baseImage);
}}
onImageRemove={(baseImage) => {
if (
pendingGenerations.length === 0 &&
store.historyImageBundles.length === 1 &&
store.historyImageBundles[0].images.length <= 1
historyImageBundles.length === 1 &&
historyImageBundles[0].images.length <= 1
) {
store.RESET();
RESET();
} else {
removeHistoryImage(baseImage);
}
@@ -662,12 +712,13 @@ const PageDraw = () => {
onResolvePending={(id: string) =>
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
}
selectedImageToken={store.baseImageInfo?.mediaToken}
selectedImageToken={baseImageInfo?.mediaToken}
/>
</div>
<div
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
isSelecting ? "pointer-events-none" : "pointer-events-auto"
}`}
>
<PromptEditor
onAspectRatioChange={async (ratio: string) => {
@@ -685,7 +736,7 @@ const PageDraw = () => {
};
const aspectRatioType = ratioToType(ratio);
store.setAspectRatioType(aspectRatioType);
setAspectRatioType(aspectRatioType);
await new Promise((resolve) => requestAnimationFrame(resolve));
onFitPressed();
@@ -705,28 +756,28 @@ const PageDraw = () => {
<SideToolbar
className="fixed left-0 top-1/2 z-10 -translate-y-1/2 transform"
onSelect={(): void => {
store.setActiveTool("select");
setActiveTool("select");
}}
onActivateShapeTool={(
shape: "rectangle" | "circle" | "triangle",
): void => {
store.selectNode(null);
store.setCurrentShape(shape);
store.setActiveTool("shape");
store.selectNode(null);
selectNode(null);
setCurrentShape(shape);
setActiveTool("shape");
selectNode(null);
}}
onPaintBrush={(hex: string, size: number, opacity: number): void => {
store.setActiveTool("draw");
store.setBrushColor(hex);
store.setBrushSize(size);
store.setBrushOpacity(opacity);
setActiveTool("draw");
setBrushColor(hex);
setBrushSize(size);
setBrushOpacity(opacity);
}}
onCanvasBackground={(hex: string): void => {
console.log("Canvas background activated", { color: hex });
// Add background change logic here
// TODO: minor bug needs to update the preview panel
// Debounce also causes issues with real time color change.
store.setFillColor(hex);
setFillColor(hex);
}}
onUploadImage={(): void => {
// Create input element dynamically like in PromptEditor
@@ -765,14 +816,14 @@ const PageDraw = () => {
input.click();
}}
supportsMaskTool={supportsMaskedInpainting}
activeToolId={store.activeTool}
currentShape={store.currentShape}
activeToolId={activeTool}
currentShape={currentShape}
/>
<div className="relative z-0">
<ContextMenuContainer
onAction={(e, action) => {
if (action === "contextMenu") {
const hasSelection = store.selectedNodeIds.length > 0;
const hasSelection = selectedNodeIds.length > 0;
if (hasSelection) {
console.log("An item is selected.");
return true;
@@ -786,51 +837,54 @@ const PageDraw = () => {
onMenuAction={async (action) => {
switch (action) {
case "LOCK":
store.toggleLock(store.selectedNodeIds);
toggleLock(selectedNodeIds);
break;
case "REMOVE_BACKGROUND":
await store.beginRemoveBackground(store.selectedNodeIds);
await beginRemoveBackground(selectedNodeIds);
break;
case "BRING_TO_FRONT":
store.bringToFront(store.selectedNodeIds);
bringToFront(selectedNodeIds);
break;
case "BRING_FORWARD":
store.bringForward(store.selectedNodeIds);
bringForward(selectedNodeIds);
break;
case "SEND_BACKWARD":
store.sendBackward(store.selectedNodeIds);
sendBackward(selectedNodeIds);
break;
case "SEND_TO_BACK":
store.sendToBack(store.selectedNodeIds);
sendToBack(selectedNodeIds);
break;
case "DUPLICATE":
store.copySelectedItems();
store.pasteItems();
copySelectedItems();
pasteItems();
break;
case "DELETE":
store.deleteSelectedItems();
deleteSelectedItems();
break;
default:
// No action
}
}}
isLocked={store.selectedNodeIds.some((id) => {
const node = store.nodes.find((n) => n.id === id);
const lineNode = store.lineNodes.find((n) => n.id === id);
isLocked={selectedNodeIds.some((id: string) => {
const node = nodes.find((n: (typeof nodes)[number]) => n.id === id);
const lineNode = lineNodes.find(
(n: (typeof lineNodes)[number]) => n.id === id,
);
return (node?.locked || lineNode?.locked) ?? false;
})}
>
<PaintSurface
nodes={store.nodes}
selectedNodeIds={store.selectedNodeIds}
nodes={nodes}
lineNodes={lineNodes}
selectedNodeIds={selectedNodeIds}
onCanvasSizeChange={(width: number, height: number): void => {
canvasWidth.current = width;
canvasHeight.current = height;
}}
fillColor={store.fillColor}
activeTool={store.activeTool}
brushColor={store.brushColor}
brushSize={store.brushSize}
fillColor={fillColor}
activeTool={activeTool}
brushColor={brushColor}
brushSize={brushSize}
onSelectionChange={setIsSelecting}
stageRef={stageRef}
transformerRefs={transformerRefs}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { Vector2d } from 'konva/lib/types';
import { useEffect } from 'react';
import { DragState } from '~/pages/PageEdit/EditPaintSurface';
import { Vector2d } from "konva/lib/types";
import { useEffect } from "react";
import { DragState } from "~/pages/PageEdit/EditPaintSurface";
// This piece of code ensures that when you are selecting or dragging and go off the stage,
// it releases the controls mouse drag.
export const useGlobalMouseUp = (
@@ -10,11 +10,12 @@ export const useGlobalMouseUp = (
setIsSelecting: (value: boolean) => void,
isSelectingRef: React.MutableRefObject<boolean>,
setSelectionRect: (value: any) => void,
setLastPoint: (value: { x: number, y: number } | null) => void,
onSelectionChange?: (isSelecting: boolean) => void,
onBeforeReset?: () => void,
) => {
useEffect(() => {
const handleGlobalMouseUp = () => {
onBeforeReset?.();
// Always reset all states when mouse is released anywhere
setIsDragging(undefined);
setIsDrawing(false);
@@ -23,16 +24,15 @@ export const useGlobalMouseUp = (
isSelectingRef.current = false;
onSelectionChange?.(false);
setSelectionRect(null);
setLastPoint(null);
};
// Listen to mouseup on the entire window
window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('touchend', handleGlobalMouseUp);
window.addEventListener("mouseup", handleGlobalMouseUp);
window.addEventListener("touchend", handleGlobalMouseUp);
return () => {
window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('touchend', handleGlobalMouseUp);
window.removeEventListener("mouseup", handleGlobalMouseUp);
window.removeEventListener("touchend", handleGlobalMouseUp);
};
}, [onSelectionChange]);
}
}, [onSelectionChange, onBeforeReset]);
};
@@ -86,11 +86,17 @@ type SerializedNodeData = {
locked: boolean;
};
interface SceneState {
interface HistoryNodeData {
nodes: Node[];
lineNodes: LineNode[];
}
export interface SceneState {
// Nodes
nodes: Node[];
selectedNodeIds: string[];
lineNodes: LineNode[]; // Add lineNodes to state
historyImageNodeMap: Map<BaseSelectorImage, HistoryNodeData>;
// Clipboard
clipboard: (Node | LineNode)[]; // To store copied items
@@ -292,6 +298,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
nodes: [],
lineNodes: [],
selectedNodeIds: [],
historyImageNodeMap: new Map<BaseSelectorImage, HistoryNodeData>(),
clipboard: [],
history: [],
historyIndex: -1,
@@ -1587,15 +1594,40 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
},
setBaseImageInfo: (image: BaseSelectorImage | null) => {
set({ baseImageInfo: image });
// If the new base image is null, return early because nothing needs to be loaded.
if (!image) {
// Set the new base image info - setting null is allowed.
set({ baseImageInfo: null });
return;
}
// Load the image bitmap as well
// Save the current node data to the history image node map
const currentBaseImage = get().baseImageInfo;
if (currentBaseImage) {
get().historyImageNodeMap.set(currentBaseImage, {
nodes: get().nodes,
lineNodes: get().lineNodes,
});
}
// Set the new base image info
set({ baseImageInfo: image });
// Load the image bitmap now
const imgBitmap = new Image();
imgBitmap.onload = () => {
// Once the base image is set, check previous node data - or create new empty one
const previousNodeData = get().historyImageNodeMap.get(image);
if (previousNodeData) {
set({
nodes: previousNodeData.nodes,
lineNodes: previousNodeData.lineNodes,
});
} else {
set({ nodes: [], lineNodes: [] });
}
// Finally, set the base image bitmap
set({ baseImageBitmap: imgBitmap });
};
imgBitmap.onerror = (event) => {
@@ -1615,7 +1647,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
},
clearHistoryImages: () => {
set({ historyImageBundles: [] });
set({
historyImageBundles: [],
historyImageNodeMap: new Map<BaseSelectorImage, HistoryNodeData>(),
});
},
addHistoryImageBundle(bundle) {
@@ -1627,6 +1662,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
removeHistoryImage(image) {
console.log("Removing history image:", image);
set((state) => {
// Remove the history image node data
state.historyImageNodeMap.delete(image);
// Remove the image from the history image bundles
const updatedBundles = state.historyImageBundles
.map((bundle) => {
return {
@@ -90,7 +90,6 @@ export const EditPaintSurface = ({
/* 1️⃣ Track SplitPane percent so we can re-measure */
const [leftPct, setLeftPct] = useState(singlePaneMode ? 100 : 50);
const [isDrawing, setIsDrawing] = useState(false);
const [, setLastPoint] = useState<{ x: number; y: number } | null>(null);
const selectionRectRef = React.useRef<Konva.Rect>(null);
const [selectionRect, setSelectionRect] = useState<{
@@ -144,7 +143,6 @@ export const EditPaintSurface = ({
setIsSelecting,
isSelectingRef,
setSelectionRect,
setLastPoint,
onSelectionChange,
);
@@ -282,7 +280,6 @@ export const EditPaintSurface = ({
store.addLineNode(newLineNode, false);
setCurrentLineId(lineId);
setIsDrawing(true);
setLastPoint(stagePoint);
return;
}
@@ -451,8 +448,6 @@ export const EditPaintSurface = ({
lineNode.points(currentPoints);
lineNode.getLayer()?.batchDraw();
}
setLastPoint(stagePoint);
}
// Handle selection rectangle - Update directly through Konva
@@ -481,11 +476,11 @@ export const EditPaintSurface = ({
setSelectionRect((prev) =>
prev
? {
// Ensure prev is not null
...prev,
endX: clampedPoint.x,
endY: clampedPoint.y,
}
// Ensure prev is not null
...prev,
endX: clampedPoint.x,
endY: clampedPoint.y,
}
: null,
);
}
@@ -562,7 +557,6 @@ export const EditPaintSurface = ({
isSelectingRef.current = false;
onSelectionChange?.(false);
setSelectionRect(null);
setLastPoint(null);
setIsMiddleMousePressed(false); // Reset middle mouse state
};
@@ -638,8 +632,8 @@ export const EditPaintSurface = ({
if (!container) return;
const defaultCursor =
activeTool === "edit" ||
activeTool === "marker" ||
activeTool === "eraser"
activeTool === "marker" ||
activeTool === "eraser"
? "none"
: activeTool === "select"
? "grab"
@@ -693,7 +687,7 @@ export const EditPaintSurface = ({
const touch2 = touches[1];
return Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2),
Math.pow(touch2.clientY - touch1.clientY, 2),
);
};
@@ -1473,11 +1467,11 @@ export const EditPaintSurface = ({
scaleY={previewScale}
x={0}
y={0}
// style={{
// background: '#f5f5f5',
// border: '1px solid #ddd',
// borderRadius: '8px',
// }}
// style={{
// background: '#f5f5f5',
// border: '1px solid #ddd',
// borderRadius: '8px',
// }}
>
<Layer>
<Image
@@ -120,7 +120,11 @@ export function LightboxModal({
const [prompt, setPrompt] = useState<string | null>(null);
const [promptLoading, setPromptLoading] = useState<boolean>(false);
const [hasPromptToken, setHasPromptToken] = useState<boolean>(false);
const [isPromptHovered, setIsPromptHovered] = useState<boolean>(false);
const [isPromptExpanded, setIsPromptExpanded] = useState<boolean>(false);
const promptRef = useRef<HTMLDivElement>(null);
const [isPromptClamped, setIsPromptClamped] = useState<boolean>(false);
const [promptCopied, setPromptCopied] = useState<boolean>(false);
const promptCopiedTimeoutRef = useRef<number | null>(null);
const [generationProvider, setGenerationProvider] = useState<string | null>(
null,
);
@@ -151,6 +155,7 @@ export function LightboxModal({
setSelectedIndex(0);
setMediaLoaded(false);
setShareCopied(false);
setIsPromptExpanded(false);
if (shareCopiedTimeoutRef.current) {
window.clearTimeout(shareCopiedTimeoutRef.current);
shareCopiedTimeoutRef.current = null;
@@ -234,6 +239,23 @@ export function LightboxModal({
fetchPrompt();
}, [currentMediaId]);
// Detect if prompt text is clamped (overflows the 4-line clamp)
useEffect(() => {
if (!promptRef.current || !prompt || promptLoading) {
setIsPromptClamped(false);
return;
}
// Use requestAnimationFrame to ensure DOM has painted
const raf = requestAnimationFrame(() => {
if (promptRef.current) {
setIsPromptClamped(
promptRef.current.scrollHeight > promptRef.current.clientHeight,
);
}
});
return () => cancelAnimationFrame(raf);
}, [prompt, promptLoading, isPromptExpanded]);
useEffect(() => {
const fetchBatch = async () => {
if (!batchImageToken) {
@@ -533,7 +555,7 @@ export function LightboxModal({
{/* info + actions - fixed width */}
<div className="flex h-full w-[280px] shrink-0 flex-col">
<div className="flex-1 space-y-5 text-base-fg">
<div className="flex-1 overflow-y-auto space-y-5 text-base-fg min-h-0 pb-2">
{/* <div className="text-xl font-medium">
{title || "Image Generation"}
</div> */}
@@ -552,25 +574,48 @@ export function LightboxModal({
{hasPromptToken && (
<>
{/* Prompt */}
<div className="relative space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
Prompt
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-base-fg/90">
Prompt
</div>
{!promptLoading && prompt && (
<button
className="flex items-center gap-1.5 text-xs text-base-fg/60 hover:text-base-fg transition-colors"
onClick={(e) => {
e.stopPropagation();
if (!prompt) return;
navigator.clipboard
.writeText(prompt)
.catch(() => {});
toast.success("Prompt copied");
setPromptCopied(true);
if (promptCopiedTimeoutRef.current) {
window.clearTimeout(
promptCopiedTimeoutRef.current,
);
}
promptCopiedTimeoutRef.current = window.setTimeout(
() => {
setPromptCopied(false);
promptCopiedTimeoutRef.current = null;
},
1500,
);
}}
>
<FontAwesomeIcon
icon={promptCopied ? faCheck : faCopy}
className="h-3 w-3"
/>
<span>{promptCopied ? "Copied!" : "Copy"}</span>
</button>
)}
</div>
<div
className={twMerge(
"relative text-sm text-base-fg break-words p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed",
)}
className="text-sm text-base-fg break-words p-3 rounded-lg leading-relaxed"
style={{
background: isPromptHovered
? "rgb(var(--st-controls-rgb) / 0.30)"
: "rgb(var(--st-controls-rgb) / 0.20)",
}}
onMouseEnter={() => setIsPromptHovered(true)}
onMouseLeave={() => setIsPromptHovered(false)}
onClick={() => {
if (!prompt) return;
navigator.clipboard.writeText(prompt).catch(() => {});
toast.success("Prompt copied");
background: "rgb(var(--st-controls-rgb) / 0.20)",
}}
>
{promptLoading ? (
@@ -581,32 +626,51 @@ export function LightboxModal({
</span>
</div>
) : (
prompt || (
<span className="text-sm text-base-fg">
No prompt
</span>
)
<div
ref={promptRef}
className={twMerge(
!isPromptExpanded && "line-clamp-4",
)}
>
{prompt || (
<span className="text-sm text-base-fg">
No prompt
</span>
)}
</div>
)}
</div>
{!promptLoading && (
<div
className={twMerge(
"pointer-events-none absolute inset-0 flex items-end justify-end opacity-0 transition-opacity duration-50",
isPromptHovered && "opacity-100",
)}
>
<div
className="flex items-center gap-1 text-xs text-base-fg backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg"
style={{
background: "rgb(var(--st-controls-rgb) / 0.80)",
{!promptLoading &&
prompt &&
(isPromptClamped || isPromptExpanded) && (
<button
className="flex w-full items-center justify-center gap-1 text-xs text-base-fg/70 hover:text-base-fg transition-colors py-1"
onClick={(e) => {
e.stopPropagation();
setIsPromptExpanded((prev) => !prev);
}}
>
<FontAwesomeIcon icon={faCopy} className="h-3 w-3" />
<span>Copy prompt</span>
</div>
</div>
)}
<span>{isPromptExpanded ? "Hide" : "See all"}</span>
<svg
className={twMerge(
"h-3 w-3 transition-transform duration-200",
isPromptExpanded && "rotate-180",
)}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4.5L6 8L9.5 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
{contextImages && contextImages.length > 0 && (
@@ -1,23 +1,30 @@
import { PricingContent } from "@storyteller/ui-pricing-modal";
interface ArtcraftBillingBlockProps {
isVideoPage?: boolean;
}
export function ArtcraftBillingBlock({
isVideoPage = false,
}: ArtcraftBillingBlockProps) {
const title = isVideoPage
? "Video Generation is Resource Intensive"
: "Subscribe for Credits to Generate";
const subtitle = isVideoPage
? `Creating high-quality videos requires significant computing power.
To generate more, you can subscribe to ArtCraft or buy ArtCraft credits.
You can also add 3rd party providers and use your existing services.
Your support helps us keep building and improving ArtCraft!
We're building the image and video tool that you can own forever.`
: `To generate more, subscribe to ArtCraft or buy credits.
You can also add 3rd party providers and use your existing services.
Your support helps fund open source development and keeps ArtCraft improving!
We're building the creative tool that you can own forever.`;
return (
<div className="w-full">
<PricingContent
title="Video Generation is Resource Intensive"
subtitle="
Creating high-quality videos requires significant computing power.
To generate more, you can subscribe to ArtCraft or buy ArtCraft credits.
You can also add 3rd party providers and use your existing services.
Your support helps us keep building and improving ArtCraft!
We're building the image and video tool that you can own forever.
"
/>
<PricingContent title={title} subtitle={subtitle} />
</div>
);
}
@@ -7,15 +7,22 @@ import { ArtcraftBillingBlock } from "./artcraft/ArtcraftBillingBlock";
import { twMerge } from "tailwind-merge";
interface ProviderBillingModalProps {
isVideoPage?: boolean;
}
export function ProviderBillingModal({
isVideoPage = false,
}: ProviderBillingModalProps) {
const [showModal, setShowModal] = useState(false);
const [provider, setProvider] = useState<GenerationProvider>(GenerationProvider.Artcraft);
const [provider, setProvider] = useState<GenerationProvider>(
GenerationProvider.Artcraft,
);
useShowProviderBillingModalEvent(async (event) => {
console.log("Show provider billing modal event received from Tauri:", event);
console.log(
"Show provider billing modal event received from Tauri:",
event,
);
setProvider(event.provider);
setShowModal(true);
});
@@ -32,7 +39,7 @@ export function ProviderBillingModal({
block = <GenericProviderBillingBlock provider={provider} />;
break;
case GenerationProvider.Artcraft:
block = <ArtcraftBillingBlock />;
block = <ArtcraftBillingBlock isVideoPage={isVideoPage} />;
break;
}
@@ -46,14 +53,18 @@ export function ProviderBillingModal({
}}
className={twMerge(
"bg-ui-panel border border-ui-panel-border transition-all duration-300 overflow-y-auto",
isArtcraft ? "max-w-screen-2xl max-h-[90vh]" : "max-w-2xl max-h-[500px]"
isArtcraft
? "max-w-screen-2xl max-h-[90vh]"
: "max-w-2xl max-h-[500px]",
)}
showClose={true}
>
<div className={twMerge(
"flex flex-col items-center justify-center",
isArtcraft ? "h-full w-full" : "gap-6 p-6"
)}>
<div
className={twMerge(
"flex flex-col items-center justify-center",
isArtcraft ? "h-full w-full" : "gap-6 p-6",
)}
>
{block}
</div>
</Modal>
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Database migration script for running queries in a loop with failure fallback.
Reads MySQL credentials from a TOML secrets file.
Requirements:
pip install pymysql
Secrets file (script/database/secrets.toml):
[database]
url = "mysql://user:password@host:3306/dbname"
"""
import sys
import time
import tomllib
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
try:
import pymysql
except ImportError:
print("pymysql is required: pip install pymysql")
sys.exit(1)
SECRETS_PATH = "secrets.toml"
# -- Migration query to run in batches --
# Tweak this as needed. Use LIMIT to control batch size.
QUERY = """
delete
from media_files
where maybe_creator_user_token is null
limit 10000
"""
BATCH_PAUSE_SECONDS = 0.5
FAILURE_BACKOFF_SECONDS = 5
MAX_BACKOFF_SECONDS = 300
def load_config():
with open(SECRETS_PATH, "rb") as f:
config = tomllib.load(f)
url = config["database"]["url"]
parsed = urlparse(url)
return {
"host": parsed.hostname,
"port": parsed.port or 3306,
"user": parsed.username,
"password": parsed.password,
"database": parsed.path.lstrip("/"),
}
def connect(config):
return pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
connect_timeout=10,
read_timeout=30,
write_timeout=30,
autocommit=True,
)
def run_migration():
config = load_config()
conn = connect(config)
total_affected = 0
failures = 0
start_time = time.monotonic()
print(f"Connected to {config['host']}/{config['database']}")
print(f"Running query:\n{QUERY.strip()}\n")
try:
while True:
try:
batch_start = time.monotonic()
with conn.cursor() as cursor:
cursor.execute(QUERY)
affected = cursor.rowcount
total_affected += affected
failures = 0
batch_time = int(time.monotonic() - batch_start)
elapsed = int(time.monotonic() - start_time)
now = datetime.now().strftime("%H:%M:%S")
print(f" Batch: {affected} rows {batch_time}s | Total: {total_affected} rows {elapsed}s | Clock: {now}")
if affected == 0:
print("No more rows to process. Done.")
break
time.sleep(BATCH_PAUSE_SECONDS)
except (pymysql.OperationalError, pymysql.InterfaceError) as e:
failures += 1
backoff = min(FAILURE_BACKOFF_SECONDS * failures, MAX_BACKOFF_SECONDS)
print(f" Error ({failures}): {e}")
print(f" Reconnecting in {backoff}s...")
time.sleep(backoff)
try:
conn.close()
except Exception:
pass
conn = connect(config)
except KeyboardInterrupt:
print(f"\nInterrupted. Total rows affected: {total_affected}")
finally:
conn.close()
print(f"Finished. Total rows affected: {total_affected}")
if __name__ == "__main__":
run_migration()
+2
View File
@@ -0,0 +1,2 @@
[database]
url = "mysql://user:password@host:3306/dbname"
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
Database status polling script. Runs a count query in a loop and logs results.
Reads MySQL credentials from the same secrets.toml as migrate.py.
Requirements:
pip install pymysql
"""
import sys
import time
import tomllib
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
try:
import pymysql
except ImportError:
print("pymysql is required: pip install pymysql")
sys.exit(1)
SECRETS_PATH = "secrets.toml"
# -- Count query to poll --
QUERY = """
select count(*)
from media_files
where maybe_creator_user_token is null
"""
POLL_INTERVAL_SECONDS = 60
FAILURE_BACKOFF_SECONDS = 5
MAX_BACKOFF_SECONDS = 300
def load_config():
with open(SECRETS_PATH, "rb") as f:
config = tomllib.load(f)
url = config["database"]["url"]
parsed = urlparse(url)
return {
"host": parsed.hostname,
"port": parsed.port or 3306,
"user": parsed.username,
"password": parsed.password,
"database": parsed.path.lstrip("/"),
}
def connect(config):
return pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
connect_timeout=10,
read_timeout=30,
write_timeout=30,
autocommit=True,
)
def run_status():
config = load_config()
conn = connect(config)
failures = 0
print(f"Connected to {config['host']}/{config['database']}")
print(f"Polling every {POLL_INTERVAL_SECONDS}s with:\n{QUERY.strip()}\n")
try:
while True:
try:
query_start = time.monotonic()
with conn.cursor() as cursor:
cursor.execute(QUERY)
count = cursor.fetchone()[0]
failures = 0
query_time = int(time.monotonic() - query_start)
now = datetime.now().strftime("%H:%M:%S")
print(f" Remaining: {count:,} rows | Query: {query_time}s | Clock: {now}")
time.sleep(POLL_INTERVAL_SECONDS)
except (pymysql.OperationalError, pymysql.InterfaceError) as e:
failures += 1
backoff = min(FAILURE_BACKOFF_SECONDS * failures, MAX_BACKOFF_SECONDS)
print(f" Error ({failures}): {e}")
print(f" Reconnecting in {backoff}s...")
time.sleep(backoff)
try:
conn.close()
except Exception:
pass
conn = connect(config)
except KeyboardInterrupt:
print("\nInterrupted.")
finally:
conn.close()
if __name__ == "__main__":
run_status()