const hostAvailable = Boolean(window.chrome?.webview)
const appRoot = document.getElementById("app")
const pendingReplies = new Map()
const audioElement = new Audio()
audioElement.preload = "auto"

let requestSequence = 0
let audioDecodeToken = 0
let playbackFrame = 0
let mediaSurfaceFrame = 0
let lastTimelinePreviewSceneId = ""
let waveformInteraction = null
const storyboardFrameAspectRatio = 16 / 9
const defaultFramingMode = "source"
const defaultPanMode = "none"
const framingModes = [
  { value: "source", label: "Source", aspectRatio: null, presentation: "source" },
  { value: "verticalFill", label: "Fill 9:16", aspectRatio: 9 / 16, presentation: "fill" },
  { value: "verticalFit", label: "Fit 9:16", aspectRatio: 9 / 16, presentation: "fit" },
  { value: "square", label: "Square 1:1", aspectRatio: 1, presentation: "fill" },
  { value: "widescreen", label: "Widescreen 16:9", aspectRatio: 16 / 9, presentation: "fill" },
]
const initialProject = makeProject(1)

const state = {
  project: initialProject,
  showStartScreen: false,
  activeScreen: "board",
  editorMode: "simple",
  boardLayoutMode: "vertical",
  selectedSceneId: initialProject.scenes[0]?.id ?? null,
  waveformSamples: [],
  waveformLoading: false,
  audioPlayhead: 0,
  isAudioPlaying: false,
  statusMessage: "Ready for scene 1.",
  boardPreviewAudioSceneId: null,
  currentProjectPath: "",
  host: {
    grokExpanded: false,
  },
  pendingAutoSpread: false,
  dropTargetSceneId: null,
  cropEditor: null,
  pendingGrokUploadDrag: null,
  suppressChooseMediaUntil: 0,
  suppressBoardClickUntil: 0,
  boardScrollDrag: null,
}

installRuntimeDiagnostics()

audioElement.addEventListener("ended", () => {
  state.isAudioPlaying = false
  state.audioPlayhead = currentTimelineDuration()
  cancelPlaybackLoop()
  updatePlaybackUi()
})

appRoot.addEventListener("click", handleClick)
appRoot.addEventListener("pointerdown", handleScenePointerDown)
appRoot.addEventListener("change", handleChange)
appRoot.addEventListener("dragstart", handleMediaDragStart)
appRoot.addEventListener("dragend", handleMediaDragEnd)
appRoot.addEventListener("dragover", handleDragOver)
appRoot.addEventListener("dragleave", handleDragLeave)
appRoot.addEventListener("drop", handleDrop)
appRoot.addEventListener("wheel", handleAppWheel, { passive: false })
window.addEventListener("keydown", handleKeydown)
window.addEventListener("pointermove", handleGlobalPointerMove)
window.addEventListener("pointerup", handleGlobalPointerUp)
window.addEventListener("pointercancel", handleGlobalPointerUp)
window.addEventListener("resize", () => {
  if (state.activeScreen === "timeline" && !state.showStartScreen) {
    drawWaveform()
    syncTimelinePreviewVideo()
  }
  syncCropEditorOverlay()
  drawAllMediaStages()
  refreshMediaSurfacePump()
})

if (hostAvailable) {
  window.chrome.webview.addEventListener("message", handleHostMessage)
}

window.storyboardForgeHostGetSceneIdAtPoint = hostSceneIdAtPoint
window.storyboardForgeHostSetDropTarget = hostSetDropTarget
window.storyboardForgeHostClearDropTarget = hostClearDropTarget
window.storyboardForgeHostImportAssetIntoScene = hostImportAssetIntoScene
window.storyboardForgeDebugGetProjectSnapshot = debugGetProjectSnapshot
window.storyboardForgeDebugResetForAutomation = debugResetForAutomation

try {
  boot()
} catch (error) {
  reportFatalError(error, "Storyboard Forge startup failed")
}

async function boot() {
  try {
    if (hostAvailable) {
      try {
        const hostState = await hostRequest("getHostState")
        state.host.grokExpanded = Boolean(hostState?.grokExpanded)
      } catch (error) {
        state.statusMessage = `Host bridge failed: ${String(error)}`
      }
    } else {
      state.statusMessage = "Running without the native host bridge."
    }

    state.project = normalizeProject(state.project)
    render()
    refreshMediaSurfacePump()
  } catch (error) {
    reportFatalError(error, "Storyboard Forge boot failed")
  }
}

function installRuntimeDiagnostics() {
  window.addEventListener("error", (event) => {
    reportFatalError(event.error || event.message || "Unknown script error", "Storyboard Forge runtime error")
  })

  window.addEventListener("unhandledrejection", (event) => {
    reportFatalError(event.reason || "Unhandled promise rejection", "Storyboard Forge promise rejection")
  })
}

function reportFatalError(error, heading = "Storyboard Forge failed") {
  const message = error instanceof Error
    ? `${error.name}: ${error.message}\n${error.stack || ""}`
    : String(error)

  console.error(heading, error)

  if (!appRoot) {
    return
  }

  appRoot.innerHTML = `
    <div style="min-height:100vh;padding:32px;background:#171b24;color:#f8f8fb;font:14px/1.5 'Segoe UI',sans-serif;">
      <div style="max-width:980px;margin:0 auto;">
        <h1 style="margin:0 0 12px;font-size:34px;">Storyboard Forge</h1>
        <p style="margin:0 0 16px;color:#ffb36a;font-weight:700;">${escapeHtml(heading)}</p>
        <pre style="margin:0;white-space:pre-wrap;word-break:break-word;padding:18px;border-radius:18px;background:#0f1218;border:1px solid rgba(255,255,255,.12);">${escapeHtml(message)}</pre>
      </div>
    </div>
  `
}

function makeProject(sceneCount = 1) {
  return {
    id: crypto.randomUUID(),
    title: "Untitled Storyboard",
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    scenes: freshScenes(sceneCount),
    audioTrack: {
      asset: null,
      duration: 0,
    },
  }
}

function freshScenes(count) {
  return Array.from({ length: count }, () => makeScene())
}

function makeScene() {
  return {
    id: crypto.randomUUID(),
    name: "",
    prompt: "",
    caption: "",
    lyrics: "",
    transition: "None",
    startTime: 0,
    primaryVideo: null,
    primaryImage: null,
    videoMetadata: null,
    framingMode: defaultFramingMode,
    manualCropRect: null,
    panMode: defaultPanMode,
    panStartRect: null,
    panEndRect: null,
    isPrimaryVideoMuted: true,
    extraImages: {},
    isPromptExpanded: true,
    isCaptionExpanded: false,
    isLyricsExpanded: false,
    isExtrasExpanded: false,
  }
}

function render() {
  ensureSelectedScene()
  appRoot.innerHTML = `
    <div class="shell">
      ${renderWorkspace()}
      ${renderCropEditor()}
    </div>
  `

  lastTimelinePreviewSceneId = timelinePreviewScene()?.id ?? ""
  bindWaveformCanvas()
  syncCropEditorOverlay()
  drawAllMediaStages()
  refreshMediaSurfacePump()
  updatePlaybackUi()
}

function renderStartScreen() {
  return `
    <div class="start-shell">
      <div class="start-panel">
        <section class="start-card">
          <p class="brand-eyebrow">Storyboard Forge</p>
          <h2>Start from an image</h2>
          <p>
            Drop straight into storyboard mode with a first frame already loaded, then keep building scenes from there.
          </p>
          <div class="start-actions">
            <button class="primary-button" data-action="start-image">Add Image</button>
          </div>
        </section>
        <section class="start-card">
          <p class="brand-eyebrow">Timing First</p>
          <h2>Start from audio</h2>
          <p>
            Build an 8-scene timing skeleton, generate the waveform, and place scene markers against the song immediately.
          </p>
          <div class="start-actions">
            <button class="primary-button" data-action="start-audio">Add Audio</button>
            <button class="ghost-button" data-action="open-project">Open Project</button>
          </div>
        </section>
      </div>
    </div>
  `
}

function renderWorkspace() {
  const addSceneAction = state.activeScreen === "timeline" ? "add-marker" : "add-scene"
  const addSceneLabel = state.activeScreen === "timeline" ? "+ Add Point" : "+ Add Scene"
  return `
    <div class="workspace">
      <header class="hero-panel">
        <div class="hero-title-row">
          <div class="hero-copy">
            <h1 class="hero-title">Storyboard Forge</h1>
            <p class="hero-subtitle">${escapeHtml(modeSubtitle())}</p>
          </div>
          <div class="auto-save-badge">${escapeHtml(autoSaveBadgeText())}</div>
        </div>
        <div class="hero-controls">
          <div class="control-group">
            <span class="control-label">Mode</span>
            <div class="segmented-group">
              <button class="toolbar-button ${state.editorMode === "simple" ? "is-active" : ""}" data-action="switch-mode" data-mode="simple">Simple</button>
              <button class="toolbar-button ${state.editorMode === "advanced" ? "is-active" : ""}" data-action="switch-mode" data-mode="advanced">Advanced</button>
            </div>
          </div>
          <div class="control-group">
            <span class="control-label">Board Layout</span>
            <div class="segmented-group">
              <button class="toolbar-button ${state.boardLayoutMode === "vertical" ? "is-active" : ""}" data-action="switch-layout" data-layout="vertical">Vertical</button>
              <button class="toolbar-button ${state.boardLayoutMode === "horizontal" ? "is-active" : ""}" data-action="switch-layout" data-layout="horizontal">Horizontal</button>
            </div>
          </div>
          <button class="primary-button primary-add-button" data-action="${addSceneAction}">${addSceneLabel}</button>
          <button class="ghost-button export-button" data-action="export-project">Export</button>
          <div class="hero-status">${escapeHtml(headerStatusText())}</div>
        </div>
        <div class="utility-row">
          <div class="utility-actions">
            <button class="toolbar-button ${state.activeScreen === "board" ? "is-active" : ""}" data-action="switch-screen" data-screen="board">Storyboard</button>
            <button class="toolbar-button ${state.activeScreen === "timeline" ? "is-active" : ""}" data-action="switch-screen" data-screen="timeline">Timeline</button>
            <button class="toolbar-button" data-action="open-project">Open</button>
            <button class="toolbar-button" data-action="save-project">Save</button>
            <button class="toolbar-button" data-action="save-project-as">Save As</button>
            <button class="toolbar-button" data-action="choose-audio">${state.project.audioTrack.asset ? "Replace Audio" : "Import Audio"}</button>
            <button class="danger-button" data-action="start-over">Start Over</button>
          </div>
          <div class="utility-note">${escapeHtml(projectLocationSummary())}</div>
        </div>
      </header>
      <main class="content-grid ${state.activeScreen}">
        ${state.activeScreen === "board" ? renderBoardView() : renderTimelineView()}
      </main>
    </div>
  `
}

function renderBoardView() {
  return `
    <section class="board-panel ${state.boardLayoutMode}">
      <div class="board-panel-body" data-board-scroll="${state.boardLayoutMode}">
        <div class="scene-grid ${state.boardLayoutMode}">
          ${state.project.scenes.map((scene, index) => renderSceneCard(scene, index)).join("")}
        </div>
      </div>
    </section>
  `
}

function renderSceneCard(scene, index) {
  const isSelected = state.selectedSceneId === scene.id
  const audioLive = state.boardPreviewAudioSceneId === scene.id && !scene.isPrimaryVideoMuted
  const canShowSimpleDelete = state.project.scenes.length > 1 || sceneHasMedia(scene) || !isBlankScene(scene)
  const manualCropIsSet = sceneHasManualCrop(scene)

  return `
    <article class="scene-card ${isSelected ? "is-selected" : ""} ${state.dropTargetSceneId === scene.id ? "is-drop-target" : ""}" data-scene-id="${scene.id}">
      <div class="scene-head">
        <div class="scene-head-main">
          <span class="scene-index">${index + 1}</span>
          <input
            class="scene-title-input"
            type="text"
            data-field="scene-name"
            data-scene-id="${scene.id}"
            value="${escapeAttr(scene.name || `Scene ${index + 1}`)}"
          >
        </div>
        <div class="scene-head-actions">
          ${sceneHasMedia(scene) ? `<button class="toolbar-button compact-button" data-action="open-crop-editor" data-scene-id="${scene.id}">Crop</button>` : ""}
          ${sceneHasMedia(scene) ? `<button class="toolbar-button compact-button" data-action="reset-scene-crop" data-scene-id="${scene.id}" ${manualCropIsSet || sceneHasPanPath(scene) ? "" : "disabled"}>Reset</button>` : ""}
          ${state.editorMode === "advanced" ? `<button class="toolbar-button compact-button" data-action="duplicate-scene" data-scene-id="${scene.id}">Duplicate</button>` : ""}
          ${state.editorMode === "advanced" && sceneHasMedia(scene) ? `<button class="toolbar-button compact-button" data-action="clear-primary-media" data-scene-id="${scene.id}">Clear</button>` : ""}
          ${canShowSimpleDelete ? `<button class="danger-button compact-button" data-action="delete-scene" data-scene-id="${scene.id}">Delete</button>` : ""}
        </div>
      </div>
      <div class="scene-media ${sceneHasMedia(scene) ? "has-media is-editable" : "is-empty"}" data-action="${sceneHasMedia(scene) ? "open-crop-editor" : "choose-primary-media"}" data-scene-id="${scene.id}">
        ${renderSceneMedia(scene, index, "board", audioLive)}
        <button class="scene-surface-chip" data-action="choose-primary-media" data-scene-id="${scene.id}">${sceneHasMedia(scene) ? "Replace Media" : "Import Media"}</button>
      </div>
      ${sceneHasMedia(scene) ? renderMotionTools(scene) : ""}
      ${state.editorMode === "advanced"
        ? `
          <div class="scene-actions">
            <button class="media-button" data-action="select-scene" data-scene-id="${scene.id}">${isSelected ? "Selected" : "Select"}</button>
            <button class="media-button" data-action="choose-primary-media" data-scene-id="${scene.id}">Choose Media</button>
            ${sceneHasMedia(scene) ? `<button class="media-button" data-action="clear-primary-media" data-scene-id="${scene.id}">Clear Media</button>` : ""}
            <button class="media-button" data-action="duplicate-scene" data-scene-id="${scene.id}">Duplicate</button>
          </div>
          ${renderAdvancedFields(scene)}
        `
        : renderSimpleSceneMeta(scene, index)}
    </article>
  `
}

function renderSimpleSceneMeta(scene, index) {
  if (sceneHasMedia(scene)) {
    return ""
  }

  return `
    <div class="scene-simple-meta">
      <p class="scene-simple-note">${escapeHtml(sceneCardSummary(scene, index))}</p>
    </div>
  `
}

function renderAdvancedFields(scene) {
  return `
    <div class="field-stack">
      <label class="field-label">Prompt
        <textarea class="field-textarea" data-field="scene-prompt" data-scene-id="${scene.id}">${escapeHtml(scene.prompt)}</textarea>
      </label>
      <label class="field-label">Caption
        <textarea class="field-textarea" data-field="scene-caption" data-scene-id="${scene.id}">${escapeHtml(scene.caption)}</textarea>
      </label>
      <label class="field-label">Lyrics
        <textarea class="field-textarea" data-field="scene-lyrics" data-scene-id="${scene.id}">${escapeHtml(scene.lyrics)}</textarea>
      </label>
      <div class="field-grid">
        <button class="media-button" data-action="copy-packet" data-scene-id="${scene.id}">Copy Packet</button>
      </div>
    </div>
  `
}

function renderSceneMedia(scene, index, context, audioLive = false) {
  if (!sceneHasMedia(scene)) {
    return `
      <div class="scene-media-placeholder">
        <div>
          <strong>Drop image or video here</strong>
          <p class="panel-copy">Drag straight from Grok or click to import.</p>
        </div>
      </div>
    `
  }

  const label = escapeAttr(scene.name || `Scene ${index + 1}`)
  const isVideo = Boolean(scene.primaryVideo)
  const canDragToGrok = hostAvailable && context !== "editor"
  const dragAttributes = canDragToGrok
    ? `draggable="true" data-grok-upload-scene-id="${scene.id}" title="Drag this media into Grok"`
    : `draggable="false"`
  const sourceMarkup = isVideo
    ? `
        <video
        ${context === "timeline" ? `id="timelinePreviewVideo"` : ""}
        class="media-source"
        data-media-source
        src="${escapeAttr(scene.primaryVideo?.url ?? "")}"
        autoplay
        loop
        playsinline
        preload="auto"
        ${audioLive ? "" : "muted"}
      ></video>
    `
    : `<img class="media-source" data-media-source src="${escapeAttr(scene.primaryImage?.url ?? "")}" alt="${label}">`

  return `
      <div class="media-stage ${canDragToGrok ? "is-grok-draggable" : ""}" data-media-stage data-scene-id="${scene.id}" data-render-context="${context}" data-has-video="${isVideo}" ${dragAttributes}>
        <canvas class="media-canvas" data-media-canvas></canvas>
        ${sourceMarkup}
      </div>
    ${isVideo && context === "board" ? `
      <button class="audio-pill ${audioLive ? "is-live" : ""}" data-action="toggle-scene-audio" data-scene-id="${scene.id}">
        ${audioLive ? "Audio is live" : "Click for audio"}
      </button>
    ` : ""}
  `
}

function renderMotionTools(scene) {
  const manualCropIsSet = sceneHasManualCrop(scene)
  const panPathIsSet = sceneHasPanPath(scene)
  const helperCopy = panPathIsSet
    ? "A motion path is animating the 16:9 frame across this shot."
    : manualCropIsSet
      ? "A hand crop is locked in. Add pan only if you want motion inside it."
      : "Auto Crop is a quick framing rule. Use Crop above for a precise hand crop."
  return `
    <section class="motion-card">
      <div class="motion-card-head">
        <div>
          <h3 class="motion-card-title">Auto Crop & Motion</h3>
          <p class="panel-copy">${escapeHtml(helperCopy)}</p>
        </div>
        <span class="motion-badge">${escapeHtml(sceneMotionSummary(scene))}</span>
      </div>
      <div class="motion-card-body">
        <label class="field-label motion-select-field">Auto Crop
          <select class="field-select" data-field="scene-framing-mode" data-scene-id="${scene.id}">
            ${framingModes.map((mode) => `<option value="${mode.value}" ${normalizeFramingMode(scene.framingMode) === mode.value ? "selected" : ""}>${escapeHtml(mode.label)}</option>`).join("")}
          </select>
        </label>
        <div class="motion-button-row">
          <button class="media-button" data-action="open-pan-editor" data-scene-id="${scene.id}">${panPathIsSet ? "Edit Pan" : "Add Pan"}</button>
          <button class="media-button" data-action="reset-pan" data-scene-id="${scene.id}" ${panPathIsSet ? "" : "disabled"}>Reset Pan</button>
        </div>
      </div>
    </section>
  `
}

function renderTimelineView() {
  const previewScene = timelinePreviewScene()
  const previewMarkup = previewScene ? renderTimelinePreview(previewScene) : ""
  const audioLoaded = Boolean(state.project.audioTrack.asset)

  return `
    <section class="panel">
      <div class="panel-body timeline-layout">
        <div class="timeline-controls-card">
          <div class="timeline-toolbar">
            <button class="timeline-button" data-action="choose-audio">${audioLoaded ? "Replace Audio" : "Import Audio"}</button>
            <button class="timeline-button" data-action="play-toggle" ${audioLoaded ? "" : "disabled"}>${state.isAudioPlaying ? "Pause" : "Play"}</button>
            <button class="timeline-button" data-action="auto-space" ${state.project.scenes.length > 0 ? "" : "disabled"}>Auto-Space</button>
            <button class="timeline-button" data-action="add-marker">Add Point</button>
          </div>
          <div class="timeline-secondary-toolbar">
            <button class="timeline-button" data-action="build-skeleton-8">Build 8 Scenes</button>
            <button class="timeline-button" data-action="build-skeleton-12">Build 12 Scenes</button>
            <button class="danger-button" data-action="delete-selected-marker" ${state.selectedSceneId ? "" : "disabled"}>Delete Point</button>
          </div>
        </div>
        ${previewMarkup}
        <div class="waveform-card">
          <div class="waveform-meta">
            <div>
              <h3 class="timeline-title">Waveform Timing</h3>
              <p class="timeline-subtitle">
                ${audioLoaded
                  ? `Space toggles playback. Drag numbered markers directly on the waveform.`
                  : `Import audio to use timeline mode. Until then, the storyboard rides on a blank waveform placeholder.`}
              </p>
            </div>
            <div>
              <div class="timeline-time" data-playhead-readout>Playhead ${escapeHtml(formattedTime(state.audioPlayhead))}</div>
              <div class="muted-note" data-duration-readout>Duration ${escapeHtml(formattedTime(currentTimelineDuration()))}</div>
            </div>
          </div>
          <div class="waveform-shell">
            <canvas id="waveformCanvas"></canvas>
            ${audioLoaded ? "" : `
              <div class="placeholder-overlay">
                <strong>Import audio to use timeline mode</strong>
                <div class="muted-note">The waveform is a placeholder until the soundtrack is loaded.</div>
              </div>
            `}
          </div>
        </div>
      </div>
      </section>
      <aside class="timeline-rail-card">
        <div class="timeline-rail-head">
          <div>
            <h3 class="timeline-title">Scene Markers</h3>
            <p class="timeline-subtitle">Compact timing handles for each scene.</p>
          </div>
          <span class="timeline-rail-count">${state.project.scenes.length}</span>
        </div>
        <div class="timeline-rail-list">
          ${state.project.scenes.map((scene, index) => renderTimelineRailItem(scene, index)).join("")}
        </div>
      </aside>
  `
}

function renderTimelinePreview(scene) {
  const audioLive = !scene.isPrimaryVideoMuted
  return `
    <section class="timeline-preview-card">
      <div>
        <h3 class="timeline-preview-title">Playback Preview</h3>
        <p class="timeline-subtitle">${escapeHtml(previewSubtitle(scene))}</p>
      </div>
      <div class="timeline-preview-media" data-timeline-preview-media>
        ${sceneHasMedia(scene)
          ? renderSceneMedia(scene, sceneOrdinal(scene.id) - 1, "timeline", audioLive)
          : `<div class="scene-media-placeholder"><div><strong>No media on this scene yet</strong><p class="panel-copy">Add an image or video and it will preview here.</p></div></div>`
        }
      </div>
      <div class="timeline-preview-meta">
        ${scene.primaryVideo ? `
          <button class="audio-pill ${audioLive ? "is-live" : ""}" data-action="toggle-scene-audio" data-scene-id="${scene.id}">
            ${audioLive ? "Audio is live" : "Click for audio"}
          </button>
        ` : ""}
        <span class="timeline-time" data-preview-offset>Offset ${escapeHtml(formattedTime(Math.max(state.audioPlayhead - scene.startTime, 0)))}</span>
      </div>
    </section>
  `
}

function renderCropEditor() {
  const editor = state.cropEditor
  const scene = editor ? findScene(editor.sceneId) : null
  if (!editor || !scene || !sceneHasMedia(scene)) {
    return ""
  }

  const isPanEditor = editor.editorMode === "pan"
  const mediaLabel = escapeHtml(scene.name || `Scene ${sceneOrdinal(scene.id)}`)
  const editorSource = scene.primaryVideo
    ? `
      <video
        class="crop-editor-source"
        data-crop-editor-source
        src="${escapeAttr(scene.primaryVideo.url ?? "")}"
        autoplay
        loop
        muted
        playsinline
        preload="auto"
      ></video>
    `
    : `<img class="crop-editor-source" data-crop-editor-source src="${escapeAttr(scene.primaryImage?.url ?? "")}" alt="${mediaLabel}">`

  return `
    <div class="crop-editor-backdrop">
      <section class="crop-editor-modal" role="dialog" aria-label="${isPanEditor ? "Pan path editor" : "Crop editor"}">
        <header class="crop-editor-header">
          <div>
            <h2 class="crop-editor-title">${isPanEditor ? "Pan Path" : "Manual Crop"}</h2>
            <p class="panel-copy">${isPanEditor ? "Set the start and end crop boxes. The shot will move between them while the clip plays." : "Drag the crop box or pull any corner freely. Everything outside the crop is removed from the storyboard shot."}</p>
          </div>
          <div class="crop-editor-actions">
            ${isPanEditor
              ? `
                <div class="segmented-group">
                  <button class="toolbar-button ${editor.activeViewport === "start" ? "is-active" : ""}" data-action="set-editor-viewport" data-viewport="start">Start</button>
                  <button class="toolbar-button ${editor.activeViewport === "end" ? "is-active" : ""}" data-action="set-editor-viewport" data-viewport="end">End</button>
                </div>
              `
              : `<span class="motion-badge">Free Crop</span>`
            }
            <button class="toolbar-button" data-action="${isPanEditor ? "reset-pan-editor" : "reset-crop-editor"}" data-scene-id="${scene.id}">Reset</button>
            <button class="toolbar-button" data-action="close-crop-editor">Cancel</button>
            <button class="primary-button" data-action="${isPanEditor ? "apply-pan-editor" : "apply-crop-editor"}">${isPanEditor ? "Apply Pan" : "Apply Crop"}</button>
          </div>
        </header>
        <div class="crop-editor-layout">
          <section class="crop-editor-stage-shell">
            <div class="crop-editor-stage" data-crop-editor-stage>
              ${editorSource}
              ${renderCropEditorViewports(editor)}
            </div>
          </section>
          <aside class="crop-editor-sidebar">
            <section class="motion-card">
              <div class="motion-card-head">
                <div>
                  <h3 class="motion-card-title">Live Preview</h3>
                  <p class="panel-copy">${isPanEditor ? `${mediaLabel} with motion` : `${mediaLabel} fitted inside the final 16:9 frame`}</p>
                </div>
                <span class="motion-badge">${escapeHtml(sceneMotionSummary(scene, editor))}</span>
              </div>
              <div class="crop-editor-preview">
                ${renderSceneMedia(scene, sceneOrdinal(scene.id) - 1, "editor", false)}
              </div>
            </section>
              <section class="motion-card">
                <div class="motion-card-head">
                  <div>
                    <h3 class="motion-card-title">What This Saves</h3>
                    <p class="panel-copy">${isPanEditor ? "The pan path is saved separately from Auto Crop, so you can reset motion without losing the hand crop." : "The hand crop is saved into the project and then fitted inside this scene's 16:9 storyboard frame."}</p>
                  </div>
                </div>
              </section>
            </aside>
          </div>
      </section>
    </div>
  `
}

function renderCropEditorViewports(editor) {
  if (editor.editorMode === "pan") {
    return `
      <div class="crop-editor-viewport" data-pan-viewport="start">
        <span class="crop-editor-viewport-label">Start</span>
        ${renderViewportHandles("start")}
      </div>
      <div class="crop-editor-viewport" data-pan-viewport="end">
        <span class="crop-editor-viewport-label">End</span>
        ${renderViewportHandles("end")}
      </div>
    `
  }

  return `
    <div class="crop-editor-viewport is-active" data-pan-viewport="crop">
      <span class="crop-editor-viewport-label">Crop</span>
      ${renderViewportHandles("crop")}
    </div>
  `
}

function renderViewportHandles(viewportId) {
  return ["topLeft", "topRight", "bottomLeft", "bottomRight"]
    .map((corner) => `<button class="crop-editor-handle" data-pan-handle="${corner}" data-viewport="${viewportId}" aria-label="${viewportId} ${corner} handle"></button>`)
    .join("")
}

function renderTimelineRailItem(scene, index) {
  const displayedTime = displayedMarkerTime(scene, index)
  const label = scene.name?.trim() || `Scene ${index + 1}`
  const summary = timelineMarkerSummary(scene)
  return `
    <article class="timeline-rail-item ${state.selectedSceneId === scene.id ? "is-selected" : ""}">
      <div class="timeline-rail-row">
        <div class="timeline-rail-scene">
          <span class="timeline-rail-index">${index + 1}</span>
          <strong class="timeline-rail-name">${escapeHtml(label)}</strong>
        </div>
        <span class="timeline-time">${escapeHtml(formattedTime(displayedTime))}</span>
      </div>
      <div class="timeline-rail-summary">${escapeHtml(summary)}</div>
      <div class="timeline-rail-actions">
        <button class="timeline-rail-button ${state.selectedSceneId === scene.id ? "is-active" : ""}" data-action="select-scene" data-scene-id="${scene.id}">
          ${state.selectedSceneId === scene.id ? "Focused" : "Focus"}
        </button>
        <button class="timeline-rail-button is-accent" data-action="use-playhead" data-scene-id="${scene.id}">Set Here</button>
        <button class="timeline-rail-button is-danger" data-action="delete-scene" data-scene-id="${scene.id}">Delete</button>
      </div>
    </article>
  `
}

async function handleClick(event) {
  if (performance.now() < state.suppressBoardClickUntil) {
    const target = event.target
    if (target instanceof Element && target.closest("[data-board-scroll]")) {
      event.preventDefault()
      return
    }
  }

  const actionTarget = event.target.closest("[data-action]")
  if (!actionTarget) {
    return
  }

  const action = actionTarget.dataset.action
  const sceneId = actionTarget.dataset.sceneId

  switch (action) {
    case "start-image":
      await startFromImage()
      break
    case "start-audio":
      await startFromAudio()
      break
    case "open-project":
      await openProject()
      break
    case "export-project":
      await exportProject()
      break
    case "save-project":
      await saveProject(false)
      break
    case "save-project-as":
      await saveProject(true)
      break
    case "start-over":
      await startOver()
      break
    case "switch-screen":
      state.activeScreen = actionTarget.dataset.screen
      render()
      break
    case "switch-mode":
      state.editorMode = actionTarget.dataset.mode
      render()
      break
    case "switch-layout":
      state.boardLayoutMode = actionTarget.dataset.layout
      render()
      break
    case "add-scene":
      addScene()
      render()
      break
    case "select-scene":
      selectScene(sceneId)
      render()
      break
    case "choose-primary-media":
      if (performance.now() < state.suppressChooseMediaUntil) {
        return
      }
      await choosePrimaryMedia(sceneId)
      break
    case "clear-primary-media":
      clearPrimaryMedia(sceneId)
      render()
      break
    case "duplicate-scene":
      duplicateScene(sceneId)
      render()
      break
    case "delete-scene":
      deleteScene(sceneId)
      render()
      break
    case "choose-audio":
      await chooseAudio()
      break
    case "play-toggle":
      await togglePlayback()
      break
    case "auto-space":
      distributeScenesEvenly()
      render()
      break
    case "build-skeleton-8":
      ensureSceneSkeleton(8)
      render()
      break
    case "build-skeleton-12":
      ensureSceneSkeleton(12)
      render()
      break
    case "add-marker":
      addSceneMarker()
      render()
      break
    case "delete-selected-marker":
      deleteSelectedMarker()
      render()
      break
    case "use-playhead":
      setSceneTime(sceneId, state.audioPlayhead)
      render()
      break
    case "toggle-scene-audio":
      toggleSceneAudio(sceneId)
      render()
      break
    case "copy-packet":
      await copyScenePacket(sceneId)
      break
    case "open-crop-editor":
      openCropEditor(sceneId)
      render()
      break
    case "open-pan-editor":
      openPanEditor(sceneId)
      render()
      break
    case "close-crop-editor":
      closeCropEditor()
      render()
      break
    case "apply-crop-editor":
      applyCropEditor()
      render()
      break
    case "apply-pan-editor":
      applyPanEditor()
      render()
      break
    case "reset-scene-crop":
      resetSceneCrop(sceneId)
      render()
      break
    case "reset-pan":
      resetScenePan(sceneId)
      render()
      break
    case "reset-crop-editor":
      resetCropEditor()
      render()
      break
    case "reset-pan-editor":
      resetPanEditor()
      render()
      break
    case "set-editor-viewport":
      if (state.cropEditor) {
        state.cropEditor.activeViewport = actionTarget.dataset.viewport === "end" ? "end" : "start"
        syncCropEditorOverlay()
      }
      break
    default:
      break
  }
}

function handleScenePointerDown(event) {
  if (handleBoardScrollPointerDown(event)) {
    return
  }

  if (handleCropEditorPointerDown(event)) {
    return
  }

  const target = event.target
  if (!(target instanceof Element)) {
    return
  }

  if (target.closest("[data-action], input, textarea, button, label")) {
    return
  }

  const sceneCard = target.closest(".scene-card[data-scene-id]")
  if (!sceneCard) {
    return
  }

  const { sceneId } = sceneCard.dataset
  if (!sceneId || sceneId === state.selectedSceneId) {
    return
  }

  selectScene(sceneId)
  render()
}

function handleBoardScrollPointerDown(event) {
  if (state.showStartScreen || state.activeScreen !== "board" || event.button !== 0) {
    return false
  }

  const target = event.target
  if (!(target instanceof Element)) {
    return false
  }

  if (target.closest(".crop-editor-backdrop, button, input, textarea, select, label, a")) {
    return false
  }

  const scroller = target.closest("[data-board-scroll]")
  if (!(scroller instanceof HTMLElement)) {
    return false
  }

  state.boardScrollDrag = {
    pointerId: event.pointerId,
    scroller,
    layout: scroller.dataset.boardScroll === "horizontal" ? "horizontal" : "vertical",
    startX: event.clientX,
    startY: event.clientY,
    startScrollLeft: scroller.scrollLeft,
    startScrollTop: scroller.scrollTop,
    moved: false,
  }
  scroller.classList.add("is-panning")
  try {
    scroller.setPointerCapture(event.pointerId)
  } catch {}
  event.preventDefault()
  return true
}

function handleChange(event) {
  const target = event.target
  const field = target.dataset.field
  if (!field) {
    return
  }

  if (field === "project-title") {
    state.project.title = target.value.trim() || "Untitled Storyboard"
    touchProject()
    setStatus("Project title updated.")
    return
  }

  const scene = findScene(target.dataset.sceneId)
  if (!scene) {
    return
  }

  switch (field) {
    case "scene-name":
      scene.name = target.value.trim()
      break
    case "scene-prompt":
      scene.prompt = target.value
      break
    case "scene-caption":
      scene.caption = target.value
      break
    case "scene-lyrics":
      scene.lyrics = target.value
      break
    case "scene-framing-mode":
      scene.framingMode = normalizeFramingMode(target.value)
      break
    default:
      return
  }

  touchProject()
  setStatus(field === "scene-framing-mode" ? "Scene crop updated." : "Scene text updated.")
  if (field === "scene-framing-mode") {
    render()
  }
}

async function handleKeydown(event) {
  if (state.showStartScreen) {
    return
  }

  const target = event.target
  if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) || target?.isContentEditable) {
    return
  }

  if (event.code === "Space" && state.project.audioTrack.asset) {
    event.preventDefault()
    await togglePlayback()
  }
}

function handleDragOver(event) {
  if (!(event.target instanceof Element)) {
    return
  }

  const sceneCard = event.target.closest(".scene-card[data-scene-id]")
  if (!sceneCard) {
    if (state.dropTargetSceneId) {
      clearDropTarget()
    }
    return
  }

  event.preventDefault()
  if (event.dataTransfer) {
    event.dataTransfer.dropEffect = "copy"
  }

  const { sceneId } = sceneCard.dataset
  if (sceneId && state.dropTargetSceneId !== sceneId) {
    state.dropTargetSceneId = sceneId
    render()
  }
}

function handleDragLeave(event) {
  if (event.currentTarget.contains(event.relatedTarget)) {
    return
  }

  clearDropTarget()
}

function handleMediaDragStart(event) {
  const target = event.target
  if (!(target instanceof Element)) {
    return
  }

  const draggableStage = target.closest("[data-grok-upload-scene-id]")
  if (!(draggableStage instanceof Element)) {
    return
  }

  const sceneId = draggableStage.dataset.grokUploadSceneId
  if (!sceneId || state.pendingGrokUploadDrag?.sceneId === sceneId) {
    event.preventDefault()
    return
  }

  const scene = findScene(sceneId)
  if (!scene || !sceneHasMedia(scene)) {
    event.preventDefault()
    return
  }

  if (!state.host.grokExpanded) {
    event.preventDefault()
    state.suppressChooseMediaUntil = performance.now() + 400
    setStatus("Open the Grok drawer first, then drag the scene media into Ask Grok.")
    render()
    return
  }

  event.preventDefault()
  state.suppressChooseMediaUntil = performance.now() + 1200
  void startGrokUploadDrag(sceneId)
}

function handleMediaDragEnd() {
  if (!state.pendingGrokUploadDrag) {
    return
  }

  state.pendingGrokUploadDrag = null
}

async function handleDrop(event) {
  if (!(event.target instanceof Element)) {
    return
  }

  const sceneCard = event.target.closest(".scene-card[data-scene-id]")
  clearDropTarget(false)

  if (!sceneCard) {
    return
  }

  event.preventDefault()

  const { sceneId } = sceneCard.dataset
  if (!sceneId) {
    return
  }

  const droppedUrl = extractDroppedUrl(event.dataTransfer)
  if (!droppedUrl) {
    setStatus("That drag payload did not include a usable media URL. Use the Grok download button instead.")
    render()
    return
  }

  if (/^(blob:|data:|file:)/i.test(droppedUrl)) {
    setStatus("That source is protected from direct drop import. Use the Grok download button to place it into the next empty scene.")
    render()
    return
  }

  try {
    const result = await hostRequest("importRemoteMedia", { url: droppedUrl })
    if (result.cancelled || !result.asset) {
      setStatus("Drop import was cancelled.")
      render()
      return
    }

    assignPrimaryMedia(sceneId, result.asset, result.kind)
    setStatus(`${result.kind === "video" ? "Video" : "Image"} dropped into the storyboard scene.`)
    render()
  } catch (error) {
    setStatus(`Could not import the dropped media. ${String(error)}`)
    render()
  }
}

async function startGrokUploadDrag(sceneId) {
  const scene = findScene(sceneId)
  const asset = scene?.primaryImage || scene?.primaryVideo
  if (!scene || !asset) {
    return
  }

  if (state.pendingGrokUploadDrag?.sceneId === sceneId) {
    return
  }

  state.pendingGrokUploadDrag = { sceneId, startedAt: performance.now() }

  try {
    await hostRequest("startGrokUploadDrag", { sceneId, asset })
  } catch (error) {
    setStatus(`Could not start dragging into Grok. ${String(error)}`)
    render()
  } finally {
    state.pendingGrokUploadDrag = null
    state.suppressChooseMediaUntil = performance.now() + 150
  }
}

function handleCropEditorPointerDown(event) {
  if (!(event.target instanceof Element)) {
    return false
  }

  if (!state.cropEditor) {
    return false
  }

  const handle = event.target.closest("[data-pan-handle]")
  const viewport = event.target.closest("[data-pan-viewport]")
  const stage = appRoot.querySelector("[data-crop-editor-stage]")
  const source = stage?.querySelector("[data-crop-editor-source]")
  if (!stage || !source || (!handle && !viewport)) {
    return false
  }

  const viewportId = handle?.dataset.viewport || viewport?.dataset.panViewport
  if (!viewportId) {
    return false
  }

  const imageRect = measureCropEditorImageRect(stage, source)
  if (!imageRect) {
    return false
  }

  state.cropEditor.activeViewport = viewportId === "crop" ? "crop" : viewportId === "end" ? "end" : "start"
  const seedRect = cloneRect(getCropEditorRect(state.cropEditor.activeViewport))
  const localPoint = localPointFromPointer(event, stage)
  state.cropEditor.drag = {
    mode: handle ? "resize" : "move",
    viewportId: state.cropEditor.activeViewport,
    corner: handle?.dataset.panHandle || "",
    startPoint: localPoint,
    seedRect,
    imageRect,
  }
  event.preventDefault()
  syncCropEditorOverlay()
  return true
}

function handleGlobalPointerMove(event) {
  const boardDrag = state.boardScrollDrag
  if (boardDrag && boardDrag.pointerId === event.pointerId) {
    const dx = event.clientX - boardDrag.startX
    const dy = event.clientY - boardDrag.startY
    if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
      boardDrag.moved = true
    }

    if (boardDrag.layout === "horizontal") {
      const primaryDelta = Math.abs(dx) >= Math.abs(dy) ? dx : dy
      boardDrag.scroller.scrollLeft = boardDrag.startScrollLeft - primaryDelta
    } else {
      boardDrag.scroller.scrollLeft = boardDrag.startScrollLeft - dx
      boardDrag.scroller.scrollTop = boardDrag.startScrollTop - dy
    }

    event.preventDefault()
    return
  }

  const drag = state.cropEditor?.drag
  if (!drag) {
    return
  }

  const scene = findScene(state.cropEditor.sceneId)
  if (!scene) {
    return
  }

  const stage = appRoot.querySelector("[data-crop-editor-stage]")
  if (!stage) {
    return
  }

  const point = localPointFromPointer(event, stage)
  const imageRect = drag.imageRect
  if (drag.mode === "move") {
    const dx = (point.x - drag.startPoint.x) / Math.max(imageRect.width, 1)
    const dy = (point.y - drag.startPoint.y) / Math.max(imageRect.height, 1)
    const moved = clampNormalizedRect({
      x: drag.seedRect.x + dx,
      y: drag.seedRect.y + dy,
      width: drag.seedRect.width,
      height: drag.seedRect.height,
    })
    setCropEditorRect(drag.viewportId, moved)
  } else {
    const resized = resizeNormalizedRect(drag.seedRect, drag.corner, point, imageRect)
    setCropEditorRect(drag.viewportId, resized)
  }

  event.preventDefault()
  syncCropEditorOverlay()
}

function handleGlobalPointerUp(event) {
  if (state.boardScrollDrag) {
    const scroller = state.boardScrollDrag.scroller
    if (scroller instanceof HTMLElement) {
      scroller.classList.remove("is-panning")
      if (event?.pointerId === state.boardScrollDrag.pointerId) {
        try {
          scroller.releasePointerCapture(event.pointerId)
        } catch {}
      }
    }
    if (state.boardScrollDrag.moved) {
      state.suppressBoardClickUntil = performance.now() + 250
    }
    state.boardScrollDrag = null
  }

  if (!state.cropEditor?.drag) {
    return
  }

  state.cropEditor.drag = null
}

function handleAppWheel(event) {
  if (event.defaultPrevented || event.ctrlKey) {
    return
  }

  const target = event.target
  if (!(target instanceof Element)) {
    return
  }

  if (target.closest(".crop-editor-backdrop, textarea, input, select")) {
    return
  }

  const boardScroller = target.closest(".board-panel-body[data-board-scroll]")
  if (!(boardScroller instanceof HTMLElement)) {
    return
  }

  const layout = boardScroller.dataset.boardScroll === "horizontal" ? "horizontal" : "vertical"
  if (layout === "horizontal") {
    const dominantDelta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX
    if (Math.abs(dominantDelta) < 0.5) {
      return
    }

    boardScroller.scrollLeft += dominantDelta
    event.preventDefault()
    return
  }

  const verticalDelta = Math.abs(event.deltaY) >= 0.5 ? event.deltaY : event.deltaX
  if (Math.abs(verticalDelta) < 0.5) {
    return
  }

  boardScroller.scrollTop += verticalDelta
  event.preventDefault()
}

function syncCropEditorOverlay() {
  const editor = state.cropEditor
  if (!editor) {
    return
  }

  const scene = findScene(editor.sceneId)
  const stage = appRoot.querySelector("[data-crop-editor-stage]")
  const source = stage?.querySelector("[data-crop-editor-source]")
  if (!scene || !stage || !source) {
    return
  }

  ensureCropEditorDefaults(scene, source)
  const imageRect = measureCropEditorImageRect(stage, source)
  if (!imageRect) {
    return
  }

  for (const viewportId of ["crop", "start", "end"]) {
    const viewport = stage.querySelector(`[data-pan-viewport="${viewportId}"]`)
    if (!(viewport instanceof HTMLElement)) {
      continue
    }

    const rect = denormalizeRect(getCropEditorRect(viewportId), imageRect)
    viewport.style.left = `${rect.x}px`
    viewport.style.top = `${rect.y}px`
    viewport.style.width = `${rect.width}px`
    viewport.style.height = `${rect.height}px`
    viewport.classList.toggle("is-active", viewportId === editor.activeViewport)
  }
}

function handleHostMessage(event) {
  const message = typeof event.data === "string" ? JSON.parse(event.data) : event.data

  if (message.replyTo) {
    const pending = pendingReplies.get(message.replyTo)
    if (!pending) {
      return
    }

    pendingReplies.delete(message.replyTo)
    if (message.ok) {
      pending.resolve(message.data)
    } else {
      pending.reject(new Error(message.error || "Unknown host error"))
    }
    return
  }

  switch (message.type) {
    case "hostStateChanged":
      state.host.grokExpanded = Boolean(message.payload?.grokExpanded)
      if (state.host.grokExpanded && state.showStartScreen) {
        createNewProject(1)
        state.showStartScreen = false
        state.activeScreen = "board"
      }
      render()
      break
    case "grokDownloadReady":
      importGrokDownload(message.payload)
      break
    case "hostError":
      setStatus(message.payload?.message || "Host error.")
      render()
      break
    default:
      break
  }
}

function hostRequest(type, payload = {}) {
  if (!hostAvailable) {
    return Promise.resolve({ cancelled: true })
  }

  const id = `req_${++requestSequence}`
  window.chrome.webview.postMessage({ id, type, payload })
  return new Promise((resolve, reject) => {
    pendingReplies.set(id, { resolve, reject })
  })
}

async function startFromImage() {
  const result = await hostRequest("pickMedia", { mode: "image" })
  if (result.cancelled) {
    return
  }

  createNewProject(1)
  state.showStartScreen = false
  state.activeScreen = "board"
  assignPrimaryMedia(state.project.scenes[0].id, result.asset, result.kind)
  setStatus("Simple storyboard started from your first image.")
  render()
}

async function startFromAudio() {
  const result = await hostRequest("pickAudio")
  if (result.cancelled) {
    return
  }

  createNewProject(8)
  state.showStartScreen = false
  state.activeScreen = "timeline"
  state.pendingAutoSpread = true
  await applyAudioAsset(result.asset, true)
}

async function chooseAudio() {
  const result = await hostRequest("pickAudio")
  if (result.cancelled) {
    return
  }

  await applyAudioAsset(result.asset, false)
}

async function applyAudioAsset(asset, autoSpread) {
  stopPlayback()
  state.project.audioTrack.asset = asset
  state.project.audioTrack.duration = 0
  state.audioPlayhead = 0
  state.waveformSamples = []
  state.waveformLoading = true
  state.pendingAutoSpread = autoSpread
  touchProject()
  setStatus("Building waveform...")
  render()

  const token = ++audioDecodeToken
  audioElement.src = asset.url
  audioElement.load()

  try {
    const response = await fetch(asset.url, { cache: "no-store" })
    const buffer = await response.arrayBuffer()
    const audioContext = new (window.AudioContext || window.webkitAudioContext)()
    const decoded = await audioContext.decodeAudioData(buffer.slice(0))
    const channelData = decoded.numberOfChannels > 1
      ? mixChannels(decoded)
      : decoded.getChannelData(0)
    const samples = buildWaveformSamples(channelData, 280)
    await audioContext.close()

    if (token !== audioDecodeToken) {
      return
    }

    state.project.audioTrack.duration = decoded.duration
    state.waveformSamples = samples
    state.waveformLoading = false
    if (state.pendingAutoSpread) {
      distributeScenesEvenly()
      state.pendingAutoSpread = false
      setStatus("Waveform ready and scene points were spread across the audio.")
    } else {
      setStatus("Waveform ready.")
    }
    render()
  } catch (error) {
    if (token !== audioDecodeToken) {
      return
    }

    state.project.audioTrack.duration = 0
    state.waveformSamples = []
    state.waveformLoading = false
    state.pendingAutoSpread = false
    setStatus(`Could not build the waveform for that audio file: ${String(error)}`)
    render()
  }
}

function mixChannels(decoded) {
  const output = new Float32Array(decoded.length)
  for (let channelIndex = 0; channelIndex < decoded.numberOfChannels; channelIndex += 1) {
    const channel = decoded.getChannelData(channelIndex)
    for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
      output[sampleIndex] += channel[sampleIndex] / decoded.numberOfChannels
    }
  }
  return output
}

function buildWaveformSamples(channelData, sampleCount) {
  if (!channelData.length) {
    return Array(sampleCount).fill(0.08)
  }

  const bucketSize = Math.max(Math.floor(channelData.length / sampleCount), 1)
  const buckets = []
  for (let index = 0; index < channelData.length; index += bucketSize) {
    let peak = 0
    for (let scan = index; scan < Math.min(index + bucketSize, channelData.length); scan += 1) {
      peak = Math.max(peak, Math.abs(channelData[scan]))
    }
    buckets.push(Math.max(peak, 0.05))
  }

  while (buckets.length < sampleCount) {
    buckets.push(buckets[buckets.length - 1] ?? 0.08)
  }

  return buckets.slice(0, sampleCount)
}

async function choosePrimaryMedia(sceneId) {
  const result = await hostRequest("pickMedia", { mode: "imageOrVideo" })
  if (result.cancelled) {
    return
  }

  assignPrimaryMedia(sceneId, result.asset, result.kind)
  render()
}

function assignPrimaryMedia(sceneId, asset, kind) {
  const scene = findScene(sceneId)
  if (!scene) {
    return
  }

  scene.primaryImage = null
  scene.primaryVideo = null
  scene.videoMetadata = null
  scene.manualCropRect = null
  scene.panStartRect = null
  scene.panEndRect = null
  scene.panMode = defaultPanMode
  scene.framingMode = normalizeFramingMode(scene.framingMode)
  scene.isPrimaryVideoMuted = true

  if (kind === "video") {
    scene.primaryVideo = asset
    loadVideoMetadata(scene)
  } else {
    scene.primaryImage = asset
  }

  state.selectedSceneId = scene.id
  state.showStartScreen = false
  state.dropTargetSceneId = null
  touchProject()
}

function clearPrimaryMedia(sceneId) {
  const scene = findScene(sceneId)
  if (!scene) {
    return
  }

  scene.primaryImage = null
  scene.primaryVideo = null
  scene.videoMetadata = null
  scene.manualCropRect = null
  scene.panStartRect = null
  scene.panEndRect = null
  scene.panMode = defaultPanMode
  scene.isPrimaryVideoMuted = true
  if (state.boardPreviewAudioSceneId === scene.id) {
    state.boardPreviewAudioSceneId = null
  }
  if (state.cropEditor?.sceneId === scene.id) {
    closeCropEditor()
  }
  touchProject()
  setStatus("Scene media cleared.")
}

function toggleSceneAudio(sceneId) {
  const scene = findScene(sceneId)
  if (!scene?.primaryVideo) {
    return
  }

  scene.isPrimaryVideoMuted = !scene.isPrimaryVideoMuted
  if (scene.isPrimaryVideoMuted) {
    if (state.boardPreviewAudioSceneId === scene.id) {
      state.boardPreviewAudioSceneId = null
    }
  } else {
    state.project.scenes.forEach((candidate) => {
      if (candidate.id !== scene.id) {
        candidate.isPrimaryVideoMuted = true
      }
    })
    state.boardPreviewAudioSceneId = scene.id
  }

  touchProject()
  setStatus(scene.isPrimaryVideoMuted ? "Clip audio muted." : "Clip audio live.")
}

function addScene() {
  const insertionIndex = selectedSceneIndex() != null ? selectedSceneIndex() + 1 : state.project.scenes.length
  const scene = makeScene()
  state.project.scenes.splice(insertionIndex, 0, scene)
  state.selectedSceneId = scene.id
  touchProject()
  setStatus("Added a storyboard scene.")
}

function duplicateScene(sceneId) {
  const index = state.project.scenes.findIndex((scene) => scene.id === sceneId)
  if (index < 0) {
    return
  }

  const copy = cloneData(state.project.scenes[index])
  copy.id = crypto.randomUUID()
  state.project.scenes.splice(index + 1, 0, copy)
  state.selectedSceneId = copy.id
  touchProject()
  setStatus("Scene duplicated.")
}

function deleteScene(sceneId) {
  const index = state.project.scenes.findIndex((scene) => scene.id === sceneId)
  if (index < 0) {
    return
  }

  state.project.scenes.splice(index, 1)
  if (!state.project.scenes.length) {
    state.project.scenes = [makeScene()]
  }
  state.selectedSceneId = state.project.scenes[Math.min(index, state.project.scenes.length - 1)].id
  if (state.boardPreviewAudioSceneId === sceneId) {
    state.boardPreviewAudioSceneId = null
  }
  if (state.cropEditor?.sceneId === sceneId) {
    closeCropEditor()
  }
  touchProject()
  setStatus("Scene deleted.")
}

function addSceneMarker() {
  const scene = makeScene()
  scene.startTime = clampTime(state.audioPlayhead)
  const insertionIndex = state.project.scenes.findIndex((candidate) => candidate.startTime > scene.startTime)
  const targetIndex = insertionIndex === -1 ? state.project.scenes.length : insertionIndex
  state.project.scenes.splice(targetIndex, 0, scene)
  state.selectedSceneId = scene.id
  touchProject()
  setStatus(`Added a timeline point at ${formattedTime(scene.startTime)}.`)
}

function deleteSelectedMarker() {
  if (!state.selectedSceneId) {
    return
  }

  deleteScene(state.selectedSceneId)
}

function createNewProject(sceneCount = 1) {
  stopPlayback()
  state.project = makeProject(sceneCount)
  state.showStartScreen = false
  state.selectedSceneId = state.project.scenes[0]?.id ?? null
  state.waveformSamples = []
  state.waveformLoading = false
  state.audioPlayhead = 0
  state.boardPreviewAudioSceneId = null
  state.currentProjectPath = ""
  state.dropTargetSceneId = null
  state.cropEditor = null
  touchProject()
}

function ensureSceneSkeleton(count) {
  if (count <= 0) {
    return
  }

  if (state.showStartScreen) {
    createNewProject(count)
    state.showStartScreen = false
    state.activeScreen = "board"
    setStatus(`Storyboard skeleton ready with ${state.project.scenes.length} scenes.`)
    return
  }

  if (state.project.scenes.length === 1 && isBlankScene(state.project.scenes[0])) {
    state.project.scenes = freshScenes(count)
  } else if (state.project.scenes.length < count) {
    state.project.scenes.push(...freshScenes(count - state.project.scenes.length))
  }

  state.selectedSceneId = state.project.scenes[0]?.id ?? null
  touchProject()
  setStatus(`Storyboard skeleton ready with ${state.project.scenes.length} scenes.`)
}

function distributeScenesEvenly() {
  const duration = currentTimelineDuration()
  if (!state.project.scenes.length) {
    return
  }

  if (state.project.scenes.length === 1) {
    state.project.scenes[0].startTime = 0
  } else {
    const spacing = duration / Math.max(state.project.scenes.length - 1, 1)
    state.project.scenes.forEach((scene, index) => {
      scene.startTime = spacing * index
    })
  }

  state.audioPlayhead = state.project.scenes[0]?.startTime ?? 0
  touchProject()
  setStatus(state.project.audioTrack.asset ? "Scene markers were spread evenly across the audio." : "Scene markers were spread across the placeholder waveform.")
}

function setSceneTime(sceneId, time) {
  const scene = findScene(sceneId)
  if (!scene) {
    return
  }

  scene.startTime = clampTime(time)
  state.audioPlayhead = scene.startTime
  touchProject()
}

function selectScene(sceneId) {
  const wasAudioLive = state.boardPreviewAudioSceneId === state.selectedSceneId
  state.selectedSceneId = sceneId
  const scene = findScene(sceneId)
  if (!scene?.primaryVideo || scene.isPrimaryVideoMuted || wasAudioLive) {
    state.boardPreviewAudioSceneId = null
  }
}

function selectedSceneIndex() {
  return state.project.scenes.findIndex((scene) => scene.id === state.selectedSceneId)
}

function findScene(sceneId) {
  return state.project.scenes.find((scene) => scene.id === sceneId) ?? null
}

function sceneHasMedia(scene) {
  return Boolean(scene.primaryImage || scene.primaryVideo)
}

function isBlankScene(scene) {
  return !scene.prompt.trim() && !scene.caption.trim() && !scene.lyrics.trim() && !scene.primaryImage && !scene.primaryVideo
}

async function togglePlayback() {
  if (!state.project.audioTrack.asset) {
    return
  }

  if (state.isAudioPlaying) {
    stopPlayback()
    updatePlaybackUi()
    return
  }

  try {
    audioElement.currentTime = clampTime(state.audioPlayhead)
    await audioElement.play()
    state.isAudioPlaying = true
    startPlaybackLoop()
    updatePlaybackUi()
  } catch (error) {
    setStatus(`Playback failed: ${String(error)}`)
    render()
  }
}

function stopPlayback() {
  audioElement.pause()
  cancelPlaybackLoop()
  state.isAudioPlaying = false
}

function startPlaybackLoop() {
  cancelPlaybackLoop()
  const tick = () => {
    state.audioPlayhead = audioElement.currentTime
    updatePlaybackUi()
    if (state.isAudioPlaying) {
      playbackFrame = requestAnimationFrame(tick)
    }
  }
  playbackFrame = requestAnimationFrame(tick)
}

function cancelPlaybackLoop() {
  if (playbackFrame) {
    cancelAnimationFrame(playbackFrame)
    playbackFrame = 0
  }
}

function updatePlaybackUi() {
  const previewSceneId = timelinePreviewScene()?.id ?? ""
  if (previewSceneId !== lastTimelinePreviewSceneId && !state.showStartScreen) {
    render()
    return
  }

  updatePlaybackReadouts()
  drawWaveform()
  syncTimelinePreviewVideo()
  drawAllMediaStages()
  syncCropEditorOverlay()
}

function updatePlaybackReadouts() {
  const playheadNode = appRoot.querySelector("[data-playhead-readout]")
  if (playheadNode) {
    playheadNode.textContent = `Playhead ${formattedTime(state.audioPlayhead)}`
  }

  const durationNode = appRoot.querySelector("[data-duration-readout]")
  if (durationNode) {
    durationNode.textContent = `Duration ${formattedTime(currentTimelineDuration())}`
  }

  const previewOffsetNode = appRoot.querySelector("[data-preview-offset]")
  const previewScene = timelinePreviewScene()
  if (previewOffsetNode && previewScene) {
    previewOffsetNode.textContent = `Offset ${formattedTime(Math.max(state.audioPlayhead - previewScene.startTime, 0))}`
  }
}

function syncTimelinePreviewVideo() {
  const previewScene = timelinePreviewScene()
  const previewVideo = document.getElementById("timelinePreviewVideo")
  if (!(previewVideo instanceof HTMLVideoElement) || !previewScene?.primaryVideo) {
    return
  }

  const clipDuration = previewScene.videoMetadata?.duration || previewVideo.duration || 0
  const rawOffset = Math.max(state.audioPlayhead - previewScene.startTime, 0)
  const offset = clipDuration > 0 ? rawOffset % clipDuration : rawOffset

  if (Number.isFinite(offset) && Math.abs(previewVideo.currentTime - offset) > (state.isAudioPlaying ? 0.2 : 0.04)) {
    try {
      previewVideo.currentTime = offset
    } catch (_) {
      // Ignore seek races while metadata is still settling.
    }
  }

  previewVideo.muted = previewScene.isPrimaryVideoMuted
  if (state.isAudioPlaying) {
    previewVideo.play().catch(() => {})
  } else {
    previewVideo.pause()
  }
}

function timelinePreviewScene() {
  if (!state.project.scenes.length) {
    return null
  }

  if (state.isAudioPlaying) {
    return activeSceneAtTime(state.audioPlayhead)
  }

  return findScene(state.selectedSceneId) || activeSceneAtTime(state.audioPlayhead)
}

function activeSceneAtTime(time) {
  const ordered = [...state.project.scenes]
    .map((scene, index) => ({ scene, index }))
    .sort((left, right) => {
      if (left.scene.startTime === right.scene.startTime) {
        return left.index - right.index
      }
      return left.scene.startTime - right.scene.startTime
    })
    .map((entry) => entry.scene)

  if (!ordered.length) {
    return null
  }

  if (time <= ordered[0].startTime) {
    return ordered[0]
  }

  const priorScenes = ordered.filter((scene) => scene.startTime <= time)
  return priorScenes[priorScenes.length - 1] || ordered[ordered.length - 1] || null
}

function previewSubtitle(scene) {
  const sceneIndex = state.project.scenes.findIndex((candidate) => candidate.id === scene.id) + 1
  if (scene.primaryVideo) {
    return `Scene ${sceneIndex} video starts at ${formattedTime(scene.startTime)}.`
  }
  if (scene.primaryImage) {
    return `Scene ${sceneIndex} image is anchored at ${formattedTime(scene.startTime)}.`
  }
  return "No media on this scene yet."
}

function currentTimelineDuration() {
  if (state.project.audioTrack.asset) {
    return Math.max(state.project.audioTrack.duration || 0, 0.1)
  }

  const actualMaxTime = state.project.scenes.reduce((max, scene) => Math.max(max, scene.startTime || 0), 0)
  return Math.max(actualMaxTime, Math.max(state.project.scenes.length - 1, 1), 1)
}

function placeholderWaveformSamples() {
  return Array.from({ length: 280 }, (_, index) => {
    switch (index % 6) {
      case 0:
        return 0.08
      case 1:
        return 0.1
      case 2:
        return 0.09
      case 3:
        return 0.11
      case 4:
        return 0.09
      default:
        return 0.1
    }
  })
}

function displayedMarkers() {
  const duration = currentTimelineDuration()
  const usesRecordedTimes = Boolean(state.project.audioTrack.asset) || state.project.scenes.some((scene) => scene.startTime > 0)

  return state.project.scenes.map((scene, index) => {
    let time
    if (waveformInteraction?.activeMarkerId === scene.id) {
      time = waveformInteraction.tempTime
    } else if (usesRecordedTimes) {
      time = Math.min(Math.max(scene.startTime, 0), duration)
    } else if (state.project.scenes.length === 1) {
      time = 0
    } else {
      time = duration * index / Math.max(state.project.scenes.length - 1, 1)
    }

    return {
      id: scene.id,
      index: index + 1,
      time,
      isSelected: state.selectedSceneId === scene.id,
    }
  })
}

function displayedMarkerTime(scene, index) {
  if (state.project.audioTrack.asset) {
    return scene.startTime
  }

  return displayedMarkers()[index]?.time ?? scene.startTime
}

function bindWaveformCanvas() {
  const canvas = document.getElementById("waveformCanvas")
  if (!(canvas instanceof HTMLCanvasElement)) {
    return
  }

  canvas.onpointerdown = handleWaveformPointerDown
  canvas.onpointermove = handleWaveformPointerMove
  canvas.onpointerup = handleWaveformPointerUp
  canvas.onpointercancel = handleWaveformPointerUp
  drawWaveform()
}

function handleWaveformPointerDown(event) {
  const canvas = event.currentTarget
  if (!(canvas instanceof HTMLCanvasElement)) {
    return
  }

  const marker = nearestMarker(canvas, event.clientX)
  if (marker) {
    selectScene(marker.id)
    waveformInteraction = {
      pointerId: event.pointerId,
      activeMarkerId: marker.id,
      tempTime: marker.time,
    }
  } else {
    waveformInteraction = {
      pointerId: event.pointerId,
      activeMarkerId: null,
      tempTime: null,
    }
    state.audioPlayhead = timeFromCanvasX(canvas, event.clientX)
  }

  event.preventDefault()
  canvas.setPointerCapture(event.pointerId)
  updatePlaybackUi()
}

function handleWaveformPointerMove(event) {
  const canvas = event.currentTarget
  if (!(canvas instanceof HTMLCanvasElement) || !waveformInteraction || waveformInteraction.pointerId !== event.pointerId) {
    return
  }

  const time = timeFromCanvasX(canvas, event.clientX)
  state.audioPlayhead = time

  if (waveformInteraction.activeMarkerId) {
    waveformInteraction.tempTime = time
  }

  event.preventDefault()
  updatePlaybackUi()
}

function handleWaveformPointerUp(event) {
  const canvas = event.currentTarget
  if (!(canvas instanceof HTMLCanvasElement) || !waveformInteraction || waveformInteraction.pointerId !== event.pointerId) {
    return
  }

  const time = timeFromCanvasX(canvas, event.clientX)
  state.audioPlayhead = time

  if (waveformInteraction.activeMarkerId) {
    setSceneTime(waveformInteraction.activeMarkerId, time)
    setStatus(`Moved scene point to ${formattedTime(time)}.`)
  }

  if (canvas.hasPointerCapture(event.pointerId)) {
    canvas.releasePointerCapture(event.pointerId)
  }
  waveformInteraction = null
  render()
}

function nearestMarker(canvas, clientX) {
  const rect = canvas.getBoundingClientRect()
  const width = Math.max(rect.width, 1)
  const x = clamp(clientX - rect.left, 0, width)
  const markers = displayedMarkers()
  const threshold = markerSelectionThreshold(width, markers.length)

  return markers.reduce((nearest, marker) => {
    const markerX = marker.time / currentTimelineDuration() * width
    const distance = Math.abs(markerX - x)
    if (distance > threshold) {
      return nearest
    }

    if (!nearest || distance < nearest.distance) {
      return { ...marker, distance }
    }
    return nearest
  }, null)
}

function markerSelectionThreshold(width, markerCount) {
  const spacing = width / Math.max(markerCount, 1)
  return Math.min(Math.max(spacing * 0.45, 18), 34)
}

function timeFromCanvasX(canvas, clientX) {
  const rect = canvas.getBoundingClientRect()
  const x = clamp(clientX - rect.left, 0, rect.width)
  return currentTimelineDuration() * x / Math.max(rect.width, 1)
}

