# Carousel Composer

> Multi-item media post composer — drag in photos and videos, reorder them on a rail, and edit each through a shared editor panel.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/carousel-composer).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/carousel-composer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/carousel-composer-light-e8b9148b251d5f0b.png
- Registry item: https://ui.ilinxa.com/r/carousel-composer.json
- Categories: media
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/carousel-composer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, `lucide-react`
- Registry items: `@ilinxa/media-editor`, `button`, `scroll-area`

## Files

### `src/registry/components/media/carousel-composer/carousel-composer.tsx`

```tsx
"use client";

import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  DndContext,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
  type Announcements,
  type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { AlertCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  DEFAULT_CAROUSEL_LABELS,
  type CarouselComposerHandle,
  type CarouselComposerProps,
} from "./types";
import { useCarouselState } from "./hooks/use-carousel-state";
import { filesToItems } from "./lib/file-intake";
import { clampSources } from "./lib/clamp-sources";
import { aspectToCss, resolveAspect } from "./lib/aspect";
import { MediaDropzone } from "./parts/media-dropzone";
import { PreviewRail } from "./parts/preview-rail";
import { MainPreview } from "./parts/main-preview";
import { EditPanel } from "./parts/edit-panel";

/**
 * carousel-composer — a multi-item media composer (Instagram-feed-post
 * semantics): drag-drop / browse one-or-more mixed photo+video files into an
 * ordered, reorderable rail with a main preview, and edit any item through a
 * single shared `media-editor` instance (loaded serially, never N at once).
 * Composes media-editor without modifying it.
 */
function CarouselComposerImpl(
  props: CarouselComposerProps,
  ref: React.Ref<CarouselComposerHandle>,
) {
  const {
    value,
    defaultValue,
    onChange,
    maxItems = 10,
    maxFileSizeMb = 50,
    accept,
    sources,
    aspect = "auto",
    editorProps,
    labels: labelOverrides,
    className,
    revokeOnUnmount,
    onItemAdd,
    onItemRemove,
    onReorder,
    onSelect,
    onEditOpen,
    onEditApply,
    onEditCancel,
    onValidationError,
    onMaxItemsExceeded,
  } = props;

  const labels = useMemo(
    () => ({ ...DEFAULT_CAROUSEL_LABELS, ...labelOverrides }),
    [labelOverrides],
  );
  const acceptKinds = useMemo(
    () => accept ?? ["image" as const, "video" as const],
    [accept],
  );
  // v0.1: "library" is clamped out — intake is upload-only (drop / browse).
  const uploadEnabled = useMemo(
    () => clampSources(sources).includes("upload"),
    [sources],
  );

  const [ingesting, setIngesting] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);
  const [announce, setAnnounce] = useState("");

  const fillTemplate = (tpl: string) => tpl.replace("{max}", String(maxItems));

  const state = useCarouselState({
    value,
    defaultValue,
    onChange,
    maxItems,
    revokeOnUnmount,
    onItemAdd,
    onItemRemove: (id) => {
      onItemRemove?.(id);
      setAnnounce(labels.remove);
    },
    onReorder: (items) => {
      onReorder?.(items);
      setAnnounce(labels.reorderHint);
    },
    onSelect,
    onEditOpen,
    onEditApply,
    onEditCancel,
    onMaxItemsExceeded: (attempted, max) => {
      onMaxItemsExceeded?.(attempted, max);
      const msg = fillTemplate(labels.maxReached);
      setErrors((prev) => (prev.includes(msg) ? prev : [...prev, msg]));
      setAnnounce(msg);
    },
  });

  // Latest items for stable async reads (file intake + imperative handle).
  const itemsRef = useRef(state.items);
  useEffect(() => {
    itemsRef.current = state.items;
  });

  const resolvedAspect = useMemo(
    () => resolveAspect(state.items, aspect),
    [state.items, aspect],
  );

  const addFiles = async (files: File[] | FileList) => {
    setErrors([]);
    setIngesting(true);
    try {
      const res = await filesToItems(files, {
        accept: acceptKinds,
        maxFileSizeMb,
      });
      res.errors.forEach((err) => onValidationError?.(err));
      if (res.errors.length > 0) {
        setErrors((prev) => [...prev, ...res.errors.map((e) => e.message)]);
      }
      // addItems caps to maxItems synchronously + fires onMaxItemsExceeded.
      if (res.items.length > 0) {
        state.addItems(res.items);
        setAnnounce(
          `${res.items.length} item${res.items.length === 1 ? "" : "s"} added`,
        );
      }
    } finally {
      setIngesting(false);
    }
  };

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const dndAnnouncements: Announcements = useMemo(() => {
    const pos = (id: string | number) =>
      itemsRef.current.findIndex((it) => it.id === id) + 1;
    const total = () => itemsRef.current.length;
    return {
      onDragStart: ({ active }) => `Picked up item ${pos(active.id)}.`,
      onDragOver: ({ active, over }) =>
        over ? `Item ${pos(active.id)} over position ${pos(over.id)}.` : "",
      onDragEnd: ({ active, over }) =>
        over
          ? `Item dropped at position ${pos(over.id)} of ${total()}.`
          : `Item ${pos(active.id)} dropped.`,
      onDragCancel: ({ active }) =>
        `Reorder cancelled; item ${pos(active.id)} returned.`,
    };
  }, []);

  const onDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    const items = itemsRef.current;
    const oldIndex = items.findIndex((it) => it.id === active.id);
    const newIndex = items.findIndex((it) => it.id === over.id);
    if (oldIndex < 0 || newIndex < 0) return;
    state.reorder(arrayMove(items, oldIndex, newIndex));
  };

  useImperativeHandle(
    ref,
    (): CarouselComposerHandle => ({
      getItems: () => itemsRef.current.map((it) => ({ ...it })),
      export: async () => itemsRef.current.map((it) => ({ ...it })),
      addFiles,
      removeItem: state.removeItem,
      select: state.select,
      openEditor: state.openEditor,
      reset: state.reset,
    }),
    // addFiles is recreated each render (intentional); the rest are stable.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [state.removeItem, state.select, state.openEditor, state.reset],
  );

  const isEmpty = state.items.length === 0;
  const isEditing = state.editingId !== null && state.editingItem !== null;
  const canAddMore = uploadEnabled && state.items.length < maxItems;

  return (
    <div className={cn("flex flex-col gap-3", className)}>
      {isEmpty ? (
        uploadEnabled ? (
          <MediaDropzone
            variant="empty"
            accept={acceptKinds}
            maxItems={maxItems}
            busy={ingesting}
            labels={labels}
            onFiles={addFiles}
          />
        ) : null
      ) : isEditing ? (
        <EditPanel
          key={state.editingItem!.id}
          item={state.editingItem!}
          aspect={resolvedAspect}
          editorProps={editorProps}
          labels={labels}
          onApply={state.applyEdit}
          onCancel={state.cancelEdit}
        />
      ) : (
        <MainPreview
          item={state.selectedItem}
          aspectCss={aspectToCss(resolvedAspect)}
          canEdit={state.selectedItem?.kind === "image"}
          labels={labels}
          onEdit={() =>
            state.selectedItem && state.openEditor(state.selectedItem.id)
          }
        />
      )}

      {errors.length > 0 ? (
        <div
          role="alert"
          className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-foreground"
        >
          <AlertCircle
            className="mt-0.5 size-4 shrink-0 text-destructive"
            aria-hidden
          />
          <ul className="flex-1 space-y-0.5">
            {errors.map((msg, i) => (
              <li key={i}>{msg}</li>
            ))}
          </ul>
          <button
            type="button"
            aria-label="Dismiss"
            onClick={() => setErrors([])}
            className="shrink-0 rounded p-0.5 text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <X className="size-4" aria-hidden />
          </button>
        </div>
      ) : null}

      {!isEmpty ? (
        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          accessibility={{ announcements: dndAnnouncements }}
          onDragEnd={onDragEnd}
        >
          <PreviewRail
            items={state.items}
            selectedId={state.selectedId}
            disabled={isEditing}
            canAddMore={canAddMore}
            busy={ingesting}
            accept={acceptKinds}
            maxItems={maxItems}
            labels={labels}
            onSelect={state.select}
            onRemove={state.removeItem}
            onFiles={addFiles}
          />
        </DndContext>
      ) : null}

      <output aria-live="polite" className="sr-only">
        {announce}
      </output>
    </div>
  );
}

export const CarouselComposer = forwardRef(CarouselComposerImpl);
CarouselComposer.displayName = "CarouselComposer";

// ─── Tail type re-exports (cross-procomp consumers) ──────────────────────────
// Procomps that compose carousel-composer must import these types from
// THIS component-file path, not `./types`: the shadcn path rewriter resolves a
// barrel/directory import to this `.tsx` file but mangles `/types` subpaths
// (F-01). Mirrors media-editor's tail band.
export type {
  MediaCarouselItem,
  MediaKind,
  MediaCarouselSource,
  MediaCarouselError,
  CarouselComposerProps,
  CarouselComposerHandle,
  CarouselComposerLabels,
} from "./types";
```

