{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media-editor-capture",
  "title": "Media Editor — Capture",
  "author": "ilinxa",
  "description": "Camera photo/video capture for Media Editor — permission flows, shutter control, multi-instance guard. Wire in via MediaEditor's capture prop.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/media-editor",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/components/media/media-editor/features/capture/index.ts",
      "content": "// media-editor capture feature — camera photo/video capture, permission\n// flows, shutter control, multi-instance guard (P3 S3 feature-slicing).\n//\n// Injected into the base via `capture={mediaCapture}` (MediaEditorProps —\n// see `../../hooks/use-capture-extension.ts`). Ships as its own registry\n// item, `@ilinxa/media-editor-capture`, with a `registryDependencies: [\n// \"@ilinxa/media-editor\"]` back onto the base.\n//\n// Re-exports every symbol the base barrel (../../index.ts) dropped when this\n// slice was extracted — pre-split `@ilinxa/media-editor` imports of these\n// names now resolve here instead. `validateGalleryFile` is the one\n// exception: it's generic file-intake, not a capture concern, so it stayed\n// (and remains exported from) the base.\n\nimport type { MediaCaptureExtension } from \"../../hooks/use-capture-extension\";\nimport { MediaCaptureProvider } from \"./provider\";\n\n/** Pass straight through: `<MediaEditor capture={mediaCapture} />`. */\nexport const mediaCapture: MediaCaptureExtension = {\n  Provider: MediaCaptureProvider,\n};\n\nexport { MediaCaptureProvider } from \"./provider\";\n\nexport {\n  useMediaCapture,\n  suggestedVideoFilename,\n  type UseMediaCaptureOptions,\n  type UseMediaCaptureResult,\n  type CapturedPhoto,\n  type CapturedVideo,\n  type CaptureStatus,\n  type FacingMode,\n} from \"./hooks/use-media-capture\";\n\nexport {\n  useCameraPermissions,\n  type CameraPermissionState,\n  type UseCameraPermissionsResult,\n} from \"./hooks/use-camera-permissions\";\n\nexport { useMultiInstanceGuard } from \"./hooks/use-multi-instance-guard\";\n\nexport { EditorCamera } from \"./parts/editor-camera\";\nexport type { EditorCameraProps } from \"./parts/editor-camera\";\n\nexport { CameraPermissionPrompt } from \"./parts/camera-permission-prompt\";\nexport { ShutterButton } from \"./parts/shutter-button\";\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/index.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/provider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { MediaCaptureExtensionContext } from \"../../hooks/use-capture-extension\";\nimport { EditorCamera } from \"./parts/editor-camera\";\n\n/**\n * Supplies the base orchestrator with the capture feature's camera surface\n * via context (renderer-registry style — the base never statically imports\n * this folder; see `hooks/use-capture-extension.ts`). Thin by design:\n * capture's own state (permissions, live stream, recording, multi-instance\n * guard) lives inside `EditorCamera` itself, so there's nothing else to hold\n * here today.\n */\nexport function MediaCaptureProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const value = React.useMemo(() => ({ CameraSurface: EditorCamera }), []);\n  return (\n    <MediaCaptureExtensionContext.Provider value={value}>\n      {children}\n    </MediaCaptureExtensionContext.Provider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/provider.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/hooks/use-media-capture.ts",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type RefObject,\n} from \"react\";\nimport {\n  containerFor,\n  selectRecorderMime,\n} from \"../../../lib/mime-fallback\";\n\nexport type CaptureStatus =\n  | \"idle\"\n  | \"acquiring\"\n  | \"ready\"\n  | \"denied\"\n  | \"no-camera\"\n  | \"error\";\n\nexport type FacingMode = \"user\" | \"environment\";\n\nexport interface CapturedPhoto {\n  blob: Blob;\n  width: number;\n  height: number;\n  mimeType: string;\n}\n\nexport interface CapturedVideo {\n  blob: Blob;\n  durationMs: number;\n  mimeType: string;\n}\n\nexport interface UseMediaCaptureOptions {\n  /** Enabled while the composer is open; releasing flips back to \"idle\". */\n  enabled: boolean;\n  /** Initial camera facing. Auto-detected from UA touch capability if undefined. */\n  defaultFacing?: FacingMode;\n  /**\n   * Request an audio track alongside the video. Default false.\n   *\n   * **Critical:** only set true when the consumer actually needs audio\n   * (i.e. video-mode recording). Photo / text capture should always pass\n   * false — requesting mic permission for photo capture is the leading\n   * cause of the v0.1.3 flicker-loop: if the user grants camera but\n   * denies mic, getUserMedia({audio:true}) rejects, status becomes\n   * \"denied\", the auto-retry effect re-fires, repeat.\n   */\n  requestAudio?: boolean;\n  /** Hard cap on a single recording in seconds. Default 30. */\n  maxVideoDurationSec?: number;\n  /**\n   * When true (default), automatically call `acquire()` on mount and when\n   * `enabled` flips on. Set to false to require an explicit user gesture\n   * (e.g. a \"Tap to enable camera\" button) before the camera starts —\n   * recommended when the user hasn't previously granted permission, since\n   * browser permission prompts are jarring without a triggering interaction.\n   */\n  autoAcquire?: boolean;\n}\n\nexport interface UseMediaCaptureResult {\n  videoRef: RefObject<HTMLVideoElement | null>;\n  status: CaptureStatus;\n  facing: FacingMode;\n  error: Error | null;\n  /** True when ≥2 video input devices are enumerated (controls switch-camera visibility). */\n  canSwitchCamera: boolean;\n  /** Currently active MediaStream (null until \"ready\"). Used by MediaRecorder. */\n  stream: MediaStream | null;\n  /** Re-acquire the camera (after permission grant or facing change). */\n  acquire: () => Promise<void>;\n  /** Stop tracks + release the stream. */\n  release: () => void;\n  /**\n   * Snap a still frame from the current video preview.\n   *\n   * When `aspectRatio` is provided, the captured frame is center-cropped to\n   * match — mirroring the CSS `object-cover` crop the user saw in the\n   * preview. Pass the dialog/viewport aspect (e.g. `9/16`) to keep\n   * what-you-see-is-what-you-get parity; pass `null`/omit to capture the\n   * full native frame.\n   */\n  takePhoto: (opts?: { aspectRatio?: number | null }) => Promise<CapturedPhoto>;\n  /** Flip facingMode and re-acquire. */\n  switchCamera: () => Promise<void>;\n  // ─── Video recording ──────────────────────────────────────────────────\n  isRecording: boolean;\n  /** Elapsed recording time in ms (updates ~10×/s). */\n  recordingMs: number;\n  /** Selected MIME type — null if MediaRecorder is unsupported. */\n  recorderMime: string | null;\n  /** Begin recording. Throws if no stream or no supported codec. */\n  startRecording: () => Promise<void>;\n  /** Stop recording and return the assembled blob + duration. */\n  stopRecording: () => Promise<CapturedVideo>;\n}\n\nfunction isTouchUA(): boolean {\n  if (typeof window === \"undefined\") return false;\n  return (\n    \"ontouchstart\" in window ||\n    (navigator.maxTouchPoints ?? 0) > 0 ||\n    /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent)\n  );\n}\n\n/**\n * getUserMedia + photo-capture lifecycle.\n *\n * C4 surface: acquire / release / takePhoto / switchCamera.\n * C5 will extend with startRecording / stopRecording.\n */\nexport function useMediaCapture(\n  options: UseMediaCaptureOptions,\n): UseMediaCaptureResult {\n  const {\n    enabled,\n    defaultFacing,\n    requestAudio = false,\n    maxVideoDurationSec = 30,\n    autoAcquire = true,\n  } = options;\n  const videoRef = useRef<HTMLVideoElement | null>(null);\n  const streamRef = useRef<MediaStream | null>(null);\n\n  const [stream, setStream] = useState<MediaStream | null>(null);\n  const [status, setStatus] = useState<CaptureStatus>(\"idle\");\n  const [error, setError] = useState<Error | null>(null);\n  const [facing, setFacing] = useState<FacingMode>(\n    defaultFacing ?? (isTouchUA() ? \"environment\" : \"user\"),\n  );\n  const [canSwitchCamera, setCanSwitchCamera] = useState(false);\n\n  // ─── Recording state ────────────────────────────────────────────────\n  const recorderRef = useRef<MediaRecorder | null>(null);\n  const chunksRef = useRef<Blob[]>([]);\n  const recordStartedAtRef = useRef<number>(0);\n  const tickIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);\n  const autoStopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const stopResolverRef = useRef<{\n    resolve: (v: CapturedVideo) => void;\n    reject: (e: Error) => void;\n  } | null>(null);\n  const [isRecording, setIsRecording] = useState(false);\n  const [recordingMs, setRecordingMs] = useState(0);\n  // Recorder MIME probed once on the first client render via useState's lazy\n  // initializer (SSR yields null; selectRecorderMime is a deterministic\n  // capability check). Replaces the old write-a-ref-during-render init,\n  // which the React Compiler forbids.\n  const [recorderMime] = useState<string | null>(() =>\n    typeof window === \"undefined\" ? null : selectRecorderMime(),\n  );\n\n  // Acquire epoch — a monotonically increasing token claimed per acquire().\n  // getUserMedia can resolve long after release()/unmount/a competing acquire\n  // (permission prompt open, slow device): a resolve under a stale epoch must\n  // stop its tracks and bail instead of orphaning a live camera stream (LED\n  // stays on) or clobbering the newer acquire's stream. (Review 5.11.)\n  const acquireEpochRef = useRef(0);\n\n  const release = useCallback(() => {\n    // Invalidate any in-flight acquire before stopping the current stream.\n    acquireEpochRef.current += 1;\n    const s = streamRef.current;\n    if (s) {\n      for (const track of s.getTracks()) track.stop();\n      streamRef.current = null;\n    }\n    setStream(null);\n    setStatus(\"idle\");\n  }, []);\n\n  // Shared acquire pipeline. `nextFacing` is explicit so switchCamera doesn't\n  // depend on the (stale-closure-prone) `facing` state committing first.\n  const acquireWith = useCallback(\n    async (nextFacing: FacingMode) => {\n      if (typeof navigator === \"undefined\" || !navigator.mediaDevices?.getUserMedia) {\n        setStatus(\"no-camera\");\n        setError(new Error(\"MediaDevices.getUserMedia is not supported\"));\n        return;\n      }\n      // Release any prior stream before re-acquiring (switch-camera flow,\n      // photo↔video mode flip), then claim a fresh epoch.\n      release();\n      const epoch = ++acquireEpochRef.current;\n      setStatus(\"acquiring\");\n      setError(null);\n      try {\n        const next = await navigator.mediaDevices.getUserMedia({\n          video: { facingMode: { ideal: nextFacing } },\n          audio: requestAudio,\n        });\n        if (epoch !== acquireEpochRef.current) {\n          // Superseded (released / re-acquired / unmounted) while the prompt\n          // or device was pending — don't adopt, don't leak.\n          for (const track of next.getTracks()) track.stop();\n          return;\n        }\n        streamRef.current = next;\n        setStream(next);\n        setStatus(\"ready\");\n\n        // Enumerate devices to decide if switch-camera should be offered.\n        try {\n          const devices = await navigator.mediaDevices.enumerateDevices();\n          if (epoch === acquireEpochRef.current) {\n            setCanSwitchCamera(\n              devices.filter((d) => d.kind === \"videoinput\").length >= 2,\n            );\n          }\n        } catch {\n          if (epoch === acquireEpochRef.current) setCanSwitchCamera(false);\n        }\n      } catch (err) {\n        if (epoch !== acquireEpochRef.current) return; // stale failure — a newer acquire owns the status\n        const e = err as DOMException;\n        if (e?.name === \"NotAllowedError\" || e?.name === \"SecurityError\") {\n          setStatus(\"denied\");\n        } else if (e?.name === \"NotFoundError\") {\n          setStatus(\"no-camera\");\n        } else {\n          setStatus(\"error\");\n        }\n        setError(err as Error);\n      }\n    },\n    [requestAudio, release],\n  );\n\n  const acquire = useCallback(() => acquireWith(facing), [acquireWith, facing]);\n\n  // Auto-acquire on mount when enabled AND autoAcquire is allowed; release\n  // on unmount / disable. When autoAcquire is false the consumer is expected\n  // to call `acquire()` in response to an explicit user gesture (avoids\n  // jarring permission prompts on first paint).\n  useEffect(() => {\n    if (!enabled) {\n      // External-system sync (camera hardware): release() stops the live\n      // MediaStream tracks and mirrors the result into state. The epoch\n      // invalidation + track teardown + state reset must stay one atomic\n      // chokepoint — splitting the state writes out of the effect would fork\n      // the acquire-cancellation logic (v0.1.4 fix).\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      release();\n      return;\n    }\n    if (autoAcquire) void acquire();\n    return release;\n    // We don't add `acquire` to deps directly because it changes when facing\n    // flips (handled by switchCamera). But we DO depend on `requestAudio` so\n    // toggling photo↔video correctly releases + re-acquires with/without an\n    // audio track (without it, video mode would inherit a video-only stream\n    // from a prior photo-mode acquire and record silent video).\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [enabled, requestAudio, release, autoAcquire]);\n\n  // Attach the stream to <video>. The <video> tag will be null on first render\n  // (rendered in composer-camera), so this fires whenever stream becomes ready.\n  useEffect(() => {\n    const v = videoRef.current;\n    if (!v) return;\n    if (stream) {\n      v.srcObject = stream;\n      v.play().catch(() => {\n        /* autoplay may be blocked; ignore — user gesture will unblock */\n      });\n    } else {\n      v.srcObject = null;\n    }\n  }, [stream]);\n\n  const switchCamera = useCallback(async () => {\n    const next: FacingMode = facing === \"user\" ? \"environment\" : \"user\";\n    setFacing(next);\n    // acquireWith takes the target facing explicitly, so there's no\n    // stale-closure microtask dance — and its epoch guard means a rapid\n    // double-flip can't interleave two live streams. (Review 5.11.)\n    await acquireWith(next);\n  }, [facing, acquireWith]);\n\n  // ─── Recording controls ─────────────────────────────────────────────\n\n  const clearRecordingTimers = useCallback(() => {\n    if (tickIntervalRef.current) {\n      clearInterval(tickIntervalRef.current);\n      tickIntervalRef.current = null;\n    }\n    if (autoStopTimeoutRef.current) {\n      clearTimeout(autoStopTimeoutRef.current);\n      autoStopTimeoutRef.current = null;\n    }\n  }, []);\n\n  const startRecording = useCallback(async () => {\n    const s = streamRef.current;\n    if (!s) throw new Error(\"Camera not ready\");\n    // Defensive re-probe if the lazy init yielded null (matches the old\n    // ref-cached behavior — selectRecorderMime is deterministic, so a null\n    // here re-probes to null and throws below).\n    const mime = recorderMime ?? selectRecorderMime();\n    if (!mime) {\n      throw new Error(\"This browser does not support video recording\");\n    }\n    if (recorderRef.current) return; // already recording\n\n    const rec = new MediaRecorder(s, { mimeType: mime });\n    recorderRef.current = rec;\n    chunksRef.current = [];\n    rec.ondataavailable = (ev) => {\n      if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);\n    };\n    rec.onstop = () => {\n      const durationMs = Date.now() - recordStartedAtRef.current;\n      const blob = new Blob(chunksRef.current, { type: mime });\n      const resolver = stopResolverRef.current;\n      stopResolverRef.current = null;\n      recorderRef.current = null;\n      chunksRef.current = [];\n      clearRecordingTimers();\n      setIsRecording(false);\n      setRecordingMs(0);\n      resolver?.resolve({ blob, durationMs, mimeType: mime });\n    };\n    rec.onerror = (ev) => {\n      const e =\n        (ev as unknown as { error?: Error }).error ??\n        new Error(\"MediaRecorder failed\");\n      const resolver = stopResolverRef.current;\n      stopResolverRef.current = null;\n      recorderRef.current = null;\n      chunksRef.current = [];\n      clearRecordingTimers();\n      setIsRecording(false);\n      setRecordingMs(0);\n      resolver?.reject(e);\n    };\n\n    recordStartedAtRef.current = Date.now();\n    setRecordingMs(0);\n    setIsRecording(true);\n    rec.start(250); // 250ms timeslice — smooth UI ticks\n\n    // Tick recording time for UI\n    tickIntervalRef.current = setInterval(() => {\n      setRecordingMs(Date.now() - recordStartedAtRef.current);\n    }, 100);\n\n    // Hard cap auto-stop\n    autoStopTimeoutRef.current = setTimeout(\n      () => {\n        if (recorderRef.current?.state === \"recording\") {\n          recorderRef.current.stop();\n        }\n      },\n      maxVideoDurationSec * 1000,\n    );\n  }, [clearRecordingTimers, maxVideoDurationSec, recorderMime]);\n\n  const stopRecording = useCallback((): Promise<CapturedVideo> => {\n    const rec = recorderRef.current;\n    if (!rec || rec.state !== \"recording\") {\n      return Promise.reject(new Error(\"Not recording\"));\n    }\n    return new Promise<CapturedVideo>((resolve, reject) => {\n      stopResolverRef.current = { resolve, reject };\n      rec.stop();\n    });\n  }, []);\n\n  // Cleanup any in-flight recording when stream is released / hook unmounts.\n  useEffect(() => {\n    return () => {\n      const rec = recorderRef.current;\n      if (rec && rec.state !== \"inactive\") {\n        try {\n          rec.stop();\n        } catch {\n          /* ignore */\n        }\n      }\n      recorderRef.current = null;\n      chunksRef.current = [];\n      clearRecordingTimers();\n    };\n  }, [clearRecordingTimers]);\n\n  const takePhoto = useCallback(\n    async (opts?: { aspectRatio?: number | null }): Promise<CapturedPhoto> => {\n      const v = videoRef.current;\n      if (!v || !streamRef.current) {\n        throw new Error(\"Camera not ready\");\n      }\n      const nativeW = v.videoWidth;\n      const nativeH = v.videoHeight;\n      if (nativeW === 0 || nativeH === 0) {\n        throw new Error(\"Video has no frame yet\");\n      }\n\n      // Determine the source-rect to capture. With aspectRatio provided, we\n      // center-crop the native frame to match — mirroring CSS object-cover:\n      // scale-to-fill + center-crop. videoAspect > target → crop horizontal;\n      // videoAspect < target → crop vertical; equal → no crop.\n      const target =\n        opts?.aspectRatio && opts.aspectRatio > 0 ? opts.aspectRatio : null;\n      let sx = 0;\n      let sy = 0;\n      let sw = nativeW;\n      let sh = nativeH;\n      if (target !== null) {\n        const videoAspect = nativeW / nativeH;\n        if (videoAspect > target) {\n          // Video is wider than target — crop horizontal sides.\n          sw = nativeH * target;\n          sx = (nativeW - sw) / 2;\n        } else if (videoAspect < target) {\n          // Video is taller than target — crop top/bottom.\n          sh = nativeW / target;\n          sy = (nativeH - sh) / 2;\n        }\n      }\n\n      const outW = Math.round(sw);\n      const outH = Math.round(sh);\n      const canvas = document.createElement(\"canvas\");\n      canvas.width = outW;\n      canvas.height = outH;\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) throw new Error(\"2D canvas context unavailable\");\n      ctx.drawImage(v, sx, sy, sw, sh, 0, 0, outW, outH);\n      const blob: Blob = await new Promise((resolve, reject) => {\n        canvas.toBlob(\n          (b) => (b ? resolve(b) : reject(new Error(\"toBlob returned null\"))),\n          \"image/jpeg\",\n          0.92,\n        );\n      });\n      return { blob, width: outW, height: outH, mimeType: \"image/jpeg\" };\n    },\n    [],\n  );\n\n  return {\n    videoRef,\n    status,\n    facing,\n    error,\n    canSwitchCamera,\n    stream,\n    acquire,\n    release,\n    takePhoto,\n    switchCamera,\n    isRecording,\n    recordingMs,\n    recorderMime,\n    startRecording,\n    stopRecording,\n  };\n}\n\n/** Suggested filename for a recorded clip — useful for the publish step. */\nexport function suggestedVideoFilename(mime: string): string {\n  return `story-${Date.now()}.${containerFor(mime) === \"unknown\" ? \"bin\" : containerFor(mime)}`;\n}\n\n// `validateGalleryFile` moved to the BASE (P3 S3 — it's generic file-intake,\n// not a capture concern) — see `../../../lib/validate-media-file.ts`.\n// `parts/editor-camera.tsx` imports it directly from there.\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/hooks/use-media-capture.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/hooks/use-camera-permissions.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nexport type CameraPermissionState =\n  | \"unknown\"\n  | \"prompt\"\n  | \"granted\"\n  | \"denied\";\n\nexport interface UseCameraPermissionsResult {\n  state: CameraPermissionState;\n  /** Browser supports the Permissions API for \"camera\". */\n  supported: boolean;\n  /** Force a re-check (useful after explicit retry). */\n  refresh: () => Promise<void>;\n}\n\n/**\n * Watches `navigator.permissions.query({ name: \"camera\" })` and auto-updates\n * on settings changes (Chrome / Firefox / Edge). Safari has no Permissions\n * API for camera; we expose `supported=false` and consumers fall back to\n * trying `getUserMedia` directly to discover the permission state.\n */\nexport function useCameraPermissions(): UseCameraPermissionsResult {\n  const [state, setState] = useState<CameraPermissionState>(\"unknown\");\n  const [supported, setSupported] = useState(false);\n  // Held in STATE (not a ref) so the change-listener effect below re-binds to\n  // each PermissionStatus a refresh() yields — binding only the first one left\n  // later statuses unwatched.\n  const [status, setStatusObj] = useState<PermissionStatus | null>(null);\n\n  const refresh = useCallback(async () => {\n    if (typeof navigator === \"undefined\" || !navigator.permissions?.query) {\n      setSupported(false);\n      setState(\"unknown\");\n      return;\n    }\n    try {\n      // `camera` is in the Permissions spec but TS lib.dom doesn't list it.\n      const next = await navigator.permissions.query({\n        name: \"camera\" as PermissionName,\n      });\n      setStatusObj(next);\n      setSupported(true);\n      setState(next.state as CameraPermissionState);\n    } catch {\n      // Safari throws TypeError for \"camera\"\n      setSupported(false);\n      setState(\"unknown\");\n    }\n  }, []);\n\n  useEffect(() => {\n    // Mount-time query of an external system (Permissions API). Every state\n    // write lands in refresh()'s async continuation EXCEPT the\n    // no-Permissions-API guard, whose synchronous writes equal the initial\n    // state (supported=false / \"unknown\") — a bail-out no-op on mount.\n    // Restructuring to useSyncExternalStore would fork the status-in-state\n    // listener-rebind fix (see the `status` state comment above).\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    void refresh();\n  }, [refresh]);\n\n  useEffect(() => {\n    if (!status) return;\n    const handler = () => setState(status.state as CameraPermissionState);\n    status.addEventListener(\"change\", handler);\n    return () => status.removeEventListener(\"change\", handler);\n  }, [status]);\n\n  return { state, supported, refresh };\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/hooks/use-camera-permissions.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/hooks/use-multi-instance-guard.ts",
      "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * Multi-instance guard — fires a dev-only `console.warn` when 2+ CAMERA\n * SURFACES are actually mounted at once (Q-P5 lock = `b`). Since the P3\n * capture split the guard lives in `EditorCamera` (gated on its `enabled`\n * prop), so instances merely CONFIGURED for camera no longer warn until a\n * camera surface really mounts — the trigger tracks live-stream contention,\n * the thing being guarded, rather than configuration intent (R4 verdict\n * 2026-08-11; pre-split behavior warned on config alone).\n *\n * Rationale: each instance owns its own `Konva.Stage` — fine, no\n * conflict. But `getUserMedia` against the same physical camera with\n * multiple live streams degrades quickly (most browsers serialize\n * constraints to the first opener, perf cliffs hit fast). The dev-warn\n * catches the layout-builder footgun without blocking valid two-camera\n * UIs the consumer knows what they're doing about.\n *\n * Edit-only instances (no capture mode enabled) bypass the counter\n * unconditionally — per description §1 they have no camera contention\n * to worry about. The caller decides whether to engage the guard via\n * the `enabled` arg.\n *\n * The counter is module-scoped (not React state) so multiple instances\n * across the tree observe the same value. Reset semantics are\n * mount-increment / unmount-decrement; HMR drift is acceptable for a\n * dev-only diagnostic.\n */\n\nlet captureInstanceCount = 0;\nlet warnedAt = -1;\n\nexport function useMultiInstanceGuard(enabled: boolean): void {\n  useEffect(() => {\n    if (!enabled) return;\n    if (process.env.NODE_ENV === \"production\") return;\n    captureInstanceCount += 1;\n    if (captureInstanceCount >= 2 && warnedAt !== captureInstanceCount) {\n      warnedAt = captureInstanceCount;\n      console.warn(\n        `media-editor: ${captureInstanceCount} capture-enabled instances mounted simultaneously. ` +\n          \"Multiple live camera previews tend to degrade quickly — consider \" +\n          \"`presentation=\\\"dialog\\\"` (one-at-a-time UX) or restricting secondary instances to edit-only \" +\n          \"(`enabledModes` without `photo`/`video`).\",\n      );\n    }\n    return () => {\n      captureInstanceCount = Math.max(0, captureInstanceCount - 1);\n      if (captureInstanceCount < 2) {\n        warnedAt = -1;\n      }\n    };\n  }, [enabled]);\n}\n\n/**\n * Test-only — reset the module-scoped counter. Not exported via the\n * barrel; used by vitest if it ever lands.\n */\nexport function __resetMultiInstanceGuard(): void {\n  captureInstanceCount = 0;\n  warnedAt = -1;\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/hooks/use-multi-instance-guard.ts"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/parts/editor-camera.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Image as ImageIcon, SwitchCamera } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n// Direct relative imports — NEVER through the base barrel (F-S1 / R1\n// VERDICT). `useMediaCapture` / `useCameraPermissions` are co-located in\n// this feature; `validateGalleryFile` is generic file-intake and lives in\n// BASE (moved there in P3 S3); `useMultiInstanceGuard` is co-located too —\n// its call site lives HERE (not in the base orchestrator) since the\n// contention it guards against (2+ live camera streams) only exists while a\n// camera surface is actually mounted.\nimport { useMediaCapture, type CapturedPhoto, type CapturedVideo } from \"../hooks/use-media-capture\";\nimport { useCameraPermissions } from \"../hooks/use-camera-permissions\";\nimport { useMultiInstanceGuard } from \"../hooks/use-multi-instance-guard\";\nimport { validateGalleryFile } from \"../../../lib/validate-media-file\";\nimport { CameraPermissionPrompt } from \"./camera-permission-prompt\";\nimport { ShutterButton } from \"./shutter-button\";\nimport type {\n  ComposerMode,\n  StoryComposerLabels,\n  ValidationError,\n} from \"../../../types\";\n\nexport interface EditorCameraProps {\n  /** Composer is open + we should hold a live camera stream. */\n  enabled: boolean;\n  /** Drives mirror behavior, default facing, and the shutter aria-label. */\n  mode: ComposerMode;\n  defaultFacing?: \"user\" | \"environment\";\n  recordAudio?: boolean;\n  maxFileSizeMb?: number;\n  maxVideoDurationSec?: number;\n  /**\n   * Numeric aspect-ratio (width/height) of the visible preview region. When\n   * provided, the captured photo is center-cropped to match — mirroring the\n   * preview's CSS `object-cover` crop. Pass `null` for free (no crop).\n   */\n  captureAspectRatio?: number | null;\n  /**\n   * When true, force auto-acquire of the camera on mount even if browser\n   * permission isn't pre-granted — used after an explicit user gesture (e.g.\n   * the \"Connect to camera\" entry button or a mode-tab click) so the\n   * subsequent permission prompt fires in the same gesture-credited tick.\n   * Default behavior auto-acquires only when `perms.state === \"granted\"`.\n   */\n  autoAcquireOverride?: boolean;\n  labels: Required<StoryComposerLabels>;\n  onPhoto: (photo: CapturedPhoto) => void;\n  onVideo: (video: CapturedVideo) => void;\n  /** Loads an image / video file picked from the gallery into the editor. */\n  onGalleryFile: (file: File) => void;\n  onValidationError?: (error: ValidationError) => void;\n  /** Fires when permission is denied so the host can show its own help. */\n  onPermissionDenied?: () => void;\n  /**\n   * Replaces the built-in denied-permission UI. Receives `retry` (re-request\n   * the camera) + `usePicker` (open the gallery file picker). Only substitutes\n   * the `denied` state — the pending / no-camera / error states keep the\n   * built-in prompt.\n   */\n  renderPermissionDenied?: (ctx: {\n    retry: () => void;\n    usePicker: () => void;\n  }) => React.ReactNode;\n}\n\nexport function EditorCamera({\n  enabled,\n  mode,\n  defaultFacing,\n  recordAudio = true,\n  maxFileSizeMb = 50,\n  maxVideoDurationSec = 30,\n  captureAspectRatio,\n  autoAcquireOverride,\n  labels,\n  onPhoto,\n  onVideo,\n  onGalleryFile,\n  onValidationError,\n  onPermissionDenied,\n  renderPermissionDenied,\n}: EditorCameraProps) {\n  const fileInputRef = useRef<HTMLInputElement | null>(null);\n  const [busy, setBusy] = useState(false);\n\n  // Multi-instance guard (moved from the base orchestrator in P3 S3 — see\n  // import comment above). Gated on `enabled`, which mirrors the original\n  // `cameraIntakeAvailable` intent closely enough: this component only\n  // mounts while the editor actually wants a live camera surface, so the\n  // guard now fires exactly while camera contention could occur, rather than\n  // merely \"configured for camera\" (a slightly tighter, arguably more\n  // accurate trigger window than the pre-split behavior).\n  useMultiInstanceGuard(enabled);\n\n  const perms = useCameraPermissions();\n  // Only request mic permission for video mode — requesting audio in photo\n  // mode caused the v0.1.3 flicker loop (user might have mic denied while\n  // camera is granted; getUserMedia rejects → status flips → auto-retry).\n  const requestAudio = mode === \"video\" && recordAudio;\n  // Auto-acquire only if the browser has already granted camera permission;\n  // otherwise wait for an explicit user gesture (the \"Enable camera\" button\n  // on the idle prompt). Avoids surprising users with a permission prompt\n  // the moment they open the composer.\n  // Destructure the hook result to locals up front. `videoRef` is a ref-typed\n  // field; passing `capture.videoRef` as a JSX ref makes the React Compiler's\n  // aliasing analysis treat the whole result object as a ref container and\n  // flag every later `capture.*` read as \"ref access during render\" (known\n  // false-positive class — same fix as story-viewer/reply-composer).\n  const {\n    videoRef,\n    status: captureStatus,\n    facing: captureFacing,\n    canSwitchCamera,\n    acquire,\n    takePhoto,\n    switchCamera,\n    isRecording,\n    recordingMs,\n    startRecording,\n    stopRecording,\n  } = useMediaCapture({\n    enabled,\n    defaultFacing,\n    requestAudio,\n    maxVideoDurationSec,\n    autoAcquire: autoAcquireOverride || perms.state === \"granted\",\n  });\n\n  // Surface permission-denied to the consumer once.\n  const deniedFiredRef = useRef(false);\n  useEffect(() => {\n    if (captureStatus === \"denied\" && !deniedFiredRef.current) {\n      deniedFiredRef.current = true;\n      onPermissionDenied?.();\n    } else if (captureStatus !== \"denied\") {\n      deniedFiredRef.current = false;\n    }\n  }, [captureStatus, onPermissionDenied]);\n\n  // Front-camera preview is mirrored to match user expectation; rear is not.\n  const mirrored = captureFacing === \"user\";\n\n  const handleShutterPhoto = async () => {\n    if (busy || captureStatus !== \"ready\") return;\n    setBusy(true);\n    try {\n      const photo = await takePhoto({\n        aspectRatio: captureAspectRatio ?? null,\n      });\n      onPhoto(photo);\n    } catch (err) {\n      // No ValidationError kind fits a transient shutter failure (no frame\n      // yet / toBlob null) — dev-warn instead of swallowing silently.\n      if (process.env.NODE_ENV !== \"production\") {\n        console.warn(\"media-editor: takePhoto failed\", err);\n      }\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  const handleVideoStart = async () => {\n    if (busy || captureStatus !== \"ready\") return;\n    try {\n      await startRecording();\n    } catch (err) {\n      onValidationError?.({\n        kind: \"unsupported-codec\",\n        message:\n          (err as Error)?.message ?? \"Video recording is not supported here\",\n      });\n    }\n  };\n\n  const handleVideoStop = async () => {\n    if (!isRecording) return;\n    setBusy(true);\n    try {\n      const video = await stopRecording();\n      onVideo(video);\n    } catch (err) {\n      // A failed stop means the recording is lost — surface it on the same\n      // channel handleVideoStart already uses.\n      onValidationError?.({\n        kind: \"unsupported-codec\",\n        message: (err as Error)?.message ?? \"Video recording failed\",\n      });\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  const handleGalleryClick = () => {\n    fileInputRef.current?.click();\n  };\n\n  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    e.target.value = \"\"; // reset so picking the same file twice re-fires\n    if (!file) return;\n    const err = validateGalleryFile(file, maxFileSizeMb);\n    if (err) {\n      onValidationError?.(err);\n      return;\n    }\n    onGalleryFile(file);\n  };\n\n  // ─── Permission UI ────────────────────────────────────────────────────\n  const permissionVariant:\n    | null\n    | \"idle\"\n    | \"pending\"\n    | \"denied\"\n    | \"no-camera\"\n    | \"error\" =\n    captureStatus === \"acquiring\"\n      ? \"pending\"\n      : captureStatus === \"denied\"\n        ? \"denied\"\n        : captureStatus === \"no-camera\"\n          ? \"no-camera\"\n          : captureStatus === \"error\"\n            ? \"error\"\n            : captureStatus === \"idle\"\n              ? \"idle\"\n              : null;\n\n  // Auto-retry on permission change (Q-P8a) — when the watcher flips to\n  // \"granted\" but our capture is still in \"denied\", silently re-acquire.\n  //\n  // BOUNDED — fires AT MOST ONCE per granted-state transition. Without this\n  // bound, a stable denial (e.g. mic denied while photo mode requests it)\n  // makes acquire() → fail → status=\"denied\" → effect re-fires → infinite\n  // loop. Resets when status becomes \"ready\" so a future denial can retry.\n  // Also deps on the destructured captureStatus/acquire locals, not the whole\n  // capture result object (which is a fresh ref every render).\n  const autoRetryAttemptedRef = useRef(false);\n  useEffect(() => {\n    if (perms.state === \"granted\") {\n      if (captureStatus === \"denied\" && !autoRetryAttemptedRef.current) {\n        autoRetryAttemptedRef.current = true;\n        void acquire();\n      } else if (captureStatus === \"ready\") {\n        autoRetryAttemptedRef.current = false;\n      }\n    } else {\n      autoRetryAttemptedRef.current = false;\n    }\n  }, [perms.state, captureStatus, acquire]);\n\n  // Resolve the permission-overlay body once. The consumer's\n  // renderPermissionDenied slot replaces the built-in prompt for the `denied`\n  // state only; pending / no-camera / error keep the built-in prompt.\n  let permissionOverlay: React.ReactNode = null;\n  if (permissionVariant === \"denied\" && renderPermissionDenied) {\n    // `usePicker` reads fileInputRef only when the CONSUMER invokes it from an\n    // event handler — never during this render — so the ref-during-render rule\n    // is a false positive here (same deferred-invocation contract as the\n    // CameraPermissionPrompt `onUsePicker` prop below, which isn't flagged).\n    // eslint-disable-next-line react-hooks/refs\n    permissionOverlay = renderPermissionDenied({\n      retry: () => void acquire(),\n      usePicker: handleGalleryClick,\n    });\n  } else if (permissionVariant !== null) {\n    permissionOverlay = (\n      <CameraPermissionPrompt\n        variant={permissionVariant}\n        labels={labels}\n        onRetry={() => void acquire()}\n        onUsePicker={handleGalleryClick}\n      />\n    );\n  }\n\n  return (\n    <div className=\"@container relative flex-1 flex flex-col overflow-hidden\">\n      {/* Live preview — black fallback shows when no stream */}\n      <video\n        ref={videoRef}\n        autoPlay\n        playsInline\n        muted\n        className={cn(\n          \"absolute inset-0 w-full h-full object-cover bg-black\",\n          mirrored && \"scale-x-[-1]\",\n          permissionVariant !== null && \"opacity-0\",\n        )}\n      />\n\n      {/* Permission overlay (denied / pending / no-camera / error). Body\n          resolved above — the renderPermissionDenied slot can replace the\n          denied state. */}\n      {permissionOverlay !== null && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-black\">\n          {permissionOverlay}\n        </div>\n      )}\n\n      {/* Recording indicator — red dot + mm:ss top-center */}\n      {isRecording && (\n        <div\n          role=\"status\"\n          aria-live=\"polite\"\n          className=\"absolute top-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/60 backdrop-blur-md px-3 py-1.5 text-white\"\n        >\n          <span className=\"size-2 rounded-full bg-red-500 animate-pulse\" />\n          <span className=\"text-xs font-mono tabular-nums\">\n            {formatRecordingTime(recordingMs)}\n          </span>\n          <span className=\"sr-only\">{labels.recordingLabel}</span>\n        </div>\n      )}\n\n      {/* Bottom control row — gallery / shutter / switch-camera */}\n      <div\n        className={cn(\n          \"absolute left-0 right-0 z-20 flex items-center justify-between px-6\",\n          \"bottom-[max(1.25rem,env(safe-area-inset-bottom))]\",\n        )}\n      >\n        {/* Gallery picker */}\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={handleGalleryClick}\n          aria-label={labels.galleryPicker}\n          className=\"size-[clamp(2rem,9cqw,2.5rem)] rounded-full bg-black/40 backdrop-blur-md text-white hover:bg-black/60 hover:text-white\"\n        >\n          <ImageIcon className=\"size-[55%]\" />\n        </Button>\n\n        {/* Shutter — photo: tap | video: long-press hold OR tap-to-toggle */}\n        <ShutterButton\n          mode={mode === \"video\" ? \"video\" : \"photo\"}\n          // Sized to the camera width (container query) — not the viewport — so\n          // it stays proportional in the dialog / chat embeds instead of\n          // ballooning when the browser window is wide.\n          className=\"size-[clamp(2.75rem,15cqw,3.5rem)]\"\n          onPress={handleShutterPhoto}\n          onStart={handleVideoStart}\n          onStop={handleVideoStop}\n          isRecording={isRecording}\n          recordProgress={\n            isRecording\n              ? Math.min(\n                  1,\n                  recordingMs / (maxVideoDurationSec * 1000),\n                )\n              : 0\n          }\n          disabled={\n            busy ||\n            captureStatus !== \"ready\" ||\n            mode === \"text\"\n          }\n          ariaLabel={\n            mode === \"video\"\n              ? isRecording\n                ? labels.shutterVideoStop\n                : labels.shutterVideoStart\n              : labels.shutterPhoto\n          }\n        />\n\n        {/* Switch camera (only when ≥2 devices) */}\n        {canSwitchCamera ? (\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => void switchCamera()}\n            aria-label={labels.switchCamera}\n            className=\"size-[clamp(2rem,9cqw,2.5rem)] rounded-full bg-black/40 backdrop-blur-md text-white hover:bg-black/60 hover:text-white\"\n          >\n            <SwitchCamera className=\"size-[55%]\" />\n          </Button>\n        ) : (\n          <div className=\"size-[clamp(2rem,9cqw,2.5rem)]\" aria-hidden />\n        )}\n      </div>\n\n      {/* Hidden file input for gallery pick */}\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\"image/*,video/*\"\n        onChange={handleFileChange}\n        className=\"hidden\"\n      />\n    </div>\n  );\n}\n\nfunction formatRecordingTime(ms: number): string {\n  const totalSec = Math.floor(ms / 1000);\n  const m = Math.floor(totalSec / 60);\n  const s = totalSec % 60;\n  return `${String(m).padStart(2, \"0\")}:${String(s).padStart(2, \"0\")}`;\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/parts/editor-camera.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/parts/camera-permission-prompt.tsx",
      "content": "\"use client\";\n\nimport { Camera, AlertTriangle } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { StoryComposerLabels } from \"../../../types\";\n\nexport type PermissionPromptVariant =\n  | \"idle\"\n  | \"pending\"\n  | \"denied\"\n  | \"no-camera\"\n  | \"error\";\n\nexport interface CameraPermissionPromptProps {\n  variant: PermissionPromptVariant;\n  labels: Required<StoryComposerLabels>;\n  onRetry: () => void;\n  onUsePicker: () => void;\n  className?: string;\n}\n\nexport function CameraPermissionPrompt({\n  variant,\n  labels,\n  onRetry,\n  onUsePicker,\n  className,\n}: CameraPermissionPromptProps) {\n  if (variant === \"pending\") {\n    return (\n      <div\n        className={cn(\n          \"flex flex-col items-center justify-center gap-3 text-center text-white/70 px-6\",\n          className,\n        )}\n      >\n        <Camera className=\"size-8 animate-pulse\" />\n        <p className=\"text-sm\">{labels.permissionRequesting}</p>\n      </div>\n    );\n  }\n\n  if (variant === \"idle\") {\n    return (\n      <div\n        className={cn(\n          \"flex flex-col items-center justify-center gap-4 text-center text-white px-6 max-w-sm mx-auto\",\n          className,\n        )}\n      >\n        <Camera className=\"size-8\" />\n        <div className=\"space-y-1.5\">\n          <p className=\"text-base font-medium\">Camera ready</p>\n          <p className=\"text-sm text-white/70 leading-relaxed\">\n            Tap below to enable your camera, or pick a file from the gallery.\n          </p>\n        </div>\n        <div className=\"flex flex-col gap-2 w-full\">\n          <Button\n            onClick={onRetry}\n            variant=\"secondary\"\n            className=\"bg-white text-black hover:bg-white/90\"\n          >\n            Enable camera\n          </Button>\n          <Button\n            onClick={onUsePicker}\n            variant=\"ghost\"\n            className=\"text-white hover:bg-white/10 hover:text-white\"\n          >\n            {labels.permissionUsePicker}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  const title =\n    variant === \"no-camera\"\n      ? \"No camera available\"\n      : variant === \"error\"\n        ? \"Camera unavailable\"\n        : labels.permissionDeniedTitle;\n\n  return (\n    <div\n      role=\"alert\"\n      aria-live=\"polite\"\n      className={cn(\n        \"flex flex-col items-center justify-center gap-4 text-center text-white px-6 max-w-sm mx-auto\",\n        className,\n      )}\n    >\n      <AlertTriangle className=\"size-8 text-amber-400\" />\n      <div className=\"space-y-1.5\">\n        <p className=\"text-base font-medium\">{title}</p>\n        <p className=\"text-sm text-white/70 leading-relaxed\">\n          {labels.permissionDeniedBody}\n        </p>\n      </div>\n      <div className=\"flex flex-col gap-2 w-full\">\n        <Button\n          onClick={onRetry}\n          variant=\"secondary\"\n          className=\"bg-white text-black hover:bg-white/90\"\n        >\n          {labels.permissionRetry}\n        </Button>\n        <Button\n          onClick={onUsePicker}\n          variant=\"ghost\"\n          className=\"text-white hover:bg-white/10 hover:text-white\"\n        >\n          {labels.permissionUsePicker}\n        </Button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/parts/camera-permission-prompt.tsx"
    },
    {
      "path": "src/registry/components/media/media-editor/features/capture/parts/shutter-button.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useRef, type PointerEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ShutterButtonProps {\n  mode: \"photo\" | \"video\";\n  /** Photo-mode tap. Ignored when mode=\"video\". */\n  onPress?: () => void;\n  /** Video-mode start. Called for both long-press hold and tap-to-start. */\n  onStart?: () => void;\n  /** Video-mode stop. Called on long-press release OR tap-to-stop. */\n  onStop?: () => void;\n  /** Recording in-flight (drives ring fill + inner shape). */\n  isRecording?: boolean;\n  /** 0..1 — fraction of maxVideoDuration elapsed. Drives ring conic-gradient. */\n  recordProgress?: number;\n  disabled?: boolean;\n  ariaLabel: string;\n  className?: string;\n}\n\nconst LONG_PRESS_THRESHOLD_MS = 250;\n\n/**\n * Shutter behavior matrix (Instagram parity):\n *\n *   Mode photo + tap     → onPress\n *   Mode video + tap     → onStart (then a later tap → onStop)\n *   Mode video + hold    → onStart on down, onStop on up\n */\nexport function ShutterButton({\n  mode,\n  onPress,\n  onStart,\n  onStop,\n  isRecording = false,\n  recordProgress = 0,\n  disabled = false,\n  ariaLabel,\n  className,\n}: ShutterButtonProps) {\n  const pressStartRef = useRef<number>(0);\n  const startedThisPressRef = useRef(false);\n\n  const handlePointerDown = useCallback(\n    (e: PointerEvent<HTMLButtonElement>) => {\n      if (disabled) return;\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      pressStartRef.current = Date.now();\n      if (mode === \"photo\") {\n        startedThisPressRef.current = false;\n        return;\n      }\n      if (!isRecording) {\n        startedThisPressRef.current = true;\n        onStart?.();\n      } else {\n        startedThisPressRef.current = false;\n      }\n    },\n    [disabled, mode, isRecording, onStart],\n  );\n\n  const handlePointerUp = useCallback(\n    (e: PointerEvent<HTMLButtonElement>) => {\n      if (disabled) return;\n      e.currentTarget.releasePointerCapture?.(e.pointerId);\n      const elapsed = Date.now() - pressStartRef.current;\n\n      if (mode === \"photo\") {\n        onPress?.();\n        return;\n      }\n\n      const wasHold = elapsed >= LONG_PRESS_THRESHOLD_MS;\n      if (wasHold) {\n        onStop?.();\n      } else if (!startedThisPressRef.current) {\n        // Tap on already-recording shutter → stop (tap-to-toggle stop)\n        onStop?.();\n      }\n      // else: tap started recording this press → leave running (tap-to-toggle start)\n      startedThisPressRef.current = false;\n    },\n    [disabled, mode, onPress, onStop],\n  );\n\n  const handlePointerCancel = useCallback(() => {\n    // Treat cancellation as a stop if we started recording.\n    if (mode === \"video\" && isRecording && startedThisPressRef.current) {\n      onStop?.();\n      startedThisPressRef.current = false;\n    }\n  }, [mode, isRecording, onStop]);\n\n  // Ring fill via conic-gradient when recording.\n  const ringStyle =\n    mode === \"video\" && isRecording\n      ? {\n          background: `conic-gradient(#ef4444 ${recordProgress * 360}deg, rgba(255,255,255,0.3) ${recordProgress * 360}deg)`,\n        }\n      : undefined;\n\n  return (\n    <button\n      type=\"button\"\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={handlePointerCancel}\n      disabled={disabled}\n      aria-label={ariaLabel}\n      aria-pressed={mode === \"video\" ? isRecording : undefined}\n      className={cn(\n        // Responsive size — min 48px on narrow viewports, max 68px on wide.\n        \"relative grid place-items-center size-[clamp(3rem,12vw,4.25rem)] rounded-full\",\n        \"transition-transform active:scale-95\",\n        \"disabled:opacity-40 disabled:cursor-not-allowed\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent\",\n        className,\n      )}\n      style={ringStyle}\n    >\n      {/* Outer ring (idle white border; conic on record) */}\n      {mode === \"video\" && isRecording ? (\n        // Filled ring is the gradient on the button itself; inner stop-square sits inside\n        <span className=\"grid place-items-center size-[80%] rounded-full bg-black/60\">\n          <span className=\"size-[36%] rounded-md bg-red-500\" />\n        </span>\n      ) : (\n        <span\n          className={cn(\n            \"size-full rounded-full ring-4 ring-white/30 ring-offset-2 ring-offset-transparent grid place-items-center\",\n            \"bg-white\",\n          )}\n        >\n          {mode === \"video\" ? (\n            <span className=\"size-[30%] rounded-full bg-red-500\" />\n          ) : null}\n        </span>\n      )}\n      <span className=\"sr-only\">{ariaLabel}</span>\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-editor/features/capture/parts/shutter-button.tsx"
    }
  ],
  "meta": {
    "featureOf": "media-editor",
    "budgetKB": 65
  },
  "categories": [
    "media"
  ],
  "type": "registry:block"
}