+ {/* Live camera preview — kept mounted so the stream can attach. */}
+
+
+
+
+ {/* Recording timer: a stroke that sweeps around the capture circle. */}
+ {phase === "recording" ? (
+
+
+
+ ) : null}
+
+ {/* Review preview: circle-cropped like the real avatar, shows the
+ selected poster frame as a still, and plays the composed
+ animation on hover — exactly how the avatar behaves in the app.
+ Dragging repositions the active framing target. */}
+
{
+ if (phase !== "review" || disabled || isSaving) {
+ return;
+ }
+ const step = event.shiftKey ? 16 : 4;
+ const moves: Record = {
+ ArrowDown: [0, step],
+ ArrowLeft: [-step, 0],
+ ArrowRight: [step, 0],
+ ArrowUp: [0, -step],
+ };
+ const move = moves[event.key];
+ if (!move) {
+ return;
+ }
+ event.preventDefault();
+ setActiveOffset((previous) => ({
+ x: clampOffset(previous.x + move[0]),
+ y: clampOffset(previous.y + move[1]),
+ }));
+ }}
+ onMouseEnter={() => setIsPreviewPlaying(true)}
+ onMouseLeave={() => setIsPreviewPlaying(false)}
+ onPointerCancel={() => {
+ dragStateRef.current = null;
+ setIsDraggingPerson(false);
+ }}
+ onPointerDown={(event) => {
+ if (phase !== "review" || disabled || isSaving) {
+ return;
+ }
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragStateRef.current = {
+ baseOffsetX: activeOffset.x,
+ baseOffsetY: activeOffset.y,
+ pointerId: event.pointerId,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ };
+ setIsDraggingPerson(true);
+ }}
+ onPointerMove={(event) => {
+ const drag = dragStateRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) {
+ return;
+ }
+ const rect = event.currentTarget.getBoundingClientRect();
+ const toFrame = ANIMATED_AVATAR_SIZE / Math.max(rect.width, 1);
+ setActiveOffset({
+ x: clampOffset(
+ drag.baseOffsetX + (event.clientX - drag.startClientX) * toFrame,
+ ),
+ y: clampOffset(
+ drag.baseOffsetY + (event.clientY - drag.startClientY) * toFrame,
+ ),
+ });
+ }}
+ onPointerUp={() => {
+ dragStateRef.current = null;
+ setIsDraggingPerson(false);
+ }}
+ role="application"
+ tabIndex={phase === "review" ? 0 : -1}
+ >
+
+
+
+ {phase === "idle" && !usePortal ? (
+
+
+
+ ) : phase === "starting" ? (
+
+
+
+
+ Starting camera
+
+
+
+ ) : phase === "processing" ? (
+
+
+
+ ) : null}
+
+ );
+
+ return (
+
+ {usePortal && previewContainer
+ ? createPortal(stageContent, previewContainer)
+ : null}
+
+ {showCaptureCard ? (
+
+ {usePortal ? null : stageContent}
+
+ {inlineCaptureHelpText ? (
+
+ {inlineCaptureHelpText}
+
+ ) : null}
+
+ {reviewWarning ? (
+
+ {reviewWarning}
+
+ ) : null}
+
+ ) : null}
+
+ {!showCaptureCard && reviewWarning ? (
+
+ {reviewWarning}
+
+ ) : null}
+
+ {phase === "review" ? (
+
+ ) : null}
+
+ {phase === "review" && isFramingSection ? (
+
+
{
+ if (activeSection === "shape" && value > 0) {
+ setBackdropColor((current) => current ?? randomBackdropColor());
+ }
+ setActiveScale(value / 100);
+ }}
+ onReset={resetActiveFraming}
+ resetValue={Math.round(activeScaleReset * 100)}
+ resetTestId={`${testIdPrefix}-animated-reset-framing`}
+ testId={`${testIdPrefix}-animated-size`}
+ tipText={activeSection === "person" ? PERSON_SIZE_TIP : null}
+ value={Math.round(activeScale * 100)}
+ />
+ {activeSection === "person" ? (
+
+ ) : null}
+
+ ) : null}
+
+ {phase === "review" && activeSection === "color" ? (
+
+ ) : null}
+
+ {phase === "review" && activeSection === "poster" ? (
+
+ setPosterIndex(clampFrameIndex(index, bitmaps.length))
+ }
+ selectedFrame={posterIndex}
+ testIdPrefix={testIdPrefix}
+ />
+ ) : null}
+
+ {showCameraPicker ? (
+
+
0 && !computerCamera}
+ disabled={disabled || phase === "starting"}
+ iphoneDisabled={
+ cameraDevices.length > 0 && !iphoneCamera && hasCameraLabels
+ }
+ onSelectSource={selectCameraSource}
+ testIdPrefix={testIdPrefix}
+ />
+ {usePortal && inlineCaptureHelpText ? (
+
+ {inlineCaptureHelpText}
+
+ ) : null}
+
+ {phase === "live" ? (
+ void record()}
+ type="button"
+ >
+
+
+ Record {RECORD_SECONDS} seconds
+
+
+ ) : null}
+
+
+ ) : usePortal && inlineCaptureHelpText ? (
+
+ {inlineCaptureHelpText}
+
+ ) : null}
+
+ {phase === "review" && showApplyButton ? (
+ void apply()}
+ type="button"
+ >
+ {isSaving ? (
+
+ ) : (
+ "Use as avatar"
+ )}
+
+ ) : null}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ {
+ setBackdropColor(customColorDraft);
+ setIsCustomPickerOpen(false);
+ }}
+ onHueChange={setCustomHue}
+ onSaturationValueChange={(nextSaturation, nextValue) => {
+ setCustomSaturation(nextSaturation);
+ setCustomValue(nextValue);
+ }}
+ saturation={customSaturation}
+ className="h-[504px]"
+ testIdPrefix={`${testIdPrefix}-animated`}
+ value={customValue}
+ visible={isCustomPickerVisible}
+ />
+
+ );
+}
diff --git a/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx b/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx
new file mode 100644
index 000000000..d7d41bdae
--- /dev/null
+++ b/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx
@@ -0,0 +1,470 @@
+import { Circle, CircleDashed } from "lucide-react";
+import * as React from "react";
+
+import { clampFrameIndex } from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
+import { cn } from "@/shared/lib/cn";
+import { performDefaultHaptic } from "@/shared/lib/haptics";
+import { Spinner } from "@/shared/ui/spinner";
+
+const FILMSTRIP_SELECTOR_SIZE = 48;
+const SLIDER_TICK_STEP = 10;
+
+function percentFromSliderValue(
+ value: number,
+ min: number,
+ max: number,
+): number {
+ if (max === min) {
+ return 0;
+ }
+ return ((value - min) / (max - min)) * 100;
+}
+
+function buildAnchoredSliderTicks(
+ min: number,
+ max: number,
+ resetValue: number,
+): number[] {
+ const ticks = new Set