### `src/registry/components/media/carousel-composer/index.ts`

```ts
export { CarouselComposer } from "./carousel-composer";

export type {
  MediaCarouselItem,
  MediaKind,
  MediaCarouselSource,
  MediaCarouselError,
  CarouselComposerProps,
  CarouselComposerHandle,
  CarouselComposerLabels,
} from "./types";
export { DEFAULT_CAROUSEL_LABELS } from "./types";

// Headless state primitive — for consumers that recompose the surface from the
// parts below and need the model (items / selection / editing / URL lifecycle).
export { useCarouselState } from "./hooks/use-carousel-state";
export type {
  UseCarouselStateOptions,
  UseCarouselStateResult,
  CarouselStateCallbacks,
  ApplyEditPatch,
} from "./hooks/use-carousel-state";

// Public parts — for sealed-folder consumers that want to recompose the surface.
export { MediaDropzone } from "./parts/media-dropzone";
export type { MediaDropzoneProps } from "./parts/media-dropzone";
export { PreviewRail } from "./parts/preview-rail";
export type { PreviewRailProps } from "./parts/preview-rail";
export { RailThumb } from "./parts/rail-thumb";
export type { RailThumbProps } from "./parts/rail-thumb";
export { MainPreview } from "./parts/main-preview";
export type { MainPreviewProps } from "./parts/main-preview";
export { EditPanel } from "./parts/edit-panel";
export type { EditPanelProps } from "./parts/edit-panel";
```

### `src/registry/components/media/carousel-composer/types.ts`

