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:
parent
41d121452f
commit
eed020d198
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -167,4 +167,14 @@ 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -123,12 +123,22 @@ export function useLiveImage(
|
|||
useEffect(() => {
|
||||
// Skip on server-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
let prevHash = ''
|
||||
let prevPrompt = ''
|
||||
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue