Example PRO

import {
  Button, CaptureVideoPreviewView, Image, List, Navigation, NavigationStack,
  Path, Script, Section, Text, VStack, VideoPlayer, useEffect, useRef, useState,
} from "scripting"
import type { CameraControlAsset, CameraControlParameter } from "scripting"

let alive = true
let closing = false
let incoming: CameraControlParameter | null = Script.cameraControlParameter
// A null resume restores the capture experience without replaying a file batch.
let captureRequested = !incoming && Script.env === "camera_control"
let receive: (batch: CameraControlParameter | null) => void = batch => {
  if (batch) incoming = batch
  else captureRequested = Script.env === "camera_control"
}
const unsubscribe = Script.onResume(event => {
  if (!closing && (event.cameraControlParameter || Script.env === "camera_control")) receive(event.cameraControlParameter)
})
let ownedSession: AVCaptureSession | null = null
let ownedPlayer: AVPlayer | null = null

let stopping: Promise<void> = Promise.resolve()
async function stopCamera() {
  const session = ownedSession
  ownedSession = null
  const previous = stopping
  const current = session
    ? session.stopRunning().finally(() => session.dispose()) : Promise.resolve()
  // A resource handoff can cancel a start while its permission request is open.
  // A later start must also await that earlier stop before acquiring the camera.
  stopping = Promise.all([previous, current]).then(() => {}, () => {})
  await Promise.all([previous, current])
}