```ts
import type {
  AspectRatio,
  ExportMetadata,
  InitialSource,
  MediaEditorHandle,
  MediaEditorProps,
  MediaEditorState,
} from "@/registry/components/media/media-editor/media-editor";
// ↑ The SOLE cross-procomp module surface (F-01). Types come from the .tsx
//   entry's tail re-export band — NOT a `/types` subpath (the rewriter mangles
//   that). `ValidationError` is NOT in that band, so the carousel defines its
//   own `MediaCarouselError` below and media-editor stays untouched.

// Re-export the media-editor types this procomp surfaces, so sibling files
// pull them from the local types barrel and the external module path stays in
// as few files as possible.
export type {
  AspectRatio,
  ExportMetadata,
  InitialSource,
  MediaEditorHandle,
  MediaEditorState,
};

export type MediaKind = "image" | "video";
export type MediaCarouselSource = "upload" | "library"; // "library" clamped in v0.1

/**
 * Own error type — media-editor's `ValidationError` is not re-exported from
 * its `.tsx` entry, and we keep media-editor untouched. Structurally aligned.
 */
export interface MediaCarouselError {
  // The cap has its own `onMaxItemsExceeded` channel — this type is per-file
  // validation only.
  kind: "unsupported-type" | "file-too-large";
  message: string;
  file?: File;
}

/**
 * One item in the carousel. `url` is the CURRENT displayable source — an object
 * URL for local/edited media, or a remote URL for re-edit seeds. After an
 * edit-apply, `url`/`blob` reflect the flattened export and `editorState`
 * retains the editable layers so a re-open can `loadState` them (photo path).
 */
export interface MediaCarouselItem {
  id: string;
  kind: MediaKind;
  url: string;
  /** Present for local/edited media; absent for a remote-only re-edit seed. */
  blob?: Blob;
  /** Present once edited (photo path; `videoBlob` is always nulled). */
  editorState?: MediaEditorState;
  /**
   * Internal — the blob backing `editorState.imageSrc`, persisted at
   * edit-apply. `editorState.imageSrc` is an object URL that dies with the
   * edit panel's editor instance; on re-edit the panel passes this blob to
   * `loadState` so a fresh URL is minted (otherwise: black canvas). Not
   * serializable; lives only in in-memory item arrays.
   */
  sourceBlob?: Blob;
  /** From the last `export()` of this item. */
  exportMeta?: ExportMetadata;
  width?: number;
  height?: number;
  fileName?: string;
}

export interface CarouselComposerLabels {
  dropzoneTitle?: string;
  dropzoneBrowse?: string;
  dropzoneHint?: string;
  addMore?: string;
  edit?: string;
  editDone?: string;
  editCancel?: string;
  editSaving?: string;
  editError?: string;
  remove?: string;
  reorderHint?: string;
  maxReached?: string;
  videoNotEditable?: string;
  finishEditingHint?: string;
  /** Template with `{n}` / `{total}` / `{kind}`. */
  itemAria?: string;
}

export const DEFAULT_CAROUSEL_LABELS: Required<CarouselComposerLabels> = {
  dropzoneTitle: "Drag photos & videos here",
  dropzoneBrowse: "Browse",
  dropzoneHint: "or drop up to {max} files",
  addMore: "Add more",
  edit: "Edit",
  editDone: "Done",
  editCancel: "Cancel",
  editSaving: "Saving…",
  editError: "Couldn't save your edit. Try again.",
  remove: "Remove",
  reorderHint: "Drag to reorder",
  maxReached: "Maximum {max} items reached",
  videoNotEditable: "Video editing arrives in v0.2",
  finishEditingHint: "Finish editing to reorder or add media",
  itemAria: "Media item {n} of {total}, {kind}",
};

export interface CarouselComposerProps {
  // value
  value?: MediaCarouselItem[];
  defaultValue?: MediaCarouselItem[];
  onChange?: (items: MediaCarouselItem[]) => void;

  // capability dials
  maxItems?: number; // default 10
  maxFileSizeMb?: number; // default 50 (mirrors media-editor)
  accept?: MediaKind[]; // default ["image","video"]
  sources?: MediaCarouselSource[]; // default ["upload"]; "library" clamped/no-op
  aspect?: AspectRatio | "auto"; // default "auto" → derive from item 1 dims

  // forwarded subset of media-editor dials (edit panel). Crop aspect is OWNED
  // by `aspect` here (shared-aspect guarantee) — deliberately not in this Pick.
  editorProps?: Pick<
    MediaEditorProps,
    "enabledTools" | "stickers" | "fonts" | "colorPresets" | "filterPresets" | "labels"
  >;

  labels?: Partial<CarouselComposerLabels>;
  className?: string;
  /**
   * Revoke object URLs this component created when it unmounts. Default true.
   * Set false ONLY when a host preserves the live items across remounts and
   * takes over final cleanup (e.g. content-composer's carousel cache).
   */
  revokeOnUnmount?: boolean;

  // events
  onItemAdd?: (item: MediaCarouselItem) => void;
  onItemRemove?: (id: string) => void;
  onReorder?: (items: MediaCarouselItem[]) => void;
  onSelect?: (id: string | null) => void;
  onEditOpen?: (id: string) => void;
  onEditApply?: (item: MediaCarouselItem) => void;
  onEditCancel?: (id: string) => void;
  onValidationError?: (error: MediaCarouselError) => void;
  onMaxItemsExceeded?: (attempted: number, max: number) => void;
}

export interface CarouselComposerHandle {
  getItems: () => MediaCarouselItem[];
  /**
   * Pull-only: resolves a defensive copy of the COMMITTED ordered items (already
   * flattened on edit-apply). An open-but-unapplied edit is NOT included — the
   * host should gate publish while editing.
   */
  export: () => Promise<MediaCarouselItem[]>;
  addFiles: (files: File[] | FileList) => void;
  removeItem: (id: string) => void;
  select: (id: string | null) => void;
  openEditor: (id: string) => void;
  /** Revoke all owned object URLs; clears items. Also auto-runs on unmount. */
  reset: () => void;
}
```

### `src/registry/components/media/carousel-composer/hooks/use-carousel-state.ts`

```ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import type { ExportMetadata, MediaCarouselItem, MediaEditorState } from "../types";
import { useControllableState } from "./use-controllable-state";

export interface CarouselStateCallbacks {
  onItemAdd?: (item: MediaCarouselItem) => void;
  onItemRemove?: (id: string) => void;
  onReorder?: (items: MediaCarouselItem[]) => void;
  onSelect?: (id: string | null) => void;
  onEditOpen?: (id: string) => void;
  onEditApply?: (item: MediaCarouselItem) => void;
  onEditCancel?: (id: string) => void;
  onMaxItemsExceeded?: (attempted: number, max: number) => void;
}

export interface UseCarouselStateOptions extends CarouselStateCallbacks {
  value?: MediaCarouselItem[];
  defaultValue?: MediaCarouselItem[];
  onChange?: (items: MediaCarouselItem[]) => void;
  maxItems: number;
  /**
   * Revoke owned object URLs when the component unmounts. Default true. A host
   * that persists the live items across remounts (e.g. content-composer's
   * carousel cache) sets this false and owns the final cleanup itself.
   */
  revokeOnUnmount?: boolean;
}

export interface ApplyEditPatch {
  url: string;
  blob?: Blob;
  editorState?: MediaEditorState;
  /** Blob backing `editorState.imageSrc` — see MediaCarouselItem.sourceBlob. */
  sourceBlob?: Blob;
  exportMeta?: ExportMetadata;
  width?: number;
  height?: number;
}

export interface UseCarouselStateResult {
  items: MediaCarouselItem[];
  selectedId: string | null;
  editingId: string | null;
  selectedItem: MediaCarouselItem | null;
  editingItem: MediaCarouselItem | null;
  addItems: (next: MediaCarouselItem[]) => void;
  removeItem: (id: string) => void;
  reorder: (next: MediaCarouselItem[]) => void;
  select: (id: string | null) => void;
  openEditor: (id: string) => void;
  cancelEdit: () => void;
  applyEdit: (id: string, patch: ApplyEditPatch) => void;
  reset: () => void;
}

/** Only object URLs we created are ours to revoke; remote/consumer URLs aren't. */
function isOwnable(url: string): boolean {
  return url.startsWith("blob:");
}

/**
 * Owns the carousel model: a controllable `items` array (value/defaultValue/
 * onChange) plus internal `selectedId` / `editingId`, and the object-URL
 * lifecycle. `itemsRef` is updated SYNCHRONOUSLY inside every mutator (not via an
 * effect) so async/batched intakes can't read a stale base. An orphan-revoke
 * effect catches URLs dropped by any path — including a wholesale controlled
 * `value` swap (e.g. the host's `loadValue`) that bypasses removeItem.
 */
export function useCarouselState(
  opts: UseCarouselStateOptions,
): UseCarouselStateResult {
  const { maxItems, revokeOnUnmount = true } = opts;

  const [items, setItemsRaw] = useControllableState<MediaCarouselItem[]>({
    value: opts.value,
    defaultValue: opts.defaultValue ?? [],
    onChange: opts.onChange,
    componentName: "CarouselComposer",
    valuePropName: "value",
  });
  const [rawSelectedId, setSelectedId] = useState<string | null>(
    opts.value?.[0]?.id ?? opts.defaultValue?.[0]?.id ?? null,
  );
  const [editingId, setEditingId] = useState<string | null>(null);

  const selectedId =
    rawSelectedId != null && items.some((it) => it.id === rawSelectedId)
      ? rawSelectedId
      : (items[0]?.id ?? null);

  const cbRef = useRef<CarouselStateCallbacks>(opts);
  useEffect(() => {
    cbRef.current = opts;
  });

  // itemsRef tracks committed render state; mutators below ALSO write it
  // synchronously so a second mutation in the same tick reads the latest base.
  const itemsRef = useRef(items);
  const selectedIdRef = useRef(selectedId);
  const editingIdRef = useRef(editingId);
  useEffect(() => {
    itemsRef.current = items;
    selectedIdRef.current = selectedId;
    editingIdRef.current = editingId;
  });

  /** Synchronous-ref write + state set, so chained mutators compose correctly. */
  const setItems = useCallback(
    (next: MediaCarouselItem[]) => {
      itemsRef.current = next;
      setItemsRaw(next);
    },
    [setItemsRaw],
  );

  // Object-URL ownership.
  const ownedUrls = useRef<Set<string>>(new Set());
  const revoke = useCallback((url: string) => {
    if (ownedUrls.current.has(url)) {
      URL.revokeObjectURL(url);
      ownedUrls.current.delete(url);
    }
  }, []);

  // Catch-all: revoke any owned URL no longer present in items — including
  // drops via a controlled `value` swap that never went through removeItem.
  useEffect(() => {
    const present = new Set(items.map((it) => it.url));
    ownedUrls.current.forEach((u) => {
      if (!present.has(u)) {
        URL.revokeObjectURL(u);
        ownedUrls.current.delete(u);
      }
    });
  }, [items]);

  useEffect(() => {
    const owned = ownedUrls.current;
    return () => {
      if (!revokeOnUnmount) return;
      owned.forEach((u) => URL.revokeObjectURL(u));
      owned.clear();
    };
  }, [revokeOnUnmount]);

  const addItems = useCallback(
    (incoming: MediaCarouselItem[]) => {
      if (incoming.length === 0) return;
      // Cap synchronously against the latest items (race-safe).
      const room = Math.max(0, maxItems - itemsRef.current.length);
      const toAdd = incoming.slice(0, room);
      const dropped = incoming.slice(room);
      // Revoke object URLs of items we won't keep (intake created them).
      dropped.forEach((it) => {
        if (isOwnable(it.url)) URL.revokeObjectURL(it.url);
      });
      if (toAdd.length > 0) {
        toAdd.forEach((it) => {
          if (isOwnable(it.url)) ownedUrls.current.add(it.url);
        });
        setItems([...itemsRef.current, ...toAdd]);
        toAdd.forEach((it) => cbRef.current.onItemAdd?.(it));
      }
      if (dropped.length > 0) {
        cbRef.current.onMaxItemsExceeded?.(
          itemsRef.current.length + dropped.length,
          maxItems,
        );
      }
    },
    [maxItems, setItems],
  );

  const removeItem = useCallback(
    (id: string) => {
      const cur = itemsRef.current;
      const idx = cur.findIndex((it) => it.id === id);
      if (idx < 0) return;
      revoke(cur[idx].url);
      const next = cur.filter((it) => it.id !== id);
      setItems(next);
      cbRef.current.onItemRemove?.(id);
      if (selectedIdRef.current === id) {
        const neighbor = next[Math.min(idx, next.length - 1)] ?? null;
        const nid = neighbor?.id ?? null;
        setSelectedId(nid);
        cbRef.current.onSelect?.(nid);
      }
      if (editingIdRef.current === id) {
        setEditingId(null);
        cbRef.current.onEditCancel?.(id);
      }
    },
    [revoke, setItems],
  );

  const reorder = useCallback(
    (next: MediaCarouselItem[]) => {
      setItems(next);
      cbRef.current.onReorder?.(next);
    },
    [setItems],
  );

  const select = useCallback((id: string | null) => {
    setSelectedId(id);
    cbRef.current.onSelect?.(id);
  }, []);

  const openEditor = useCallback((id: string) => {
    setSelectedId(id);
    setEditingId(id);
    cbRef.current.onEditOpen?.(id);
  }, []);

  const cancelEdit = useCallback(() => {
    const id = editingIdRef.current;
    if (id == null) return;
    setEditingId(null);
    cbRef.current.onEditCancel?.(id);
  }, []);

  const applyEdit = useCallback(
    (id: string, patch: ApplyEditPatch) => {
      const cur = itemsRef.current;
      const idx = cur.findIndex((it) => it.id === id);
      if (idx < 0) return;
      const prev = cur[idx];
      if (prev.url !== patch.url) revoke(prev.url);
      if (isOwnable(patch.url)) ownedUrls.current.add(patch.url);
      const updated: MediaCarouselItem = {
        ...prev,
        url: patch.url,
        blob: patch.blob ?? prev.blob,
        editorState: patch.editorState ?? prev.editorState,
        sourceBlob: patch.sourceBlob ?? prev.sourceBlob,
        exportMeta: patch.exportMeta ?? prev.exportMeta,
        width: patch.width ?? prev.width,
        height: patch.height ?? prev.height,
      };
      setItems(cur.map((it, i) => (i === idx ? updated : it)));
      setEditingId(null);
      cbRef.current.onEditApply?.(updated);
    },
    [revoke, setItems],
  );

  const reset = useCallback(() => {
    ownedUrls.current.forEach((u) => URL.revokeObjectURL(u));
    ownedUrls.current.clear();
    setItems([]);
    setSelectedId(null);
    setEditingId(null);
  }, [setItems]);

  const selectedItem = items.find((it) => it.id === selectedId) ?? null;
  const editingItem = items.find((it) => it.id === editingId) ?? null;

  return {
    items,
    selectedId,
    editingId,
    selectedItem,
    editingItem,
    addItems,
    removeItem,
    reorder,
    select,
    openEditor,
    cancelEdit,
    applyEdit,
    reset,
  };
}
```

### `src/registry/components/media/carousel-composer/hooks/use-controllable-state.ts`

