ux update inpainting history (#815)

- reverted to previous ux
- added placeholder loaders for generating images (matched by frontend
subscriber id)
- can now generate multiple images at a time
- auto scrolls to top of history stack on enqueue
This commit is contained in:
BFlat authored and GitHub committed 2025-09-03 23:05:05 +07:00
1 parent 77614e06d9
commit 36780e5669
4 files changed
+154 -199

No files matched your search

@@ -36,7 +36,6 @@ export type EditPaintSurfaceProps = {
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>;
leftPanelRef: React.RefObject<Konva.Layer>;
baseImageRef: React.RefObject<Konva.Image>;
isGenerating?: boolean;
};
export const EditPaintSurface = ({
@@ -52,7 +51,6 @@ export const EditPaintSurface = ({
transformerRefs,
leftPanelRef,
baseImageRef,
isGenerating = false,
}: EditPaintSurfaceProps) => {
// switch off to be preview panel mode.
const singlePaneMode = true;
@@ -69,9 +67,7 @@ export const EditPaintSurface = ({
const rightContainerRef = React.useRef<HTMLDivElement>(null);
const cursorLayerRef = React.useRef<Konva.Layer>(null);
const cursorShapeRef = React.useRef<Konva.Circle>(null);
const borderLayerRef = React.useRef<Konva.Layer>(null);
const runnerRectRef = React.useRef<Konva.Rect>(null);
const runnerRectRef2 = React.useRef<Konva.Rect>(null);
// Removed generating border refs
// Layout dimensions
const leftPanelWidth = 1024;
@@ -647,124 +643,6 @@ export const EditPaintSurface = ({
}
};
// Animated generating border around the actual canvas area (continuous, cornerless)
useEffect(() => {
if (
!isGenerating ||
!runnerRectRef.current ||
!borderLayerRef.current ||
!runnerRectRef2.current
) {
return;
}
const rectA = runnerRectRef.current;
const rectB = runnerRectRef2.current;
const layer = borderLayerRef.current;
let distance = 0;
const speedPixelsPerSecond = 1200; // very fast runner speed
const runnerThickness = 10; // thicker
const anim = new Konva.Animation((frame) => {
if (!frame) return;
const w = store.getAspectRatioDimensions().width;
const h = store.getAspectRatioDimensions().height;
const perimeter = 2 * (w + h);
const baseRunner = Math.max(24, Math.round(perimeter * 0.12));
const maxRunner = Math.floor(Math.max(w, h) * 0.5);
const runnerLength = Math.min(baseRunner, maxRunner);
distance =
(distance + speedPixelsPerSecond * (frame.timeDiff / 1000)) % perimeter;
let d = distance;
// Top edge: left -> right
if (d < w) {
const seg = Math.min(runnerLength, w - d);
rectA.x(d);
rectA.y(0);
rectA.width(seg);
rectA.height(runnerThickness);
const overflow = runnerLength - seg;
if (overflow > 0) {
rectB.x(w - runnerThickness);
rectB.y(0);
rectB.width(runnerThickness);
rectB.height(Math.min(overflow, h));
} else {
rectB.width(0);
rectB.height(0);
}
return;
}
d -= w;
// Right edge: top -> bottom
if (d < h) {
const seg = Math.min(runnerLength, h - d);
rectA.x(w - runnerThickness);
rectA.y(d);
rectA.width(runnerThickness);
rectA.height(seg);
const overflow = runnerLength - seg;
if (overflow > 0) {
rectB.x(Math.max(0, w - overflow));
rectB.y(h - runnerThickness);
rectB.width(Math.min(overflow, w));
rectB.height(runnerThickness);
} else {
rectB.width(0);
rectB.height(0);
}
return;
}
d -= h;
// Bottom edge: right -> left
if (d < w) {
const seg = Math.min(runnerLength, w - d);
rectA.x(w - d - seg);
rectA.y(h - runnerThickness);
rectA.width(seg);
rectA.height(runnerThickness);
const overflow = runnerLength - seg;
if (overflow > 0) {
rectB.x(0);
rectB.y(Math.max(0, h - overflow));
rectB.width(runnerThickness);
rectB.height(Math.min(overflow, h));
} else {
rectB.width(0);
rectB.height(0);
}
return;
}
d -= w;
// Left edge: bottom -> top
{
const seg = Math.min(runnerLength, h - d);
rectA.x(0);
rectA.y(h - d - seg);
rectA.width(runnerThickness);
rectA.height(seg);
const overflow = runnerLength - seg;
if (overflow > 0) {
rectB.x(0);
rectB.y(0);
rectB.width(Math.min(overflow, w));
rectB.height(runnerThickness);
} else {
rectB.width(0);
rectB.height(0);
}
}
}, layer);
anim.start();
return () => {
anim.stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isGenerating]);
useLayoutEffect(() => {
const cursorNode = cursorShapeRef.current;
const cursorLayer = cursorLayerRef.current;
@@ -1451,47 +1329,6 @@ export const EditPaintSurface = ({
<Layer ref={cursorLayerRef} listening={false} draggable={false}>
<Circle ref={cursorShapeRef} visible={false} />
</Layer>
{isGenerating && (
<Layer ref={borderLayerRef} listening={false}>
{(() => {
const w = store.getAspectRatioDimensions().width;
const h = store.getAspectRatioDimensions().height;
const perimeter = 2 * (w + h);
const runnerLength = Math.max(
24,
Math.round(perimeter * 0.08),
);
return (
<>
<Rect
ref={runnerRectRef}
x={0}
y={0}
width={runnerLength}
height={10}
fill={"rgba(59,130,246,1)"}
shadowColor={"rgba(59,130,246,1)"}
shadowBlur={16}
shadowOpacity={0.9}
listening={false}
/>
<Rect
ref={runnerRectRef2}
x={0}
y={0}
width={0}
height={0}
fill={"rgba(59,130,246,1)"}
shadowColor={"rgba(59,130,246,1)"}
shadowBlur={16}
shadowOpacity={0.9}
listening={false}
/>
</>
);
})()}
</Layer>
)}
</Stage>
</div>
</div>
@@ -5,7 +5,7 @@ import {
faTrashXmark,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { Fragment, useRef } from "react";
import { Fragment, useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { BaseSelectorImage } from "./BaseImageSelector";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -24,7 +24,10 @@ interface HistoryStackProps {
onImageSelect?: (image: BaseSelectorImage) => void;
imageBundles: ImageBundle[];
onNewImageBundle?: (newBundle: ImageBundle) => void;
pendingPlaceholders?: { id: string; count: number }[];
onResolvePending?: (id: string) => void;
selectedImageToken?: string;
blurredBackgroundUrl?: string;
}
export const HistoryStack = ({
@@ -32,7 +35,10 @@ export const HistoryStack = ({
onImageSelect = () => {},
imageBundles,
onNewImageBundle = () => {},
pendingPlaceholders = [],
onResolvePending = () => {},
selectedImageToken,
blurredBackgroundUrl,
}: HistoryStackProps) => {
useImageEditCompleteEvent(async (event) => {
const newBundle: ImageBundle = {
@@ -46,24 +52,37 @@ export const HistoryStack = ({
};
onNewImageBundle(newBundle);
if (newBundle.images.length > 0) {
onImageSelect(newBundle.images[0]);
// ensure the scroll container jumps to top for latest bundle
setTimeout(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, 0);
// Resolve any matching pending placeholders using subscriber id
if (event.maybe_frontend_subscriber_id) {
onResolvePending(event.maybe_frontend_subscriber_id);
}
// Do not auto-select the new image; only scroll to top for visibility
setTimeout(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, 0);
});
// This is used to force image reloads in different sessions
// and prevent fetching CORS-tainted images from cache
const sessionRandBuster = useRef(Math.random());
const scrollRef = useRef<HTMLDivElement | null>(null);
const prevPendingCountRef = useRef<number>(0);
const handleClear = () => {
onClear();
};
// Scroll to top when new pending placeholders are added (after enqueue)
useEffect(() => {
const current = pendingPlaceholders.length;
if (current > prevPendingCountRef.current) {
setTimeout(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, 0);
}
prevPendingCountRef.current = current;
}, [pendingPlaceholders.length]);
return (
<div className="h-auto w-20 rounded-lg">
<div className="glass rounded-lg p-1.5">
@@ -76,9 +95,65 @@ export const HistoryStack = ({
<div
ref={scrollRef}
className={
"scrollbar-hidden flex max-h-[50vh] flex-col items-center justify-start gap-2 overflow-y-auto"
"scrollbar-hidden flex max-h-[50vh] flex-col items-center justify-start gap-1 overflow-y-auto"
}
>
{/* Pending placeholders first (newest at top) */}
{(() => {
const reversed = [...pendingPlaceholders].slice().reverse();
return reversed.map((p, idx) => (
<Fragment key={`pending-group-${p.id}`}>
{Array.from({ length: Math.max(1, p.count || 1) }).map(
(_, i) => (
<div
key={`pending-${p.id}-${i}`}
className="relative w-full"
>
<div className="st-loading-tile relative aspect-square w-full overflow-hidden rounded-lg">
{blurredBackgroundUrl && (
<img
src={`${blurredBackgroundUrl}?placeholderbg`}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-80 blur-lg"
crossOrigin="anonymous"
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div>
{/* SVG running border (single solid line) */}
<svg
className="st-border-svg"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
className="st-border-solid"
x="1"
y="1"
width="98"
height="98"
rx="16"
ry="16"
pathLength="200"
/>
</svg>
</div>
</div>
),
)}
{idx < reversed.length - 1 && (
<hr className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-white/15" />
)}
</Fragment>
));
})()}
{pendingPlaceholders.length > 0 && (
<hr className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-white/15" />
)}
{/* Completed images below placeholders, newest bundles first */}
{[...imageBundles]
.slice()
.reverse()
@@ -96,7 +171,6 @@ export const HistoryStack = ({
onImageSelect(image);
}}
>
{/* TODO: Fix CORS issue here */}
<img
src={
image.url + "?historystack+" + sessionRandBuster.current
@@ -109,7 +183,7 @@ export const HistoryStack = ({
))}
{index < imageBundles.length - 1 && (
<hr
className="h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-white/15"
className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-white/15"
key={"hr" + index}
/>
)}
@@ -40,12 +40,14 @@ const PageEdit = () => {
const leftPanelRef = useRef<Konva.Layer>({} as Konva.Layer);
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
const [isEnqueuing, setIsEnqueuing] = useState<boolean>(false);
const [isGenerating, setIsGenerating] = useState<boolean>(false);
const [pendingGenerations, setPendingGenerations] = useState<
{ id: string; count: number }[]
>([]);
const [generationCount, setGenerationCount] = useState<number>(1);
// Use the Zustand store
const store = useEditStore();
const baseImageUrl = store.baseImageInfo?.url;
const addHistoryImageBundle = useEditStore(
(state) => state.addHistoryImageBundle,
);
@@ -203,14 +205,6 @@ const PageEdit = () => {
const handleGenerate = useCallback(
async (prompt: string) => {
if (isEnqueuing) {
console.warn("Already enqueuing an image, please wait.");
return;
}
setIsEnqueuing(true);
setIsGenerating(true);
const editedImageToken = store.baseImageInfo?.mediaToken;
if (!editedImageToken) {
@@ -218,31 +212,38 @@ const PageEdit = () => {
return;
}
// TODO: Call inference API here
const arrayBuffer = await getMaskArrayBuffer();
const subscriberId =
(typeof crypto !== "undefined" && (crypto as any).randomUUID
? (crypto as any).randomUUID()
: undefined) ||
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
await EnqueueImageInpaint({
const result = await EnqueueImageInpaint({
model: selectedImageModel,
image_media_token: editedImageToken,
mask_image_raw_bytes: arrayBuffer,
prompt: prompt,
image_count: generationCount,
frontend_caller: "image_editor",
frontend_subscriber_id: subscriberId,
});
if (result.status === "success") {
setPendingGenerations((prev) => [
...prev,
{ id: subscriberId as string, count: generationCount },
]);
}
} catch (error) {
setIsGenerating(false);
setPendingGenerations((prev) =>
prev.filter((p) => p.id !== subscriberId),
);
throw error;
} finally {
setIsEnqueuing(false);
}
},
[
generationCount,
isEnqueuing,
selectedImageModel,
store.baseImageInfo?.mediaToken,
],
[generationCount, selectedImageModel, store.baseImageInfo?.mediaToken],
);
//const modelConfig = lookupModelByTauriId(selectedImageModel!.tauriId);
@@ -315,17 +316,21 @@ const PageEdit = () => {
<HistoryStack
onClear={() => {
store.RESET();
setIsGenerating(false);
setPendingGenerations([]);
}}
imageBundles={historyImageBundles}
pendingPlaceholders={pendingGenerations}
blurredBackgroundUrl={baseImageUrl}
onImageSelect={(baseImage) => {
store.clearLineNodes();
store.setBaseImageInfo(baseImage);
}}
onNewImageBundle={(newBundle: ImageBundle) => {
addHistoryImageBundle(newBundle);
setIsGenerating(false);
}}
onResolvePending={(id: string) =>
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
}
selectedImageToken={store.baseImageInfo?.mediaToken}
/>
</div>
@@ -342,7 +347,7 @@ const PageEdit = () => {
selectedMode={store.activeTool}
onGenerateClick={handleGenerate}
onFitPressed={onFitPressed}
isDisabled={isEnqueuing || isGenerating}
isDisabled={false}
generationCount={generationCount}
onGenerationCountChange={setGenerationCount}
supportsMaskedInpainting={supportsMaskedInpainting}
@@ -399,7 +404,6 @@ const PageEdit = () => {
transformerRefs={transformerRefs}
leftPanelRef={leftPanelRef}
baseImageRef={baseImageKonvaRef}
isGenerating={isGenerating}
/>
</ContextMenuContainer>
</div>
@@ -276,3 +276,43 @@ img {
.ReactCrop__crop-selection:focus {
outline: none !important;
}
/* Editor3D HistoryStack pending running border (continuous) */
.st-loading-tile {
position: relative;
}
.st-border-svg {
position: absolute;
inset: -2px;
width: calc(100% + 4px);
height: calc(100% + 4px);
pointer-events: none;
}
/* base rect style for runner */
.st-border-svg rect {
fill: none;
stroke: rgba(59, 130, 246, 1);
stroke-width: 8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Single runner: a solid line chasing its tail */
/* Continuous runner with wrapping to avoid flicker at corners */
.st-border-solid {
stroke-dasharray: 90 110; /* shorter visible segment + larger gap = clearer motion */
stroke-dashoffset: 0;
animation: st-solid-wrap 1.6s linear infinite;
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 18px rgba(59, 130, 246, 0.5));
}
@keyframes st-solid-wrap {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -200;
}
}