mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 16:38:14 +00:00
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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user