```ts
import { useCallback, useEffect, useRef, useState } from "react";

interface UseControllableStateOpts<T> {
  /** Controlled value. When provided, state is fully controlled by parent. */
  value?: T;
  /** Initial value for uncontrolled mode. */
  defaultValue: T;
  /** Fires on every state change (both modes). */
  onChange?: (next: T) => void;
  /** Component name used in dev warnings. */
  componentName: string;
  /** Prop name used in dev warnings (e.g., `"value"`). */
  valuePropName: string;
}

/**
 * Controlled+uncontrolled state-machine helper. Sealed local copy of the
 * library's standard generic (navigation/account-switcher + code-block) —
 * registry components can't share an app-level util beyond `@/lib/utils`, so
 * each procomp seals its own copy.
 *
 *   - Locks the controlled/uncontrolled mode based on the FIRST render's
 *     `value` and dev-warns when consumers flip modes mid-life.
 *   - Dev-warns when controlled mode is used without an `onChange` handler.
 *
 * Both warns tree-shake out of prod bundles.
 *
 * Internal helper — not exported from the procomp's public API.
 */
export function useControllableState<T>({
  value,
  defaultValue,
  onChange,
  componentName,
  valuePropName,
}: UseControllableStateOpts<T>): readonly [T, (next: T) => void] {
  const [internal, setInternal] = useState<T>(defaultValue);
  const isControlled = value !== undefined;
  const onChangeRef = useRef(onChange);
  useEffect(() => {
    onChangeRef.current = onChange;
  });

  const wasControlledRef = useRef(isControlled);
  useEffect(() => {
    if (process.env.NODE_ENV === "production") return;
    if (wasControlledRef.current !== isControlled) {
      console.warn(
        `[${componentName}] \`${valuePropName}\` switched from ${
          wasControlledRef.current ? "controlled" : "uncontrolled"
        } to ${
          isControlled ? "controlled" : "uncontrolled"
        } mode. Components should not switch modes mid-life; pick one at mount.`,
      );
      wasControlledRef.current = isControlled;
    }
  }, [isControlled, componentName, valuePropName]);

  useEffect(() => {
    if (process.env.NODE_ENV === "production") return;
    if (isControlled && !onChangeRef.current) {
      const capitalized =
        valuePropName.charAt(0).toUpperCase() + valuePropName.slice(1);
      console.warn(
        `[${componentName}] \`${valuePropName}\` is controlled but no onChange handler was provided. ` +
          `State will appear frozen. Pass \`on${capitalized}Change\` ` +
          `(or use the uncontrolled variant by passing \`default${capitalized}\` instead).`,
      );
    }
  }, [isControlled, componentName, valuePropName]);

  const current = isControlled ? (value as T) : internal;

  const set = useCallback(
    (next: T) => {
      if (!isControlled) setInternal(next);
      onChangeRef.current?.(next);
    },
    [isControlled],
  );

  return [current, set] as const;
}
```

### `src/registry/components/media/carousel-composer/lib/aspect.ts`

```ts
import type { AspectRatio, MediaCarouselItem } from "../types";

const RATIOS: { aspect: Exclude<AspectRatio, "free">; value: number }[] = [
  { aspect: "9:16", value: 9 / 16 },
  { aspect: "4:5", value: 4 / 5 },
  { aspect: "1:1", value: 1 },
  { aspect: "16:9", value: 16 / 9 },
];

/**
 * Resolve the carousel's shared aspect (Instagram behaviour): an explicit prop
 * wins; `"auto"` derives from item 1's natural ratio (nearest of the four
 * standard ratios); falls back to `"1:1"` until item 1's dimensions are known.
 */
export function resolveAspect(
  items: MediaCarouselItem[],
  prop: AspectRatio | "auto",
): AspectRatio {
  if (prop !== "auto") return prop;
  const first = items[0];
  if (!first?.width || !first?.height) return "1:1";
  const ratio = first.width / first.height;
  let best: Exclude<AspectRatio, "free"> = "1:1";
  let bestDiff = Infinity;
  for (const cand of RATIOS) {
    const diff = Math.abs(cand.value - ratio);
    if (diff < bestDiff) {
      bestDiff = diff;
      best = cand.aspect;
    }
  }
  return best;
}

/**
 * CSS `aspect-ratio` value for a frame. `"free"` falls back to a 1:1 frame so the
 * rail + main preview keep a consistent footprint.
 */
export function aspectToCss(aspect: AspectRatio): string {
  switch (aspect) {
    case "9:16":
      return "9 / 16";
    case "4:5":
      return "4 / 5";
    case "16:9":
      return "16 / 9";
    case "1:1":
    case "free":
    default:
      return "1 / 1";
  }
}
```

### `src/registry/components/media/carousel-composer/lib/clamp-sources.ts`

```ts
import type { MediaCarouselSource } from "../types";

/**
 * `"library"` is declared for forward-compat (pick from existing backend media)
 * but has no implementation in v0.1 — clamp it out so a config that lists it
 * stays valid with no crash and no cosmetic leak (mirrors content-composer's
 * media-source clamp). Always resolves to a non-empty source list.
 */
export function clampSources(
  sources: MediaCarouselSource[] | undefined,
): MediaCarouselSource[] {
  const base = sources && sources.length > 0 ? sources : ["upload"];
  const clamped = base.filter((s) => s === "upload");
  return clamped.length > 0 ? clamped : ["upload"];
}
```

### `src/registry/components/media/carousel-composer/lib/file-intake.ts`

```ts
import type { MediaCarouselError, MediaCarouselItem, MediaKind } from "../types";
import { validateMediaFile } from "./validate-media-file";

// Per-module-load random prefix so the counter fallback can't collide with ids
// reconstructed from a prior draft after a navigation re-evaluates the module.
const ID_PREFIX = `mci-${Math.floor(Math.random() * 1e9).toString(36)}`;
let idCounter = 0;
function nextId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `${ID_PREFIX}-${(idCounter++).toString(36)}`;
}

function readImageDims(url: string): Promise<{ width: number; height: number }> {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () =>
      resolve({ width: img.naturalWidth, height: img.naturalHeight });
    img.onerror = () => resolve({ width: 0, height: 0 });
    img.src = url;
  });
}

function readVideoDims(url: string): Promise<{ width: number; height: number }> {
  return new Promise((resolve) => {
    const video = document.createElement("video");
    video.preload = "metadata";
    video.onloadedmetadata = () =>
      resolve({ width: video.videoWidth, height: video.videoHeight });
    video.onerror = () => resolve({ width: 0, height: 0 });
    video.src = url;
  });
}

