/**
* ImageGeneration — the place an image will be, while it is being made.
*
* ```tsx
*
*
*
* ```
*
* A generated image arrives seconds after it is asked for, and arrives at a
* size nobody knew in advance. Left to itself that is a screen that reflows
* under the reader's thumb at the moment they were about to tap something —
* so this reserves the box first, at the aspect ratio the image will have, and
* fills it with something to watch.
*
* ## The dot field
*
* A grid of dots with a soft light moving through it, drawn as three layers:
* the field at rest, and two copies of the lit dots fading into one another.
*
* Every picture the loop will ever draw is built when the box is measured, so
* running it hands over a string that already exists. That leaves twenty-four
* pictures to spread across the pass, which on its own is a flipbook — a new
* one every 175ms, however fast the screen refreshes. The two layers are what
* fills the gap: one holds the frame being left and one the frame being
* arrived at, and only their opacities move, which the compositor animates at
* the display's own rate without the drawing being touched.
*
* They are opacities on views rather than on the dots, and that is not a
* detail. A value that changes every frame sitting beside the path string
* pushes the path every frame too, and every push of a path is a re-parse of
* several hundred subpaths — which is the cost this whole arrangement exists
* to avoid. Kept apart, the path is pushed only when it actually changes.
*
* ## What arrives, and when
*
* `status` moves the picture through the work rather than switching it: the
* field fades as the image fades in, and for one step in the middle both are
* visible at once. That overlap is the point — an image that appears the
* instant the field vanishes has been swapped in, and one that surfaces
* through it has been developed.
*
* ## Reduced motion
*
* The light stops, and the field holds wherever the clock was left. A
* placeholder that shows nothing is indistinguishable from a component that
* failed to load, so this is a quieter picture rather than an empty one.
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type LayoutChangeEvent,
type ViewProps,
} from 'react-native';
import Svg, { Path } from 'react-native-svg';
import Animated, {
Easing,
cancelAnimation,
useAnimatedProps,
useAnimatedStyle,
useFrameCallback,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { tv } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from '../../icons';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import {
DOT_REST_OPACITY,
FRAMES,
crossfadeAlphas,
framePhase,
gridPath,
litFrames,
type DotFieldAnimation,
} from './dot-field';
/** How far the generation has got. */
export type ImageGenerationStatus =
| 'queued'
| 'generating'
| 'refining'
| 'complete'
| 'error';
/**
* The moment a held field is frozen at.
*
* Not zero: at zero the light sits dead centre, which reads as a target rather
* than as something passing through.
*/
const STILL_FRAME = 900;
/** How visible the field is at each step of the work. */
const FIELD_OPACITY: Record = {
queued: 1,
generating: 1,
refining: 0.48,
complete: 0,
error: 0,
};
/**
* How visible the image is at each step.
*
* `refining` is the overlap: the field is still there at half strength and the
* image is already coming through it. Both fully on for one step is what makes
* the picture surface rather than get swapped in.
*/
const MEDIA_OPACITY: Record = {
queued: 0,
generating: 0,
refining: 0.62,
complete: 1,
error: 0.28,
};
/**
* How much larger than final the image starts.
*
* Small, and it settles rather than zooms. A generated image is not arriving
* from anywhere; the scale is there to give the last of the fade somewhere to
* go, so it does not simply stop.
*/
const MEDIA_SCALE: Record = {
queued: 1.02,
generating: 1.02,
refining: 1.008,
complete: 1,
error: 1,
};
const STATUS_TEXT: Record = {
queued: 'Waiting to generate',
generating: 'Creating image',
refining: 'Refining details',
complete: 'Image ready',
error: 'Generation failed',
};
const imageGenerationVariants = tv({
slots: {
root: 'w-full',
frame: 'relative w-full overflow-hidden rounded-2xl bg-muted',
media: 'absolute inset-0',
resolution:
'absolute end-2 top-2 rounded-full bg-background/75 px-2 py-0.5 text-[10px] text-muted-foreground',
status: 'mt-3 flex-row items-center gap-2',
statusText: 'text-sm font-medium text-foreground',
prompt: 'mt-0.5 text-xs text-muted-foreground',
retry: 'mt-3 min-h-12 flex-row items-center gap-2 self-start rounded-full px-3 active:bg-accent',
retryLabel: 'text-sm font-medium text-foreground',
},
variants: {
size: {
/** Held to a thumbnail, centred — a result inside a conversation. */
compact: { root: 'max-w-52 self-center' },
/** As wide as it is given. */
fluid: {},
},
error: {
true: { statusText: 'text-destructive' },
},
},
defaultVariants: {
size: 'compact',
},
});
const AnimatedPath = Animated.createAnimatedComponent(Path);
export interface ImageGenerationFieldProps extends Omit {
className?: string;
/** Holds the light still, at one representative frame. */
paused?: boolean;
/**
* How the light moves through the field.
*
* `drift` wanders around the middle; `pulse` is a ring leaving the centre;
* `scan` crosses as a band. All three cost the same.
*/
animation?: DotFieldAnimation;
}
/**
* The dot field on its own, for a placeholder that is not an image.
*
* It fills its parent absolutely rather than sizing to its contents, because
* what it draws has no intrinsic height — laid out normally it measures to
* nothing and draws a single row of dots along the top.
*
* ## What it costs
*
* Two paths: the resting grid, and the lit dots over it. Both are built when
* the box is measured — every frame of the loop at once — so running it is
* handing over a string that already exists, and a page of these costs the
* same as a page of static drawings plus one property assignment each.
*/
function ImageGenerationField({
className,
paused = false,
animation = 'drift',
style,
...props
}: ImageGenerationFieldProps) {
const reducedMotion = useReducedMotion();
const [size, setSize] = useState({ width: 0, height: 0 });
const clock = useSharedValue(STILL_FRAME);
const tint = useCSSVariable('--color-muted-foreground');
const color = typeof tint === 'string' ? tint : '#737373';
const running = !paused && !reducedMotion;
const grid = useMemo(
() => gridPath(size.width, size.height),
[size.width, size.height]
);
const frames = useMemo(
() => litFrames(size.width, size.height, animation),
[animation, size.width, size.height]
);
const frame = useFrameCallback((info) => {
'worklet';
// Accumulated rather than read off the total, so pausing and resuming does
// not jump the light to wherever the clock would have carried it. A dropped
// frame is clamped: a 300ms hitch played back whole is a lurch.
clock.value += Math.min(info.timeSincePreviousFrame ?? 16, 48);
}, false);
const { setActive } = frame;
useEffect(() => {
setActive(running);
return () => setActive(false);
}, [running, setActive]);
/*
* A still field is not an empty one.
*
* Held and reduced-motion fields keep whichever frame the clock is on, and
* the clock starts partway through the loop rather than at zero — so the
* light is somewhere legible rather than dead centre or absent, which is the
* difference between "not animating" and "failed to load".
*/
const slotA = useAnimatedProps(() => ({
d: frames[Math.floor(framePhase(clock.value, animation))] ?? '',
}));
const slotB = useAnimatedProps(() => ({
d: frames[(Math.floor(framePhase(clock.value, animation)) + 1) % FRAMES] ?? '',
}));
// Only these move between one frame and the next, and a view's opacity is a
// layer alpha — so the drawing underneath is composited again rather than
// drawn again.
const fadeOut = useAnimatedStyle(() => ({
opacity: crossfadeAlphas(framePhase(clock.value, animation) % 1)[0],
}));
const fadeIn = useAnimatedStyle(() => ({
opacity: crossfadeAlphas(framePhase(clock.value, animation) % 1)[1],
}));
const onLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setSize((was) => (was.width === width && was.height === height ? was : { width, height }));
};
return (
{grid ? (
<>
{/* The field at rest, under everything and never touched again. */}
{/* The dots the light has reached, drawn larger over their own
resting copies rather than instead of them — so a dot brightens
in place instead of appearing to move.
Two of them: the frame being left and the frame being arrived at.
Each carries its dots at full strength and the pair is brought to
LIT_OPACITY by the views, because what fades has to be the layer
and not the fill — see crossfadeAlphas for why the ramps are not
simply t and 1 - t. */}
>
) : null}
);
}
export interface ImageGenerationProps extends Omit {
className?: string;
/** How far the generation has got. Defaults to `generating`. */
status?: ImageGenerationStatus;
/**
* What the box is, for a screen reader. Defaults to the status text, with
* the prompt after it where there is one.
*/
label?: string;
/** The instruction the image was made from. Shown under the status. */
prompt?: string;
/** Shown in the corner of the frame. Pass an empty string to drop it. */
resolution?: string;
/**
* The box's shape, as width over height. Defaults to `1` — square, which is
* what most models return, and what the frame must be before there is an
* image to measure.
*/
aspectRatio?: number;
/** `compact` caps the width at a thumbnail and centres it; `fluid` fills. */
size?: 'compact' | 'fluid';
/**
* How the light behind the dots moves while there is work outstanding.
* `drift` wanders, `pulse` leaves the centre as a ring, `scan` crosses as a
* band. All three cost the same.
*/
animation?: DotFieldAnimation;
/** Replaces the sentence under the frame. */
statusText?: string;
/** Hides the status line, leaving the frame and the prompt. */
showStatus?: boolean;
/** Shown as a button under an `error`. Without it there is no button. */
onRetry?: () => void;
/** Extra classes for the frame — its radius, ground and aspect. */
frameClassName?: string;
/** Extra classes for the layer the image sits in. */
mediaClassName?: string;
/** Extra classes for the status line. */
statusClassName?: string;
/** The finished image. Anything that fills its parent — an `Image`, a video. */
children?: ReactNode;
}
/** The mark beside the status: a turning square while it works, then an answer. */
function StatusMark({ status }: { status: ImageGenerationStatus }) {
const reducedMotion = useReducedMotion();
const spin = useSharedValue(0);
const working = status !== 'complete' && status !== 'error';
useEffect(() => {
if (!working || reducedMotion) {
cancelAnimation(spin);
spin.value = 0;
return;
}
spin.value = 0;
spin.value = withRepeat(
withTiming(1, { duration: 2400, easing: Easing.inOut(Easing.quad) }),
-1
);
return () => cancelAnimation(spin);
}, [reducedMotion, spin, working]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${spin.value * 360}deg` }],
}));
const tint = useCSSVariable('--color-muted-foreground');
const color = typeof tint === 'string' ? tint : '#737373';
if (status === 'complete') return ;
if (status === 'error') return ;
// Four dots turning as a block: the same shape as the field it stands for,
// small enough to sit on a line of text.
return (
{[1, 0.55, 0.55, 1].map((opacity, index) => (
))}
);
}
function ImageGenerationRoot({
className,
status = 'generating',
label,
prompt,
resolution = '1024 × 1024',
aspectRatio = 1,
size = 'compact',
animation = 'drift',
statusText,
showStatus = true,
onRetry,
frameClassName,
mediaClassName,
statusClassName,
children,
...props
}: ImageGenerationProps) {
const reducedMotion = useReducedMotion();
const working = status === 'queued' || status === 'generating' || status === 'refining';
const slots = imageGenerationVariants({ size, error: status === 'error' });
const sentence = statusText ?? STATUS_TEXT[status];
const progress = useSharedValue(MEDIA_OPACITY[status]);
const fieldFade = useSharedValue(FIELD_OPACITY[status]);
const scale = useSharedValue(MEDIA_SCALE[status]);
useEffect(() => {
const duration = reducedMotion ? 0 : 400;
progress.value = withTiming(MEDIA_OPACITY[status], { duration });
fieldFade.value = withTiming(FIELD_OPACITY[status], { duration });
scale.value = withTiming(MEDIA_SCALE[status], { duration });
}, [fieldFade, progress, reducedMotion, scale, status]);
useEffect(
() => () => {
cancelAnimation(progress);
cancelAnimation(fieldFade);
cancelAnimation(scale);
},
[fieldFade, progress, scale]
);
const mediaStyle = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ scale: scale.value }],
}));
const fieldStyle = useAnimatedStyle(() => ({ opacity: fieldFade.value }));
return (
{children}
{/* Kept mounted at zero rather than unmounted, so the image is not
fading in over a box that is simultaneously being torn down. The
field stops drawing as soon as there is no work left, so an idle
one costs nothing but the views. */}
{resolution ? {resolution} : null}
{showStatus ? (
{sentence}
) : null}
{prompt ? (
{prompt}
) : null}
{status === 'error' && onRetry ? (
Try again
) : null}
);
}
ImageGenerationRoot.displayName = 'ImageGeneration';
ImageGenerationField.displayName = 'ImageGeneration.Field';
export const ImageGeneration = Object.assign(ImageGenerationRoot, {
Field: ImageGenerationField,
});