Switch to thumbnails and thumbnail creation on the sample grid page until clicked.

This commit is contained in:
Jaret Burkett
2026-07-25 08:40:33 -06:00
parent e00f3791e2
commit efb58c8641
7 changed files with 105 additions and 13 deletions

View File

@@ -1227,6 +1227,58 @@ class GenerateImageConfig:
# join with folder # join with folder
return os.path.join(self.output_folder, filename) return os.path.join(self.output_folder, filename)
def save_image_atomic(self, image, count: int = 0, max_count=0):
# write into a hidden tmp subfolder, then atomically move into place so
# watchers (UI/CDN) never see and cache a partially written file. Wraps
# self.save_image so it also covers models that replace that function.
real_folder = self.output_folder
tmp_folder = os.path.join(real_folder, '.tmp')
os.makedirs(tmp_folder, exist_ok=True)
self.output_folder = tmp_folder
try:
self.save_image(image, count, max_count)
finally:
self.output_folder = real_folder
files = os.listdir(tmp_folder)
# thumbs move into place first so they already exist when the media
# file appears in the samples folder
thumbs_folder = os.path.join(real_folder, '.thumbs')
for file in files:
tmp_thumb = os.path.join(tmp_folder, file + '.thumb')
try:
if self._generate_thumbnail(os.path.join(tmp_folder, file), tmp_thumb):
os.makedirs(thumbs_folder, exist_ok=True)
os.replace(tmp_thumb, os.path.join(thumbs_folder, file + '.jpg'))
except Exception as e:
print(f"Failed to generate thumbnail for {file}: {e}")
for file in files:
os.replace(os.path.join(tmp_folder, file), os.path.join(real_folder, file))
def _generate_thumbnail(self, media_path, thumb_path):
# 300x300 center-cropped 90% jpg. Returns True if one was written.
from PIL import Image as PILImage
ext = os.path.splitext(media_path)[1].lower()
img = None
if ext in ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']:
img = PILImage.open(media_path) # animated formats open on the first frame
elif ext == '.mp4':
import cv2
cap = cv2.VideoCapture(media_path)
ok, frame = cap.read()
cap.release()
if ok:
img = PILImage.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
if img is None:
return False
img = img.convert('RGB')
w, h = img.size
side = min(w, h)
left = (w - side) // 2
top = (h - side) // 2
img = img.crop((left, top, left + side, top + side)).resize((300, 300), PILImage.LANCZOS)
img.save(thumb_path, format='JPEG', quality=90)
return True
def save_image(self, image, count: int = 0, max_count=0): def save_image(self, image, count: int = 0, max_count=0):
# make parent dirs # make parent dirs
os.makedirs(self.output_folder, exist_ok=True) os.makedirs(self.output_folder, exist_ok=True)

View File

@@ -666,7 +666,7 @@ class BaseModel:
extra, extra,
) )
gen_config.save_image(img, i) gen_config.save_image_atomic(img, i)
gen_config.log_image(img, i) gen_config.log_image(img, i)
self._after_sample_image(i, len(image_configs)) self._after_sample_image(i, len(image_configs))
flush() flush()

View File

@@ -1715,7 +1715,7 @@ class StableDiffusion:
generator=generator, generator=generator,
).images[0] ).images[0]
gen_config.save_image(img, i) gen_config.save_image_atomic(img, i)
gen_config.log_image(img, i) gen_config.log_image(img, i)
self._after_sample_image(i, len(image_configs)) self._after_sample_image(i, len(image_configs))
flush() flush()

View File