function Demo() {
  const dismiss = Navigation.useDismiss()
  const [batch, setBatch] = useState(incoming)
  const [message, setMessage] = useState("Ready")
  const [image, setImage] = useState<UIImage | null>(null)
  const [video, setVideo] = useState<AVPlayer | null>(null)
  const [selected, setSelected] = useState<CameraControlAsset | null>(null)
  const [camera, setCamera] = useState<{ session: AVCaptureSession; device: AVCaptureDevice; photo: AVCapturePhotoOutput } | null>(null)
  const [busy, setBusy] = useState(false)
  const [captureMode, setCaptureMode] = useState(captureRequested)
  const pendingCamera = useRef(false)
  const revision = useRef(0)
  const working = useRef(false)
  const currentCamera = useRef(camera)
  currentCamera.current = camera
  const report = (value: string) => { if (alive) setMessage(value) }

  async function run(action: () => Promise<void>) {
    if (!alive || closing || working.current) return
    const startedAt = revision.current
    working.current = true; setBusy(true)
    try { await action() } catch (error) { if (revision.current === startedAt) report(String(error)) }
    finally {
      working.current = false
      if (alive) {
        setBusy(false)
        // Do not lose an entry request that arrived while another action awaited.
        if (pendingCamera.current) void run(startCamera)
      }
    }
  }

  async function closeCamera() {
    if (closing) return
    closing = true
    // Reject new native file deliveries while this instance is closing.
    unsubscribe()
    revision.current += 1
    pendingCamera.current = false
    setBusy(true)
    report("Closing camera…")
    // Keep the preview mounted until native stop finishes. Dismissing first
    // can make preview teardown wait synchronously for the running camera.
    try { await stopCamera() }
    catch (error) { report(String(error)) }
    finally { dismiss() }
  }

  async function startCamera() {
    pendingCamera.current = false
    const startedAt = revision.current
    const existing = currentCamera.current
    if (existing && ownedSession === existing.session && existing.session.isRunning) return
    currentCamera.current = null; setCamera(null)
    await stopCamera()
    if (!alive || closing || revision.current !== startedAt) return
    if (ownedPlayer) { ownedPlayer.dispose(); ownedPlayer = null; setVideo(null) }
    const device = AVCaptureDevice.default("video")
    if (!device) throw new Error("No camera is available. Simulator can still test the page and file handoff.")
    const session = new AVCaptureSession()
    const photo = new AVCapturePhotoOutput()
    ownedSession = session
    try {
      const input = new AVCaptureDeviceInput(device)
      session.configure(() => {
        if (!session.canAddInput(input)) throw new Error("Cannot add the camera input")
        session.addInput(input)
        if (!session.canAddOutput(photo)) throw new Error("Cannot add photo output")
        session.addOutput(photo)
        if (session.supportsControls) {
          const zoom = new AVCaptureSystemZoomSlider(device)
          if (session.canAddControl(zoom)) session.addControl(zoom)
          session.setControlsDelegate({ didBecomeActive() {}, willEnterFullscreenAppearance() {},
            willExitFullscreenAppearance() {}, didBecomeInactive() {} })
        }
      })
      await session.startRunning()
      if (!alive || closing || revision.current !== startedAt || ownedSession !== session) {
        session.dispose(); if (ownedSession === session) ownedSession = null
        return
      }
      currentCamera.current = { session, device, photo }
      setCamera(currentCamera.current); setImage(null); setSelected(null)
      report("Camera ready. Touch Capture or use a physical capture button.")
    } catch (error) { session.dispose(); if (ownedSession === session) ownedSession = null; throw error }
  }

  async function capture() {
    const startedAt = revision.current
    const current = currentCamera.current
    if (!current || ownedSession !== current.session) return
    const folder = Path.join(FileManager.documentsDirectory, "Camera Control Demo")
    await FileManager.createDirectory(folder, true)
    const filePath = Path.join(folder, `photo-${Date.now()}.jpg`)
    const result = await current.photo.capturePhoto({ codec: "jpeg", photoFile: filePath })
    if (!alive || closing || revision.current !== startedAt) return
    setImage(result.image)
    setSelected({ id: filePath, type: "photo", filePath, typeIdentifier: "public.jpeg", capturedAt: Date.now() })
    report(`Photo saved: ${filePath}`)
  }

  async function preview(asset: CameraControlAsset) {
    const startedAt = revision.current
    // Keep the visible viewfinder alive in a Camera Control capture experience.
    if (!captureRequested) { await stopCamera(); currentCamera.current = null; if (alive) setCamera(null) }
    if (!alive || closing || revision.current !== startedAt) return
    setSelected(asset); setImage(null)
    ownedPlayer?.dispose(); ownedPlayer = null; setVideo(null)
    if (asset.type === "photo") {
      const decoded = UIImage.fromFile(asset.filePath)
      if (!decoded) throw new Error("Could not decode the photo")
      setImage(decoded)
    } else {
      const player = new AVPlayer()
      if (!player.setSource(asset.filePath)) { player.dispose(); throw new Error("Could not open the video") }
      ownedPlayer = player; setVideo(player)
    }
    report(`${asset.type}: ${asset.filePath}`)
  }

  useEffect(() => {
    receive = next => {
      if (!alive || closing) return
      if (!next) {
        captureRequested = true; setCaptureMode(true)
        pendingCamera.current = true
        if (!working.current) void run(startCamera)
        return
      }
      incoming = next
      revision.current += 1
      setBatch(next); setSelected(null); setImage(null)
      ownedPlayer?.dispose(); ownedPlayer = null; setVideo(null)
      report(`Received ${next.assets.length} files · ${next.captureId}`)
      if (captureRequested && currentCamera.current?.session === ownedSession && ownedSession?.isRunning) {
        // A batch update must not tear down a live, visible capture experience.
        return
      }
      currentCamera.current = null; setCamera(null)
      void stopCamera().catch(error => report(String(error)))
      if (captureRequested) {
        pendingCamera.current = true
        if (!working.current) void run(startCamera)
      }
    }
    // File handoff alone never starts capture or repeats file processing.
    if (captureRequested) void run(startCamera)
    return () => { receive = () => {}; currentCamera.current = null }
  }, [])

  return <NavigationStack onCameraCaptureEvent={{
    // Keep system capture interaction available while processing a photo.
    // run() ignores duplicate actions until the current operation finishes.
    isEnabled: !!camera,
    primaryAction: event => { if (event.phase === "ended") void run(capture) },
  }}><VStack spacing={0}>
    {camera ? <CaptureVideoPreviewView session={camera.session} videoDevice={camera.device}
      frame={{ height: 240 }} accessibilityLabel="Camera preview" /> : null}
    <List navigationTitle="Camera Control Demo"
    toolbar={{ cancellationAction: <Button title="Close" action={() => { void closeCamera() }} disabled={closing} /> }}>
    <Section header={<Text>Entry</Text>}>
      <Text>{`Environment: ${Script.env}`}</Text>
      <Text>{message}</Text>
      <Text>{batch ? `Capture batch: ${batch.captureId}` : "No handed-off batch"}</Text>
    </Section>
    {batch ? <Section header={<Text>Captured files</Text>}>
      {batch.assets.map(asset => <Button key={asset.id}
        title={`${asset.type} · ${Path.basename(asset.filePath)}`}
        action={() => { void run(() => preview(asset)) }} disabled={busy} />)}
    </Section> : null}
    <Section header={<Text>Script camera</Text>}>
      <Button title="Start Camera" action={() => { void run(startCamera) }} disabled={busy || !!camera} />
      <Button title="Capture" action={() => { void run(capture) }} disabled={busy || !camera} />
      {captureMode ? <Text>Keep this viewfinder open while using Camera Control. Use Close to finish.</Text>
        : <Button title="Stop Camera" action={() => { void run(async () => {
          await stopCamera(); currentCamera.current = null; if (alive) setCamera(null)
        }) }} disabled={busy || !camera} />}
    </Section>
    {image ? <Image image={image} resizable scaleToFit accessibilityLabel="Captured photo" /> : null}
    {video ? <VideoPlayer player={video} frame={{ height: 300 }} /> : null}
    {selected ? <Button title="Save Selected to Photos" disabled={busy} action={() => {
      void run(async () => {
        const success = selected.type === "photo"
          ? await Photos.savePhoto(selected.filePath, { shouldMoveFile: false })
          : await Photos.saveVideo(selected.filePath, { shouldMoveFile: false })
        report(success ? "Saved to Photos" : "The file was not saved to Photos")
      })
    }} /> : null}
  </List></VStack></NavigationStack>
}

async function main() {
  try { await Navigation.present({ element: <Demo />, modalPresentationStyle: "fullScreen" }) }
  finally {
    closing = true; alive = false; unsubscribe()
    ownedPlayer?.dispose(); ownedPlayer = null
    try { await stopCamera() } finally { Script.exit() }
  }
}
void main()