export interface FilesToItemsOptions {
  accept: MediaKind[];
  maxFileSizeMb: number;
}

export interface FilesToItemsResult {
  items: MediaCarouselItem[];
  errors: MediaCarouselError[];
}

/**
 * Validate + ingest dropped/picked files into `MediaCarouselItem`s. Creates
 * object URLs and reads natural dimensions (async) so the resolved aspect is
 * correct on first paint. The `maxItems` cap is enforced downstream in the
 * state hook (`addItems`), synchronously against the latest items — so two rapid
 * drops can't each compute room against a stale count.
 */
export async function filesToItems(
  fileList: File[] | FileList,
  opts: FilesToItemsOptions,
): Promise<FilesToItemsResult> {
  const files = Array.from(fileList);
  const errors: MediaCarouselError[] = [];
  const accepted: File[] = [];

  for (const file of files) {
    const err = validateMediaFile(file, opts.maxFileSizeMb, opts.accept);
    if (err) {
      errors.push(err);
      continue;
    }
    accepted.push(file);
  }

  const items = await Promise.all(
    accepted.map(async (file): Promise<MediaCarouselItem> => {
      const kind: MediaKind = file.type.startsWith("video/")
        ? "video"
        : "image";
      const url = URL.createObjectURL(file);
      const dims =
        kind === "image" ? await readImageDims(url) : await readVideoDims(url);
      return {
        id: nextId(),
        kind,
        url,
        blob: file,
        fileName: file.name,
        width: dims.width || undefined,
        height: dims.height || undefined,
      };
    }),
  );

  return { items, errors };
}
```

### `src/registry/components/media/carousel-composer/lib/validate-media-file.ts`

```ts
import type { MediaCarouselError, MediaKind } from "../types";

/**
 * Local reimplementation of media-editor's `validateGalleryFile` — type +
 * size check, plus an `accept` gate. Kept local (NOT imported) to hold the
 * cross-procomp surface to one module (F-01) and keep media-editor untouched.
 */
export function validateMediaFile(
  file: File,
  maxFileSizeMb: number,
  accept: MediaKind[],
): MediaCarouselError | null {
  const isImage = file.type.startsWith("image/");
  const isVideo = file.type.startsWith("video/");
  const kind: MediaKind | null = isImage ? "image" : isVideo ? "video" : null;

  if (!kind) {
    return {
      kind: "unsupported-type",
      message: `Unsupported file type: ${file.type || "unknown"}`,
      file,
    };
  }
  if (!accept.includes(kind)) {
    return {
      kind: "unsupported-type",
      message: `${kind === "image" ? "Image" : "Video"} files are not accepted here.`,
      file,
    };
  }
  const maxBytes = maxFileSizeMb * 1024 * 1024;
  if (file.size > maxBytes) {
    return {
      kind: "file-too-large",
      message: `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — maximum is ${maxFileSizeMb} MB.`,
      file,
    };
  }
  return null;
}
```

### `src/registry/components/media/carousel-composer/parts/media-dropzone.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import { ImagePlus, Loader2, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import type { MediaKind } from "../types";

export interface MediaDropzoneProps {
  variant: "empty" | "add-more";
  accept: MediaKind[];
  maxItems: number;
  disabled?: boolean;
  /** Ingestion in progress — show a spinner + block input. */
  busy?: boolean;
  labels: {
    dropzoneTitle: string;
    dropzoneBrowse: string;
    dropzoneHint: string;
    addMore: string;
  };
  onFiles: (files: File[] | FileList) => void;
}

function acceptAttr(accept: MediaKind[]): string {
  return accept.map((k) => (k === "image" ? "image/*" : "video/*")).join(",");
}

/**
 * File intake surface. `variant="empty"` is the full-bleed first-run dropzone;
 * `variant="add-more"` is a compact rail-sized tile. Both wrap a hidden
 * `<input type="file" multiple>`, both accept drag-and-drop with hover feedback,
 * and both surface a `busy` spinner during ingestion. Drag-and-drop is an
 * enhancement on top of the always-present Browse button (keyboard path).
 */
export function MediaDropzone({
  variant,
  accept,
  maxItems,
  disabled,
  busy,
  labels,
  onFiles,
}: MediaDropzoneProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = useState(false);
  const blocked = disabled || busy;

  const open = () => inputRef.current?.click();

  const onDragOver = (e: React.DragEvent) => {
    e.preventDefault();
    if (!blocked) setDragging(true);
  };
  const onDrop = (e: React.DragEvent) => {
    e.preventDefault();
    setDragging(false);
    if (blocked) return;
    if (e.dataTransfer.files?.length) onFiles(e.dataTransfer.files);
  };

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Snapshot into an array BEFORE resetting `value`: clearing a file input's
    // value empties its live `FileList` in place, so the captured reference
    // would otherwise reach `onFiles` already empty (silent no-add on browse).
    const files = e.target.files ? Array.from(e.target.files) : [];
    e.target.value = ""; // allow re-picking the same file
    if (files.length) onFiles(files);
  };

  const input = (
    <input
      ref={inputRef}
      type="file"
      multiple
      accept={acceptAttr(accept)}
      onChange={onChange}
      className="sr-only"
      tabIndex={-1}
      aria-hidden
    />
  );

  if (variant === "add-more") {
    return (
      <button
        type="button"
        onClick={open}
        onDragOver={onDragOver}
        onDragLeave={() => setDragging(false)}
        onDrop={onDrop}
        disabled={blocked}
        aria-label={labels.addMore}
        aria-busy={busy || undefined}
        className={cn(
          "grid size-16 shrink-0 place-items-center rounded-md border border-dashed bg-muted/40 text-muted-foreground transition hover:border-ring hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          dragging ? "border-ring bg-accent/40 text-foreground" : "border-border",
        )}
      >
        {busy ? (
          <Loader2 className="size-5 animate-spin" aria-hidden />
        ) : (
          <Plus className="size-5" aria-hidden />
        )}
        {input}
      </button>
    );
  }

  return (
    <div
      onDragOver={onDragOver}
      onDragLeave={() => setDragging(false)}
      onDrop={onDrop}
      aria-label={labels.dropzoneTitle}
      aria-busy={busy || undefined}
      className={cn(
        "flex w-full flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 text-center transition",
        dragging ? "border-ring bg-accent/40" : "border-border bg-muted/30",
      )}
    >
      {busy ? (
        <Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
      ) : (
        <ImagePlus className="size-8 text-muted-foreground" aria-hidden />
      )}
      <div className="flex flex-col gap-1">
        <p className="text-sm font-medium text-foreground">
          {busy ? "Adding media…" : labels.dropzoneTitle}
        </p>
        <p className="text-xs text-muted-foreground">
          {labels.dropzoneHint.replace("{max}", String(maxItems))}
        </p>
      </div>
      <Button type="button" size="sm" onClick={open} disabled={blocked}>
        {labels.dropzoneBrowse}
      </Button>
      {input}
    </div>
  );
}
```

### `src/registry/components/media/carousel-composer/parts/preview-rail.tsx`

```tsx
"use client";