function drawWaveform() {
  const canvas = document.getElementById("waveformCanvas")
  if (!(canvas instanceof HTMLCanvasElement)) {
    return
  }

  const rect = canvas.getBoundingClientRect()
  if (rect.width <= 0 || rect.height <= 0) {
    return
  }

  const dpr = window.devicePixelRatio || 1
  canvas.width = Math.floor(rect.width * dpr)
  canvas.height = Math.floor(rect.height * dpr)
  const context = canvas.getContext("2d")
  if (!context) {
    return
  }

  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.clearRect(0, 0, rect.width, rect.height)

  const width = rect.width
  const height = rect.height
  const samples = state.project.audioTrack.asset ? (state.waveformSamples.length ? state.waveformSamples : placeholderWaveformSamples()) : placeholderWaveformSamples()
  const duration = currentTimelineDuration()
  const centerY = height / 2
  const step = width / Math.max(samples.length, 1)

  const background = context.createLinearGradient(0, 0, 0, height)
  background.addColorStop(0, "rgba(22, 24, 30, 0.95)")
  background.addColorStop(1, "rgba(8, 10, 13, 0.98)")
  context.fillStyle = background
  context.fillRect(0, 0, width, height)

  for (let index = 0; index < samples.length; index += 1) {
    const amplitude = Math.max(samples[index], 0.03) * height * 0.42
    const x = index * step
    context.fillStyle = index % 2 === 0 ? "rgba(245, 161, 40, 0.92)" : "rgba(245, 161, 40, 0.56)"
    roundRect(context, x, centerY - amplitude, Math.max(step * 0.78, 1), amplitude * 2, Math.min(step * 0.35, 2))
    context.fill()
  }

  drawTimeRuler(context, width, duration)
  drawMarkers(context, width, height, duration)

  const playheadX = clamp(state.audioPlayhead / Math.max(duration, 0.001) * width, 0, width)
  context.strokeStyle = "rgba(255, 255, 255, 0.96)"
  context.lineWidth = 2
  context.beginPath()
  context.moveTo(playheadX, 0)
  context.lineTo(playheadX, height)
  context.stroke()
}

function drawTimeRuler(context, width, duration) {
  context.save()
  context.font = "12px Segoe UI"
  context.fillStyle = "rgba(255,255,255,0.46)"
  const steps = 8
  for (let step = 0; step <= steps; step += 1) {
    const time = duration * step / steps
    const text = shortTimestamp(time)
    const x = step === steps ? width - 40 : width * step / steps
    context.fillText(text, x + 10, 18)
  }
  context.restore()
}

function drawMarkers(context, width, height, duration) {
  const markers = displayedMarkers()
  markers.forEach((marker) => {
    const stemX = clamp(marker.time / Math.max(duration, 0.001) * width, 0, width)
    const bulbRadius = 13
    const bulbX = clamp(stemX, bulbRadius, Math.max(width - bulbRadius, bulbRadius))
    const stemHeight = Math.max(height - 42, 10)

    context.strokeStyle = marker.isSelected ? "rgba(245, 161, 40, 0.98)" : "rgba(255, 255, 255, 0.32)"
    context.lineWidth = 2
    context.beginPath()
    context.moveTo(stemX, 32)
    context.lineTo(stemX, 32 + stemHeight)
    context.stroke()

    context.beginPath()
    context.fillStyle = marker.isSelected ? "rgba(245, 161, 40, 0.98)" : "rgba(255, 255, 255, 0.18)"
    context.arc(bulbX, 19, bulbRadius, 0, Math.PI * 2)
    context.fill()

    context.fillStyle = marker.isSelected ? "#12100c" : "#ffffff"
    context.font = "bold 12px Segoe UI"
    context.textAlign = "center"
    context.textBaseline = "middle"
    context.fillText(String(marker.index), bulbX, 19)
  })
}

function roundRect(context, x, y, width, height, radius) {
  context.beginPath()
  context.moveTo(x + radius, y)
  context.lineTo(x + width - radius, y)
  context.quadraticCurveTo(x + width, y, x + width, y + radius)
  context.lineTo(x + width, y + height - radius)
  context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
  context.lineTo(x + radius, y + height)
  context.quadraticCurveTo(x, y + height, x, y + height - radius)
  context.lineTo(x, y + radius)
  context.quadraticCurveTo(x, y, x + radius, y)
  context.closePath()
}

async function saveProject(saveAs) {
  const result = await hostRequest("saveProject", {
    project: state.project,
    path: state.currentProjectPath,
    saveAs,
  })

  if (result.cancelled) {
    return
  }

  state.currentProjectPath = result.path || state.currentProjectPath
  setStatus("Project saved.")
  render()
}

async function exportProject() {
  const result = await hostRequest("exportProject", {
    project: state.project,
  })
  if (result.cancelled) {
    return
  }

  setStatus("Organized export folder is ready.")
  render()
}

async function openProject() {
  const result = await hostRequest("openProject")
  if (result.cancelled) {
    return
  }

  stopPlayback()
  state.project = normalizeProject(result.project)
  state.currentProjectPath = result.path || ""
  state.showStartScreen = false
  state.activeScreen = state.project.audioTrack?.asset ? "timeline" : "board"
  state.selectedSceneId = state.project.scenes[0]?.id ?? null
  state.boardPreviewAudioSceneId = null
  state.audioPlayhead = 0
  state.waveformSamples = []
  state.waveformLoading = false
  state.dropTargetSceneId = null
  state.cropEditor = null

  if (state.project.audioTrack?.asset) {
    await applyAudioAsset(state.project.audioTrack.asset, false)
    return
  }

  setStatus("Project opened.")
  render()
}

async function startOver() {
  const confirmed = window.confirm("Would you really like to restart? You will lose all your work.")
  if (!confirmed) {
    return
  }

  stopPlayback()
  await hostRequest("startOver")
  state.project = makeProject(1)
  state.showStartScreen = false
  state.activeScreen = "board"
  state.editorMode = "simple"
  state.boardLayoutMode = "vertical"
  state.selectedSceneId = state.project.scenes[0]?.id ?? null
  state.waveformSamples = []
  state.waveformLoading = false
  state.audioPlayhead = 0
  state.boardPreviewAudioSceneId = null
  state.currentProjectPath = ""
  state.dropTargetSceneId = null
  state.cropEditor = null
  setStatus("Ready for scene 1.")
  render()
}

async function copyScenePacket(sceneId) {
  const scene = findScene(sceneId)
  if (!scene) {
    return
  }

  const packet = [
    `Scene ${scene.name || sceneId}`,
    "",
    "Prompt:",
    scene.prompt || "(empty)",
    "",
    "Caption:",
    scene.caption || "(empty)",
    "",
    "Lyrics:",
    scene.lyrics || "(empty)",
  ].join("\n")

  await hostRequest("copyText", { text: packet })
  setStatus("Scene packet copied.")
  render()
}

function importGrokDownload(payload) {
  if (!payload?.asset) {
    return
  }

  if (state.showStartScreen) {
    createNewProject(1)
    state.showStartScreen = false
    state.activeScreen = "board"
  }

  const targetScene = findOrCreateGrokTargetScene()

  assignPrimaryMedia(targetScene.id, payload.asset, payload.kind || "image")
  setStatus(`${payload.kind === "video" ? "Video" : "Image"} imported from Grok into scene ${sceneOrdinal(targetScene.id)}.`)
  render()
}

function findOrCreateGrokTargetScene() {
  const selectedIndex = selectedSceneIndex()
  if (selectedIndex >= 0) {
    const followingEmptyScene = state.project.scenes
      .slice(selectedIndex + 1)
      .find((scene) => !scene.primaryImage && !scene.primaryVideo)
    if (followingEmptyScene) {
      return followingEmptyScene
    }
  }

  const firstEmptyScene = state.project.scenes.find((scene) => !scene.primaryImage && !scene.primaryVideo)
  if (firstEmptyScene) {
    return firstEmptyScene
  }

  const insertionIndex = selectedIndex >= 0 ? selectedIndex + 1 : state.project.scenes.length
  const scene = makeScene()
  if (state.activeScreen === "timeline") {
    scene.startTime = clampTime(state.audioPlayhead)
  } else if (selectedIndex >= 0) {
    scene.startTime = state.project.scenes[selectedIndex]?.startTime ?? 0
  }

  state.project.scenes.splice(insertionIndex, 0, scene)
  touchProject()
  return scene
}

function clearDropTarget(shouldRender = true) {
  if (!state.dropTargetSceneId) {
    return
  }

  state.dropTargetSceneId = null
  if (shouldRender) {
    render()
  }
}

function hostSetDropTarget(sceneId) {
  const normalizedSceneId = typeof sceneId === "string" && sceneId ? sceneId : null
  if (state.dropTargetSceneId === normalizedSceneId) {
    return normalizedSceneId
  }

  state.dropTargetSceneId = normalizedSceneId
  render()
  return normalizedSceneId
}

function hostClearDropTarget() {
  clearDropTarget()
  return true
}

function hostImportAssetIntoScene(sceneId, asset, kind = "image", sourceLabel = "Grok") {
  if (!sceneId || !asset) {
    return false
  }

  if (state.showStartScreen) {
    createNewProject(1)
    state.showStartScreen = false
    state.activeScreen = "board"
  }

  assignPrimaryMedia(sceneId, asset, kind)
  setStatus(`${sourceLabel} ${kind === "video" ? "video" : "image"} imported into the storyboard scene.`)
  render()
  return true
}

function hostSceneIdAtPoint(x, y) {
  const points = buildPointProbeList(x, y)
  for (const [probeX, probeY] of points) {
    const element = document.elementFromPoint(probeX, probeY)
    const sceneCard = element?.closest?.(".scene-card[data-scene-id]")
    const sceneId = sceneCard?.dataset?.sceneId
    if (sceneId) {
      return sceneId
    }
  }

  return ""
}

function debugGetProjectSnapshot() {
  return cloneData(state.project)
}

function debugResetForAutomation() {
  stopPlayback()
  state.project = makeProject(1)
  state.showStartScreen = false
  state.activeScreen = "board"
  state.editorMode = "simple"
  state.boardLayoutMode = "vertical"
  state.selectedSceneId = state.project.scenes[0]?.id ?? null
  state.waveformSamples = []
  state.waveformLoading = false
  state.audioPlayhead = 0
  state.boardPreviewAudioSceneId = null
  state.currentProjectPath = ""
  state.dropTargetSceneId = null
  state.cropEditor = null
  touchProject()
  setStatus("Automation reset ready.")
  render()
  return true
}

function buildPointProbeList(x, y) {
  const numericX = Number(x)
  const numericY = Number(y)
  if (!Number.isFinite(numericX) || !Number.isFinite(numericY)) {
    return []
  }

  const ratio = Number(window.devicePixelRatio) || 1
  const probes = [
    [numericX, numericY],
    [numericX / ratio, numericY / ratio],
  ]

  if (ratio !== 1) {
    probes.push([numericX * ratio, numericY * ratio])
  }

  const seen = new Set()
  return probes.filter(([probeX, probeY]) => {
    const key = `${Math.round(probeX * 100)}/${Math.round(probeY * 100)}`
    if (seen.has(key)) {
      return false
    }
    seen.add(key)
    return true
  })
}

function openCropEditor(sceneId) {
  const scene = findScene(sceneId)
  if (!scene || !sceneHasMedia(scene)) {
    return
  }

  state.selectedSceneId = scene.id
  state.cropEditor = {
    sceneId: scene.id,
    editorMode: "crop",
    activeViewport: "crop",
    cropRect: cloneRect(scene.manualCropRect),
    startRect: null,
    endRect: null,
    drag: null,
  }
}

function openPanEditor(sceneId) {
  const scene = findScene(sceneId)
  if (!scene || !sceneHasMedia(scene)) {
    return
  }

  state.selectedSceneId = scene.id
  state.cropEditor = {
    sceneId: scene.id,
    editorMode: "pan",
    activeViewport: "start",
    cropRect: cloneRect(scene.manualCropRect),
    startRect: cloneRect(scene.panStartRect),
    endRect: cloneRect(scene.panEndRect),
    drag: null,
  }
}

function closeCropEditor() {
  state.cropEditor = null
}

function closePanEditor() {
  closeCropEditor()
}

function applyCropEditor() {
  if (!state.cropEditor) {
    return
  }

  const scene = findScene(state.cropEditor.sceneId)
  if (!scene) {
    closeCropEditor()
    return
  }

  const cropRect = clampNormalizedRect(cloneRect(state.cropEditor.cropRect) || fullNormalizedRect())
  scene.manualCropRect = rectIsFullFrame(cropRect) ? null : cloneRect(cropRect)
  scene.panStartRect = null
  scene.panEndRect = null
  scene.panMode = defaultPanMode
  touchProject()
  setStatus("Scene crop saved.")
  closeCropEditor()
}

function applyPanEditor() {
  if (!state.cropEditor) {
    return
  }

  const scene = findScene(state.cropEditor.sceneId)
  if (!scene) {
    closeCropEditor()
    return
  }

  scene.panStartRect = cloneRect(state.cropEditor.startRect)
  scene.panEndRect = cloneRect(state.cropEditor.endRect)
  scene.panMode = defaultPanMode
  touchProject()
  setStatus("Scene pan path saved.")
  closeCropEditor()
}

function resetCropEditor() {
  if (!state.cropEditor) {
    return
  }

  const scene = findScene(state.cropEditor.sceneId)
  if (!scene) {
    closeCropEditor()
    return
  }

  const sourceAspectRatio = inferSceneSourceAspectRatio(scene, appRoot.querySelector("[data-crop-editor-source]"))
  state.cropEditor.cropRect = cloneRect(scene.manualCropRect || defaultManualCropRect(sourceAspectRatio))
  state.cropEditor.drag = null
  syncCropEditorOverlay()
}

