Fix AI output rendering lag with preloading, loading indicators, and smarter throttling

- Preload FAL result images before swapping to eliminate blank flash
- Add shimmer loading indicator overlay during AI generation
- Filter side effects to only trigger on shapes touching live-image frames
- Adaptive throttle: 150ms during drawing, 32ms on idle for fast final results
- Remove duplicate initial trigger that caused wasted generation on load
- Increase timeout 3s→5s to reduce spurious retry cycles
- Add CSS transitions for smooth image crossfade

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 14:53:23 -04:00
parent 41d121452f
commit eed020d198
6 changed files with 2798 additions and 1364 deletions

4018
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@
"@fal-ai/serverless-client": "^0.6.0",
"@fal-ai/serverless-proxy": "^0.6.0",
"@tldraw/tldraw": "3.1.0",
"next": "14.0.3",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"uuid": "^9.0.1"

View File

@ -168,3 +168,13 @@ body {
a {
color: black
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Smooth image transitions for AI-generated output */
.tl-overlays img {
transition: opacity 0.15s ease-in-out;
}

View File

@ -125,22 +125,45 @@ function SneakySideEffects() {
const editor = useEditor()
useEffect(() => {
editor.sideEffects.registerAfterChangeHandler('shape', () => {
editor.emit('update-drawings' as any)
})
editor.sideEffects.registerAfterCreateHandler('shape', () => {
editor.emit('update-drawings' as any)
})
editor.sideEffects.registerAfterDeleteHandler('shape', () => {
editor.emit('update-drawings' as any)
})
// Only emit update-drawings when shapes that could affect a live-image frame change.
// Skip changes to the live-image shape itself (handled by prompt change detection in useLiveImage).
function shouldEmitForShape(shape: { type: string; id: string }) {
if (shape.type === 'live-image') {
// Do emit for prompt/name changes on the live-image frame
return true
}
// Check if this shape touches any live-image frame
const liveFrames = editor.getCurrentPageShapes().filter(s => s.type === 'live-image')
for (const frame of liveFrames) {
const frameBounds = editor.getShapePageBounds(frame.id)
const shapeBounds = editor.getShapePageBounds(shape.id as any)
if (frameBounds && shapeBounds && shapeBounds.collides(frameBounds)) {
return true
}
}
return false
}
// Trigger initial generation on mount
const timer = setTimeout(() => {
editor.emit('update-drawings' as any)
}, 500)
const removers = [
editor.sideEffects.registerAfterChangeHandler('shape', (_prev, next) => {
if (shouldEmitForShape(next)) {
editor.emit('update-drawings' as any)
}
}),
editor.sideEffects.registerAfterCreateHandler('shape', (shape) => {
if (shouldEmitForShape(shape)) {
editor.emit('update-drawings' as any)
}
}),
editor.sideEffects.registerAfterDeleteHandler('shape', (shape) => {
// Always emit on delete — deleted shape may have been touching a frame
editor.emit('update-drawings' as any)
}),
]
return () => clearTimeout(timer)
return () => {
removers.forEach(remove => remove())
}
}, [editor])
return null
@ -187,6 +210,7 @@ const LiveImageAsset = track(function LiveImageAsset({ shape }: { shape: LiveIma
transform,
transformOrigin: 'top left',
opacity: shape.opacity,
transition: 'opacity 0.15s ease-in-out',
}}
/>
)

View File

@ -21,6 +21,7 @@ import {
} from '@tldraw/tldraw'
import { useLiveImage } from '@/hooks/useLiveImage'
import { useEffect, useState } from 'react'
import { FrameHeading } from './FrameHeading'
// See https://www.fal.ai/models/latent-consistency-sd
@ -151,9 +152,22 @@ export class LiveImageShapeUtil extends ShapeUtil<LiveImageShape> {
override component(shape: LiveImageShape) {
const editor = useEditor()
const [isGenerating, setIsGenerating] = useState(false)
useLiveImage(shape.id)
useEffect(() => {
function handleGenerationState(event: { shapeId: string; generating: boolean }) {
if (event.shapeId === shape.id) {
setIsGenerating(event.generating)
}
}
editor.on('generation-state' as any, handleGenerationState)
return () => {
editor.off('generation-state' as any, handleGenerationState)
}
}, [editor, shape.id])
const bounds = this.editor.getShapeGeometry(shape).bounds
const assetId = AssetRecordType.createId(shape.id.split(':')[1])
const asset = editor.getAsset(assetId)
@ -188,6 +202,23 @@ export class LiveImageShapeUtil extends ShapeUtil<LiveImageShape> {
left: shape.props.w,
width: shape.props.w,
height: shape.props.h,
transition: 'opacity 0.15s ease-in-out',
}}
/>
)}
{isGenerating && (
<div
style={{
position: 'absolute',
top: 0,
left: shape.props.overlayResult ? 0 : shape.props.w,
width: shape.props.w,
height: shape.props.h,
pointerEvents: 'none',
background: 'linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.15) 50%, transparent 70%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s ease-in-out infinite',
borderRadius: 2,
}}
/>
)}

View File

@ -29,7 +29,7 @@ export function LiveImageProvider({
children,
appId,
throttleTime = 0,
timeoutTime = 3000,
timeoutTime = 5000,
}: {
children: React.ReactNode
appId: string
@ -114,7 +114,7 @@ export function LiveImageProvider({
export function useLiveImage(
shapeId: TLShapeId,
{ throttleTime = 32 }: { throttleTime?: number } = {}
{ throttleTime = 32, activeThrottleTime = 150 }: { throttleTime?: number; activeThrottleTime?: number } = {}
) {
const editor = useEditor()
const fetchImage = useContext(LiveImageContext)
@ -129,6 +129,16 @@ export function useLiveImage(
let startedIteration = 0
let finishedIteration = 0
let isPointerDown = false
function handlePointerDown() { isPointerDown = true }
function handlePointerUp() {
isPointerDown = false
// Trigger a final update with low throttle on pointer up
requestUpdate()
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('pointerup', handlePointerUp)
async function updateDrawing() {
const shapes = getShapesTouching(shapeId, editor)
@ -144,6 +154,9 @@ export function useLiveImage(
prevHash = hash
prevPrompt = frame.props.name
// Signal generation started
editor.emit('generation-state' as any, { shapeId, generating: true })
try {
const svgStringResult = await editor.getSvgString([...shapes], {
background: true,
@ -156,6 +169,7 @@ export function useLiveImage(
if (!svgStringResult) {
console.warn('No SVG')
updateImage(editor, frame.id, null)
editor.emit('generation-state' as any, { shapeId, generating: false })
return
}
@ -176,6 +190,7 @@ export function useLiveImage(
if (!blob) {
console.warn('No Blob')
updateImage(editor, frame.id, null)
editor.emit('generation-state' as any, { shapeId, generating: false })
return
}
@ -201,13 +216,23 @@ export function useLiveImage(
if (iteration <= finishedIteration) return
finishedIteration = iteration
// Preload the image before displaying it
await preloadImage(result.url)
// cancel if stale after preload:
if (iteration < finishedIteration) return
updateImage(editor, frame.id, result.url)
editor.emit('generation-state' as any, { shapeId, generating: false })
} catch (e) {
const isTimeout = e instanceof Error && e.message === 'Timeout'
if (!isTimeout) {
console.error(e)
}
editor.emit('generation-state' as any, { shapeId, generating: false })
// retry if this was the most recent request:
if (iteration === startedIteration) {
requestUpdate()
@ -218,17 +243,31 @@ export function useLiveImage(
let timer: ReturnType<typeof setTimeout> | null = null
function requestUpdate() {
if (timer !== null) return
// Use longer throttle while actively drawing to batch strokes
const currentThrottle = isPointerDown ? activeThrottleTime : throttleTime
timer = setTimeout(() => {
timer = null
updateDrawing()
}, throttleTime)
}, currentThrottle)
}
editor.on('update-drawings' as any, requestUpdate)
return () => {
editor.off('update-drawings' as any, requestUpdate)
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('pointerup', handlePointerUp)
if (timer !== null) clearTimeout(timer)
}
}, [editor, fetchImage, shapeId, throttleTime])
}, [editor, fetchImage, shapeId, throttleTime, activeThrottleTime])
}
function preloadImage(url: string): Promise<void> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => resolve() // resolve anyway, don't block on preload failure
img.src = url
})
}
function updateImage(editor: Editor, shapeId: TLShapeId, url: string | null) {