import {
  SortableContext,
  horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import type { MediaCarouselItem, MediaKind } from "../types";
import { MediaDropzone } from "./media-dropzone";
import { RailThumb } from "./rail-thumb";

export interface PreviewRailProps {
  items: MediaCarouselItem[];
  selectedId: string | null;
  /** Editing in progress → the whole rail is read-only. */
  disabled: boolean;
  canAddMore: boolean;
  /** Ingestion in progress — spinner on the add-more tile. */
  busy?: boolean;
  accept: MediaKind[];
  maxItems: number;
  labels: {
    remove: string;
    reorderHint: string;
    itemAria: string;
    dropzoneTitle: string;
    dropzoneBrowse: string;
    dropzoneHint: string;
    addMore: string;
    finishEditingHint: string;
  };
  onSelect: (id: string) => void;
  onRemove: (id: string) => void;
  onFiles: (files: File[] | FileList) => void;
}

/**
 * Horizontal thumbnail strip. `SortableContext` (horizontal strategy) drives
 * reorder; the wrapping `DndContext` lives in the root component. A trailing
 * compact dropzone tile adds more items when under `maxItems` and not editing.
 */
export function PreviewRail({
  items,
  selectedId,
  disabled,
  canAddMore,
  busy,
  accept,
  maxItems,
  labels,
  onSelect,
  onRemove,
  onFiles,
}: PreviewRailProps) {
  return (
    <ScrollArea className="w-full">
      {disabled ? (
        <p className="px-1 pb-1 text-xs text-muted-foreground">
          {labels.finishEditingHint}
        </p>
      ) : null}
      <div
        className={cn(
          "flex items-center gap-2 p-1 transition-opacity",
          disabled && "pointer-events-none opacity-60",
        )}
      >
        <SortableContext
          items={items.map((it) => it.id)}
          strategy={horizontalListSortingStrategy}
        >
          {items.map((item, i) => (
            <RailThumb
              key={item.id}
              item={item}
              index={i}
              total={items.length}
              selected={item.id === selectedId}
              disabled={disabled}
              labels={labels}
              onSelect={onSelect}
              onRemove={onRemove}
            />
          ))}
        </SortableContext>
        {canAddMore && !disabled ? (
          <MediaDropzone
            variant="add-more"
            accept={accept}
            maxItems={maxItems}
            busy={busy}
            labels={labels}
            onFiles={onFiles}
          />
        ) : null}
      </div>
      <ScrollBar orientation="horizontal" />
    </ScrollArea>
  );
}
```

### `src/registry/components/media/carousel-composer/parts/rail-thumb.tsx`

```tsx
"use client";

import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Film, GripVertical, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { MediaCarouselItem } from "../types";

export interface RailThumbProps {
  item: MediaCarouselItem;
  index: number;
  total: number;
  selected: boolean;
  /** Editing in progress → rail is read-only (no drag, remove, or re-select). */
  disabled: boolean;
  labels: { remove: string; reorderHint: string; itemAria: string };
  onSelect: (id: string) => void;
  onRemove: (id: string) => void;
}

/**
 * A single rail thumbnail. The body is a native `<button>` for select (clean
 * Enter/Space keyboard activation). The drag `listeners` live on a SEPARATE
 * handle button so the `@dnd-kit` keyboard sensor never fights the select
 * button's native activation. Remove is a third button with `stopPropagation`.
 */
export function RailThumb({
  item,
  index,
  total,
  selected,
  disabled,
  labels,
  onSelect,
  onRemove,
}: RailThumbProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id: item.id, disabled });

  const style: React.CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  const aria = labels.itemAria
    .replace("{n}", String(index + 1))
    .replace("{total}", String(total))
    .replace("{kind}", item.kind);

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={cn("group relative shrink-0", isDragging && "z-10 opacity-60")}
    >
      <button
        type="button"
        aria-label={aria}
        aria-current={selected ? "true" : undefined}
        onClick={() => !disabled && onSelect(item.id)}
        disabled={disabled}
        className={cn(
          "relative block size-16 overflow-hidden rounded-md border bg-muted outline-none transition focus-visible:ring-2 focus-visible:ring-ring",
          selected ? "border-ring ring-2 ring-ring" : "border-border",
        )}
      >
        {item.kind === "video" ? (
          <>
            <video
              src={item.url}
              muted
              playsInline
              preload="metadata"
              onLoadedMetadata={(e) => {
                // Nudge to a frame so the thumb isn't a black box (Safari/Chrome
                // won't paint frame 0 of an unplayed inline video otherwise).
                try {
                  e.currentTarget.currentTime = 0.1;
                } catch {
                  /* seeking unsupported — leave as-is */
                }
              }}
              className="size-full object-cover"
            />
            <span className="absolute bottom-0.5 right-0.5 grid place-items-center rounded-sm bg-black/60 p-0.5 text-white">
              <Film className="size-3" aria-hidden />
            </span>
          </>
        ) : (
          <img
            src={item.url}
            alt={item.fileName ?? ""}
            className="size-full object-cover"
          />
        )}
      </button>

      {!disabled ? (
        <>
          <button
            type="button"
            aria-label={labels.reorderHint}
            {...attributes}
            {...listeners}
            className="absolute bottom-0.5 left-0.5 grid size-6 cursor-grab touch-none place-items-center rounded bg-black/55 text-white opacity-90 transition hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
          >
            <GripVertical className="size-4" aria-hidden />
          </button>
          <button
            type="button"
            aria-label={labels.remove}
            onClick={(e) => {
              e.stopPropagation();
              onRemove(item.id);
            }}
            className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full border border-border bg-background text-foreground shadow-sm transition hover:bg-destructive hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <X className="size-3.5" aria-hidden />
          </button>
        </>
      ) : null}
    </div>
  );
}
```

### `src/registry/components/media/carousel-composer/parts/main-preview.tsx`

```tsx
"use client";