@@ -118,7 +118,7 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
.map(decodeURIComponent) .map(decodeURIComponent)
.join('/'); .join('/');
const resolvedFilePath = path.resolve(decodedFilePath); let resolvedFilePath = path.resolve(decodedFilePath);
const roots = await getRoots(); const roots = await getRoots();
const allowedDirs = isImg ? [roots.datasets, roots.training, roots.data] : [roots.datasets, roots.training]; const allowedDirs = isImg ? [roots.datasets, roots.training, roots.data] : [roots.datasets, roots.training];
const isAllowed = allowedDirs.some( const isAllowed = allowedDirs.some(
@@ -131,6 +131,17 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
return; return;
} }
// ?thumb=1 serves the pre-generated 300x300 jpg from the sibling .thumbs
// folder (<name>.<ext>.jpg) when it exists; otherwise falls through to
// the full file exactly as before. Mirrors the Next.js /api/img route.
if (isImg && new URL(req.url || '', 'http://localhost').searchParams.has('thumb')) {
const thumbPath = path.join(path.dirname(resolvedFilePath), '.thumbs', path.basename(resolvedFilePath) + '.jpg');
const thumbStat = await fs.promises.stat(thumbPath).catch(() => null);
if (thumbStat && thumbStat.isFile()) {
resolvedFilePath = thumbPath;
}
}
let stat: fs.Stats; let stat: fs.Stats;
try { try {
stat = await fs.promises.stat(resolvedFilePath); stat = await fs.promises.stat(resolvedFilePath);

View File

@@ -45,7 +45,7 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat
// Security check: resolve the path so any `..` segments are collapsed, // Security check: resolve the path so any `..` segments are collapsed,
// then ensure it's still under an allowed root. (Plain `.includes('..')` // then ensure it's still under an allowed root. (Plain `.includes('..')`
// false-positives on filenames that contain `..` as text, e.g. an ellipsis.) // false-positives on filenames that contain `..` as text, e.g. an ellipsis.)
const resolved = path.resolve(filepath); let resolved = path.resolve(filepath);
const isAllowed = allowedDirs.some( const isAllowed = allowedDirs.some(
allowedDir => resolved === allowedDir || resolved.startsWith(allowedDir + path.sep), allowedDir => resolved === allowedDir || resolved.startsWith(allowedDir + path.sep),
); );
@@ -55,6 +55,17 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat
return new NextResponse('Access denied', { status: 403 }); return new NextResponse('Access denied', { status: 403 });
} }
// ?thumb=1 serves the pre-generated 300x300 jpg from the sibling .thumbs
// folder (<name>.<ext>.jpg) when it exists; otherwise falls through to
// the full file exactly as before.
if (request.nextUrl.searchParams.has('thumb')) {
const thumbPath = path.join(path.dirname(resolved), '.thumbs', path.basename(resolved) + '.jpg');
const thumbStat = await fs.promises.stat(thumbPath).catch(() => null);
if (thumbStat && thumbStat.isFile()) {
resolved = thumbPath;
}
}
// Bail out early if the client already gave up // Bail out early if the client already gave up
if (request.signal.aborted) { if (request.signal.aborted) {
return new NextResponse(null, { status: 499 }); return new NextResponse(null, { status: 499 });

View File

@@ -25,8 +25,12 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
return NextResponse.json({ samples: [] }); return NextResponse.json({ samples: [] });
} }
// find all img (png, jpg, jpeg) files in the samples folder // find all img (png, jpg, jpeg) files in the samples folder. Thumbnails
const samples = (await fs.promises.readdir(samplesFolder)) // live in the hidden .thumbs subfolder (and partial writes in .tmp) — the
// isFile check keeps those directories out even if their names ever match.
const samples = (await fs.promises.readdir(samplesFolder, { withFileTypes: true }))
.filter(entry => entry.isFile())
.map(entry => entry.name)
.filter(file => { .filter(file => {
return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp') || file.endsWith('.mp4') || file.endsWith('mp3') || file.endsWith('wav') || file.endsWith('flac') || file.endsWith('ogg'); return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp') || file.endsWith('.mp4') || file.endsWith('mp3') || file.endsWith('wav') || file.endsWith('flac') || file.endsWith('ogg');
}) })

View File

@@ -32,10 +32,11 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [blobUrl, setBlobUrl] = useState<string | null>(null); const [blobUrl, setBlobUrl] = useState<string | null>(null);
// videos with no pre-generated thumb (older samples) fall back to the <video> element
const [videoFallback, setVideoFallback] = useState(false);
const isItAudio = isAudio(imageUrl); const isItAudio = isAudio(imageUrl);
const isItVideo = isVideo(imageUrl); const isItVideo = isVideo(imageUrl);
const isImageType = !isItAudio && !isItVideo;
// Observe both enter and exit // Observe both enter and exit
useEffect(() => { useEffect(() => {
@@ -66,7 +67,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
// the element unmounts). A short debounce skips requests entirely during fast // the element unmounts). A short debounce skips requests entirely during fast
// scrolls where the card is only briefly visible. // scrolls where the card is only briefly visible.
useEffect(() => { useEffect(() => {
if (!isImageType) return; if (isItAudio) return;
if (!isVisible) return; if (!isVisible) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -74,13 +75,25 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
let objectUrl: string | null = null; let objectUrl: string | null = null;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal }) // ?thumb=1: the server sends the small pre-generated thumbnail when one
// exists, otherwise the full file. Videos without a thumb come back as
// video/* — abort the transfer and render the <video> element instead.
fetch(`/api/img/${encodeURIComponent(imageUrl)}?thumb=1`, { signal: controller.signal })
.then(r => { .then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
const contentType = r.headers.get('content-type') || '';
if (isItVideo && !contentType.startsWith('image/')) {
controller.abort();
if (!cancelled) {
setVideoFallback(true);
setLoaded(true);
}
return null;
}
return r.blob(); return r.blob();
}) })
.then(blob => { .then(blob => {
if (cancelled) return; if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl); setBlobUrl(objectUrl);
setLoaded(true); setLoaded(true);
@@ -97,15 +110,16 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
setBlobUrl(null); setBlobUrl(null);
setLoaded(false); setLoaded(false);
setVideoFallback(false);
}; };
}, [isVisible, isImageType, imageUrl]); }, [isVisible, isItAudio, isItVideo, imageUrl]);
return ( return (
<div className={`flex flex-col ${className}`}> <div className={`flex flex-col ${className}`}>
<div ref={cardRef} className="relative w-full cursor-pointer" style={{ paddingBottom: '100%' }} onClick={onClick}> <div ref={cardRef} className="relative w-full cursor-pointer" style={{ paddingBottom: '100%' }} onClick={onClick}>
<div <div
className={`absolute inset-0 rounded-t-lg shadow-md bg-gray-900 ${ className={`absolute inset-0 rounded-t-lg shadow-md bg-gray-900 ${
isVisible && isImageType && !loaded ? 'animate-pulse' : '' isVisible && !isItAudio && !loaded ? 'animate-pulse' : ''
}`} }`}
> >
{isVisible ? ( {isVisible ? (
@@ -120,7 +134,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
}} }}
/> />
</div> </div>
) : isItVideo ? ( ) : isItVideo && videoFallback ? (
<video <video
ref={videoRef} ref={videoRef}
src={`/api/img/${encodeURIComponent(imageUrl)}`} src={`/api/img/${encodeURIComponent(imageUrl)}`}