function resetPanEditor() {
  if (!state.cropEditor) {
    return
  }

  const scene = findScene(state.cropEditor.sceneId)
  if (!scene) {
    closeCropEditor()
    return
  }

  const sourceAspectRatio = inferSceneSourceAspectRatio(scene, appRoot.querySelector("[data-crop-editor-source]"))
  const rect = cloneRect(scene.manualCropRect) || defaultManualCropRect(sourceAspectRatio)
  state.cropEditor.startRect = cloneRect(rect)
  state.cropEditor.endRect = cloneRect(rect)
  state.cropEditor.drag = null
  syncCropEditorOverlay()
}

function resetScenePan(sceneId) {
  const scene = findScene(sceneId)
  if (!scene) {
    return
  }

  scene.panStartRect = null
  scene.panEndRect = null
  scene.panMode = defaultPanMode
  if (state.cropEditor?.sceneId === scene.id && state.cropEditor.editorMode === "pan") {
    closeCropEditor()
  }
  touchProject()
  setStatus("Scene pan reset.")
}

function resetSceneCrop(sceneId) {
  const scene = findScene(sceneId)
  if (!scene) {
    return
  }

  scene.manualCropRect = null
  scene.panStartRect = null
  scene.panEndRect = null
  scene.panMode = defaultPanMode
  if (state.cropEditor?.sceneId === scene.id) {
    closeCropEditor()
  }
  touchProject()
  setStatus("Scene crop reset.")
}

function ensureCropEditorDefaults(scene, sourceElement) {
  if (!state.cropEditor || state.cropEditor.sceneId !== scene.id) {
    return
  }

  const sourceAspectRatio = inferSceneSourceAspectRatio(scene, sourceElement)
  const fallback = defaultManualCropRect(sourceAspectRatio)
  if (state.cropEditor.editorMode === "crop") {
    if (!state.cropEditor.cropRect) {
      state.cropEditor.cropRect = cloneRect(scene.manualCropRect || fallback)
    }
    return
  }

  const seedRect = cloneRect(scene.manualCropRect || scene.panStartRect || scene.panEndRect || fallback)
  if (!state.cropEditor.startRect) {
    state.cropEditor.startRect = cloneRect(scene.panStartRect || seedRect)
  }
  if (!state.cropEditor.endRect) {
    state.cropEditor.endRect = cloneRect(scene.panEndRect || state.cropEditor.startRect || seedRect)
  }
}

function getCropEditorRect(viewportId) {
  if (!state.cropEditor) {
    return fullNormalizedRect()
  }

  if (viewportId === "crop" || state.cropEditor.editorMode === "crop") {
    return cloneRect(state.cropEditor.cropRect) || fullNormalizedRect()
  }

  return cloneRect(viewportId === "end" ? state.cropEditor.endRect : state.cropEditor.startRect) || fullNormalizedRect()
}

function setCropEditorRect(viewportId, rect) {
  if (!state.cropEditor) {
    return
  }

  if (viewportId === "crop" || state.cropEditor.editorMode === "crop") {
    state.cropEditor.cropRect = clampNormalizedRect(rect)
  } else if (viewportId === "end") {
    state.cropEditor.endRect = clampNormalizedRect(rect)
  } else {
    state.cropEditor.startRect = clampNormalizedRect(rect)
  }
}

function refreshMediaSurfacePump() {
  const shouldAnimate = Boolean(appRoot.querySelector('[data-media-stage][data-has-video="true"]')) || Boolean(state.cropEditor)
  if (shouldAnimate && !mediaSurfaceFrame) {
    mediaSurfaceFrame = requestAnimationFrame(runMediaSurfaceFrame)
    return
  }

  if (!shouldAnimate && mediaSurfaceFrame) {
    cancelAnimationFrame(mediaSurfaceFrame)
    mediaSurfaceFrame = 0
  }
}

function runMediaSurfaceFrame() {
  drawAllMediaStages()
  syncCropEditorOverlay()
  const shouldContinue = Boolean(appRoot.querySelector('[data-media-stage][data-has-video="true"]')) || Boolean(state.cropEditor)
  mediaSurfaceFrame = shouldContinue ? requestAnimationFrame(runMediaSurfaceFrame) : 0
}

function drawAllMediaStages() {
  appRoot.querySelectorAll("[data-media-stage]").forEach((stage) => {
    drawMediaStage(stage)
  })
}

function drawMediaStage(stage) {
  if (!(stage instanceof HTMLElement)) {
    return
  }

  const scene = findScene(stage.dataset.sceneId)
  const canvas = stage.querySelector("[data-media-canvas]")
  const source = stage.querySelector("[data-media-source]")
  if (!scene || !(canvas instanceof HTMLCanvasElement) || !(source instanceof HTMLImageElement || source instanceof HTMLVideoElement)) {
    return
  }

  const stageRect = stage.getBoundingClientRect()
  if (stageRect.width <= 0 || stageRect.height <= 0) {
    return
  }

  const ratio = window.devicePixelRatio || 1
  const canvasWidth = Math.max(1, Math.round(stageRect.width * ratio))
  const canvasHeight = Math.max(1, Math.round(stageRect.height * ratio))
  if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
    canvas.width = canvasWidth
    canvas.height = canvasHeight
  }

  const sourceWidth = source instanceof HTMLVideoElement ? source.videoWidth : source.naturalWidth
  const sourceHeight = source instanceof HTMLVideoElement ? source.videoHeight : source.naturalHeight
  if (!sourceWidth || !sourceHeight) {
    return
  }

  const context = canvas.getContext("2d")
  if (!context) {
    return
  }

  const renderState = resolveSceneRenderState(scene, stage, sourceWidth, sourceHeight, source)
  const outputFrame = shouldUseStoryboardFrame(stage.dataset.renderContext)
    ? fitRect(storyboardFrameAspectRatio, canvasWidth, canvasHeight)
    : { x: 0, y: 0, width: canvasWidth, height: canvasHeight }
  const frameRadius = Math.min(outputFrame.width, outputFrame.height) * 0.06
  context.clearRect(0, 0, canvasWidth, canvasHeight)
  context.fillStyle = "rgba(15, 17, 22, 0.94)"
  context.fillRect(0, 0, canvasWidth, canvasHeight)
  context.save()
  roundRect(context, outputFrame.x, outputFrame.y, outputFrame.width, outputFrame.height, frameRadius)
  context.clip()

  if (renderState.mode === "fit") {
    const frame = fitRect(renderState.targetAspectRatio || sourceWidth / sourceHeight, outputFrame.width, outputFrame.height, outputFrame.x, outputFrame.y)
    const mediaRect = fitRect(sourceWidth / sourceHeight, frame.width, frame.height, frame.x, frame.y)
    context.drawImage(source, 0, 0, sourceWidth, sourceHeight, mediaRect.x, mediaRect.y, mediaRect.width, mediaRect.height)
  } else if (renderState.mode === "source") {
    const mediaRect = fitRect(sourceWidth / sourceHeight, outputFrame.width, outputFrame.height, outputFrame.x, outputFrame.y)
    context.drawImage(source, 0, 0, sourceWidth, sourceHeight, mediaRect.x, mediaRect.y, mediaRect.width, mediaRect.height)
  } else {
    const crop = renderState.sourceRect
    const cropAspectRatio = crop.width * sourceWidth / Math.max(crop.height * sourceHeight, 1)
    const destinationRect = renderState.presentation === "fit"
      ? fitRect(cropAspectRatio, outputFrame.width, outputFrame.height, outputFrame.x, outputFrame.y)
      : outputFrame
    context.drawImage(
      source,
      crop.x * sourceWidth,
      crop.y * sourceHeight,
      crop.width * sourceWidth,
      crop.height * sourceHeight,
      destinationRect.x,
      destinationRect.y,
      destinationRect.width,
      destinationRect.height,
    )
  }
  context.restore()

  if (shouldUseStoryboardFrame(stage.dataset.renderContext)) {
    context.save()
    roundRect(context, outputFrame.x + 1, outputFrame.y + 1, outputFrame.width - 2, outputFrame.height - 2, Math.max(frameRadius - 1, 8))
    context.strokeStyle = "rgba(255, 255, 255, 0.12)"
    context.lineWidth = Math.max(2, ratio * 1.5)
    context.stroke()
    context.restore()
  }
}

function resolveSceneRenderState(scene, stage, sourceWidth, sourceHeight, sourceElement) {
  const mode = framingModes.find((candidate) => candidate.value === normalizeFramingMode(scene.framingMode)) || framingModes[1]
  const sourceAspectRatio = sourceWidth / Math.max(sourceHeight, 1)
  const isEditorPreview = stage.dataset.renderContext === "editor" && state.cropEditor?.sceneId === scene.id
  const resolvedManualCrop = sanitizeNormalizedRect(isEditorPreview && state.cropEditor?.editorMode === "crop" ? state.cropEditor?.cropRect : scene.manualCropRect)
  const manualCrop = resolvedManualCrop && !rectIsFullFrame(resolvedManualCrop) ? resolvedManualCrop : null
  const customStart = sanitizeNormalizedRect(isEditorPreview && state.cropEditor?.editorMode === "pan" ? state.cropEditor?.startRect : scene.panStartRect)
  const customEnd = sanitizeNormalizedRect(isEditorPreview && state.cropEditor?.editorMode === "pan" ? state.cropEditor?.endRect : scene.panEndRect)
  const targetAspectRatio = mode.aspectRatio || sourceAspectRatio

  if (customStart || customEnd) {
    const baseRect = manualCrop || defaultManualCropRect(sourceAspectRatio)
    return {
      mode: "crop",
      presentation: "fit",
      targetAspectRatio,
      sourceRect: interpolatedRect(customStart || baseRect, customEnd || customStart || baseRect, mediaProgress(sourceElement)),
    }
  }

  if (manualCrop) {
    return {
      mode: "crop",
      presentation: "fit",
      targetAspectRatio,
      sourceRect: manualCrop,
    }
  }

  if (mode.presentation === "fit") {
    return {
      mode: "fit",
      targetAspectRatio,
      sourceRect: fullNormalizedRect(),
    }
  }

  if (mode.presentation === "source") {
    return {
      mode: "source",
      targetAspectRatio,
      sourceRect: fullNormalizedRect(),
    }
  }

  return {
    mode: "crop",
    presentation: "fill",
    targetAspectRatio,
    sourceRect: defaultViewportRect(mode.value, sourceAspectRatio),
  }
}

function mediaProgress(sourceElement) {
  if (!(sourceElement instanceof HTMLVideoElement) || !Number.isFinite(sourceElement.duration) || sourceElement.duration <= 0) {
    return 0
  }

  return clamp(sourceElement.currentTime / sourceElement.duration, 0, 1)
}

function normalizeProject(project) {
  const normalized = cloneData(project || makeProject(1))
  normalized.scenes = Array.isArray(normalized.scenes) && normalized.scenes.length
    ? normalized.scenes.map((scene) => normalizeScene(scene))
    : [makeScene()]
  normalized.audioTrack = normalized.audioTrack || { asset: null, duration: 0 }
  return normalized
}

function normalizeScene(scene) {
  const explicitManualCrop = sanitizeNormalizedRect(scene?.manualCropRect)
  let migratedPanStart = sanitizeNormalizedRect(scene?.panStartRect)
  let migratedPanEnd = sanitizeNormalizedRect(scene?.panEndRect)
  let migratedManualCrop = explicitManualCrop

  if (!migratedManualCrop && (migratedPanStart || migratedPanEnd)) {
    migratedManualCrop = cloneRect(migratedPanStart || migratedPanEnd)
    if (!migratedPanStart || !migratedPanEnd || rectsRoughlyEqual(migratedPanStart, migratedPanEnd)) {
      migratedPanStart = null
      migratedPanEnd = null
    }
  }

  return {
    ...makeScene(),
    ...scene,
    framingMode: normalizeFramingMode(scene?.framingMode),
    manualCropRect: migratedManualCrop,
    panMode: typeof scene?.panMode === "string" ? scene.panMode : defaultPanMode,
    panStartRect: migratedPanStart,
    panEndRect: migratedPanEnd,
    extraImages: scene?.extraImages || {},
  }
}

function normalizeFramingMode(value) {
  return framingModes.some((mode) => mode.value === value) ? value : defaultFramingMode
}

function sceneHasManualCrop(scene, editorState = null) {
  const source = editorState && editorState.sceneId === scene.id ? editorState : scene
  const rect = sanitizeNormalizedRect(source.manualCropRect || source.cropRect)
  return Boolean(rect && !rectIsFullFrame(rect))
}

function sceneHasPanPath(scene, editorState = null) {
  const source = editorState && editorState.sceneId === scene.id ? editorState : scene
  const startRect = sanitizeNormalizedRect(source.panStartRect || source.startRect)
  const endRect = sanitizeNormalizedRect(source.panEndRect || source.endRect)
  return Boolean(startRect && endRect && !rectsRoughlyEqual(startRect, endRect))
}

function sceneMotionSummary(scene, editorState = null) {
  if (sceneHasPanPath(scene, editorState)) {
    return "Pan Path"
  }

  if (sceneHasManualCrop(scene, editorState)) {
    return "Manual Crop"
  }

  return framingModeLabel(scene.framingMode)
}

function framingModeLabel(mode) {
  return framingModes.find((candidate) => candidate.value === normalizeFramingMode(mode))?.label || "Source"
}

function fullNormalizedRect() {
  return { x: 0, y: 0, width: 1, height: 1 }
}

function sanitizeNormalizedRect(rect) {
  if (!rect) {
    return null
  }

  const x = Number(rect.x)
  const y = Number(rect.y)
  const width = Number(rect.width)
  const height = Number(rect.height)
  if (![x, y, width, height].every((value) => Number.isFinite(value)) || width <= 0 || height <= 0) {
    return null
  }

  return clampNormalizedRect({ x, y, width, height })
}

function clampNormalizedRect(rect) {
  const width = clamp(rect.width, 0.05, 1)
  const height = clamp(rect.height, 0.05, 1)
  return {
    x: clamp(rect.x, 0, 1 - width),
    y: clamp(rect.y, 0, 1 - height),
    width,
    height,
  }
}

function rectsRoughlyEqual(left, right, epsilon = 0.001) {
  if (!left || !right) {
    return false
  }

  return Math.abs(left.x - right.x) <= epsilon
    && Math.abs(left.y - right.y) <= epsilon
    && Math.abs(left.width - right.width) <= epsilon
    && Math.abs(left.height - right.height) <= epsilon
}

function rectIsFullFrame(rect, epsilon = 0.001) {
  return Boolean(rect)
    && Math.abs(rect.x) <= epsilon
    && Math.abs(rect.y) <= epsilon
    && Math.abs(rect.width - 1) <= epsilon
    && Math.abs(rect.height - 1) <= epsilon
}

function defaultViewportRect(mode, sourceAspectRatio) {
  const normalizedMode = normalizeFramingMode(mode)
  const framingMode = framingModes.find((candidate) => candidate.value === normalizedMode) || framingModes[1]
  if (!framingMode.aspectRatio || framingMode.presentation === "fit") {
    return fullNormalizedRect()
  }

  return viewportRectForAspectRatio(framingMode.aspectRatio, sourceAspectRatio)
}

function defaultManualCropRect(sourceAspectRatio) {
  return fullNormalizedRect()
}

function viewportRectForAspectRatio(targetAspectRatio, sourceAspectRatio) {
  if (!Number.isFinite(sourceAspectRatio) || sourceAspectRatio <= 0) {
    return fullNormalizedRect()
  }

  if (sourceAspectRatio > targetAspectRatio) {
    const width = clamp(targetAspectRatio / sourceAspectRatio, 0.08, 1)
    return { x: (1 - width) / 2, y: 0, width, height: 1 }
  }

  const height = clamp(sourceAspectRatio / targetAspectRatio, 0.08, 1)
  return { x: 0, y: (1 - height) / 2, width: 1, height }
}

function interpolatedRect(startRect, endRect, progress) {
  return clampNormalizedRect({
    x: startRect.x + (endRect.x - startRect.x) * progress,
    y: startRect.y + (endRect.y - startRect.y) * progress,
    width: startRect.width + (endRect.width - startRect.width) * progress,
    height: startRect.height + (endRect.height - startRect.height) * progress,
  })
}

function fitRect(aspectRatio, boundsWidth, boundsHeight, offsetX = 0, offsetY = 0) {
  if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) {
    return { x: offsetX, y: offsetY, width: boundsWidth, height: boundsHeight }
  }

  const boundsAspect = boundsWidth / Math.max(boundsHeight, 1)
  if (boundsAspect > aspectRatio) {
    const width = boundsHeight * aspectRatio
    return {
      x: offsetX + (boundsWidth - width) / 2,
      y: offsetY,
      width,
      height: boundsHeight,
    }
  }

  const height = boundsWidth / aspectRatio
  return {
    x: offsetX,
    y: offsetY + (boundsHeight - height) / 2,
    width: boundsWidth,
    height,
  }
}

function denormalizeRect(rect, imageRect) {
  return {
    x: imageRect.x + rect.x * imageRect.width,
    y: imageRect.y + rect.y * imageRect.height,
    width: rect.width * imageRect.width,
    height: rect.height * imageRect.height,
  }
}

function cloneRect(rect) {
  return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null
}

function cloneData(value) {
  if (typeof structuredClone === "function") {
    return structuredClone(value)
  }

  return JSON.parse(JSON.stringify(value))
}

function inferSceneSourceAspectRatio(scene, sourceElement) {
  if (sourceElement instanceof HTMLVideoElement && sourceElement.videoWidth && sourceElement.videoHeight) {
    return sourceElement.videoWidth / Math.max(sourceElement.videoHeight, 1)
  }

  if (sourceElement instanceof HTMLImageElement && sourceElement.naturalWidth && sourceElement.naturalHeight) {
    return sourceElement.naturalWidth / Math.max(sourceElement.naturalHeight, 1)
  }

  if (scene.videoMetadata?.width && scene.videoMetadata?.height) {
    return scene.videoMetadata.width / Math.max(scene.videoMetadata.height, 1)
  }

  return 16 / 9
}

function cropAspectRatio(scene, sourceAspectRatio) {
  return storyboardFrameAspectRatio
}

function shouldUseStoryboardFrame(renderContext) {
  return renderContext === "board" || renderContext === "editor"
}

function measureCropEditorImageRect(stage, source) {
  if (!(stage instanceof HTMLElement) || !(source instanceof HTMLImageElement || source instanceof HTMLVideoElement)) {
    return null
  }

  const stageRect = stage.getBoundingClientRect()
  const width = stage.clientWidth || stageRect.width
  const height = stage.clientHeight || stageRect.height
  if (width <= 0 || height <= 0) {
    return null
  }

  const sourceAspectRatio = source instanceof HTMLVideoElement
    ? source.videoWidth / Math.max(source.videoHeight, 1)
    : source.naturalWidth / Math.max(source.naturalHeight, 1)

  if (!Number.isFinite(sourceAspectRatio) || sourceAspectRatio <= 0) {
    return { x: 0, y: 0, width, height }
  }

  return fitRect(sourceAspectRatio, width, height)
}

function localPointFromPointer(event, stage) {
  const bounds = stage.getBoundingClientRect()
  return {
    x: event.clientX - bounds.left,
    y: event.clientY - bounds.top,
  }
}

function resizeNormalizedRect(seedRect, corner, point, imageRect) {
  const minSpan = 0.08
  const normalizedX = clamp((point.x - imageRect.x) / Math.max(imageRect.width, 1), 0, 1)
  const normalizedY = clamp((point.y - imageRect.y) / Math.max(imageRect.height, 1), 0, 1)
  const rect = cloneRect(seedRect) || fullNormalizedRect()
  let left = rect.x
  let right = rect.x + rect.width
  let top = rect.y
  let bottom = rect.y + rect.height

  switch (corner) {
    case "topLeft":
      left = normalizedX
      top = normalizedY
      break
    case "topRight":
      right = normalizedX
      top = normalizedY
      break
    case "bottomLeft":
      left = normalizedX
      bottom = normalizedY
      break
    case "bottomRight":
      right = normalizedX
      bottom = normalizedY
      break
    default:
      break
  }

  if (right - left < minSpan) {
    if (corner === "topLeft" || corner === "bottomLeft") {
      left = right - minSpan
    } else {
      right = left + minSpan
    }
  }

  if (bottom - top < minSpan) {
    if (corner === "topLeft" || corner === "topRight") {
      top = bottom - minSpan
    } else {
      bottom = top + minSpan
    }
  }

  left = clamp(left, 0, 1 - minSpan)
  top = clamp(top, 0, 1 - minSpan)
  right = clamp(right, left + minSpan, 1)
  bottom = clamp(bottom, top + minSpan, 1)

  return clampNormalizedRect({
    x: left,
    y: top,
    width: right - left,
    height: bottom - top,
  })
}

function touchProject() {
  state.project.updatedAt = new Date().toISOString()
}

function setStatus(message) {
  state.statusMessage = message
}

function autoSaveSummary() {
  return state.currentProjectPath ? `Saved as ${savedFileName()}` : "Local draft"
}

function ensureSelectedScene() {
  if (findScene(state.selectedSceneId)) {
    return
  }

  state.selectedSceneId = state.project.scenes[0]?.id ?? null
}

function modeSubtitle() {
  if (state.editorMode === "advanced") {
    return "Advanced mode exposes prompts, transitions, scene ordering, packet copy, and extra frame slots for each storyboard scene."
  }

  return "Simple mode is bare bones: numbered picture steps, add scene, copy image, export, and autosave. Switch to Advanced for prompts, transitions, extra uploads, and project management."
}

function autoSaveBadgeText() {
  return state.currentProjectPath
    ? `Saved ${formatUpdatedTime()}`
    : "Local draft"
}

function headerStatusText() {
  const sceneNumber = sceneOrdinal(state.selectedSceneId)
  const selectedScene = findScene(state.selectedSceneId)
  const importantMessage = [
    "Could not",
    "Host bridge",
    "Playback failed",
    "Waveform ready",
    "Project opened",
    "Project saved",
  ].some((prefix) => state.statusMessage.startsWith(prefix))

  if (importantMessage) {
    return state.statusMessage
  }

  if (state.activeScreen === "timeline") {
    return state.project.audioTrack.asset
      ? `Timing is ready for ${state.project.scenes.length} scene${state.project.scenes.length === 1 ? "" : "s"}.`
      : "Import audio to begin timing."
  }

  if (selectedScene?.primaryVideo) {
    return `Scene ${sceneNumber} ready.`
  }

  if (selectedScene?.primaryImage) {
    return `Scene ${sceneNumber} ready.`
  }

  return `Ready for scene ${sceneNumber}.`
}

function sceneCardSummary(scene, index) {
  if (scene.primaryVideo) {
    return `Video ready for scene ${index + 1}.`
  }

  if (scene.primaryImage) {
    return `Image ready for scene ${index + 1}.`
  }

  return "Drag straight from Grok or click to import."
}

function timelineMarkerSummary(scene) {
  const promptLine = firstMeaningfulLine(scene.prompt)
  if (state.editorMode === "advanced" && promptLine) {
    return promptLine
  }

  if (scene.primaryVideo) {
    return "Video scene"
  }

  if (scene.primaryImage) {
    return "Image scene"
  }

  return "Empty marker"
}

function sceneOrdinal(sceneId) {
  const index = state.project.scenes.findIndex((scene) => scene.id === sceneId)
  return index >= 0 ? index + 1 : 1
}

function projectLocationSummary() {
  return state.currentProjectPath
    ? `${autoSaveSummary()}`
    : "Export builds an organized folder. Save As keeps the .storyboardforge project."
}

function savedFileName() {
  const parts = state.currentProjectPath.split(/[\\/]/)
  return parts[parts.length - 1] || state.currentProjectPath
}

function formatUpdatedTime() {
  const updatedAt = new Date(state.project.updatedAt)
  if (Number.isNaN(updatedAt.getTime())) {
    return "recently"
  }

  return updatedAt.toLocaleTimeString([], {
    hour: "numeric",
    minute: "2-digit",
  })
}

function loadVideoMetadata(scene) {
  const url = scene.primaryVideo?.url
  if (!url) {
    return
  }

  const video = document.createElement("video")
  video.preload = "metadata"
  video.src = url
  video.addEventListener("loadedmetadata", () => {
    scene.videoMetadata = {
      duration: video.duration || 0,
      width: video.videoWidth || 0,
      height: video.videoHeight || 0,
      frameRate: 0,
      fileSizeBytes: 0,
      codec: "",
      hasAudio: true,
    }
    touchProject()
    render()
  }, { once: true })
}

function extractDroppedUrl(dataTransfer) {
  if (!dataTransfer) {
    return ""
  }

  const uriList = dataTransfer.getData("text/uri-list")
  if (uriList) {
    const first = uriList
      .split(/\r?\n/)
      .map((line) => line.trim())
      .find((line) => line && !line.startsWith("#"))
    if (first) {
      return first
    }
  }

  const plainText = dataTransfer.getData("text/plain").trim()
  if (/^(https?:|blob:|data:|file:)/i.test(plainText)) {
    return plainText
  }

  const html = dataTransfer.getData("text/html")
  if (html) {
    const match = html.match(/\b(?:src|href)=["']([^"']+)["']/i)
    if (match?.[1]) {
      return match[1]
    }
  }

  return ""
}

function clampTime(time) {
  return clamp(time, 0, currentTimelineDuration())
}

function shortTimestamp(time) {
  const totalSeconds = Math.max(Math.round(time), 0)
  const minutes = Math.floor(totalSeconds / 60)
  const seconds = totalSeconds % 60
  return `${minutes}:${String(seconds).padStart(2, "0")}`
}

function formattedTime(seconds) {
  if (!Number.isFinite(seconds)) {
    return "0:00.0"
  }

  const totalTenths = Math.round(seconds * 10)
  const minutes = Math.floor(totalTenths / 600)
  const remainingTenths = totalTenths % 600
  const secs = Math.floor(remainingTenths / 10)
  const tenths = remainingTenths % 10
  return `${minutes}:${String(secs).padStart(2, "0")}.${tenths}`
}

function firstMeaningfulLine(value) {
  return value
    .split(/\r?\n/)
    .map((line) => line.trim())
    .find((line) => line.length > 0) || ""
}

function clamp(value, min, max) {
  return Math.min(max, Math.max(min, value))
}

function escapeHtml(value) {
  return String(value ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;")
}

function escapeAttr(value) {
  return escapeHtml(value)
}