import { ImageIcon, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { MediaCarouselItem } from "../types";

export interface MainPreviewProps {
  item: MediaCarouselItem | null;
  /** CSS `aspect-ratio` value for the frame (the shared carousel aspect). */
  aspectCss: string;
  /** False for video items in v0.1 (edit deferred). */
  canEdit: boolean;
  labels: { edit: string; videoNotEditable: string };
  onEdit: () => void;
}

/**
 * The large preview of the currently-selected item, with an Edit affordance.
 * Images fill the shared-aspect frame (`object-cover`); videos keep their own
 * aspect on a black mat (`object-contain`). Video isn't editable in v0.1 — instead
 * of a disabled (unfocusable, unreadable) button we show a static caption that
 * IS in the reading order, so keyboard + screen-reader users get the reason.
 */
export function MainPreview({
  item,
  aspectCss,
  canEdit,
  labels,
  onEdit,
}: MainPreviewProps) {
  if (!item) {
    return (
      <div
        className="grid w-full place-items-center rounded-lg border border-dashed border-border bg-muted/30 text-muted-foreground"
        style={{ aspectRatio: aspectCss }}
      >
        <ImageIcon className="size-8" aria-hidden />
      </div>
    );
  }

  return (
    <div
      className="relative w-full overflow-hidden rounded-lg border border-border bg-muted"
      style={{ aspectRatio: aspectCss }}
    >
      {item.kind === "video" ? (
        <video
          src={item.url}
          controls
          playsInline
          preload="metadata"
          onLoadedMetadata={(e) => {
            try {
              e.currentTarget.currentTime = 0.1;
            } catch {
              /* seeking unsupported — leave as-is */
            }
          }}
          className="size-full bg-black object-contain"
        />
      ) : (
        <img
          src={item.url}
          alt={item.fileName ?? ""}
          className="size-full object-cover"
        />
      )}

      <div className="absolute right-3 top-3">
        {canEdit ? (
          <Button type="button" size="sm" onClick={onEdit}>
            <Pencil className="size-4" aria-hidden />
            {labels.edit}
          </Button>
        ) : (
          <span className="rounded-full bg-black/70 px-2.5 py-1 text-xs font-medium text-white">
            {labels.videoNotEditable}
          </span>
        )}
      </div>
    </div>
  );
}
```

### `src/registry/components/media/carousel-composer/parts/edit-panel.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MediaEditor } from "@/registry/components/media/media-editor/media-editor";
import type {
  AspectRatio,
  InitialSource,
  CarouselComposerProps,
  MediaCarouselItem,
  MediaEditorHandle,
} from "../types";
import type { ApplyEditPatch } from "../hooks/use-carousel-state";
import { aspectToCss } from "../lib/aspect";

export interface EditPanelProps {
  item: MediaCarouselItem;
  aspect: AspectRatio;
  editorProps?: CarouselComposerProps["editorProps"];
  labels: {
    editDone: string;
    editCancel: string;
    editSaving: string;
    editError: string;
  };
  onApply: (id: string, patch: ApplyEditPatch) => void;
  onCancel: () => void;
}

function initialSourceFor(item: MediaCarouselItem): InitialSource {
  const mode = item.kind === "video" ? "video" : "photo";
  return item.blob
    ? { kind: "blob", blob: item.blob, mode }
    : { kind: "url", url: item.url, mode };
}

/**
 * The single, shared edit panel. Mounts ONE `media-editor` in edit-only mode
 * (`enabledModes={[]}` → no capture chrome / no photo-video tabs) for the
 * selected item, keyed by `item.id` so switching items remounts with a fresh
 * source.
 *
 * Re-edit contract (mirrors content-composer's media-substrate): when the item
 * already has `editorState`, we DON'T pass `initialSource` — `loadState` supplies
 * both the original image (`editorState.imageSrc`) AND the editable overlays.
 * Passing `initialSource` *as well* would composite the flattened export under
 * the re-applied overlays (double overlays). `initialSource` is only for the
 * first edit, before any `editorState` exists.
 */
export function EditPanel({
  item,
  aspect,
  editorProps,
  labels,
  onApply,
  onCancel,
}: EditPanelProps) {
  const editorRef = useRef<MediaEditorHandle | null>(null);
  const [busy, setBusy] = useState(false);
  const [failed, setFailed] = useState(false);

  const seedSource = item.editorState ? undefined : initialSourceFor(item);

  // Restore prior editable layers for a re-edit (mount-only — `item` is fixed
  // for this instance because the parent keys it by `item.id`). The snapshot's
  // imageSrc is a DEAD object URL (revoked when the previous edit's editor
  // unmounted) — pass the persisted source blob so loadState re-mints a live
  // one instead of opening on a black canvas (review 1.3).
  useEffect(() => {
    if (item.editorState && editorRef.current) {
      editorRef.current.loadState(item.editorState, {
        sourceBlob: item.sourceBlob ?? null,
      });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const handleDone = async () => {
    const editor = editorRef.current;
    if (!editor) return;
    setBusy(true);
    setFailed(false);
    try {
      const { blob, metadata } = await editor.export();
      const url = URL.createObjectURL(blob);
      onApply(item.id, {
        url,
        blob,
        editorState: { ...editor.getState(), videoBlob: null },
        // The blob backing editorState.imageSrc — persisted so the NEXT
        // re-edit can re-materialize after this editor's URLs are revoked.
        sourceBlob: editor.getSourceBlob() ?? item.sourceBlob,
        exportMeta: metadata,
        width: metadata.width,
        height: metadata.height,
      });
    } catch {
      // Keep the panel open so the user can retry; nothing is committed.
      setFailed(true);
    } finally {
      setBusy(false);
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <div
        className="overflow-hidden rounded-lg border border-border"
        style={{ aspectRatio: aspectToCss(aspect) }}
      >
        <MediaEditor
          ref={editorRef}
          enabledModes={[]}
          presentation="inline"
          initialSource={seedSource}
          aspect={aspect}
          cropAspects={[aspect]}
          {...editorProps}
        />
      </div>
      {failed ? (
        <p role="alert" className="text-sm text-destructive">
          {labels.editError}
        </p>
      ) : null}
      <div className="flex items-center justify-end gap-2">
        <Button
          type="button"
          variant="ghost"
          onClick={onCancel}
          disabled={busy}
        >
          {labels.editCancel}
        </Button>
        <Button
          type="button"
          onClick={handleDone}
          disabled={busy}
          aria-busy={busy || undefined}
        >
          {busy ? (
            <>
              <Loader2 className="size-4 animate-spin" aria-hidden />
              {labels.editSaving}
            </>
          ) : (
            labels.editDone
          )}
        </Button>
      </div>
    </div>
  );
}
```
