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-client": "^0.6.0",
|
||||||
"@fal-ai/serverless-proxy": "^0.6.0",
|
"@fal-ai/serverless-proxy": "^0.6.0",
|
||||||
"@tldraw/tldraw": "3.1.0",
|
"@tldraw/tldraw": "3.1.0",
|
||||||
"next": "14.0.3",
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,13 @@ body {
|
||||||
a {
|
a {
|
||||||
color: black
|
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()
|
const editor = useEditor()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
editor.sideEffects.registerAfterChangeHandler('shape', () => {
|
// Only emit update-drawings when shapes that could affect a live-image frame change.
|
||||||
editor.emit('update-drawings' as any)
|
// Skip changes to the live-image shape itself (handled by prompt change detection in useLiveImage).
|
||||||
})
|
function shouldEmitForShape(shape: { type: string; id: string }) {
|
||||||
editor.sideEffects.registerAfterCreateHandler('shape', () => {
|
if (shape.type === 'live-image') {
|
||||||
editor.emit('update-drawings' as any)
|
// Do emit for prompt/name changes on the live-image frame
|
||||||
})
|
return true
|
||||||
editor.sideEffects.registerAfterDeleteHandler('shape', () => {
|
}
|
||||||
editor.emit('update-drawings' as any)
|
// 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 removers = [
|
||||||
const timer = setTimeout(() => {
|
editor.sideEffects.registerAfterChangeHandler('shape', (_prev, next) => {
|
||||||
editor.emit('update-drawings' as any)
|
if (shouldEmitForShape(next)) {
|
||||||
}, 500)
|
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])
|
}, [editor])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|
@ -187,6 +210,7 @@ const LiveImageAsset = track(function LiveImageAsset({ shape }: { shape: LiveIma
|
||||||
transform,
|
transform,
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
opacity: shape.opacity,
|
opacity: shape.opacity,
|
||||||
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from '@tldraw/tldraw'
|
} from '@tldraw/tldraw'
|
||||||
|
|
||||||
import { useLiveImage } from '@/hooks/useLiveImage'
|
import { useLiveImage } from '@/hooks/useLiveImage'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { FrameHeading } from './FrameHeading'
|
import { FrameHeading } from './FrameHeading'
|
||||||
|
|
||||||
// See https://www.fal.ai/models/latent-consistency-sd
|
// See https://www.fal.ai/models/latent-consistency-sd
|
||||||
|
|
@ -151,9 +152,22 @@ export class LiveImageShapeUtil extends ShapeUtil<LiveImageShape> {
|
||||||
|
|
||||||
override component(shape: LiveImageShape) {
|
override component(shape: LiveImageShape) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
useLiveImage(shape.id)
|
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 bounds = this.editor.getShapeGeometry(shape).bounds
|
||||||
const assetId = AssetRecordType.createId(shape.id.split(':')[1])
|
const assetId = AssetRecordType.createId(shape.id.split(':')[1])
|
||||||
const asset = editor.getAsset(assetId)
|
const asset = editor.getAsset(assetId)
|
||||||
|
|
@ -188,6 +202,23 @@ export class LiveImageShapeUtil extends ShapeUtil<LiveImageShape> {
|
||||||
left: shape.props.w,
|
left: shape.props.w,
|
||||||
width: shape.props.w,
|
width: shape.props.w,
|
||||||
height: shape.props.h,
|
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,
|
children,
|
||||||
appId,
|
appId,
|
||||||
throttleTime = 0,
|
throttleTime = 0,
|
||||||
timeoutTime = 3000,
|
timeoutTime = 5000,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
appId: string
|
appId: string
|
||||||
|
|
@ -114,7 +114,7 @@ export function LiveImageProvider({
|
||||||
|
|
||||||
export function useLiveImage(
|
export function useLiveImage(
|
||||||
shapeId: TLShapeId,
|
shapeId: TLShapeId,
|
||||||
{ throttleTime = 32 }: { throttleTime?: number } = {}
|
{ throttleTime = 32, activeThrottleTime = 150 }: { throttleTime?: number; activeThrottleTime?: number } = {}
|
||||||
) {
|
) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
const fetchImage = useContext(LiveImageContext)
|
const fetchImage = useContext(LiveImageContext)
|
||||||
|
|
@ -129,6 +129,16 @@ export function useLiveImage(
|
||||||
|
|
||||||
let startedIteration = 0
|
let startedIteration = 0
|
||||||
let finishedIteration = 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() {
|
async function updateDrawing() {
|
||||||
const shapes = getShapesTouching(shapeId, editor)
|
const shapes = getShapesTouching(shapeId, editor)
|
||||||
|
|
@ -144,6 +154,9 @@ export function useLiveImage(
|
||||||
prevHash = hash
|
prevHash = hash
|
||||||
prevPrompt = frame.props.name
|
prevPrompt = frame.props.name
|
||||||
|
|
||||||
|
// Signal generation started
|
||||||
|
editor.emit('generation-state' as any, { shapeId, generating: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const svgStringResult = await editor.getSvgString([...shapes], {
|
const svgStringResult = await editor.getSvgString([...shapes], {
|
||||||
background: true,
|
background: true,
|
||||||
|
|
@ -156,6 +169,7 @@ export function useLiveImage(
|
||||||
if (!svgStringResult) {
|
if (!svgStringResult) {
|
||||||
console.warn('No SVG')
|
console.warn('No SVG')
|
||||||
updateImage(editor, frame.id, null)
|
updateImage(editor, frame.id, null)
|
||||||
|
editor.emit('generation-state' as any, { shapeId, generating: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +190,7 @@ export function useLiveImage(
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
console.warn('No Blob')
|
console.warn('No Blob')
|
||||||
updateImage(editor, frame.id, null)
|
updateImage(editor, frame.id, null)
|
||||||
|
editor.emit('generation-state' as any, { shapeId, generating: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,13 +216,23 @@ export function useLiveImage(
|
||||||
if (iteration <= finishedIteration) return
|
if (iteration <= finishedIteration) return
|
||||||
|
|
||||||
finishedIteration = iteration
|
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)
|
updateImage(editor, frame.id, result.url)
|
||||||
|
editor.emit('generation-state' as any, { shapeId, generating: false })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const isTimeout = e instanceof Error && e.message === 'Timeout'
|
const isTimeout = e instanceof Error && e.message === 'Timeout'
|
||||||
if (!isTimeout) {
|
if (!isTimeout) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editor.emit('generation-state' as any, { shapeId, generating: false })
|
||||||
|
|
||||||
// retry if this was the most recent request:
|
// retry if this was the most recent request:
|
||||||
if (iteration === startedIteration) {
|
if (iteration === startedIteration) {
|
||||||
requestUpdate()
|
requestUpdate()
|
||||||
|
|
@ -218,17 +243,31 @@ export function useLiveImage(
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
function requestUpdate() {
|
function requestUpdate() {
|
||||||
if (timer !== null) return
|
if (timer !== null) return
|
||||||
|
// Use longer throttle while actively drawing to batch strokes
|
||||||
|
const currentThrottle = isPointerDown ? activeThrottleTime : throttleTime
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = null
|
timer = null
|
||||||
updateDrawing()
|
updateDrawing()
|
||||||
}, throttleTime)
|
}, currentThrottle)
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.on('update-drawings' as any, requestUpdate)
|
editor.on('update-drawings' as any, requestUpdate)
|
||||||
return () => {
|
return () => {
|
||||||
editor.off('update-drawings' as any, requestUpdate)
|
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) {
|
function updateImage(editor: Editor, shapeId: TLShapeId, url: string | null) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue