lightbox context images, generation details, canvas snapshot bug fix (#746)

- canvas snapshot aspect ratio is fixed (it was snapshotting only as
squared images)
- added reference/context images in the lightbox under prompt, hovering
shows a larger preview
- added generation details: model used with logo and provider
This commit is contained in:
BFlat authored and GitHub committed 2025-07-28 13:28:17 +07:00
1 parent b53e138dab
commit 8d4edcfafd
10 files changed
+426 -141

No files matched your search

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Recraft</title><path d="M19.667 8.275c0-4.57-4.15-8.275-9.27-8.275-1.774 0-3.213 3.705-3.213 8.275 0 1.143.09 2.233.253 3.224H4.29L1 23h9.4v-6.447c5.117 0 9.266-3.707 9.266-8.275l.001-.002zm-9.27-6.76c.93 0 1.682 3.028 1.682 6.76 0 3.733-.752 6.76-1.681 6.76-.93 0-1.681-3.027-1.681-6.76 0-3.732.752-6.76 1.68-6.76z"></path><path d="M19.848 16.552h-9.44L14.028 23h9.438l-3.618-6.448z"></path></svg>

After

Width:  |  Height:  |  Size: 562 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Tencent</title><path d="M9.976 1L24 9.8l-10.587.015L10.723 23H5.489L8.18 9.8H3.244L1 5.4h8.077L9.976 1z"></path></svg>

After

Width:  |  Height:  |  Size: 282 B

@@ -170,8 +170,15 @@ const PageDraw = () => {
};
const onEnqueuedPressed = async () => {
const { width, height } = store.getAspectRatioDimensions();
// takes snap shot and then a global variable in the engine will invoke the inference.
const image = await captureStageImageBitmap(stageRef, transformerRefs);
const image = await captureStageImageBitmap(
stageRef,
transformerRefs,
width,
height,
);
if (!image) {
console.error("Failed to capture stage image");
return;
@@ -1,114 +1,129 @@
import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";
import Konva from "konva";
const debugOutput = (uri:string, name:string)=> {
var link = document.createElement('a');
link.download = name;
link.href = uri;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
export const captureStageImageBitmap = async (
stageRef: React.RefObject<Konva.Stage>,
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>,
width: number = 1024,
height: number = 1024,
): Promise<ImageBitmap | undefined> => {
if (!stageRef.current) {
return undefined;
}
const oldPos = { ...stageRef.current.position() };
const oldScale = {
x: stageRef.current.scaleX(),
y: stageRef.current.scaleY(),
};
export const captureStageImageBitmap = async (stageRef: any,transformerRefs: any): Promise<ImageBitmap | undefined> => {
if (!stageRef.current) {
return undefined;
}
const oldPos = { ...stageRef.current.position() };
const oldScale = { x: stageRef.current.scaleX(), y: stageRef.current.scaleY() };
// Hide all transformers
Object.values(transformerRefs.current).forEach(transformer => {
// Hide all transformers
Object.values(transformerRefs.current || {}).forEach(
(transformer: Konva.Transformer) => {
transformer.visible(false);
},
);
stageRef.current.position({ x: 0, y: 0 });
stageRef.current.scale({ x: 1, y: 1 });
stageRef.current.draw();
try {
const canvas = stageRef.current.toCanvas({
width,
height,
pixelRatio: 1,
});
return await createImageBitmap(canvas);
} catch (error) {
console.error("Failed to create ImageBitmap:", error);
return undefined;
} finally {
// Restore transformers
Object.values(transformerRefs.current || {}).forEach(
(transformer: Konva.Transformer) => {
transformer.visible(true);
},
);
stageRef.current.position(oldPos);
stageRef.current.scale(oldScale);
stageRef.current.draw();
}
};
export const useStageSnapshot = (
stageRef: React.RefObject<Konva.Stage>,
imageRef: React.RefObject<Konva.Image>,
isSelectingRef: React.RefObject<boolean>,
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>,
) => {
const [animationFrameId, setAnimationFrameId] = useState<number | null>(null);
const captureStageImage = (
stageRef: React.RefObject<Konva.Stage>,
width: number = 1024,
height: number = 1024,
): string => {
if (!stageRef.current) return "";
const oldPos = { ...stageRef.current.position() };
const oldScale = {
x: stageRef.current.scaleX(),
y: stageRef.current.scaleY(),
};
// Hide all transformers
Object.values(transformerRefs.current || {}).forEach(
(transformer: Konva.Transformer) => {
transformer.visible(false);
},
);
stageRef.current.position({ x: 0, y: 0 });
stageRef.current.scale({ x: 1, y: 1 });
stageRef.current.draw();
try {
const canvas = stageRef.current.toCanvas({
width: 1024,
height: 1024,
pixelRatio: 1
});
return await createImageBitmap(canvas);
} catch (error) {
console.error('Failed to create ImageBitmap:', error);
return undefined;
} finally {
// Restore transformers
Object.values(transformerRefs.current).forEach(transformer => {
const uri = stageRef.current.toDataURL({ width, height, pixelRatio: 1 });
// Restore transformers
Object.values(transformerRefs.current || {}).forEach(
(transformer: Konva.Transformer) => {
transformer.visible(true);
});
stageRef.current.position(oldPos);
stageRef.current.scale(oldScale);
stageRef.current.draw();
}
},
);
stageRef.current.position(oldPos);
stageRef.current.scale(oldScale);
stageRef.current.draw();
return uri;
};
export const useStageSnapshot = (
stageRef: React.RefObject<any>,
imageRef: React.RefObject<any>,
isSelectingRef: React.RefObject<boolean>,
transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>
) => {
const [animationFrameId, setAnimationFrameId] = useState<number | null>(null);
const captureStageImage = (stageRef: any): string => {
const oldPos = { ...stageRef.current.position() };
const oldScale = { x: stageRef.current.scaleX(), y: stageRef.current.scaleY() };
// Hide all transformers
Object.values(transformerRefs.current).forEach(transformer => {
transformer.visible(false);
});
stageRef.current.position({ x: 0, y: 0 });
stageRef.current.scale({ x: 1, y: 1 });
stageRef.current.draw();
const uri = stageRef.current.toDataURL({ width: 1024, height: 1024, pixelRatio: 1 });
// Restore transformers
Object.values(transformerRefs.current).forEach(transformer => {
transformer.visible(true);
});
stageRef.current.position(oldPos);
stageRef.current.scale(oldScale);
stageRef.current.draw();
return uri;
};
useEffect(() => {
const updateSnapshot = () => {
if (!isSelectingRef.current && stageRef.current && imageRef.current) {
const uri = captureStageImage(stageRef);
if (uri) {
const img = new window.Image();
img.onload = () => {
imageRef.current?.setAttrs({
image: img
});
};
img.src = uri;
}
useEffect(() => {
const updateSnapshot = () => {
if (!isSelectingRef.current && stageRef.current && imageRef.current) {
const uri = captureStageImage(stageRef);
if (uri) {
const img = new window.Image();
img.onload = () => {
imageRef.current?.setAttrs({
image: img,
});
};
img.src = uri;
}
const frameId = requestAnimationFrame(updateSnapshot);
setAnimationFrameId(frameId);
};
}
const frameId = requestAnimationFrame(updateSnapshot);
setAnimationFrameId(frameId);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
};
}, []);
};
const frameId = requestAnimationFrame(updateSnapshot);
setAnimationFrameId(frameId);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, []);
};
@@ -13,4 +13,22 @@ export interface Prompts {
use_cinematic: boolean;
used_face_detailer: boolean;
used_upscaler: boolean;
maybe_model_type: string;
maybe_generation_provider: string;
maybe_context_images: [
{
media_links: {
cdn_url: string;
maybe_thumbnail_template: string;
maybe_video_previews: {
animated: string;
animated_thumbnail_template: string;
still: string;
still_thumbnail_template: string;
};
};
media_token: string;
semantic: string;
}
];
}
@@ -14,6 +14,12 @@ import { toast } from "@storyteller/ui-toaster";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import {
getModelCreatorIcon,
getModelDisplayName,
getProviderDisplayName,
} from "@storyteller/model-list";
import { Tooltip } from "@storyteller/ui-tooltip";
interface LightboxModalProps {
isOpen: boolean;
@@ -63,6 +69,18 @@ export function LightboxModal({
const [promptLoading, setPromptLoading] = React.useState<boolean>(false);
const [hasPromptToken, setHasPromptToken] = React.useState<boolean>(false);
const [isPromptHovered, setIsPromptHovered] = React.useState<boolean>(false);
const [generationProvider, setGenerationProvider] = React.useState<
string | null
>(null);
const [modelType, setModelType] = React.useState<string | null>(null);
const [contextImages, setContextImages] = React.useState<Array<{
media_links: {
cdn_url: string;
maybe_thumbnail_template: string;
};
media_token: string;
semantic: string;
}> | null>(null);
// Reset when imageUrl changes
React.useEffect(() => {
@@ -75,6 +93,9 @@ export function LightboxModal({
if (!mediaId) {
setPrompt(null);
setHasPromptToken(false);
setGenerationProvider(null);
setModelType(null);
setContextImages(null);
return;
}
@@ -92,22 +113,32 @@ export function LightboxModal({
token: mediaResponse.data.maybe_prompt_token,
});
if (
promptResponse.success &&
promptResponse.data?.maybe_positive_prompt
) {
setPrompt(promptResponse.data.maybe_positive_prompt);
if (promptResponse.success && promptResponse.data) {
const promptData = promptResponse.data;
setPrompt(promptData.maybe_positive_prompt || null);
setGenerationProvider(promptData.maybe_generation_provider || null);
setModelType(promptData.maybe_model_type || null);
setContextImages(promptData.maybe_context_images || null);
} else {
setPrompt(null);
setGenerationProvider(null);
setModelType(null);
setContextImages(null);
}
} else {
setHasPromptToken(false);
setPrompt(null);
setGenerationProvider(null);
setModelType(null);
setContextImages(null);
}
} catch (error) {
console.error("Error fetching prompt:", error);
setHasPromptToken(false);
setPrompt(null);
setGenerationProvider(null);
setModelType(null);
setContextImages(null);
} finally {
setPromptLoading(false);
}
@@ -185,51 +216,165 @@ export function LightboxModal({
</div>
</div>
)}
{hasPromptToken && (
<div className="relative space-y-1.5">
<div className="text-sm font-medium text-white/90">Prompt</div>
<div
className={twMerge(
"relative text-sm text-white/90 break-words bg-black/20 p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed",
isPromptHovered && "bg-black/30"
)}
onMouseEnter={() => setIsPromptHovered(true)}
onMouseLeave={() => setIsPromptHovered(false)}
onClick={() => {
if (!prompt) return;
navigator.clipboard.writeText(prompt).catch(() => {});
toast.success("Prompt copied");
}}
>
{promptLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner className="h-4 w-4" />
<span className="text-sm text-white/80">
Loading prompt...
</span>
<>
{/* Prompt */}
<div className="relative space-y-1.5">
<div className="text-sm font-medium text-white/90">
Prompt
</div>
<div
className={twMerge(
"relative text-sm text-white/90 break-words bg-black/20 p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed",
isPromptHovered && "bg-black/30"
)}
onMouseEnter={() => setIsPromptHovered(true)}
onMouseLeave={() => setIsPromptHovered(false)}
onClick={() => {
if (!prompt) return;
navigator.clipboard.writeText(prompt).catch(() => {});
toast.success("Prompt copied");
}}
>
{promptLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner className="h-4 w-4" />
<span className="text-sm text-white/80">
Loading prompt...
</span>
</div>
) : (
prompt || (
<span className="text-sm text-white/90">No prompt</span>
)
)}
</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-white/80 bg-black/80 backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg">
<FontAwesomeIcon icon={faCopy} className="h-3 w-3" />
<span>Copy prompt</span>
</div>
</div>
) : (
prompt || (
<span className="text-sm text-white/90">No prompt</span>
)
)}
</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-white/80 bg-black/80 backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg">
<FontAwesomeIcon icon={faCopy} className="h-3 w-3" />
<span>Copy prompt</span>
{/* Context Images */}
{contextImages && contextImages.length > 0 && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-white/90">
Reference Images
</div>
<div className="grid grid-cols-6 gap-2">
{contextImages.map((contextImage, index) => {
const thumbnailUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"128"
)
: contextImage.media_links.cdn_url;
const fullSizeUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"512"
)
: contextImage.media_links.cdn_url;
return (
// Hover preview
<Tooltip
key={contextImage.media_token}
className="bg-black p-1.5"
content={
<div>
<div className="flex flex-col items-center bg-white/10 rounded-lg">
<img
src={fullSizeUrl}
alt={`Reference image ${index + 1} preview`}
className="w-auto h-48 object-cover rounded-lg"
/>
</div>
{contextImage.semantic && (
<div className="mt-2 text-xs text-white/90 text-center max-w-48 px-1">
{contextImage.semantic}
</div>
)}
</div>
}
position="top"
delay={300}
closeOnClick={true}
>
<div
className="relative group cursor-pointer"
onClick={() => {
window.open(
contextImage.media_links.cdn_url,
"_blank"
);
}}
>
<div className="relative overflow-hidden rounded-lg border border-white/5 bg-white/10 aspect-square">
<img
src={thumbnailUrl}
alt={`Reference image ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</div>
</div>
</Tooltip>
);
})}
</div>
</div>
)}
</div>
{/* Generation Details */}
{(generationProvider || modelType) && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-white/90">
Generation Details
</div>
<div className="flex flex-col gap-1.5">
{modelType && (
<div className="flex items-center justify-between py-2 px-3 bg-black/20 rounded-lg border border-white/5">
<span className="text-sm text-white/70 font-medium">
Model
</span>
<div className="flex items-center gap-2">
{getModelCreatorIcon(modelType)}
<span className="text-sm text-white/90 rounded">
{getModelDisplayName(modelType)}
</span>
</div>
</div>
)}
{generationProvider && (
<div className="flex items-center justify-between py-2 px-3 bg-black/20 rounded-lg border border-white/5">
<span className="text-sm text-white/70 font-medium">
Provider
</span>
<span className="text-sm text-white/90 rounded">
{getProviderDisplayName(generationProvider)}
</span>
</div>
)}
</div>
</div>
)}
</>
)}
{additionalInfo}
</div>
+1
View File
@@ -4,3 +4,4 @@ export * from "./lib/ModelCreator.js";
export * from "./lib/ImageModels.js";
export * from "./lib/VideoModels.js";
export * from "./lib/ModelCreatorIcons.js";
export * from "./lib/ModelTypeMapping.js";
@@ -1,4 +1,3 @@
export enum ModelCreator {
BlackForestLabs = "BlackForestLabs",
Bytedance = "Bytedance",
@@ -9,4 +8,6 @@ export enum ModelCreator {
OpenAi = "OpenAi",
Runway = "Runway",
Stability = "Stability",
Tencent = "Tencent",
Recraft = "Recraft",
}
@@ -9,6 +9,8 @@ export const CREATOR_ICON_PATHS: Partial<Record<ModelCreator, string>> = {
[ModelCreator.OpenAi]: "/resources/images/services/openai.svg",
[ModelCreator.Bytedance]: "/resources/images/services/bytedance.svg",
[ModelCreator.Google]: "/resources/images/services/google.svg",
[ModelCreator.Recraft]: "/resources/images/services/recraft.svg",
[ModelCreator.Tencent]: "/resources/images/services/tencent.svg",
};
export const getCreatorIconPath = (creator: ModelCreator): string | undefined =>
@@ -0,0 +1,94 @@
import React from "react";
import { ModelCreator } from "./ModelCreator.js";
import { getCreatorIcon } from "./ModelCreatorIcons.js";
// Map from model type strings to ModelCreator enum values
export const MODEL_TYPE_TO_CREATOR: Record<string, ModelCreator> = {
flux_1_dev: ModelCreator.BlackForestLabs,
flux_1_schnell: ModelCreator.BlackForestLabs,
flux_pro_1p1: ModelCreator.BlackForestLabs,
flux_pro_1p1_ultra: ModelCreator.BlackForestLabs,
gpt_image_1: ModelCreator.OpenAi,
kling_1p6_pro: ModelCreator.Kling,
kling_2p1_pro: ModelCreator.Kling,
kling_2p1_master: ModelCreator.Kling,
seedance_1p0_lite: ModelCreator.Bytedance,
veo_2: ModelCreator.Google,
recraft_3: ModelCreator.Recraft,
hunyuan_3d: ModelCreator.Tencent,
};
// Get creator icon for a model type
export const getModelCreatorIcon = (
modelType: string
): React.ReactNode | null => {
const creator = MODEL_TYPE_TO_CREATOR[modelType];
if (!creator) return null;
return getCreatorIcon(creator, "h-4 w-4 invert");
};
// Get creator name for display
export const getModelCreatorName = (modelType: string): string | null => {
const creator = MODEL_TYPE_TO_CREATOR[modelType];
// Convert enum value to display name
switch (creator) {
case ModelCreator.BlackForestLabs:
return "Black Forest Labs";
case ModelCreator.OpenAi:
return "OpenAI";
case ModelCreator.Kling:
return "Kling AI";
case ModelCreator.Bytedance:
return "ByteDance";
case ModelCreator.Google:
return "Google";
case ModelCreator.Midjourney:
return "Midjourney";
case ModelCreator.Stability:
return "Stability AI";
case ModelCreator.Runway:
return "Runway";
case ModelCreator.Hailuo:
return "Hailuo";
case ModelCreator.Recraft:
return "Recraft";
case ModelCreator.Tencent:
return "Tencent";
default:
return creator;
}
};
// Convert model type string to human-readable display name
export const getModelDisplayName = (modelType: string): string => {
const displayNames: Record<string, string> = {
flux_1_dev: "Flux 1 Dev",
flux_1_schnell: "Flux 1 Schnell",
flux_pro_1p1: "Flux Pro 1.1",
flux_pro_1p1_ultra: "Flux Pro 1.1 Ultra",
gpt_image_1: "GPT Image 1",
kling_1p6_pro: "Kling 1.6 Pro",
kling_2p1_pro: "Kling 2.1 Pro",
kling_2p1_master: "Kling 2.1 Master",
seedance_1p0_lite: "Seedance 1.0 Lite",
veo_2: "Veo 2",
recraft_3: "Recraft 3",
hunyuan_3d_2p0: "Hunyuan 3D 2.0",
hunyuan_3d_2p1: "Hunyuan 3D 2.1",
hunyuan_3d: "Hunyuan 3D",
};
return displayNames[modelType] || modelType;
};
// Convert provider string to human-readable display name
export const getProviderDisplayName = (provider: string): string => {
const displayNames: Record<string, string> = {
artcraft: "ArtCraft",
fal: "FAL",
sora: "Sora",
};
return displayNames[provider] || provider;
};