auricui-skill 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/bin/install.mjs +66 -0
  4. package/package.json +29 -4
  5. package/skill/SKILL.md +64 -0
  6. package/skill/agents/openai.yaml +4 -0
  7. package/skill/assets/examples/ApprovalCard.tsx +8 -0
  8. package/skill/assets/examples/BasicApp.tsx +38 -0
  9. package/skill/assets/examples/BriefingCard.tsx +8 -0
  10. package/skill/assets/examples/ChatControls.tsx +49 -0
  11. package/skill/assets/examples/ChatPanel.tsx +73 -0
  12. package/skill/assets/examples/Checkbox.tsx +13 -0
  13. package/skill/assets/examples/Combobox.tsx +14 -0
  14. package/skill/assets/examples/Drawer.tsx +10 -0
  15. package/skill/assets/examples/MemoryCard.tsx +9 -0
  16. package/skill/assets/examples/MessageList.tsx +8 -0
  17. package/skill/assets/examples/Theme.tsx +14 -0
  18. package/skill/assets/examples/ToolCall.tsx +8 -0
  19. package/skill/assets/examples/Tree.tsx +15 -0
  20. package/skill/assets/examples/examples.css +11 -0
  21. package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
  22. package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
  23. package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
  24. package/skill/assets/upstream/LICENSE +21 -0
  25. package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
  26. package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
  27. package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
  28. package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
  29. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
  30. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
  31. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
  32. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
  33. package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
  34. package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
  35. package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
  36. package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
  37. package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
  38. package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
  39. package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
  40. package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
  41. package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
  42. package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
  43. package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
  44. package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
  45. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
  46. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
  47. package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
  48. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
  49. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
  50. package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
  51. package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
  52. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
  53. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
  54. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
  55. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
  56. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
  57. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
  58. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
  59. package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
  60. package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
  61. package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
  62. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
  63. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
  64. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
  65. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
  66. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
  67. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
  68. package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
  69. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
  70. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
  71. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
  72. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
  73. package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
  74. package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
  75. package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
  76. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
  77. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
  78. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
  79. package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
  80. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
  81. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
  82. package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
  83. package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
  84. package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
  85. package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
  86. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
  87. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
  88. package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
  89. package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
  90. package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
  91. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
  92. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
  93. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
  94. package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
  95. package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
  96. package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
  97. package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
  98. package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
  99. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
  100. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
  101. package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
  102. package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
  103. package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
  104. package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
  105. package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
  106. package/skill/assets/upstream/apps/desktop/package.json +51 -0
  107. package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
  108. package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
  109. package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
  110. package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
  111. package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
  112. package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
  113. package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
  114. package/skill/assets/upstream/packages/openfilm/package.json +88 -0
  115. package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
  116. package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
  117. package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
  118. package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
  119. package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
  120. package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
  121. package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
  122. package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
  123. package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
  124. package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
  125. package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
  126. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
  127. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
  128. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
  129. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
  130. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
  131. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
  132. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
  133. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
  134. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
  135. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
  136. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
  137. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
  138. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
  139. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
  140. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
  141. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
  142. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
  143. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
  144. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
  145. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
  146. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
  147. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
  148. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
  149. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
  150. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
  151. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
  152. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
  153. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
  154. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
  155. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
  156. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
  157. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
  158. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
  159. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
  160. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
  161. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
  162. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
  163. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
  164. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
  165. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
  166. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
  167. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
  168. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
  169. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
  170. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
  171. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
  172. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
  173. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
  174. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
  175. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
  176. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
  177. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
  178. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
  179. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
  180. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
  181. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
  182. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
  183. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
  184. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
  185. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
  186. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
  187. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
  188. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
  189. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
  190. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
  191. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
  192. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
  193. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
  194. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
  195. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
  196. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
  197. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
  198. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
  199. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
  200. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
  201. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
  202. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
  203. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
  204. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
  205. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
  206. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
  207. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
  208. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
  209. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
  210. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
  211. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
  212. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
  213. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
  214. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
  215. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
  216. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
  217. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
  218. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
  219. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
  220. package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
  221. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
  222. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
  223. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
  224. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
  225. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
  226. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
  227. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
  228. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
  229. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
  230. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
  231. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
  232. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
  233. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
  234. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
  235. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
  236. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
  237. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
  238. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
  239. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
  240. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
  241. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
  242. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
  243. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
  244. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
  245. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
  246. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
  247. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
  248. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
  249. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
  250. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
  251. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
  252. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
  253. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
  254. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
  255. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
  256. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
  257. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
  258. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
  259. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
  260. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
  261. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
  262. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
  263. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
  264. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
  265. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
  266. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
  267. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
  268. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
  269. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
  270. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
  271. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
  272. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
  273. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
  274. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
  275. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
  276. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
  277. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
  278. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
  279. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
  280. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
  281. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
  282. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
  283. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
  284. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
  285. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
  286. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
  287. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
  288. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
  289. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
  290. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
  291. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
  292. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
  293. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
  294. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
  295. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
  296. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
  297. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
  298. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
  299. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
  300. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
  301. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
  302. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
  303. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
  304. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
  305. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
  306. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
  307. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
  308. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
  309. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
  310. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
  311. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
  312. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
  313. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
  314. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
  315. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
  316. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
  317. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
  318. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
  319. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
  320. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
  321. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
  322. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
  323. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
  324. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
  325. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
  326. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
  327. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
  328. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
  329. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
  330. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
  331. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
  332. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
  333. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
  334. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
  335. package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
  336. package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
  337. package/skill/assets/upstream/packages/shared/package.json +45 -0
  338. package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
  339. package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
  340. package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
  341. package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
  342. package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
  343. package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
  344. package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
  345. package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
  346. package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
  347. package/skill/references/agent-components.md +33 -0
  348. package/skill/references/api-index.json +2693 -0
  349. package/skill/references/chat-components.md +65 -0
  350. package/skill/references/components.md +23 -0
  351. package/skill/references/core-components.md +44 -0
  352. package/skill/references/dependencies.md +22 -0
  353. package/skill/references/esm-package.md +48 -0
  354. package/skill/references/integration.md +43 -0
  355. package/skill/references/library-manifest.json +12 -0
  356. package/skill/references/source-manifest.json +325 -0
  357. package/skill/references/studio-components.md +24 -0
  358. package/skill/references/theme-motion.md +49 -0
  359. package/skill/references/troubleshooting.md +27 -0
  360. package/skill/references/verification.md +13 -0
  361. package/skill/scripts/check_dependencies.py +109 -0
  362. package/skill/scripts/inspect_api.py +60 -0
@@ -0,0 +1,2310 @@
1
+ /**
2
+ * The inspector: what is selected right now, read as a properties panel and adjusted as in an editor.
3
+ *
4
+ * One property model wherever a thing is: a whole clip on the timeline (a video, a still, a page), a layer inside a
5
+ * page on the picture, several of either, and the film itself when nothing is selected. The same sections in the
6
+ * same order, by how often they are used — position and size, crop and fill, appearance, adjustments, effects,
7
+ * timing, sound, and the raw CSS folded away under Advanced — each with the same controls (inspector-fields) over
8
+ * the same standard CSS (inspector-look, lib/clip-look). What is written is a clip's `style` (film.html), a layer's
9
+ * override (`overrides`), or a clip's own attributes (`at`, `src`'s fragment, `speed`, `volume`, `class`): nothing
10
+ * film.html does not already say.
11
+ *
12
+ * A write takes a round trip and a repaint of the film, so every field **paints first, then writes**: while a value
13
+ * is dragged or typed only the picture changes; letting go, Enter or leaving the field commits. A refused write
14
+ * paints the old value back — the picture must never keep a look the film does not have — and the reason shows in a
15
+ * banner.
16
+ */
17
+ import * as React from 'react';
18
+ import {
19
+ AlignCenterHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignEndVertical, AlignHorizontalDistributeCenter,
20
+ AlignStartHorizontal, AlignStartVertical, AlignVerticalDistributeCenter, ArrowRightToLine, AudioLines, Captions,
21
+ Check, ChevronRight, CircleAlert, Clapperboard, Copy, Diamond, Film, Gauge, Image as ImageIcon, Link2, Maximize2, MousePointerClick,
22
+ Lock, MoveHorizontal, Music, Replace, RotateCcw, Scissors, Shapes, Sparkles, Square, Type, Unlink2, Video, Volume2, X,
23
+ } from 'lucide-react';
24
+
25
+ import {
26
+ FILM_DEFAULT_STAGE, FILM_SPEED_MAX, FILM_SPEED_MIN, filmClipKind, filmGainDbToVolume, filmSrcIsStill, filmVolumeToGainDb,
27
+ parseFilmDocLoc, type FilmOverride,
28
+ } from '@/lib/film';
29
+ import { useT } from '@/i18n';
30
+ import { groupDragged, wrapDeg, type GroupDrag } from '@/lib/stage-gesture';
31
+ import {
32
+ compactScale, isSvgTextTag, scalePair, sourceLocBare,
33
+ type MgBox, type MgTransform, type NodeOverride, type OverridePivot, type StageElement, type StageElementStyle, type StageGeometry,
34
+ } from '@/lib/stage-types';
35
+ import type { OverrideGeometry } from '@/lib/film-overrides';
36
+ import { plainClipRefusal, type ClipEdit } from '@/lib/film-clip-edits';
37
+ import { clipFieldPart, clipFields, compactBox, transformOf } from '@/lib/stage-clip';
38
+ import type { TimelineBlock } from '@/lib/timeline-layout';
39
+ import { editsForMoves } from '@/lib/timeline-drag';
40
+ import { dbToVolume, VOLUME_DB_MAX, VOLUME_DB_MIN, volumeToDb } from '@/lib/loudness';
41
+ import { useTimelinePrefs } from '@/lib/timeline-prefs';
42
+ import { formatTimecode, FRAME_RATES, timecodeFps, useTimecodeFps } from '@/lib/timecode';
43
+ import { MenuSelect } from './MenuSelect';
44
+ import { CANVAS_SIDE, canvasSide, originalShape, shaped } from '@/lib/canvas-fit';
45
+ import { filmFrameOf, filmFrameRatio, type FilmFrameId } from '@/lib/film-frame';
46
+ import { CANVAS_RATIOS, canvasRatioLabel } from './FramePicker';
47
+ import { declared, withDeclarations, type ClipLook } from '@/lib/clip-look';
48
+ import { PANE_BAR, PANE_BTN, PANE_ICON, PANE_TITLE } from './dock-pane-bar';
49
+ import { Tooltip } from './Tooltip';
50
+ import { ContextMenu, type ContextMenuEntry } from './ContextMenu';
51
+ import {
52
+ ICON, MIXED, NumField, Row, RotateGlyph, Section, SectionAction, Segmented, SliderField,
53
+ useTimeUnit, type FieldApply, type Mixed,
54
+ } from './inspector-fields';
55
+ import {
56
+ AdjustSection, AdvancedSection, AppearanceSection, CropSection, EffectsSection, FillSection, MaskSection, NEUTRAL, StrokeSection,
57
+ TypographySection, camelOf,
58
+ isNeutral, kebabOf, rawDeclarations,
59
+ } from './inspector-look';
60
+ import { makeLook, type Look, type LookRead, type LookSet } from '@/lib/look';
61
+ import type { MaskSession } from '@/lib/stage-mask';
62
+ import type { MaskShape } from '@/lib/clip-mask';
63
+ import type { FadeMs } from '@/lib/clip-fade';
64
+ import type { FoundKind } from '@/lib/transitions';
65
+ import { PictureInput, PropertiesSection, type PropsHost } from './PropertiesSection';
66
+ import { propOf, withProp, withoutProp, type ComponentProp } from '@/lib/component-props';
67
+ import { layerTarget } from '@/lib/stage-layers';
68
+ import type { StagePictureActions } from './stage-menu';
69
+
70
+ /** A change of a layer's words (kept as its `text` override). `expect` is what it said before: painted back on refusal. */
71
+ export type StageTextEdit = {
72
+ loc: string;
73
+ value: string;
74
+ expect: string;
75
+ };
76
+
77
+ /** Why a text change was refused. */
78
+ export type StageEditFailure = {
79
+ error: string;
80
+ };
81
+
82
+ /** A text edit made in place on the picture was refused. `seq` goes up by one each time. */
83
+ export type InlineTextFailure = { loc: string; value: string; failure: StageEditFailure; seq: number };
84
+
85
+ /** A page clip's in-page changes (film.html `overrides`), and the selectors among them its page no longer has. */
86
+ export type PageChanges = { list: readonly FilmOverride[]; lost: readonly string[]; own?: readonly FilmOverride[] };
87
+
88
+ /** The film as the panel shows it when nothing is selected. */
89
+ export type FilmSummary = {
90
+ /** The film's name (the project's). */
91
+ title?: string;
92
+ durationMs: number;
93
+ tracks: number;
94
+ clips: number;
95
+ /** Nothing made yet: the stage itself may change shape. */
96
+ empty: boolean;
97
+ /** Each track's name (P1, V2, A1; see filmTrackBadges), by its place in film.html. */
98
+ badges?: readonly string[];
99
+ };
100
+
101
+ /** Several layers' overrides written at once (one write, one undo step). */
102
+ export type LayerEdit = { element: StageElement; patch: OverrideGeometry | null };
103
+
104
+ type T = ReturnType<typeof useT>;
105
+
106
+ /* ───────────────────────────── skin ───────────────────────────── */
107
+
108
+ const PANE = 'relative flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[7px]';
109
+
110
+ type AlignEdge = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
111
+
112
+ const PIVOT_CENTER: OverridePivot = { hx: 0, hy: 0 };
113
+ const PIVOT_TOP_LEFT: OverridePivot = { hx: -1, hy: -1 };
114
+
115
+ const round3 = (n: number) => Math.round(n * 1000) / 1000;
116
+ const round1 = (n: number) => Math.round(n * 10) / 10;
117
+ /** How far off the stage a place or a size may be typed (px): far past any stage, short of where CSS loses digits. */
118
+ const COORD_LIMIT = 100_000;
119
+
120
+ /* ───────────────────────────── the panel ───────────────────────────── */
121
+
122
+ /**
123
+ * A field is half edited (typed, not yet left) and the hand goes to pick something else on the picture: that field
124
+ * has to commit first, then the selection may change. Otherwise the stage changes the selection on pointerdown, the
125
+ * panel re-renders for the new layer, and the field is either unmounted (no blur, the edit is lost) or blurs and
126
+ * writes to the new layer's place.
127
+ *
128
+ * Listening on window in the capture phase runs before anything on the stage, while the selection is still the old one.
129
+ */
130
+ function useCommitBeforeLeaving(): void {
131
+ React.useEffect(() => {
132
+ const onDown = (e: PointerEvent) => {
133
+ const focus = document.activeElement as HTMLElement | null;
134
+ if (!focus?.closest('[data-film-inspector]') || !/^(INPUT|TEXTAREA)$/.test(focus.tagName)) return;
135
+ if ((e.target as Element | null)?.closest?.('[data-film-inspector]')) return;
136
+ focus.blur();
137
+ };
138
+ window.addEventListener('pointerdown', onDown, true);
139
+ return () => window.removeEventListener('pointerdown', onDown, true);
140
+ }, []);
141
+ }
142
+
143
+ export const FilmInspector = React.memo(function FilmInspector({
144
+ element,
145
+ clips,
146
+ mainTrack,
147
+ onEditClips,
148
+ width,
149
+ onClose,
150
+ onEditStyle,
151
+ onEditSimilar,
152
+ countSimilar,
153
+ onPreviewStyle,
154
+ onEditText,
155
+ onPreviewText,
156
+ geometry,
157
+ inlineTextFailure,
158
+ onEditOverride,
159
+ onPreviewOverride,
160
+ onSelectParent,
161
+ onSelectClip,
162
+ onPreviewClip,
163
+ pageChangesOf,
164
+ replaceEntries,
165
+ clipLookOf,
166
+ onPreviewLook,
167
+ stage,
168
+ onStartCrop,
169
+ onEditMask,
170
+ maskAsk,
171
+ onMaskAsked,
172
+ film,
173
+ onCanvas,
174
+ onCanvasPreview,
175
+ canvasDraft,
176
+ canvasOriginal,
177
+ layers,
178
+ onEditLayers,
179
+ onPlaceLayers,
180
+ locked = false,
181
+ rate,
182
+ fades,
183
+ transition,
184
+ propsHostOf,
185
+ pictures,
186
+ layerProps,
187
+ }: {
188
+ element: StageElement | null;
189
+ /** The clips selected on the timeline. A layer picked on the picture wins: one selection at a time. */
190
+ clips?: readonly TimelineBlock[];
191
+ /** The main track's film.html index (timeline-edit mainTrackOf): the magnet acts there alone. */
192
+ mainTrack?: number | null;
193
+ /** Writes clip properties, through the same queue as dragging on the timeline. Resolves to an error when refused. */
194
+ onEditClips?: (edits: ClipEdit[]) => Promise<string | null>;
195
+ /** The pane's width (dragged at the divider beside the viewer). */
196
+ width: number;
197
+ onClose: () => void;
198
+ /** Writes a style. Resolves to an error when refused (the panel then paints the old value back). */
199
+ onEditStyle: (loc: string, patch: StageElementStyle) => Promise<string | null>;
200
+ /** A style for every layer like this one (its tag and classes) in the film's pages: "All like it". */
201
+ onEditSimilar?: (element: StageElement, patch: StageElementStyle) => Promise<string | null>;
202
+ /** How many page clips "All like it" reaches. */
203
+ countSimilar?: (element: StageElement) => Promise<number>;
204
+ /** Paints a style on the picture without writing it. */
205
+ onPreviewStyle: (loc: string, patch: StageElementStyle) => void;
206
+ onEditText: (edit: StageTextEdit) => Promise<StageEditFailure | null>;
207
+ /** Paints the words as they are typed (written on blur, see TextSection). */
208
+ onPreviewText?: (loc: string, value: string) => void;
209
+ /** Where the thing in hand is on the stage now (stage px, reported by the stage). */
210
+ geometry?: StageGeometry | null;
211
+ inlineTextFailure?: InlineTextFailure | null;
212
+ /** Writes a layer's offset / scale / rotation / style override. `null` resets it. */
213
+ onEditOverride?: (
214
+ element: StageElement,
215
+ patch: OverrideGeometry | null,
216
+ allCopies: boolean,
217
+ pivot?: OverridePivot,
218
+ ) => Promise<string | null>;
219
+ /** Paints an override without writing it. `pivot`: the point that stays put while scaling or turning. */
220
+ onPreviewOverride?: (loc: string, g: NodeOverride, pivot?: OverridePivot) => void;
221
+ /** Breadcrumb: select an outer layer (in the same clip), or the whole clip. */
222
+ onSelectParent?: (loc: string, clipLoc?: string) => void;
223
+ onSelectClip?: (clipLoc: string) => void;
224
+ /** Moves the clip on the picture while its transform numbers are dragged (nothing written); null puts it back. */
225
+ onPreviewClip?: (clipLoc: string, patch: Partial<MgTransform> | null) => void;
226
+ /** A page clip's in-page changes (film.html `overrides`) and which of them its page no longer has. */
227
+ pageChangesOf?: (clipLoc: string) => PageChanges | null;
228
+ /** What can take a clip's place (the project's files, one from this computer): the clip's Replace button. */
229
+ replaceEntries?: (block: TimelineBlock) => ContextMenuEntry[];
230
+ /** A clip's own CSS and `class` in film.html and how it looks now (see clip-look): its look sections read them. */
231
+ clipLookOf?: (clipLoc: string) => { css: string; look: ClipLook | null; cls?: string } | null;
232
+ /** Shows a clip in CSS not kept yet while a look value is dragged (null: its own again). */
233
+ onPreviewLook?: (clipLoc: string, css: string | null) => void;
234
+ /** The film's stage (for a clip's place when it is not on screen). */
235
+ stage?: { w: number; h: number } | null;
236
+ /** Crop & fill's Crop button: the picture's crop on the picture itself. */
237
+ onStartCrop?: () => void;
238
+ /** The Mask section's "Edit on picture": the mask's handles over the thing in hand (null: none). */
239
+ onEditMask?: (session: MaskSession | null) => void;
240
+ /** A mask shape picked on the picture for clip `clipLoc`: its Mask section puts it on, then says so. */
241
+ maskAsk?: { shape: MaskShape | 'none'; clipLoc: string; n: number } | null;
242
+ onMaskAsked?: () => void;
243
+ /** The film when nothing is selected. */
244
+ film?: FilmSummary | null;
245
+ /** The canvas: a shape (the editor grows it around the film), or a size typed. */
246
+ onCanvas?: (next: FilmFrameId, size?: { w: number; h: number }) => void;
247
+ /** Show the canvas at a size not kept yet, as its width or height is dragged (null: as it is again). */
248
+ onCanvasPreview?: (size: { w: number; h: number } | null) => void;
249
+ /** That size, while it is shown. */
250
+ canvasDraft?: { w: number; h: number } | null;
251
+ /** The first picture's own size: the canvas's "Original" shape. None: nothing to fit to yet. */
252
+ canvasOriginal?: { w: number; h: number } | null;
253
+ /** Several layers selected on the picture, and how to write them at once. */
254
+ layers?: readonly StageElement[];
255
+ onEditLayers?: (list: LayerEdit[]) => Promise<string | null>;
256
+ /** Several layers' bounds typed: moved or scaled together on the picture (FilmStageControl.placeLayers). */
257
+ onPlaceLayers?: (to: { x?: number; y?: number; w?: number; h?: number }) => void;
258
+ /** What is selected is on a locked track: shown, not changed (its fields are off, and say why). */
259
+ locked?: boolean;
260
+ /** The project's frame rate (the film's panel): its value, whether it was taken from the footage, and a new one. */
261
+ rate?: { fps: number; fromFootage: boolean; onChange: (fps: number) => void } | null;
262
+ /** Each clip's fades, ms (clip id → [in, out]): the Fades section's values. */
263
+ fades?: ReadonlyMap<string, FadeMs>;
264
+ /** The transition picked on the timeline (no clip selected): its kind and length, made longer or taken away here. */
265
+ transition?: TransitionPick | null;
266
+ /** A page clip's properties (PropertiesSection): read from its page, written as its overrides and its data-props. */
267
+ propsHostOf?: (block: TimelineBlock) => PropsHost | null;
268
+ /** Another file in the place of a picture inside a page (a layer's Picture section). */
269
+ pictures?: StagePictureActions;
270
+ /** A layer made a property of its clip (◇ beside its words, its picture, its showing). */
271
+ layerProps?: LayerPropsActions;
272
+ }) {
273
+ const t = useT();
274
+ useCommitBeforeLeaving();
275
+ /* frames are counted at the project's rate: the panel shows them anew when it changes */
276
+ useTimecodeFps();
277
+ const layer = element && element.kind !== 'clip' ? element : null;
278
+ const clip = !layer && clips?.length === 1 ? clips[0]! : null;
279
+ const many = !layer && clips && clips.length > 1 ? clips : null;
280
+ const stageSize = stage ?? FILM_DEFAULT_STAGE;
281
+
282
+ return (
283
+ <div
284
+ data-film-inspector
285
+ className={`${PANE} shrink-0 bg-[var(--dock-pane)]`}
286
+ style={{ width }}
287
+ >
288
+ <div className={PANE_BAR}>
289
+ <span className={`${PANE_TITLE} px-1`}>{t('inspector.panel')}</span>
290
+ <span className="min-w-0 flex-1" />
291
+ <Tooltip label={t('inspector.hide')} side="bottom">
292
+ <button type="button" onClick={onClose} aria-label={t('inspector.hide')} className={PANE_BTN}>
293
+ <X size={PANE_ICON} />
294
+ </button>
295
+ </Tooltip>
296
+ </div>
297
+ {locked && (layer || clip || many) ? (
298
+ <div role="status" data-inspector-locked="" className="mx-2 mb-1 flex items-center gap-1.5 rounded-md bg-[var(--fill-tsp)] px-2 py-1 text-[11px] text-[var(--text-muted)]">
299
+ <Lock size={12} className="shrink-0" aria-hidden />
300
+ <span className="min-w-0">{t('inspector.trackLocked')}</span>
301
+ </div>
302
+ ) : null}
303
+ {/* a locked track's clip: every field off (the edits are refused anyway, see lib/track-lock) */}
304
+ <fieldset disabled={locked && Boolean(layer || clip || many)} className="contents">
305
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
306
+ {layer ? (
307
+ <LayerPanel
308
+ key={`${layer.loc}|${layer.clipLoc ?? ''}`}
309
+ element={layer}
310
+ geometry={geometry}
311
+ inlineTextFailure={inlineTextFailure}
312
+ onEditStyle={onEditStyle}
313
+ {...(onEditSimilar ? { onEditSimilar } : {})}
314
+ {...(countSimilar ? { countSimilar } : {})}
315
+ onPreviewStyle={onPreviewStyle}
316
+ onEditText={onEditText}
317
+ onPreviewText={onPreviewText}
318
+ onEditOverride={onEditOverride}
319
+ onPreviewOverride={onPreviewOverride}
320
+ onSelectParent={onSelectParent}
321
+ onSelectClip={onSelectClip}
322
+ {...(onEditMask ? { onEditMask } : {})}
323
+ {...(pictures ? { pictures } : {})}
324
+ {...(layerProps ? { layerProps } : {})}
325
+ />
326
+ ) : clip ? (
327
+ <ClipPanel
328
+ key={clip.id}
329
+ block={clip}
330
+ onMain={mainTrack != null && parseFilmDocLoc(clip.loc ?? '')?.track === mainTrack}
331
+ geometry={geometry && geometry.kind === 'clip' && geometry.loc === clip.loc ? geometry : null}
332
+ onEdit={onEditClips}
333
+ onPreviewClip={onPreviewClip}
334
+ {...(pageChangesOf ? { pageChangesOf } : {})}
335
+ {...(replaceEntries ? { replaceEntries } : {})}
336
+ {...(clipLookOf ? { clipLookOf } : {})}
337
+ {...(onPreviewLook ? { onPreviewLook } : {})}
338
+ {...(onStartCrop ? { onStartCrop } : {})}
339
+ {...(onEditMask ? { onEditMask } : {})}
340
+ {...(maskAsk && maskAsk.clipLoc === clip.loc ? { maskAsk } : {})}
341
+ {...(onMaskAsked ? { onMaskAsked } : {})}
342
+ badges={film?.badges}
343
+ stage={stageSize}
344
+ {...(fades ? { fades } : {})}
345
+ {...(propsHostOf ? { propsHostOf } : {})}
346
+ />
347
+ ) : many ? (
348
+ <MultiClipPanel
349
+ blocks={many}
350
+ onEdit={onEditClips}
351
+ stage={stageSize}
352
+ {...(fades ? { fades } : {})}
353
+ {...(clipLookOf ? { clipLookOf } : {})}
354
+ {...(onPreviewLook ? { onPreviewLook } : {})}
355
+ />
356
+ ) : transition ? (
357
+ <TransitionPanel t={t} pick={transition} />
358
+ ) : geometry?.kind === 'group' && geometry.group ? (
359
+ <GroupPanel geometry={geometry} group={geometry.group} layers={layers} onEditLayers={onEditLayers} {...(onPlaceLayers ? { onPlace: onPlaceLayers } : {})} t={t} />
360
+ ) : (
361
+ <FilmPanel t={t} stage={stageSize} draft={canvasDraft} film={film ?? null} onCanvas={onCanvas} onCanvasPreview={onCanvasPreview} original={canvasOriginal ?? null} rate={rate ?? null} />
362
+ )}
363
+ </div>
364
+ </fieldset>
365
+ </div>
366
+ );
367
+ });
368
+
369
+ /* ───────────────────────────── nothing selected: the film ───────────────────────────── */
370
+
371
+
372
+ /** Even sizes: H.264 samples chroma in 2×2 blocks and refuses odd dimensions (as film-frame does). */
373
+
374
+ /**
375
+ * For a film that has clips: the canvas changes here, and laying the film out again for it is the agent's. The words
376
+ * that ask for it, with the canvas as it is now, to copy into the chat.
377
+ */
378
+ function RedesignHint({ t, stage }: { t: T; stage: { w: number; h: number } }) {
379
+ const [open, setOpen] = React.useState(false);
380
+ const [copied, setCopied] = React.useState(false);
381
+ const shape = filmFrameOf(stage);
382
+ const ask = t('inspector.redesignAsk').replace('{ratio}', shape === 'native' ? `${stage.w}:${stage.h}` : shape).replace('{size}', `${stage.w}×${stage.h}`);
383
+ React.useEffect(() => { setCopied(false); }, [ask]);
384
+ return (
385
+ <div className="flex flex-col gap-2">
386
+ <button type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open}
387
+ className="flex h-7 items-center gap-1.5 self-start rounded-md px-1.5 text-[11px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]">
388
+ <Sparkles size={12} />
389
+ {t('inspector.redesignOpen')}
390
+ </button>
391
+ {open ? (
392
+ <div className="flex flex-col gap-2 rounded-lg border border-[var(--border)] bg-[var(--fill-tsp)] p-2.5">
393
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-muted)]">{t('inspector.redesignBody')}</p>
394
+ <p className="select-text rounded-md bg-[var(--bg)] px-2 py-1.5 text-[11px] leading-[16px] text-[var(--text)]">{ask}</p>
395
+ <button type="button"
396
+ onClick={() => { void navigator.clipboard?.writeText(ask).then(() => setCopied(true), () => {}); }}
397
+ className="flex h-7 items-center justify-center gap-1.5 rounded-md bg-[var(--text)] text-[11px] font-medium text-[var(--bg)] transition hover:opacity-90">
398
+ {copied ? <Check size={12} /> : <Copy size={12} />}
399
+ {t(copied ? 'inspector.redesignCopied' : 'inspector.redesignCopy')}
400
+ </button>
401
+ </div>
402
+ ) : null}
403
+ </div>
404
+ );
405
+ }
406
+
407
+ /**
408
+ * The film itself: its name, its stage and shape, how long it is, what is in it. Picking a shape changes the canvas
409
+ * itself, at any time, as an image editor's canvas size: the film is scaled whole into it, centered; laying the film
410
+ * out again for it is the agent's (RedesignHint). "Original" takes the shape of the first picture.
411
+ */
412
+ function FilmPanel({
413
+ t, stage: kept, draft, film, onCanvas, onCanvasPreview, original, rate,
414
+ }: {
415
+ t: T;
416
+ stage: { w: number; h: number };
417
+ /** The canvas size shown while W or H is dragged, not kept yet. */
418
+ draft?: { w: number; h: number } | null;
419
+ film: FilmSummary | null;
420
+ onCanvas?: (next: FilmFrameId, size?: { w: number; h: number }) => void;
421
+ onCanvasPreview?: (size: { w: number; h: number } | null) => void;
422
+ original: { w: number; h: number } | null;
423
+ rate: { fps: number; fromFootage: boolean; onChange: (fps: number) => void } | null;
424
+ }) {
425
+ const canvas = Boolean(onCanvas);
426
+ /* what the picture shows: the size being dragged, else the film's */
427
+ const stage = draft ?? kept;
428
+ const placed = Boolean(film && !film.empty);
429
+ const preset = CANVAS_RATIOS.find((r) => shaped(stage, filmFrameRatio(r.id, stage)));
430
+ const { frame: fit, current: isFit } = originalShape(stage, original);
431
+ const setSize = (w: number, h: number) => {
432
+ const size = { w, h };
433
+ if (size.w === kept.w && size.h === kept.h) { onCanvasPreview?.(null); return; }
434
+ onCanvas?.(filmFrameOf(size), size);
435
+ };
436
+ const info = (label: string, value: string) => (
437
+ <div className="flex h-6 items-center justify-between gap-2 text-[11px]">
438
+ <span className="text-[var(--text-muted)]">{label}</span>
439
+ <span className="tabular-nums text-[var(--text)]">{value}</span>
440
+ </div>
441
+ );
442
+ return (
443
+ <>
444
+ <Header
445
+ icon={<Film size={13} />}
446
+ name={film?.title || t('inspector.film')}
447
+ meta={`${stage.w} × ${stage.h}${film ? ` · ${formatTimecode(film.durationMs)}` : ''}`}
448
+ />
449
+ <Section id="film-stage" title={t('inspector.stage')}>
450
+ {canvas ? (
451
+ <MenuSelect
452
+ label={t('inspector.stageShape')}
453
+ value={isFit ? 'original' : preset?.id ?? 'custom'}
454
+ groups={[
455
+ {
456
+ choices: [
457
+ ...(fit ? [{ value: 'original', label: t('viewer.ratioOriginal') }] : []),
458
+ ...(preset || isFit ? [] : [{ value: 'custom', label: t('viewer.frameCustom') }]),
459
+ ],
460
+ },
461
+ { choices: CANVAS_RATIOS.filter((r) => r.group === 'wide').map((r) => ({ value: r.id, label: canvasRatioLabel(t, r) })) },
462
+ { choices: CANVAS_RATIOS.filter((r) => r.group === 'tall').map((r) => ({ value: r.id, label: canvasRatioLabel(t, r) })) },
463
+ ].filter((g) => g.choices.length)}
464
+ onPick={(id) => {
465
+ if (id === 'original') { if (fit) onCanvas?.(fit); } else if (id !== 'custom') onCanvas?.(id as FilmFrameId);
466
+ }}
467
+ />
468
+ ) : null}
469
+ <Row>
470
+ {/* dragged, the canvas changes on the picture as the mark moves; kept when it is let go */}
471
+ <NumField label={t('inspector.width')} prefix="W" value={stage.w} unit="px" step={8} min={CANVAS_SIDE.min} max={CANVAS_SIDE.max} disabled={!canvas}
472
+ onCommit={(w) => setSize(canvasSide(w, kept.h), kept.h)} onPreview={(w) => onCanvasPreview?.({ w: canvasSide(w, kept.h), h: kept.h })} onRevert={() => onCanvasPreview?.(null)} />
473
+ <NumField label={t('inspector.height')} prefix="H" value={stage.h} unit="px" step={8} min={CANVAS_SIDE.min} max={CANVAS_SIDE.max} disabled={!canvas}
474
+ onCommit={(h) => setSize(kept.w, canvasSide(h, kept.w))} onPreview={(h) => onCanvasPreview?.({ w: kept.w, h: canvasSide(h, kept.w) })} onRevert={() => onCanvasPreview?.(null)} />
475
+ </Row>
476
+ {/* with clips: the words that ask the agent to lay the film out again for this canvas */}
477
+ {placed ? <RedesignHint t={t} stage={stage} /> : null}
478
+ </Section>
479
+ {rate ? (
480
+ <Section id="film-rate" title={t('inspector.frameRate')}>
481
+ <MenuSelect
482
+ label={t('inspector.frameRate')}
483
+ value={String(rate.fps)}
484
+ groups={[{ choices: FRAME_RATES.map((r) => ({ value: String(r), label: `${r} fps` })) }]}
485
+ onPick={(v) => rate.onChange(Number(v))}
486
+ />
487
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t(rate.fromFootage ? 'inspector.frameRateFootage' : 'inspector.frameRateNote')}</p>
488
+ </Section>
489
+ ) : null}
490
+ {film ? (
491
+ <Section id="film-info" title={t('inspector.filmInfo')}>
492
+ <div className="flex flex-col">
493
+ {info(t('inspector.duration'), formatTimecode(film.durationMs))}
494
+ {info(t('inspector.tracks'), String(film.tracks))}
495
+ {info(t('inspector.clipsCount'), String(film.clips))}
496
+ </div>
497
+ </Section>
498
+ ) : null}
499
+ <div className="flex flex-col items-center px-6 pt-8 text-center">
500
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--fill-tsp)] text-[var(--text-muted)]">
501
+ <MousePointerClick size={15} strokeWidth={1.75} />
502
+ </div>
503
+ <p className="mt-2 text-[11.5px] leading-relaxed text-[var(--text-faint)]">{t('inspector.empty')}</p>
504
+ </div>
505
+ </>
506
+ );
507
+ }
508
+
509
+ /* ───────────────────────────── header ───────────────────────────── */
510
+
511
+ function kindIcon(kind: string, size = 14): React.ReactNode {
512
+ switch (kind) {
513
+ case 'text': return <Type size={size} />;
514
+ case 'image': return <ImageIcon size={size} />;
515
+ case 'shape': return <Shapes size={size} />;
516
+ case 'box': return <Square size={size} />;
517
+ case 'mg': return <Clapperboard size={size} />;
518
+ case 'video': return <Video size={size} />;
519
+ case 'voice': return <AudioLines size={size} />;
520
+ case 'sfx': return <Volume2 size={size} />;
521
+ case 'caption': return <Captions size={size} />;
522
+ default: return <Music size={size} />;
523
+ }
524
+ }
525
+
526
+ function layerKindLabel(t: T, kind: StageElement['kind']): string {
527
+ return t(
528
+ kind === 'text' ? 'inspector.kindText'
529
+ : kind === 'image' ? 'inspector.kindImage'
530
+ : kind === 'shape' ? 'inspector.kindShape'
531
+ : 'inspector.kindBox',
532
+ );
533
+ }
534
+
535
+ /** What a clip is, by its file (as the media panel names them): the timeline's voice / music / sfx are all a sound. */
536
+ type ClipWhat = 'page' | 'video' | 'image' | 'audio' | 'caption';
537
+
538
+ function clipWhat(block: TimelineBlock): ClipWhat {
539
+ if (block.kind === 'caption') return 'caption';
540
+ if (block.src) {
541
+ const kind = filmClipKind(block.src);
542
+ return kind === 'mg' ? 'page' : kind === 'audio' ? 'audio' : filmSrcIsStill(block.src) ? 'image' : 'video';
543
+ }
544
+ return block.kind === 'mg' ? 'page' : block.kind === 'video' ? 'video' : 'audio';
545
+ }
546
+
547
+ function clipIcon(what: ClipWhat, size = 14): React.ReactNode {
548
+ return kindIcon(what === 'page' ? 'mg' : what === 'audio' ? 'voice' : what, size);
549
+ }
550
+
551
+ function clipKindLabel(t: T, what: ClipWhat): string {
552
+ return t(
553
+ what === 'page' ? 'inspector.kindPage'
554
+ : what === 'video' ? 'inspector.kindVideo'
555
+ : what === 'image' ? 'inspector.kindImage'
556
+ : what === 'caption' ? 'inspector.kindCaption'
557
+ : 'inspector.kindAudio',
558
+ );
559
+ }
560
+
561
+ /**
562
+ * The header row: the kind's icon and the name, big enough to tell at a glance which thing this is; its track's
563
+ * badge; the rest (kind, time, source file) is one gray line under it.
564
+ */
565
+ function Header({
566
+ icon, name, meta, mono, badge, action,
567
+ }: {
568
+ icon: React.ReactNode;
569
+ name: string;
570
+ meta: string;
571
+ /** Source file / asset path: monospace, truncated. */
572
+ mono?: string;
573
+ /** The track it is on (V1). */
574
+ badge?: string;
575
+ /** A button at the right (a clip's Replace). */
576
+ action?: React.ReactNode;
577
+ }) {
578
+ return (
579
+ <div className="flex items-center gap-2 px-3 pb-2 pt-2.5">
580
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[5px] bg-[var(--fill-tsp)] text-[var(--text-muted)]">
581
+ {icon}
582
+ </div>
583
+ <div className="min-w-0 flex-1">
584
+ <div className="flex min-w-0 items-center gap-1.5">
585
+ <span className="truncate text-[12px] font-semibold leading-[16px] text-[var(--text)]" title={name}>{name}</span>
586
+ {badge ? (
587
+ <span className="shrink-0 rounded-[3px] bg-[var(--accent-soft)] px-1 font-mono text-[9.5px] font-medium leading-[14px] text-[var(--text-muted)]">{badge}</span>
588
+ ) : null}
589
+ </div>
590
+ <div className="truncate text-[10.5px] leading-[14px] text-[var(--text-faint)] tabular-nums" title={mono}>
591
+ {mono ? `${meta} · ${mono}` : meta}
592
+ </div>
593
+ </div>
594
+ {action}
595
+ </div>
596
+ );
597
+ }
598
+
599
+ /**
600
+ * Align: left / center / right, top / middle / bottom — to the stage for one thing, to each other for several (with
601
+ * the two ways of spacing them evenly). Grayed when the thing cannot be measured (not on screen now).
602
+ */
603
+ function AlignBar({ t, onAlign, onDistribute }: { t: T; onAlign?: (edge: AlignEdge) => void; onDistribute?: (axis: 'x' | 'y') => void }) {
604
+ const items: { edge: AlignEdge; label: string; icon: React.ReactNode }[] = [
605
+ { edge: 'left', label: t('inspector.alignLeft'), icon: <AlignStartVertical size={ICON} /> },
606
+ { edge: 'hcenter', label: t('inspector.alignHCenter'), icon: <AlignCenterVertical size={ICON} /> },
607
+ { edge: 'right', label: t('inspector.alignRight'), icon: <AlignEndVertical size={ICON} /> },
608
+ { edge: 'top', label: t('inspector.alignTop'), icon: <AlignStartHorizontal size={ICON} /> },
609
+ { edge: 'vcenter', label: t('inspector.alignVCenter'), icon: <AlignCenterHorizontal size={ICON} /> },
610
+ { edge: 'bottom', label: t('inspector.alignBottom'), icon: <AlignEndHorizontal size={ICON} /> },
611
+ ];
612
+ const btn = (key: string, label: string, icon: React.ReactNode, onClick?: () => void) => (
613
+ <Tooltip key={key} label={label}>
614
+ <button
615
+ type="button"
616
+ aria-label={label}
617
+ disabled={!onClick}
618
+ onClick={onClick}
619
+ className="flex h-6 flex-1 items-center justify-center rounded-[4px] text-[var(--text-muted)] transition hover:bg-[var(--bg)] hover:text-[var(--text)] disabled:pointer-events-none disabled:opacity-40"
620
+ >
621
+ {icon}
622
+ </button>
623
+ </Tooltip>
624
+ );
625
+ return (
626
+ <div className="flex items-center gap-1.5">
627
+ {[items.slice(0, 3), items.slice(3)].map((group, i) => (
628
+ <div key={i} className="flex h-[26px] flex-1 items-center rounded-[5px] bg-[var(--fill-tsp)] p-px">
629
+ {group.map((item) => btn(item.edge, item.label, item.icon, onAlign ? () => onAlign(item.edge) : undefined))}
630
+ </div>
631
+ ))}
632
+ {onDistribute ? (
633
+ <div className="flex h-[26px] w-[54px] shrink-0 items-center rounded-[5px] bg-[var(--fill-tsp)] p-px">
634
+ {btn('dx', t('inspector.distributeH'), <AlignHorizontalDistributeCenter size={ICON} />, () => onDistribute('x'))}
635
+ {btn('dy', t('inspector.distributeV'), <AlignVerticalDistributeCenter size={ICON} />, () => onDistribute('y'))}
636
+ </div>
637
+ ) : null}
638
+ </div>
639
+ );
640
+ }
641
+
642
+ /** The proportion lock beside W / H. */
643
+ function LockToggle({ t, locked, onChange }: { t: T; locked: boolean; onChange?: (next: boolean) => void }) {
644
+ const label = locked ? t('inspector.aspectLocked') : t('inspector.aspectFree');
645
+ return (
646
+ <Tooltip label={label}>
647
+ <button
648
+ type="button"
649
+ aria-pressed={locked}
650
+ aria-label={label}
651
+ disabled={!onChange}
652
+ onClick={() => onChange?.(!locked)}
653
+ className={`flex h-[26px] w-6 items-center justify-center rounded-[5px] transition disabled:pointer-events-none ${
654
+ locked
655
+ ? 'bg-[var(--accent-soft)] text-[var(--text)]'
656
+ : 'text-[var(--text-faint)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)]'
657
+ }`}
658
+ >
659
+ {locked ? <Link2 size={13} /> : <Unlink2 size={13} />}
660
+ </button>
661
+ </Tooltip>
662
+ );
663
+ }
664
+
665
+ /**
666
+ * Breadcrumb: the clip › outer layers › this layer, each a step back out. The picture always picks the innermost
667
+ * layer; to change the card around it, step out here.
668
+ */
669
+ function Breadcrumb({
670
+ element, onSelectParent, onSelectClip,
671
+ }: {
672
+ element: StageElement;
673
+ onSelectParent?: (loc: string, clipLoc?: string) => void;
674
+ onSelectClip?: (clipLoc: string) => void;
675
+ }) {
676
+ const parents = [...(element.parents ?? [])].reverse();
677
+ if (!element.clipLoc && !parents.length) return null;
678
+ const crumb = 'min-w-0 max-w-[7.5rem] shrink truncate rounded-[4px] px-1 py-0.5 text-[10.5px] text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]';
679
+ return (
680
+ <nav className="-mt-0.5 flex min-w-0 flex-wrap items-center gap-y-0.5 px-2.5 pb-2" aria-label="breadcrumb">
681
+ {element.clipLoc ? (
682
+ <button
683
+ type="button"
684
+ className={`${crumb} font-medium`}
685
+ onClick={() => onSelectClip?.(element.clipLoc!)}
686
+ disabled={!onSelectClip}
687
+ title={element.clipId}
688
+ >
689
+ {element.clipId ?? element.clipLoc}
690
+ </button>
691
+ ) : null}
692
+ {parents.map((p) => (
693
+ <React.Fragment key={p.loc}>
694
+ <ChevronRight size={11} className="shrink-0 text-[var(--text-faint)]" />
695
+ <button
696
+ type="button"
697
+ className={crumb}
698
+ onClick={() => onSelectParent?.(p.loc, element.clipLoc)}
699
+ disabled={!onSelectParent}
700
+ title={p.label}
701
+ >
702
+ {p.label}
703
+ </button>
704
+ </React.Fragment>
705
+ ))}
706
+ <ChevronRight size={11} className="shrink-0 text-[var(--text-faint)]" />
707
+ <span className="min-w-0 max-w-[7.5rem] truncate px-1 py-0.5 text-[10.5px] font-medium text-[var(--text)]">
708
+ {element.label}
709
+ </span>
710
+ </nav>
711
+ );
712
+ }
713
+
714
+ /** A refused change: why. */
715
+ function RefusalBanner({
716
+ message, onDismiss, t,
717
+ }: {
718
+ message: string;
719
+ onDismiss: () => void;
720
+ t: T;
721
+ }) {
722
+ return (
723
+ <div role="alert" className="mx-3 mb-2.5 rounded-[7px] border border-[color-mix(in_srgb,var(--err)_28%,transparent)] bg-[color-mix(in_srgb,var(--err)_7%,transparent)] px-2.5 py-2">
724
+ <div className="flex items-start gap-2">
725
+ <CircleAlert size={13} className="mt-[1px] shrink-0 text-[var(--err)]" />
726
+ <p className="min-w-0 flex-1 text-[11px] leading-[16px] text-[var(--text)]">{message}</p>
727
+ <button
728
+ type="button"
729
+ onClick={onDismiss}
730
+ aria-label={t('inspector.dismiss')}
731
+ className="-mr-1 -mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-[var(--text-faint)] hover:text-[var(--text)]"
732
+ >
733
+ <X size={12} />
734
+ </button>
735
+ </div>
736
+ </div>
737
+ );
738
+ }
739
+
740
+ /* ───────────────────────────── a layer's CSS ───────────────────────────── */
741
+
742
+ /** A layer's CSS property: its override's, else what the page draws it with now. */
743
+ function layerRead(element: StageElement): LookRead {
744
+ return (prop) => {
745
+ const key = camelOf(prop);
746
+ const v = element.override?.style?.[key] ?? element.style?.[key];
747
+ return v == null || v === '' ? undefined : String(v);
748
+ };
749
+ }
750
+
751
+ /**
752
+ * Properties for a layer's override. "None of mine" takes the person's change away, back to the page's own; when the
753
+ * person had none and the page's own is not neutral (its CSS fades it), the neutral value is written over it.
754
+ */
755
+ function layerStyle(element: StageElement, set: LookSet): Record<string, string | null> {
756
+ const read = layerRead(element);
757
+ const out: Record<string, string | null> = {};
758
+ for (const [prop, v] of Object.entries(set)) {
759
+ const key = camelOf(prop);
760
+ if (v != null) { out[key] = v; continue; }
761
+ const mine = element.override?.style?.[key] != null;
762
+ out[key] = !mine && !isNeutral(prop, read(prop)) && NEUTRAL[prop] ? NEUTRAL[prop]! : null;
763
+ }
764
+ return out;
765
+ }
766
+
767
+ /* ───────────────────────────── a layer on the picture ───────────────────────────── */
768
+
769
+ /**
770
+ * Which copies a style change goes to when a list renders several. Words are not scoped: copies showing the same
771
+ * words come from one template, so changing them changes all (see TextSection).
772
+ */
773
+ type Scope = 'one' | 'all' | 'similar';
774
+
775
+ function LayerPanel({
776
+ element, geometry, inlineTextFailure, onEditStyle, onEditSimilar, countSimilar, onPreviewStyle, onEditText, onPreviewText,
777
+ onEditOverride, onPreviewOverride, onSelectParent, onSelectClip, onEditMask, pictures, layerProps,
778
+ }: {
779
+ element: StageElement;
780
+ geometry?: StageGeometry | null;
781
+ inlineTextFailure?: InlineTextFailure | null;
782
+ onEditStyle: (loc: string, patch: StageElementStyle) => Promise<string | null>;
783
+ /** A style for every layer like this one (its tag and classes) in the film's pages: "All like it". */
784
+ onEditSimilar?: (element: StageElement, patch: StageElementStyle) => Promise<string | null>;
785
+ /** How many page clips "All like it" reaches. */
786
+ countSimilar?: (element: StageElement) => Promise<number>;
787
+ onPreviewStyle: (loc: string, patch: StageElementStyle) => void;
788
+ onEditText: (edit: StageTextEdit) => Promise<StageEditFailure | null>;
789
+ onPreviewText?: (loc: string, value: string) => void;
790
+ onEditOverride?: (
791
+ element: StageElement,
792
+ patch: OverrideGeometry | null,
793
+ allCopies: boolean,
794
+ pivot?: OverridePivot,
795
+ ) => Promise<string | null>;
796
+ onPreviewOverride?: (loc: string, g: NodeOverride, pivot?: OverridePivot) => void;
797
+ onSelectParent?: (loc: string, clipLoc?: string) => void;
798
+ onSelectClip?: (clipLoc: string) => void;
799
+ onEditMask?: (session: MaskSession | null) => void;
800
+ pictures?: StagePictureActions;
801
+ layerProps?: LayerPropsActions;
802
+ }) {
803
+ const t = useT();
804
+ const [refusal, setRefusalState] = React.useState<string | null>(null);
805
+ /* after a refusal the fields below remount: a field still holding the value just typed would look as if it took */
806
+ const [rev, setRev] = React.useState(0);
807
+ const setRefusal = (next: string | null) => {
808
+ setRefusalState(next);
809
+ if (next) setRev((n) => n + 1);
810
+ };
811
+ const copies = element.instances != null && element.instances > 1 ? element.instances : 0;
812
+ const [scope, setScope] = React.useState<Scope>('one');
813
+ /* "All like it": text with a class to go by; how many page clips it reaches, once counted */
814
+ const likeIt = element.kind === 'text' && Boolean(element.similar) && Boolean(onEditSimilar);
815
+ const [likeCount, setLikeCount] = React.useState<number | null>(null);
816
+ React.useEffect(() => {
817
+ if (!likeIt || !countSimilar) return undefined;
818
+ let live = true;
819
+ void countSimilar(element).then((n) => { if (live) setLikeCount(n); });
820
+ return () => { live = false; };
821
+ }, [likeIt, countSimilar, element.similar, element.clipLoc]); // eslint-disable-line react-hooks/exhaustive-deps
822
+ const loc = element.loc;
823
+ const bare = sourceLocBare(loc) ?? loc;
824
+
825
+ /* a text edit made in place on the picture was refused: the reason shows here */
826
+ const failureSeq = inlineTextFailure?.seq;
827
+ React.useEffect(() => {
828
+ const f = inlineTextFailure;
829
+ const text = element.text;
830
+ if (!f || !text || f.loc !== text.loc) return;
831
+ setRefusal(f.failure.error);
832
+ // eslint-disable-next-line react-hooks/exhaustive-deps
833
+ }, [failureSeq]);
834
+
835
+ /* the anchor below is a hook: it has to run before the early return */
836
+ const geoAnchor = React.useRef<StageGeometry | null>(null);
837
+ React.useEffect(() => { geoAnchor.current = null; }, [element.loc, element.override]);
838
+ /* aspect lock: on by default for text (squashed letters are never wanted), off otherwise */
839
+ const [ratioLocked, setRatioLocked] = React.useState(element.kind === 'text');
840
+ /* the properties a preview has painted: what a revert paints back */
841
+ const previewedKeys = React.useRef(new Set<string>());
842
+
843
+ if (!loc || !bare) return null;
844
+ const styleLoc = copies && scope === 'all' ? bare : loc;
845
+ const style = element.style ?? {};
846
+ const kind = element.kind;
847
+
848
+ /** A style patch for the stage: "none of mine" is unset (the stage takes the property away). */
849
+ const stagePatch = (set: Record<string, string | null>): StageElementStyle => {
850
+ const out: StageElementStyle = {};
851
+ for (const [k, v] of Object.entries(set)) out[k] = v ?? undefined;
852
+ return out;
853
+ };
854
+ const commitStyle = async (patch: StageElementStyle) => {
855
+ previewedKeys.current.clear();
856
+ onPreviewStyle(styleLoc, patch);
857
+ setRefusal(null);
858
+ const failed = scope === 'similar' && onEditSimilar ? await onEditSimilar(element, patch) : await onEditStyle(styleLoc, patch);
859
+ if (!failed) return;
860
+ /* refused: take the change back off the picture, or it keeps a look the film does not have */
861
+ const back: StageElementStyle = {};
862
+ for (const key of Object.keys(patch)) back[key] = style[key];
863
+ onPreviewStyle(styleLoc, back);
864
+ setRefusal(failed);
865
+ };
866
+ const look: Look = makeLook([element], layerRead, {
867
+ commit: ([[, set]]) => { void commitStyle(stagePatch(layerStyle(element, set))); },
868
+ preview: (sets) => {
869
+ if (!sets.length) return;
870
+ const patch = stagePatch(layerStyle(element, sets[0]![1]));
871
+ for (const k of Object.keys(patch)) previewedKeys.current.add(k);
872
+ onPreviewStyle(styleLoc, patch);
873
+ },
874
+ revert: () => {
875
+ const back: StageElementStyle = {};
876
+ for (const k of previewedKeys.current) back[k] = element.override?.style?.[k] ?? style[k];
877
+ previewedKeys.current.clear();
878
+ if (Object.keys(back).length) onPreviewStyle(styleLoc, back);
879
+ },
880
+ });
881
+
882
+ /*
883
+ * Position and size are an override. The panel shows X / Y, W / H on the stage and the angle as
884
+ * the stage measures them (geometry); what is written is the override's offset / factors, converted back by
885
+ * `unit` (the offset is in the parent's coordinates). Not measurable (not on screen) → the written offset.
886
+ * With "All N copies" one override covers every copy, otherwise only the picked one.
887
+ */
888
+ const override = element.override ?? {};
889
+ const ot = override.t ?? [0, 0];
890
+ const [osx, osy] = scalePair(override.s);
891
+ const liveGeo = geometry && geometry.kind === 'layer' && geometry.loc === element.loc ? geometry : null;
892
+ /*
893
+ * While previewing, work from the geometry measured when the preview **began**: the preview moves the layer, the
894
+ * measurement follows, and adding it to the written offset again would count the same move twice — a dragged
895
+ * number jitters back and a typed one misses. Released on commit or revert.
896
+ */
897
+ const geo = geoAnchor.current ?? liveGeo;
898
+ const canMove = Boolean(onEditOverride && element.clipLoc);
899
+ const commitOverride = async (patch: OverrideGeometry | null, pivot?: OverridePivot) => {
900
+ geoAnchor.current = null;
901
+ if (!onEditOverride) return;
902
+ setRefusal(null);
903
+ const failed = await onEditOverride(element, patch, Boolean(copies) && scope === 'all', pivot);
904
+ if (!failed) return;
905
+ onPreviewOverride?.(styleLoc, override);
906
+ setRefusal(failed);
907
+ };
908
+ const previewGeom = (part: NodeOverride, pivot?: OverridePivot) => {
909
+ geoAnchor.current ??= liveGeo;
910
+ onPreviewOverride?.(styleLoc, { ...override, ...part }, pivot);
911
+ };
912
+ const revertGeom = () => {
913
+ geoAnchor.current = null;
914
+ onPreviewOverride?.(styleLoc, override);
915
+ };
916
+ /** The override offset that puts the layer at stage (x, y). */
917
+ const offsetFor = (x: number | null, y: number | null): [number, number] => {
918
+ if (!geo) return [x ?? ot[0], y ?? ot[1]];
919
+ /* moved (dx, dy) on the stage; the offset is in the parent's coordinates, so turn it back by the parent's angle */
920
+ const dx = (x == null ? 0 : x - geo.x) * geo.unit;
921
+ const dy = (y == null ? 0 : y - geo.y) * geo.unit;
922
+ const pr = ((geo.parentR ?? 0) * Math.PI) / 180;
923
+ return [
924
+ round1(ot[0] + dx * Math.cos(pr) + dy * Math.sin(pr)),
925
+ round1(ot[1] - dx * Math.sin(pr) + dy * Math.cos(pr)),
926
+ ];
927
+ };
928
+ /**
929
+ * W / H write the override's x / y factors (both together when the aspect is locked). The top left stays put;
930
+ * the offset that keeps it there is added by the caller from what the stage measures.
931
+ */
932
+ const sizeFor = (w: number | null, h: number | null): { s: number | [number, number] } | null => {
933
+ if (!geo || !(geo.w > 0) || !(geo.h > 0)) return null;
934
+ const kx = w != null ? w / geo.w : ratioLocked && h != null ? h / geo.h : 1;
935
+ const ky = h != null ? h / geo.h : ratioLocked && w != null ? w / geo.w : 1;
936
+ if (!(kx > 0) || !(ky > 0)) return null;
937
+ const clampS = (v: number) => Math.round(Math.min(50, Math.max(0.02, v)) * 10000) / 10000;
938
+ return { s: compactScale(clampS(osx * kx), clampS(osy * ky)) };
939
+ };
940
+ const alignTo = (edge: AlignEdge) => {
941
+ if (!geo) return;
942
+ const { w: sw, h: sh } = geo.stage;
943
+ const x = edge === 'left' ? 0 : edge === 'hcenter' ? (sw - geo.w) / 2 : edge === 'right' ? sw - geo.w : null;
944
+ const y = edge === 'top' ? 0 : edge === 'vcenter' ? (sh - geo.h) / 2 : edge === 'bottom' ? sh - geo.h : null;
945
+ const next = offsetFor(x, y);
946
+ if (next[0] === ot[0] && next[1] === ot[1]) return;
947
+ previewGeom({ t: next });
948
+ void commitOverride({ t: next });
949
+ };
950
+ const moved = Boolean(override.t?.[0] || override.t?.[1] || osx !== 1 || osy !== 1 || override.r);
951
+ /* style keys applied as an override */
952
+ const overrideStyle = override.style ?? {};
953
+ const overrideStyleKeys = Object.keys(overrideStyle);
954
+ const stageW = geo?.stage.w ?? FILM_DEFAULT_STAGE.w;
955
+ const stageH = geo?.stage.h ?? FILM_DEFAULT_STAGE.h;
956
+
957
+ const declaredKeys = new Set(element.declaredStyle ?? []);
958
+ const bgProp = declaredKeys.has('background') ? 'background' : 'background-color';
959
+ /* text in an SVG (`<text>` / `<tspan>`): no line layout, its color is fill — those sections look different */
960
+ const svgText = kind === 'text' && isSvgTextTag(element.tag ?? '');
961
+ /* a line with its own fill (a badge, a tag): it has a background and corners, as a box */
962
+ const textFilled = kind === 'text' && !svgText && style.backgroundColor != null;
963
+ /* the override's own CSS, as declarations: what Advanced shows of it */
964
+ const overrideCss = Object.entries(overrideStyle)
965
+ .map(([k, v]) => `${kebabOf(k)}: ${typeof v === 'number' && !/opacity|z-index|font-weight|line-height|flex/.test(kebabOf(k)) ? `${v}px` : v}`)
966
+ .join('; ');
967
+
968
+ return (
969
+ <>
970
+ <Header icon={kindIcon(kind, 13)} name={element.label} meta={layerKindLabel(t, kind)} />
971
+ <Breadcrumb element={element} onSelectParent={onSelectParent} onSelectClip={onSelectClip} />
972
+ {copies || likeIt ? (
973
+ <div className="px-3 pb-2.5">
974
+ <Segmented
975
+ label={t('inspector.scope')}
976
+ value={scope}
977
+ onChange={(v) => setScope(v as Scope)}
978
+ options={[
979
+ copies
980
+ ? { value: 'one', label: t('inspector.scopeOne').replace('{i}', String(element.instance ?? 1)) }
981
+ : { value: 'one', label: t('inspector.scopeThis') },
982
+ ...(copies ? [{ value: 'all', label: t('inspector.scopeAll').replace('{n}', String(copies)) }] : []),
983
+ /* the look of every title like it, in every page that has one (lib/similar-layers); words and places stay each one's */
984
+ ...(likeIt ? [{
985
+ value: 'similar', title: t('inspector.scopeSimilarHint'),
986
+ label: likeCount && likeCount > 1 ? t('inspector.scopeSimilarIn').replace('{n}', String(likeCount)) : t('inspector.scopeSimilar'),
987
+ }] : []),
988
+ ]}
989
+ />
990
+ </div>
991
+ ) : null}
992
+ {refusal ? (
993
+ <RefusalBanner message={refusal} onDismiss={() => setRefusal(null)} t={t} />
994
+ ) : null}
995
+
996
+ <React.Fragment key={rev}>
997
+ {element.text ? (
998
+ <TextSection
999
+ text={element.text}
1000
+ {...(layerProps ? { aside: <PropButton element={element} type="text" actions={layerProps} t={t} /> } : {})}
1001
+ shared={copies}
1002
+ onEditText={onEditText}
1003
+ onPreviewText={onPreviewText}
1004
+ onRefused={setRefusal}
1005
+ />
1006
+ ) : null}
1007
+
1008
+ {/* a picture: another file in its place, from the project or this computer, or dropped from the media pane */}
1009
+ {element.picture && pictures ? (
1010
+ <Section
1011
+ id="picture"
1012
+ title={t('inspector.picture')}
1013
+ aside={(
1014
+ <span className="flex items-center">
1015
+ {element.picture.changed ? (
1016
+ <SectionAction label={t('stageMenu.pictureOwn')} onClick={() => pictures.put(element, null)}>
1017
+ <RotateCcw size={11} />
1018
+ </SectionAction>
1019
+ ) : null}
1020
+ {layerProps ? <PropButton element={element} type="image" actions={layerProps} t={t} /> : null}
1021
+ </span>
1022
+ )}
1023
+ >
1024
+ <PictureInput
1025
+ src={element.picture.src}
1026
+ host={{ pictures: pictures.pictures, ...(pictures.pickFromComputer ? { pickPicture: pictures.pickFromComputer } : {}) }}
1027
+ label={t('inspector.picture')}
1028
+ onPick={(path) => pictures.put(element, path)}
1029
+ t={t}
1030
+ />
1031
+ </Section>
1032
+ ) : null}
1033
+
1034
+ {canMove ? (
1035
+ <Section
1036
+ id="position"
1037
+ title={t('inspector.position')}
1038
+ aside={moved ? (
1039
+ <SectionAction
1040
+ label={t('inspector.resetPosition')}
1041
+ onClick={() => {
1042
+ onPreviewOverride?.(styleLoc, overrideStyleKeys.length ? { style: override.style } : {});
1043
+ /* reset the geometry only: style overrides stay (Advanced has its own Clear) */
1044
+ void commitOverride(overrideStyleKeys.length ? { t: [0, 0], s: 1, r: 0 } : null);
1045
+ }}
1046
+ >
1047
+ <RotateCcw size={11} />
1048
+ </SectionAction>
1049
+ ) : null}
1050
+ >
1051
+ <AlignBar t={t} onAlign={geo ? alignTo : undefined} />
1052
+ <Row>
1053
+ <NumField
1054
+ label={geo ? t('inspector.x') : t('inspector.offsetX')}
1055
+ prefix="X"
1056
+ value={geo ? geo.x : ot[0]}
1057
+ min={-COORD_LIMIT}
1058
+ max={COORD_LIMIT}
1059
+ ctx={{ percent: stageW }}
1060
+ onPreview={(x) => previewGeom({ t: offsetFor(x, null) })}
1061
+ onRevert={revertGeom}
1062
+ onCommit={(x) => { void commitOverride({ t: offsetFor(x, null) }); }}
1063
+ />
1064
+ <NumField
1065
+ label={geo ? t('inspector.y') : t('inspector.offsetY')}
1066
+ prefix="Y"
1067
+ value={geo ? geo.y : ot[1]}
1068
+ min={-COORD_LIMIT}
1069
+ max={COORD_LIMIT}
1070
+ ctx={{ percent: stageH }}
1071
+ onPreview={(y) => previewGeom({ t: offsetFor(null, y) })}
1072
+ onRevert={revertGeom}
1073
+ onCommit={(y) => { void commitOverride({ t: offsetFor(null, y) }); }}
1074
+ />
1075
+ </Row>
1076
+ {geo ? (
1077
+ <div className="grid grid-cols-[1fr_1fr_24px] items-center gap-1.5">
1078
+ <NumField
1079
+ label={t('inspector.width')}
1080
+ prefix="W"
1081
+ value={geo.w}
1082
+ min={1}
1083
+ max={COORD_LIMIT}
1084
+ ctx={{ percent: stageW }}
1085
+ onPreview={(w) => { const g = sizeFor(w, null); if (g) previewGeom(g, PIVOT_TOP_LEFT); }}
1086
+ onRevert={revertGeom}
1087
+ onCommit={(w) => { const g = sizeFor(w, null); if (g) void commitOverride(g, PIVOT_TOP_LEFT); }}
1088
+ />
1089
+ <NumField
1090
+ label={t('inspector.height')}
1091
+ prefix="H"
1092
+ value={geo.h}
1093
+ min={1}
1094
+ max={COORD_LIMIT}
1095
+ ctx={{ percent: stageH }}
1096
+ onPreview={(h) => { const g = sizeFor(null, h); if (g) previewGeom(g, PIVOT_TOP_LEFT); }}
1097
+ onRevert={revertGeom}
1098
+ onCommit={(h) => { const g = sizeFor(null, h); if (g) void commitOverride(g, PIVOT_TOP_LEFT); }}
1099
+ />
1100
+ <LockToggle t={t} locked={ratioLocked} onChange={setRatioLocked} />
1101
+ </div>
1102
+ ) : null}
1103
+ <Row>
1104
+ <NumField
1105
+ label={t('inspector.rotate')}
1106
+ prefix={<RotateGlyph />}
1107
+ value={geo ? geo.r : override.r ?? 0}
1108
+ unit="°"
1109
+ onPreview={(r) => previewGeom({ r: wrapDeg(r) }, PIVOT_CENTER)}
1110
+ onRevert={revertGeom}
1111
+ onCommit={(r) => { void commitOverride({ r: wrapDeg(r) }, PIVOT_CENTER); }}
1112
+ />
1113
+ {geo ? <span /> : (
1114
+ <NumField
1115
+ label={t('inspector.scale')}
1116
+ prefix={<Maximize2 size={10} />}
1117
+ value={Math.round(osx * 1000) / 10}
1118
+ unit="%"
1119
+ min={2}
1120
+ max={5000}
1121
+ onPreview={(n) => previewGeom({ s: n / 100 }, PIVOT_CENTER)}
1122
+ onRevert={revertGeom}
1123
+ onCommit={(n) => { void commitOverride({ s: round3(n / 100) }, PIVOT_CENTER); }}
1124
+ />
1125
+ )}
1126
+ </Row>
1127
+ </Section>
1128
+ ) : null}
1129
+
1130
+ {kind === 'text' ? <TypographySection look={look} t={t} svg={svgText} sample={element.text?.value ?? ''} /> : null}
1131
+
1132
+ {kind === 'image' || kind === 'box' ? (
1133
+ <CropSection
1134
+ look={look}
1135
+ t={t}
1136
+ fit={kind === 'image'}
1137
+ framing={kind === 'image'}
1138
+ box={geo ? { w: geo.w, h: geo.h } : null}
1139
+ />
1140
+ ) : null}
1141
+
1142
+ <MaskSection
1143
+ look={look}
1144
+ t={t}
1145
+ box={geo ? { w: geo.w, h: geo.h } : null}
1146
+ target={{ kind: 'layer', loc, ...(element.clipLoc ? { clipLoc: element.clipLoc } : {}) }}
1147
+ {...(onEditMask ? { onEditMask } : {})}
1148
+ />
1149
+
1150
+ {/* in this order: appearance, fill, stroke, effects; then the picture's color adjustments */}
1151
+ <AppearanceSection
1152
+ look={look}
1153
+ t={t}
1154
+ radius={kind === 'box' || kind === 'image' || textFilled}
1155
+ {...(layerProps ? { aside: <PropButton element={element} type="boolean" actions={layerProps} t={t} /> } : {})}
1156
+ />
1157
+ <FillSection
1158
+ look={look}
1159
+ t={t}
1160
+ fills={[
1161
+ ...(kind === 'text' ? [{ prop: svgText ? 'fill' : 'color', label: svgText ? t('inspector.fill') : t('inspector.color') }] : []),
1162
+ ...(textFilled ? [{ prop: bgProp, label: t('inspector.background'), removable: true }] : []),
1163
+ ...(kind === 'box' && style.backgroundColor != null ? [{ prop: bgProp, label: t('inspector.fill'), removable: true }] : []),
1164
+ /* a shape's fill, as its CSS or its own fill="" attribute paints it */
1165
+ ...(kind === 'shape' && (style.fill != null || declaredKeys.has('fill')) ? [{ prop: 'fill', label: t('inspector.fill') }] : []),
1166
+ ]}
1167
+ add={kind === 'box' && style.backgroundColor == null ? { prop: bgProp, label: t('inspector.fill'), value: '#ffffff' } : null}
1168
+ />
1169
+ {kind === 'shape' || svgText ? null : <StrokeSection look={look} t={t} />}
1170
+ <EffectsSection look={look} t={t} inner={kind === 'box' || textFilled} />
1171
+ <AdjustSection look={look} t={t} />
1172
+ {onEditOverride ? (
1173
+ <AdvancedSection
1174
+ t={t}
1175
+ css={rawDeclarations(overrideCss)}
1176
+ onCommit={(set) => {
1177
+ const patch: Record<string, string | null> = {};
1178
+ for (const [k, v] of Object.entries(set)) patch[camelOf(k)] = v;
1179
+ void commitOverride({ style: patch });
1180
+ }}
1181
+ {...(overrideStyleKeys.length ? {
1182
+ onClear: () => {
1183
+ const cleared: Record<string, null> = {};
1184
+ for (const key of overrideStyleKeys) cleared[key] = null;
1185
+ void commitOverride({ style: cleared });
1186
+ },
1187
+ } : {})}
1188
+ />
1189
+ ) : null}
1190
+ </React.Fragment>
1191
+ </>
1192
+ );
1193
+ }
1194
+
1195
+ /**
1196
+ * The words themselves, saved on blur — painted as they are typed, written once.
1197
+ */
1198
+ /** Making a layer a property of its clip, and no longer: the inspector's ◇ (lib/component-props). */
1199
+ export interface LayerPropsActions {
1200
+ /** The properties of the layer's clip now. */
1201
+ list: (element: StageElement) => ComponentProp[];
1202
+ /** Write them. */
1203
+ save: (element: StageElement, next: readonly ComponentProp[]) => void;
1204
+ }
1205
+
1206
+ /**
1207
+ * ◇ beside the layer's words, its picture or its showing: makes it a property of the clip (named from what it is, renamed
1208
+ * in the clip's Properties), or, filled when it is one, takes the property away.
1209
+ */
1210
+ function PropButton({ element, type, actions, t }: {
1211
+ element: StageElement; type: 'text' | 'image' | 'boolean'; actions: LayerPropsActions; t: T;
1212
+ }) {
1213
+ const list = actions.list(element);
1214
+ const picture = element.picture;
1215
+ /* a frame holding one picture: the property is its <img>'s */
1216
+ const own = layerTarget(element);
1217
+ const target = type === 'image' && picture?.at ? { at: picture.at, ...(picture.n ? { n: picture.n } : {}) } : own;
1218
+ if (!target || (type === 'image' && !picture)) return null;
1219
+ const bound = propOf(list, type, target);
1220
+ const words = element.text?.value.replace(/\s+/g, ' ').trim() ?? '';
1221
+ const file = picture ? decodeURIComponent(picture.src.split(/[?#]/)[0]!.split('/').pop() ?? '').replace(/\.[^.]+$/, '') : '';
1222
+ const name = type === 'text' ? (words.length > 24 ? `${words.slice(0, 24)}…` : words) || t('inspector.propText')
1223
+ : type === 'image' ? file || t('inspector.picture')
1224
+ /* a layer named by its tag alone (div) is named by its picture, else its kind */
1225
+ : t('inspector.propShowName').replace('{name}', element.label && element.label !== element.tag ? element.label
1226
+ : file || (element.kind === 'clip' ? element.label : layerKindLabel(t, element.kind)));
1227
+ const label = bound
1228
+ ? t('inspector.propBound').replace('{name}', bound.name)
1229
+ : t(type === 'text' ? 'inspector.propMakeText' : type === 'image' ? 'inspector.propMakePicture' : 'inspector.propMakeShow');
1230
+ const make = (): ComponentProp => (type === 'image'
1231
+ ? { name, type, kind: picture!.kind, ...target }
1232
+ : { name, type, ...target });
1233
+ return (
1234
+ <SectionAction label={label} onClick={() => actions.save(element, bound ? withoutProp(list, bound.name) : withProp(list, make()))}>
1235
+ <Diamond size={11} className={bound ? 'fill-[var(--accent)] text-[var(--accent)]' : ''} />
1236
+ </SectionAction>
1237
+ );
1238
+ }
1239
+
1240
+ function TextSection({
1241
+ text, shared, onEditText, onPreviewText, onRefused, aside,
1242
+ }: {
1243
+ text: NonNullable<StageElement['text']>;
1244
+ /** Beside the title: making the words a property of the clip. */
1245
+ aside?: React.ReactNode;
1246
+ /** How many copies share these words: changing them changes all, and the panel says so. */
1247
+ shared: number;
1248
+ onEditText: (edit: StageTextEdit) => Promise<StageEditFailure | null>;
1249
+ /** Paints the words as they are typed; on refusal paints the old words back (see send). */
1250
+ onPreviewText?: (loc: string, value: string) => void;
1251
+ onRefused: (message: string | null) => void;
1252
+ }) {
1253
+ const t = useT();
1254
+ const [raw, setRaw] = React.useState(text.value);
1255
+ const [saving, setSaving] = React.useState(false);
1256
+ /* the picture paints the words as they are typed, and the layer is measured again from the picture: while typing,
1257
+ `text.value` is the typed words, not the written ones. What was written is kept from before the first key. */
1258
+ const written = React.useRef(text.value);
1259
+ const typing = React.useRef(false);
1260
+ React.useEffect(() => {
1261
+ if (typing.current) return;
1262
+ written.current = text.value;
1263
+ setRaw(text.value);
1264
+ }, [text.value]);
1265
+ React.useEffect(() => {
1266
+ typing.current = false;
1267
+ written.current = text.value;
1268
+ setRaw(text.value);
1269
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1270
+ }, [text.loc]);
1271
+ const send = async (value: string) => {
1272
+ onRefused(null);
1273
+ setSaving(true);
1274
+ const loc = text.loc;
1275
+ const failed = await onEditText({ loc, value, expect: written.current });
1276
+ setSaving(false);
1277
+ typing.current = false;
1278
+ if (!failed) { written.current = value; return; }
1279
+ /* not written: the box and the picture go back to the written words, or it looks as if it took */
1280
+ setRaw(written.current);
1281
+ onPreviewText?.(loc, written.current);
1282
+ onRefused(failed.error);
1283
+ };
1284
+ /* Esc is "never mind": it blurs right after, and that blur still reads the half-typed words */
1285
+ const cancelled = React.useRef(false);
1286
+ const save = () => {
1287
+ if (cancelled.current) { cancelled.current = false; return; }
1288
+ if (raw === written.current) { typing.current = false; return; }
1289
+ void send(raw);
1290
+ };
1291
+ return (
1292
+ <Section
1293
+ id="text"
1294
+ title={t('inspector.content')}
1295
+ aside={shared || aside ? (
1296
+ <span className="flex items-center gap-1.5">
1297
+ {shared ? (
1298
+ <span className="text-[10.5px] text-[var(--text-faint)]">
1299
+ {t('inspector.mapTextShared').replace('{n}', String(shared))}
1300
+ </span>
1301
+ ) : null}
1302
+ {aside}
1303
+ </span>
1304
+ ) : null}
1305
+ >
1306
+ <textarea
1307
+ value={raw}
1308
+ aria-label={t('inspector.content')}
1309
+ onChange={(e) => {
1310
+ cancelled.current = false;
1311
+ typing.current = true;
1312
+ setRaw(e.target.value);
1313
+ /* the picture follows the words as they are typed; the write still waits for blur / Enter */
1314
+ onPreviewText?.(text.loc, e.target.value);
1315
+ }}
1316
+ onBlur={save}
1317
+ onKeyDown={(e) => {
1318
+ /* Enter saves, as every editor does; Shift+Enter is a new line */
1319
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
1320
+ e.preventDefault();
1321
+ (e.target as HTMLTextAreaElement).blur();
1322
+ }
1323
+ if (e.key === 'Escape') {
1324
+ cancelled.current = true;
1325
+ typing.current = false;
1326
+ setRaw(written.current);
1327
+ onPreviewText?.(text.loc, written.current);
1328
+ (e.target as HTMLTextAreaElement).blur();
1329
+ }
1330
+ }}
1331
+ rows={Math.min(6, Math.max(2, raw.split('\n').length))}
1332
+ className={`w-full resize-none rounded-[5px] bg-[var(--fill-tsp)] px-2 py-1.5 text-[11.5px] leading-[1.5] text-[var(--text)] outline-none ring-1 ring-inset ring-transparent transition hover:ring-[var(--border)] focus:bg-[var(--bg)] focus:ring-[1.5px] focus:ring-[var(--accent)] ${saving ? 'opacity-60' : ''}`}
1333
+ />
1334
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">
1335
+ {t('inspector.editOnCanvas')}
1336
+ </p>
1337
+ </Section>
1338
+ );
1339
+ }
1340
+
1341
+ /**
1342
+ * Several layers selected on the picture: how many, of what, and where their box is. They are moved together on
1343
+ * the picture (dragged, nudged, aligned from the menu); here what they share: their look, and their type when all
1344
+ * are text.
1345
+ */
1346
+ function GroupPanel({ geometry, group, layers, onEditLayers, onPlace, t }: {
1347
+ geometry: StageGeometry;
1348
+ group: NonNullable<StageGeometry['group']>;
1349
+ layers?: readonly StageElement[];
1350
+ onEditLayers?: (list: LayerEdit[]) => Promise<string | null>;
1351
+ /** Their bounds typed: moved (x, y) or scaled in proportion (w, h). */
1352
+ onPlace?: (to: { x?: number; y?: number; w?: number; h?: number }) => void;
1353
+ t: T;
1354
+ }) {
1355
+ const [refusal, setRefusal] = React.useState<string | null>(null);
1356
+ const cell = (label: string, value: number, key: 'x' | 'y' | 'w' | 'h') => (
1357
+ <NumField
1358
+ label={label}
1359
+ prefix={label.slice(0, 1)}
1360
+ value={Math.round(value * 10) / 10}
1361
+ {...(key === 'w' || key === 'h' ? { min: 1 } : {})}
1362
+ disabled={!onPlace}
1363
+ onCommit={(n) => onPlace?.({ [key]: n })}
1364
+ />
1365
+ );
1366
+ /* the layers selected: the app's list, else the picture's own (a marquee's, a Shift-click's) */
1367
+ const targets = (layers ?? group.elements ?? []).filter((l) => l.clipLoc);
1368
+ const look = targets.length && onEditLayers ? makeLook(targets, layerRead, {
1369
+ commit: (sets) => {
1370
+ setRefusal(null);
1371
+ void onEditLayers(sets.map(([element, set]) => ({ element, patch: { style: layerStyle(element, set) } })))
1372
+ .then((failed) => setRefusal(failed));
1373
+ },
1374
+ }) : null;
1375
+ const allText = targets.length > 0 && targets.every((l) => l.kind === 'text' && !isSvgTextTag(l.tag ?? ''));
1376
+ return (
1377
+ <>
1378
+ <Header
1379
+ icon={group.kinds.length === 1 ? kindIcon(group.kinds[0]!, 13) : <Shapes size={13} />}
1380
+ name={t('inspector.multiLayers').replace('{n}', String(group.n))}
1381
+ meta={group.kinds.map((k) => layerKindLabel(t, k)).join(' · ')}
1382
+ />
1383
+ {refusal ? <RefusalBanner message={refusal} onDismiss={() => setRefusal(null)} t={t} /> : null}
1384
+ <Section id="position" title={t('inspector.position')}>
1385
+ <Row>{cell(t('inspector.x'), geometry.x, 'x')}{cell(t('inspector.y'), geometry.y, 'y')}</Row>
1386
+ <Row>{cell(t('inspector.width'), geometry.w, 'w')}{cell(t('inspector.height'), geometry.h, 'h')}</Row>
1387
+ <p className="text-[11px] leading-[16px] text-[var(--text-faint)]">{t('inspector.multiLayersHint')}</p>
1388
+ </Section>
1389
+ {look ? (
1390
+ <>
1391
+ {allText ? <TypographySection look={look} t={t} sample={targets[0]?.text?.value ?? ''} /> : null}
1392
+ <AppearanceSection look={look} t={t} radius={targets.every((l) => l.kind === 'box' || l.kind === 'image')} />
1393
+ {allText ? <FillSection look={look} t={t} fills={[{ prop: 'color', label: t('inspector.color') }]} /> : null}
1394
+ {targets.some((l) => l.kind === 'shape') ? null : <StrokeSection look={look} t={t} />}
1395
+ <EffectsSection look={look} t={t} inner={targets.every((l) => l.kind === 'box')} />
1396
+ <AdjustSection look={look} t={t} />
1397
+ </>
1398
+ ) : null}
1399
+ </>
1400
+ );
1401
+ }
1402
+
1403
+ /* ───────────────────────────── a clip on the timeline ───────────────────────────── */
1404
+
1405
+ /** Whether a clip has a volume: sounds always, a video when it carries its own sound (a page has none). */
1406
+ function blockHasVolume(b: TimelineBlock): boolean {
1407
+ return b.kind === 'voice' || b.kind === 'sfx' || b.kind === 'music' || Boolean(b.ownAudio);
1408
+ }
1409
+
1410
+ /** The panel shows linear volume only: film.html writes `volume`, a code-shaped film `gainDb`; `1` is as is. */
1411
+ function linearVolumeOf(b: TimelineBlock): number {
1412
+ return b.volume ?? filmGainDbToVolume(b.gainDb ?? 0);
1413
+ }
1414
+
1415
+ /** One volume for a batch of clips: `volume` in film.html, `gainDb` otherwise (back to as-is removes the key). */
1416
+ function volumeEdits(blocks: readonly TimelineBlock[], apply: FieldApply): ClipEdit[] {
1417
+ return blocks.flatMap((b) => {
1418
+ if (!b.loc || !blockHasVolume(b)) return [];
1419
+ const v = dbToVolume(apply(volumeToDb(linearVolumeOf(b))));
1420
+ if (v === round3(linearVolumeOf(b))) return [];
1421
+ if (parseFilmDocLoc(b.loc) != null) return [{ loc: b.loc, prop: 'volume', value: v }];
1422
+ return [{ loc: b.loc, prop: 'gainDb', value: filmVolumeToGainDb(v) ?? null }];
1423
+ });
1424
+ }
1425
+
1426
+ /**
1427
+ * How long a clip can run on the film: until its source runs out (none for a still, or a page, which holds its last
1428
+ * frame past its end), and not into the next clip on its track, as a trim on the timeline stops there — unless the
1429
+ * timeline's magnet is on (`ripple`): then the next clip is pushed along.
1430
+ */
1431
+ function longestSec(block: TimelineBlock, durSec: number, ripple: boolean): number | undefined {
1432
+ const bySource = block.room?.tailMs != null ? durSec + block.room.tailMs / 1000 : Infinity;
1433
+ const byNext = block.nextMs != null && !ripple ? (block.nextMs - block.startMs) / 1000 : Infinity;
1434
+ const sec = Math.min(bySource, byNext);
1435
+ /* down to the ms: rounded up, the length would reach a millisecond into the next clip or past the file */
1436
+ return Number.isFinite(sec) ? Math.floor(sec * 1000 + 1e-6) / 1000 : undefined;
1437
+ }
1438
+
1439
+ /** A clip whose picture can be placed and dressed in film.html: a page, a video or a still, written there. */
1440
+ function isPicture(b: TimelineBlock): boolean {
1441
+ return (b.kind === 'mg' || b.kind === 'video') && b.loc != null && parseFilmDocLoc(b.loc) != null;
1442
+ }
1443
+
1444
+ type ClipTarget = { loc: string; css: string; look: ClipLook | null };
1445
+
1446
+ /** A clip's CSS property: its own `style`'s, else what the film's CSS gives it (its classes), as the player measured. */
1447
+ function clipRead(tg: ClipTarget): LookRead {
1448
+ const measured: Record<string, string | undefined> = {
1449
+ 'object-fit': tg.look?.fit, 'object-position': tg.look?.position, 'border-radius': tg.look?.radius, opacity: tg.look?.opacity,
1450
+ 'mix-blend-mode': tg.look?.blend, 'clip-path': tg.look?.clip, filter: tg.look?.filter,
1451
+ };
1452
+ return (prop) => declared(tg.css, prop) ?? measured[prop];
1453
+ }
1454
+
1455
+ /**
1456
+ * A clip's style with `set` written. "None of mine" takes the declaration out — unless the clip has none and its
1457
+ * classes give it a look (a `.card` with round corners): then the neutral value is written over them.
1458
+ */
1459
+ function clipCss(tg: ClipTarget, set: LookSet): string {
1460
+ const read = clipRead(tg);
1461
+ const resolved: LookSet = {};
1462
+ for (const [prop, v] of Object.entries(set)) {
1463
+ resolved[prop] = v == null && declared(tg.css, prop) === undefined && !isNeutral(prop, read(prop)) ? NEUTRAL[prop] ?? null : v;
1464
+ }
1465
+ return withDeclarations(tg.css, resolved);
1466
+ }
1467
+
1468
+ /** A Look over clips' own CSS: written as their `style`, shown while dragged with onPreviewLook. */
1469
+ function clipsLook(
1470
+ targets: readonly ClipTarget[],
1471
+ commit: (edits: ClipEdit[]) => void,
1472
+ onPreviewLook?: (clipLoc: string, css: string | null) => void,
1473
+ ): Look {
1474
+ return makeLook(targets, clipRead, {
1475
+ commit: (sets) => commit(sets.map(([tg, set]) => ({ loc: tg.loc, prop: 'style', value: clipCss(tg, set) || null }))),
1476
+ preview: (sets) => { for (const [tg, set] of sets) onPreviewLook?.(tg.loc, clipCss(tg, set)); },
1477
+ revert: () => { for (const tg of targets) onPreviewLook?.(tg.loc, null); },
1478
+ });
1479
+ }
1480
+
1481
+ /**
1482
+ * One whole clip selected on the timeline. Every field writes back to the clip at `block.loc`; a clip without one
1483
+ * can only be looked at.
1484
+ */
1485
+ function ClipPanel({
1486
+ block, onMain, geometry, stage, badges, onEdit, onPreviewClip, pageChangesOf, replaceEntries, clipLookOf, onPreviewLook, onStartCrop, onEditMask, maskAsk, onMaskAsked, fades,
1487
+ propsHostOf,
1488
+ }: {
1489
+ fades?: ReadonlyMap<string, FadeMs>;
1490
+ propsHostOf?: (block: TimelineBlock) => PropsHost | null;
1491
+ replaceEntries?: (block: TimelineBlock) => ContextMenuEntry[];
1492
+ block: TimelineBlock;
1493
+ /** On the main track, where the magnet acts. */
1494
+ onMain: boolean;
1495
+ geometry?: StageGeometry | null;
1496
+ /** The film's stage: with the clip's own size, where it lands when it is not on screen to measure. */
1497
+ stage: { w: number; h: number };
1498
+ badges?: readonly string[] | undefined;
1499
+ onEdit?: (edits: ClipEdit[]) => Promise<string | null>;
1500
+ onPreviewClip?: (clipLoc: string, patch: Partial<MgTransform> | null) => void;
1501
+ pageChangesOf?: (clipLoc: string) => PageChanges | null;
1502
+ clipLookOf?: (clipLoc: string) => { css: string; look: ClipLook | null; cls?: string } | null;
1503
+ onPreviewLook?: (clipLoc: string, css: string | null) => void;
1504
+ onStartCrop?: () => void;
1505
+ onEditMask?: (session: MaskSession | null) => void;
1506
+ maskAsk?: { shape: MaskShape | 'none'; n: number } | null;
1507
+ onMaskAsked?: () => void;
1508
+ }) {
1509
+ const t = useT();
1510
+ const [refusal, setRefusal] = React.useState<string | null>(null);
1511
+ const loc = block.loc;
1512
+ const editable = Boolean(loc && onEdit);
1513
+ const docLoc = loc != null && parseFilmDocLoc(loc) != null;
1514
+ const readPageChanges = React.useCallback(() => (pageChangesOf && loc ? pageChangesOf(loc) : null), [pageChangesOf, loc]);
1515
+ const commit = (edits: ClipEdit[]) => {
1516
+ if (!onEdit || !edits.length) return;
1517
+ setRefusal(null);
1518
+ void onEdit(edits).then((failed) => {
1519
+ if (!failed) return;
1520
+ /* the picture must not keep a place or a look the film does not have */
1521
+ if (loc && edits.some((e) => e.prop === 'box')) onPreviewClip?.(loc, null);
1522
+ if (loc && edits.some((e) => e.prop === 'style')) onPreviewLook?.(loc, null);
1523
+ setRefusal(plainClipRefusal(failed));
1524
+ });
1525
+ };
1526
+ const what = clipWhat(block);
1527
+ const picture = isPicture(block);
1528
+ const own = block.size ?? null;
1529
+ const geo = geometry ?? (own ? computedGeometry(block, own, stage) : null);
1530
+ const lookOf = picture && clipLookOf && loc ? clipLookOf(loc) : null;
1531
+ const target: ClipTarget | null = lookOf && loc ? { loc, css: lookOf.css, look: lookOf.look } : null;
1532
+ const look = target ? clipsLook([target], commit, onPreviewLook) : null;
1533
+ const at = parseFilmDocLoc(loc ?? '');
1534
+ const badge = at ? badges?.[at.track] : undefined;
1535
+ /* the same host while the same clip is in hand: the section reads the page again when its overrides change */
1536
+ const propsHost = React.useMemo(
1537
+ () => (block.kind === 'mg' && docLoc && propsHostOf ? propsHostOf(block) : null),
1538
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1539
+ [propsHostOf, block.clipId, block.kind, docLoc],
1540
+ );
1541
+
1542
+ return (
1543
+ <>
1544
+ <Header
1545
+ icon={clipIcon(what, 13)}
1546
+ name={block.title || block.clipId || clipKindLabel(t, what)}
1547
+ meta={`${clipKindLabel(t, what)} · ${formatTimecode(block.startMs)} – ${formatTimecode(block.endMs)}`}
1548
+ {...(block.src ? { mono: block.src } : {})}
1549
+ {...(badge ? { badge } : {})}
1550
+ {...(editable && block.clipId && replaceEntries ? { action: <ReplaceButton block={block} entries={replaceEntries} t={t} /> } : {})}
1551
+ />
1552
+ {refusal ? (
1553
+ <RefusalBanner message={refusal} onDismiss={() => setRefusal(null)} t={t} />
1554
+ ) : null}
1555
+ {!editable ? (
1556
+ <p className="mx-3 rounded-[6px] bg-[var(--fill-tsp)] px-2.5 py-2 text-[11px] text-[var(--text-muted)]">
1557
+ {t('inspector.noEdit')}
1558
+ </p>
1559
+ ) : (
1560
+ <>
1561
+ {/* a page's properties first: what it offers to change */}
1562
+ {propsHost ? (
1563
+ <PropertiesSection host={propsHost} commit={(next) => commit([{ loc: loc!, prop: 'overrides', value: next as unknown as Record<string, unknown> | null }])} />
1564
+ ) : null}
1565
+ {picture && own && geo ? (
1566
+ <ClipPositionSection
1567
+ t={t}
1568
+ loc={loc!}
1569
+ page={block.kind === 'mg'}
1570
+ own={own}
1571
+ placed={block.box != null}
1572
+ geometry={geo}
1573
+ value={clipTransform(block, own, stage)}
1574
+ onCommit={commit}
1575
+ {...(onPreviewClip ? { onPreview: (patch: Partial<MgTransform> | null) => onPreviewClip(loc!, patch) } : {})}
1576
+ />
1577
+ ) : null}
1578
+ {/* the fades right under the place: used on most clips */}
1579
+ {docLoc && block.clipId ? <FadeSection t={t} blocks={[block]} fades={fades} commit={commit} /> : null}
1580
+ {look ? (
1581
+ <>
1582
+ <CropSection
1583
+ look={look}
1584
+ t={t}
1585
+ fit={block.kind === 'video'}
1586
+ framing={block.kind === 'video'}
1587
+ box={geo ? { w: geo.w, h: geo.h } : null}
1588
+ own={own}
1589
+ {...(onStartCrop ? { onStartCrop } : {})}
1590
+ />
1591
+ <MaskSection
1592
+ look={look}
1593
+ t={t}
1594
+ box={geo ? { w: geo.w, h: geo.h } : null}
1595
+ target={{ kind: 'clip', clipLoc: target!.loc }}
1596
+ {...(onEditMask ? { onEditMask } : {})}
1597
+ {...(maskAsk ? { ask: maskAsk } : {})}
1598
+ {...(onMaskAsked ? { onAsked: onMaskAsked } : {})}
1599
+ />
1600
+ <AppearanceSection look={look} t={t} radius />
1601
+ <StrokeSection look={look} t={t} />
1602
+ <EffectsSection look={look} t={t} />
1603
+ <AdjustSection look={look} t={t} />
1604
+ </>
1605
+ ) : null}
1606
+ {block.anchor?.move || block.anchor?.resize === 'end' ? <TimingSection t={t} block={block} onMain={onMain} commit={commit} /> : null}
1607
+ {blockHasVolume(block) ? (
1608
+ <Section id="sound" title={t('inspector.sound')}>
1609
+ <VolumeField value={linearVolumeOf(block)} t={t} onCommit={(apply) => commit(volumeEdits([block], apply))} />
1610
+ </Section>
1611
+ ) : null}
1612
+ {block.kind === 'mg' && docLoc && pageChangesOf ? (
1613
+ <PageChangesSection
1614
+ read={readPageChanges}
1615
+ /* the clip's own entry (its fades) is not a change inside the page: it stays */
1616
+ onRemove={(list) => {
1617
+ const own = readPageChanges()?.own ?? [];
1618
+ const next = [...own, ...list];
1619
+ commit([{ loc: loc!, prop: 'overrides', value: next.length ? next as unknown as Record<string, unknown> : null }]);
1620
+ }}
1621
+ t={t}
1622
+ />
1623
+ ) : null}
1624
+ {target && lookOf ? (
1625
+ <AdvancedSection
1626
+ t={t}
1627
+ css={rawDeclarations(target.css)}
1628
+ onCommit={(set) => commit([{ loc: target.loc, prop: 'style', value: withDeclarations(target.css, set) || null }])}
1629
+ {...(lookOf.cls != null ? {
1630
+ cls: { value: lookOf.cls, onCommit: (next: string | null) => commit([{ loc: target.loc, prop: 'class', value: next }]) },
1631
+ } : {})}
1632
+ />
1633
+ ) : null}
1634
+ </>
1635
+ )}
1636
+ </>
1637
+ );
1638
+ }
1639
+
1640
+ /** A clip's Replace: another file of the project in its place, or one from this computer (use-editor-tools). */
1641
+ function ReplaceButton({ block, entries, t }: { block: TimelineBlock; entries: (block: TimelineBlock) => ContextMenuEntry[]; t: T }) {
1642
+ const ref = React.useRef<HTMLButtonElement>(null);
1643
+ const [menu, setMenu] = React.useState<{ x: number; y: number; top: number; items: ContextMenuEntry[] } | null>(null);
1644
+ return (
1645
+ <>
1646
+ <button
1647
+ ref={ref}
1648
+ type="button"
1649
+ aria-haspopup="menu"
1650
+ aria-expanded={Boolean(menu)}
1651
+ onClick={() => {
1652
+ if (menu) { setMenu(null); return; }
1653
+ const r = ref.current!.getBoundingClientRect();
1654
+ setMenu({ x: r.right, y: r.bottom + 4, top: r.top - 4, items: entries(block) });
1655
+ }}
1656
+ className="flex h-6 shrink-0 items-center gap-1 rounded-[5px] px-1.5 text-[11px] text-[var(--text-muted)] transition hover:bg-[var(--fill-tsp)] hover:text-[var(--text)]"
1657
+ >
1658
+ <Replace size={12} />
1659
+ {t('editorTools.replaceButton')}
1660
+ </button>
1661
+ {menu ? <ContextMenu x={menu.x} y={menu.y} flipTo={menu.top} align="end" dense items={menu.items} trigger={ref} onClose={() => setMenu(null)} /> : null}
1662
+ </>
1663
+ );
1664
+ }
1665
+
1666
+ /**
1667
+ * Start, length, in point and speed, as timecodes (frames at the project's rate, as the player counts them) or seconds;
1668
+ * and the source bar: the whole file, the part the clip plays drawn on it — dragged, the clip plays another part of
1669
+ * its file in the same place (a slip); its ends dragged, it is trimmed.
1670
+ *
1671
+ * `at` is written the way dragging writes it, from the parent window; `end` is `in point + length` in source time
1672
+ * when there is an in point, else (a still) the right edge in the parent window.
1673
+ */
1674
+ function TimingSection({ t, block, onMain, commit }: { t: T; block: TimelineBlock; onMain: boolean; commit: (edits: ClipEdit[]) => void }) {
1675
+ const [unit, setUnit] = useTimeUnit();
1676
+ const loc = block.loc!;
1677
+ const parentSec = (block.anchor?.parentStartMs ?? 0) / 1000;
1678
+ const durSec = (block.endMs - block.startMs) / 1000;
1679
+ /* the timeline's magnet, on the main track: a longer clip pushes the next one along (a ripple), so the next one is
1680
+ no limit */
1681
+ const magnet = useTimelinePrefs().magnet && onMain;
1682
+ const maxDurSec = longestSec(block, durSec, magnet);
1683
+ const k = block.speed ?? 1;
1684
+ /* slowed down, the clip gets longer on the film: without the magnet, no slower than fits before the next clip */
1685
+ const fitSpeed = block.nextMs != null && block.nextMs > block.startMs
1686
+ ? Math.ceil(((durSec * k) / ((block.nextMs - block.startMs) / 1000)) * 100) / 100
1687
+ : FILM_SPEED_MIN;
1688
+ const minSpeed = magnet ? FILM_SPEED_MIN : Math.max(FILM_SPEED_MIN, fitSpeed);
1689
+ const trimFrom = block.anchor?.trimFrom;
1690
+ const timecode = unit === 'timecode';
1691
+ /* a frame of the project's rate: the step of the arrows on a time field, and what a typed `12f` counts in */
1692
+ const fps = useTimecodeFps();
1693
+ const time = {
1694
+ ctx: { time: true, fps },
1695
+ step: 1 / fps,
1696
+ ...(timecode ? { format: (s: number) => formatTimecode(s * 1000) } : { unit: 's' }),
1697
+ };
1698
+ /* the clip keeps its length, so the in point goes as far as the source left after the clip: further, its end would
1699
+ pass the end of the file. A clip that plays all of its file has nowhere to slip. */
1700
+ const inMax = trimFrom == null ? 0 : block.room?.tailMs != null
1701
+ ? round3(trimFrom + (block.room.tailMs / 1000) * k)
1702
+ : round3(Math.max(0, trimFrom + durSec * k - 0.01));
1703
+ const wholeFile = trimFrom != null && trimFrom <= 0 && inMax <= 0;
1704
+ const slipTo = (n: number) => {
1705
+ /* the in point slips the source, it does not change the length: the clip stays where and as long as it is */
1706
+ const span = durSec * k;
1707
+ commit([
1708
+ { loc, prop: 'start', value: round3(n), from: round3(trimFrom!) },
1709
+ { loc, prop: 'end', value: round3(n + span), from: round3(trimFrom! + span) },
1710
+ ].filter((e) => e.value !== e.from));
1711
+ };
1712
+ const lengthTo = (n: number) => {
1713
+ /* `time=[start,end]` is a window in source time: with an in point, push `end` and keep the in point. A still has
1714
+ no window; its `end` is the right edge on the film. A film second of a sped-up clip is `speed` of its source. */
1715
+ commit(trimFrom != null
1716
+ ? [{
1717
+ loc,
1718
+ prop: 'end',
1719
+ value: Math.min(round3(trimFrom + n * k), block.sourceDurMs != null ? block.sourceDurMs / 1000 : Infinity),
1720
+ from: round3(trimFrom + durSec * k),
1721
+ }]
1722
+ : [{ loc, prop: 'end', value: round3(block.startMs / 1000 - parentSec + n), from: round3(block.endMs / 1000 - parentSec) }]);
1723
+ };
1724
+ const canSpeed = (block.kind === 'video' || block.kind === 'voice' || block.kind === 'music' || block.kind === 'sfx') && !(block.src && filmSrcIsStill(block.src));
1725
+ return (
1726
+ <Section
1727
+ id="timing"
1728
+ title={t('inspector.timing')}
1729
+ aside={(
1730
+ <div role="radiogroup" aria-label={t('inspector.timeUnit')} className="flex h-5 items-center rounded-[4px] bg-[var(--fill-tsp)] p-px text-[10px]">
1731
+ {(['timecode', 'seconds'] as const).map((u) => (
1732
+ <button
1733
+ key={u}
1734
+ type="button"
1735
+ role="radio"
1736
+ aria-checked={unit === u}
1737
+ title={t(u === 'timecode' ? 'inspector.timeTimecode' : 'inspector.timeSeconds')}
1738
+ onClick={() => setUnit(u)}
1739
+ className={`h-[18px] rounded-[3px] px-1.5 font-medium transition ${unit === u ? 'bg-[var(--bg)] text-[var(--text)]' : 'text-[var(--text-faint)] hover:text-[var(--text)]'}`}
1740
+ >
1741
+ {u === 'timecode' ? 'TC' : 's'}
1742
+ </button>
1743
+ ))}
1744
+ </div>
1745
+ )}
1746
+ >
1747
+ <Row>
1748
+ {block.anchor?.move ? (
1749
+ <NumField
1750
+ label={t('inspector.at')}
1751
+ prefix={<ArrowRightToLine size={11} />}
1752
+ value={round3(block.startMs / 1000 - parentSec)}
1753
+ min={0}
1754
+ {...time}
1755
+ onCommit={(n) => commit([{ loc, prop: 'at', value: round3(n), from: round3(block.startMs / 1000 - parentSec) }])}
1756
+ />
1757
+ ) : <span />}
1758
+ {block.anchor?.resize === 'end' ? (
1759
+ <NumField
1760
+ label={t('inspector.duration')}
1761
+ prefix={<MoveHorizontal size={11} />}
1762
+ value={round3(durSec)}
1763
+ min={1 / fps}
1764
+ {...(maxDurSec != null ? { max: maxDurSec } : {})}
1765
+ {...time}
1766
+ onCommit={lengthTo}
1767
+ />
1768
+ ) : <span />}
1769
+ </Row>
1770
+ {trimFrom != null ? (
1771
+ <Row>
1772
+ <NumField
1773
+ label={t('inspector.inPoint')}
1774
+ prefix={<Scissors size={11} />}
1775
+ value={round3(trimFrom)}
1776
+ min={0}
1777
+ max={inMax}
1778
+ disabled={wholeFile}
1779
+ {...time}
1780
+ onCommit={slipTo}
1781
+ />
1782
+ {canSpeed ? (
1783
+ <NumField
1784
+ label={t('inspector.speed')}
1785
+ prefix={<Gauge size={11} />}
1786
+ value={k}
1787
+ unit="×"
1788
+ step={0.25}
1789
+ min={minSpeed}
1790
+ max={FILM_SPEED_MAX}
1791
+ /* the source range stays; the clip gets shorter or longer on the film (its sound keeps its pitch) */
1792
+ onCommit={(n) => {
1793
+ const next = Math.round(Math.min(FILM_SPEED_MAX, Math.max(minSpeed, n)) * 100) / 100;
1794
+ if (next === k) return;
1795
+ commit([{ loc, prop: 'speed', value: next, from: k }]);
1796
+ }}
1797
+ />
1798
+ ) : <span />}
1799
+ </Row>
1800
+ ) : null}
1801
+ {wholeFile ? (
1802
+ <p data-in-point-limit="" className="px-0.5 text-[10.5px] leading-snug text-[var(--text-faint)]">
1803
+ {t('inspector.inPointWhole')}
1804
+ </p>
1805
+ ) : null}
1806
+ {/* why it cannot get slower: the next clip; the magnet would push it along instead */}
1807
+ {trimFrom != null && canSpeed && !magnet && minSpeed > FILM_SPEED_MIN ? (
1808
+ <p data-speed-limit="" className="px-0.5 text-[10.5px] leading-snug text-[var(--text-faint)]">
1809
+ {t('inspector.speedLimit').replace('{n}', String(minSpeed))}
1810
+ </p>
1811
+ ) : null}
1812
+ {trimFrom != null && block.sourceDurMs != null && block.sourceDurMs > 0 && block.anchor?.resize === 'end' && !block.anchor.still ? (
1813
+ <SourceBar t={t} block={block} timecode={timecode} onSlip={slipTo} onCommit={commit} maxDurSec={maxDurSec} />
1814
+ ) : null}
1815
+ </Section>
1816
+ );
1817
+ }
1818
+
1819
+ /**
1820
+ * The whole source file as a bar, and the part the clip plays as a window on it. The window dragged is a slip: the
1821
+ * in point moves, the clip keeps its length and its place. Its left end dragged trims the head as the timeline does
1822
+ * (the in point and the start move together, the end stays); its right end trims the tail. Kept inside the file and
1823
+ * short of the next clip.
1824
+ */
1825
+ function SourceBar({ t, block, timecode, onSlip, onCommit, maxDurSec }: {
1826
+ t: T;
1827
+ block: TimelineBlock;
1828
+ timecode: boolean;
1829
+ onSlip: (inSec: number) => void;
1830
+ onCommit: (edits: ClipEdit[]) => void;
1831
+ maxDurSec: number | undefined;
1832
+ }) {
1833
+ const bar = React.useRef<HTMLDivElement | null>(null);
1834
+ const k = block.speed ?? 1;
1835
+ const total = block.sourceDurMs! / 1000;
1836
+ const inS = block.anchor!.trimFrom!;
1837
+ const span = ((block.endMs - block.startMs) / 1000) * k;
1838
+ const minSpan = k / timecodeFps();
1839
+ const [drag, setDrag] = React.useState<{ mode: 'slip' | 'head' | 'tail'; x: number; a: number; b: number } | null>(null);
1840
+ const a = drag?.a ?? inS;
1841
+ const b = drag?.b ?? inS + span;
1842
+ const label = (s: number) => (timecode ? formatTimecode(s * 1000) : `${round1(s)}s`);
1843
+ /* the head pulled out goes no further back than the film's start (the start moves with it) */
1844
+ const atSec = (block.startMs - (block.anchor?.parentStartMs ?? 0)) / 1000;
1845
+ const headFloor = block.anchor?.move ? Math.max(0, inS - atSec * k) : inS;
1846
+ const tailCeil = Math.min(total, maxDurSec != null ? inS + maxDurSec * k : total);
1847
+ const place = (mode: 'slip' | 'head' | 'tail', rawDx: number) => {
1848
+ /* by whole frames of the film, as the timeline moves an edge */
1849
+ const dxSec = Math.round(rawDx / minSpan) * minSpan;
1850
+ if (mode === 'slip') {
1851
+ const na = Math.max(0, Math.min(total - span, inS + dxSec));
1852
+ return { a: na, b: na + span };
1853
+ }
1854
+ if (mode === 'head') return { a: Math.max(headFloor, Math.min(inS + span - minSpan, inS + dxSec)), b: inS + span };
1855
+ return { a: inS, b: Math.max(inS + minSpan, Math.min(tailCeil, inS + span + dxSec)) };
1856
+ };
1857
+ const start = (mode: 'slip' | 'head' | 'tail') => (e: React.PointerEvent) => {
1858
+ if (e.button !== 0) return;
1859
+ e.preventDefault();
1860
+ e.stopPropagation();
1861
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
1862
+ setDrag({ mode, x: e.clientX, a: inS, b: inS + span });
1863
+ };
1864
+ const secPerPx = () => total / Math.max(1, bar.current?.clientWidth ?? 1);
1865
+ /* the ends sit inside the window: their events must not reach it as well (a trim written twice) */
1866
+ const move = (e: React.PointerEvent) => {
1867
+ e.stopPropagation();
1868
+ if (!drag) return;
1869
+ setDrag({ ...drag, ...place(drag.mode, (e.clientX - drag.x) * secPerPx()) });
1870
+ };
1871
+ const end = (e: React.PointerEvent) => {
1872
+ e.stopPropagation();
1873
+ if (!drag) return;
1874
+ const next = place(drag.mode, (e.clientX - drag.x) * secPerPx());
1875
+ setDrag(null);
1876
+ if (Math.abs(next.a - inS) < 0.0005 && Math.abs(next.b - inS - span) < 0.0005) return;
1877
+ if (drag.mode === 'slip') { onSlip(round3(next.a)); return; }
1878
+ /* a trim is written as the timeline writes one (the in point and the start move together at the head) */
1879
+ const ms = (s: number) => (s / k) * 1000;
1880
+ onCommit(editsForMoves([{
1881
+ loc: block.loc!,
1882
+ anchor: block.anchor!,
1883
+ before: { startMs: block.startMs, endMs: block.endMs },
1884
+ after: drag.mode === 'head'
1885
+ ? { startMs: block.startMs + ms(next.a - inS), endMs: block.endMs }
1886
+ : { startMs: block.startMs, endMs: block.startMs + ms(next.b - inS) },
1887
+ speed: k,
1888
+ sourceDurMs: block.sourceDurMs!,
1889
+ }]));
1890
+ };
1891
+ const pct = (s: number) => `${(s / total) * 100}%`;
1892
+ return (
1893
+ <div className="flex flex-col gap-1 pt-0.5">
1894
+ <div
1895
+ ref={bar}
1896
+ role="group"
1897
+ aria-label={t('inspector.sourceBar')}
1898
+ className="relative h-[22px] rounded-[4px]"
1899
+ style={{ background: 'repeating-linear-gradient(90deg, var(--fill-tsp) 0 14px, transparent 14px 28px), var(--fill-tsp)' }}
1900
+ >
1901
+ <div
1902
+ onPointerDown={start('slip')}
1903
+ onPointerMove={move}
1904
+ onPointerUp={end}
1905
+ onPointerCancel={() => setDrag(null)}
1906
+ title={t('inspector.slipHint')}
1907
+ className={`absolute inset-y-0 touch-none rounded-[4px] bg-[var(--accent-soft)] ${drag?.mode === 'slip' ? 'cursor-grabbing' : 'cursor-grab'}`}
1908
+ style={{ left: pct(a), width: pct(b - a), boxShadow: 'inset 0 0 0 2px var(--text)' }}
1909
+ >
1910
+ <span onPointerDown={start('head')} onPointerMove={move} onPointerUp={end} title={t('inspector.trimHead')}
1911
+ className="absolute inset-y-0 -left-1 w-2.5 cursor-ew-resize touch-none" />
1912
+ <span onPointerDown={start('tail')} onPointerMove={move} onPointerUp={end} title={t('inspector.trimTail')}
1913
+ className="absolute inset-y-0 -right-1 w-2.5 cursor-ew-resize touch-none" />
1914
+ </div>
1915
+ </div>
1916
+ <div className="flex justify-between font-mono text-[9.5px] tabular-nums text-[var(--text-faint)]">
1917
+ <span>{label(a)} – {label(b)}</span>
1918
+ <span>{label(total)}</span>
1919
+ </div>
1920
+ </div>
1921
+ );
1922
+ }
1923
+
1924
+ /** Volume: dB (0 = as is) on a slider, as the ear hears it; adjusted by ear, so dragging beats typing. */
1925
+ function VolumeField({ value, onCommit, t }: { value: number | Mixed; onCommit: (apply: FieldApply) => void; t: T }) {
1926
+ return (
1927
+ <SliderField
1928
+ label={t('inspector.gainDb')}
1929
+ value={value === MIXED ? MIXED : volumeToDb(value)}
1930
+ min={VOLUME_DB_MIN}
1931
+ max={VOLUME_DB_MAX}
1932
+ neutral={0}
1933
+ step={0.1}
1934
+ unit="dB"
1935
+ fieldWidth={72}
1936
+ onCommit={(_, apply) => onCommit(apply)}
1937
+ />
1938
+ );
1939
+ }
1940
+
1941
+ /** A clip's box as the stage's numbers, from its picture's own size `own` (see stage-clip). */
1942
+ function clipTransform(block: TimelineBlock, own: MgBox, stage: { w: number; h: number }): MgTransform {
1943
+ return transformOf(block.kind === 'mg' ? 'page' : 'video', own, stage, block.box);
1944
+ }
1945
+
1946
+ /**
1947
+ * Where a clip's box is when the clip is not on screen to be measured (the playhead elsewhere): from its own size and
1948
+ * its box, the same arithmetic the stage draws by, so the panel always speaks in stage pixels.
1949
+ */
1950
+ function computedGeometry(block: TimelineBlock, own: MgBox, stage: { w: number; h: number }): StageGeometry {
1951
+ const t = clipTransform(block, own, stage);
1952
+ const b = clipFields(t, own);
1953
+ return { kind: 'clip', loc: block.loc ?? null, x: b.x, y: b.y, w: b.w, h: b.h, r: t.rotate, unit: 1, box: own, stage };
1954
+ }
1955
+
1956
+ /**
1957
+ * What the person changed inside this page (text, styles, moves of its elements), one line each, with a way to undo
1958
+ * one. A change whose element the page no longer has (the agent rewrote the page) says so: it does nothing until the
1959
+ * element comes back, and undoing it is the way to clear it.
1960
+ */
1961
+ function PageChangesSection({ read, onRemove, t }: {
1962
+ read: () => PageChanges | null;
1963
+ onRemove: (rest: FilmOverride[]) => void;
1964
+ t: T;
1965
+ }) {
1966
+ /* the page says which changes lost their element when it reports its elements after a still frame, which comes
1967
+ after this panel opens: look again while it is open (a DOM attribute read, nothing more) */
1968
+ const [changes, setChanges] = React.useState(read);
1969
+ React.useEffect(() => {
1970
+ const id = window.setInterval(() => setChanges((prev) => {
1971
+ const next = read();
1972
+ return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
1973
+ }), 1000);
1974
+ return () => window.clearInterval(id);
1975
+ }, [read]);
1976
+ if (!changes?.list.length) return null;
1977
+ const what = (o: FilmOverride) => [
1978
+ o.text != null && t('inspector.pageChangeText'),
1979
+ o.t && t('inspector.pageChangeMoved'),
1980
+ o.s != null && t('inspector.pageChangeScaled'),
1981
+ o.r != null && t('inspector.pageChangeRotated'),
1982
+ o.style && Object.keys(o.style).length && t('inspector.pageChangeStyled'),
1983
+ ].filter(Boolean).join(', ');
1984
+ return (
1985
+ <Section id="page-changes" title={t('inspector.pageChanges')}>
1986
+ <ul className="flex flex-col gap-1">
1987
+ {changes.list.map((o, i) => {
1988
+ const lost = changes.lost.includes(o.at ?? '');
1989
+ return (
1990
+ <li key={`${o.at}#${o.n ?? 0}`} className="flex min-w-0 items-center gap-1.5 text-[11px]">
1991
+ {lost ? <CircleAlert size={12} className="shrink-0 text-[var(--warn)]" aria-label={t('inspector.pageChangeLost')} /> : null}
1992
+ <span className="min-w-0 flex-1 truncate" title={lost ? `${o.at} · ${t('inspector.pageChangeLost')}` : o.at}>
1993
+ <span className="font-mono text-[var(--text)]">{o.at}{o.n ? ` (${o.n})` : ''}</span>
1994
+ <span className="text-[var(--text-muted)]"> · {lost ? t('inspector.pageChangeLost') : what(o)}</span>
1995
+ </span>
1996
+ <SectionAction label={t('inspector.pageChangeRemove')} onClick={() => onRemove(changes.list.filter((_, j) => j !== i))}>
1997
+ <X size={12} />
1998
+ </SectionAction>
1999
+ </li>
2000
+ );
2001
+ })}
2002
+ </ul>
2003
+ </Section>
2004
+ );
2005
+ }
2006
+
2007
+ /** Each picture clip's place, for moving several together. */
2008
+ type Placed = { block: TimelineBlock; loc: string; own: MgBox; value: MgTransform; x: number; y: number; w: number; h: number };
2009
+
2010
+ function placedOf(block: TimelineBlock, stage: { w: number; h: number }): Placed | null {
2011
+ if (!isPicture(block) || !block.size) return null;
2012
+ const value = clipTransform(block, block.size, stage);
2013
+ return { block, loc: block.loc!, own: block.size, value, ...clipFields(value, block.size) };
2014
+ }
2015
+
2016
+ /**
2017
+ * Several clips: what they share. Their places move together (`+20` moves each by 20, a value typed puts each
2018
+ * there), they align to each other and space out evenly; their look and their volume change on all at once.
2019
+ * "Mixed" where they differ.
2020
+ */
2021
+ function MultiClipPanel({
2022
+ blocks, onEdit, stage, clipLookOf, onPreviewLook, fades,
2023
+ }: {
2024
+ fades?: ReadonlyMap<string, FadeMs>;
2025
+ blocks: readonly TimelineBlock[];
2026
+ onEdit?: (edits: ClipEdit[]) => Promise<string | null>;
2027
+ stage: { w: number; h: number };
2028
+ clipLookOf?: (clipLoc: string) => { css: string; look: ClipLook | null } | null;
2029
+ onPreviewLook?: (clipLoc: string, css: string | null) => void;
2030
+ }) {
2031
+ const t = useT();
2032
+ const [refusal, setRefusal] = React.useState<string | null>(null);
2033
+ const commit = (edits: ClipEdit[]) => {
2034
+ if (!onEdit || !edits.length) return;
2035
+ setRefusal(null);
2036
+ void onEdit(edits).then((failed) => {
2037
+ if (!failed) return;
2038
+ for (const loc of new Set(edits.filter((e) => e.prop === 'style').map((e) => e.loc))) onPreviewLook?.(loc, null);
2039
+ setRefusal(plainClipRefusal(failed));
2040
+ });
2041
+ };
2042
+ const kinds = [...new Set(blocks.map(clipWhat))];
2043
+ const placed = blocks.map((b) => placedOf(b, stage));
2044
+ const allPlaced = onEdit && placed.every(Boolean) ? placed as Placed[] : null;
2045
+ const targets: ClipTarget[] = [];
2046
+ if (onEdit && clipLookOf && blocks.every(isPicture)) {
2047
+ for (const b of blocks) {
2048
+ const found = clipLookOf(b.loc!);
2049
+ if (found) targets.push({ loc: b.loc!, css: found.css, look: found.look });
2050
+ }
2051
+ }
2052
+ const look = targets.length === blocks.length ? clipsLook(targets, commit, onPreviewLook) : null;
2053
+ const volumeAll = Boolean(onEdit) && blocks.every((b) => b.loc && blockHasVolume(b));
2054
+ const volume = volumeAll && blocks.every((b) => linearVolumeOf(b) === linearVolumeOf(blocks[0]!)) ? linearVolumeOf(blocks[0]!) : MIXED;
2055
+
2056
+ const boxEdits = (each: (p: Placed) => Partial<MgTransform>): ClipEdit[] => (allPlaced ?? []).flatMap((p) => {
2057
+ const next = compactBox({ ...p.value, ...each(p) }, p.own);
2058
+ const before = compactBox(p.value, p.own);
2059
+ return JSON.stringify(next) === JSON.stringify(before) ? [] : [{ loc: p.loc, prop: 'box', value: next }];
2060
+ });
2061
+ /* their bounds: typed, the X / Y move them all and the W / H scale them all, in
2062
+ proportion, about the bounds' top left */
2063
+ const bounds = allPlaced?.length ? (() => {
2064
+ const x0 = Math.min(...allPlaced.map((p) => p.x)), y0 = Math.min(...allPlaced.map((p) => p.y));
2065
+ return { x: x0, y: y0, w: Math.max(...allPlaced.map((p) => p.x + p.w)) - x0, h: Math.max(...allPlaced.map((p) => p.y + p.h)) - y0 };
2066
+ })() : null;
2067
+ const groupEdit = (d: Partial<GroupDrag>) => {
2068
+ const drag: GroupDrag = { kind: 'move', dx: 0, dy: 0, kx: 1, ky: 1, turn: 0, anchor: { x: 0, y: 0 }, ...d };
2069
+ commit(boxEdits((p) => {
2070
+ /* the transform counts from the box's own offset: the anchor too */
2071
+ const next = groupDragged({ ...drag, anchor: { x: drag.anchor.x - (p.own.x ?? 0), y: drag.anchor.y - (p.own.y ?? 0) } }, p.value, p.own);
2072
+ return { x: next.x, y: next.y, scaleX: next.scaleX, scaleY: next.scaleY };
2073
+ }));
2074
+ };
2075
+ const scaleBounds = (k: number) => {
2076
+ if (bounds && k > 0 && Number.isFinite(k) && k !== 1) groupEdit({ kind: 'scale', kx: k, ky: k, anchor: { x: bounds.x, y: bounds.y } });
2077
+ };
2078
+ const common = (fn: (p: Placed) => number) => {
2079
+ if (!allPlaced) return MIXED;
2080
+ const v = round1(fn(allPlaced[0]!));
2081
+ return allPlaced.every((p) => round1(fn(p)) === v) ? v : MIXED;
2082
+ };
2083
+ const alignTo = (edge: AlignEdge) => {
2084
+ if (!allPlaced) return;
2085
+ const left = Math.min(...allPlaced.map((p) => p.x));
2086
+ const top = Math.min(...allPlaced.map((p) => p.y));
2087
+ const right = Math.max(...allPlaced.map((p) => p.x + p.w));
2088
+ const bottom = Math.max(...allPlaced.map((p) => p.y + p.h));
2089
+ commit(boxEdits((p) => {
2090
+ if (edge === 'left') return clipFieldPart('x', left, p.own);
2091
+ if (edge === 'hcenter') return clipFieldPart('x', (left + right) / 2 - p.w / 2, p.own);
2092
+ if (edge === 'right') return clipFieldPart('x', right - p.w, p.own);
2093
+ if (edge === 'top') return clipFieldPart('y', top, p.own);
2094
+ if (edge === 'vcenter') return clipFieldPart('y', (top + bottom) / 2 - p.h / 2, p.own);
2095
+ return clipFieldPart('y', bottom - p.h, p.own);
2096
+ }));
2097
+ };
2098
+ /* the same gap between each and the next, the first and the last staying */
2099
+ const distribute = (axis: 'x' | 'y') => {
2100
+ if (!allPlaced || allPlaced.length < 3) return;
2101
+ const size = (p: Placed) => (axis === 'x' ? p.w : p.h);
2102
+ const sorted = [...allPlaced].sort((p, q) => p[axis] - q[axis]);
2103
+ const first = sorted[0]!;
2104
+ const last = sorted[sorted.length - 1]!;
2105
+ const gap = (last[axis] + size(last) - first[axis] - sorted.reduce((s, p) => s + size(p), 0)) / (sorted.length - 1);
2106
+ const to = new Map<Placed, number>();
2107
+ let cursor = first[axis];
2108
+ for (const p of sorted) { to.set(p, cursor); cursor += size(p) + gap; }
2109
+ commit(boxEdits((p) => clipFieldPart(axis, to.get(p)!, p.own)));
2110
+ };
2111
+
2112
+ return (
2113
+ <>
2114
+ <Header
2115
+ icon={kinds.length === 1 ? clipIcon(kinds[0]!, 13) : kindIcon('mg', 13)}
2116
+ name={t('inspector.multi').replace('{n}', String(blocks.length))}
2117
+ meta={kinds.map((k) => clipKindLabel(t, k)).join(' · ')}
2118
+ />
2119
+ {refusal ? (
2120
+ <RefusalBanner message={refusal} onDismiss={() => setRefusal(null)} t={t} />
2121
+ ) : null}
2122
+ {allPlaced ? (
2123
+ <Section id="position" title={t('inspector.position')}>
2124
+ <AlignBar t={t} onAlign={alignTo} {...(allPlaced.length >= 3 ? { onDistribute: distribute } : {})} />
2125
+ <Row>
2126
+ <NumField label={t('inspector.x')} prefix="X" value={bounds ? round1(bounds.x) : MIXED} min={-COORD_LIMIT} max={COORD_LIMIT} ctx={{ percent: stage.w }}
2127
+ onCommit={(x) => { if (bounds) groupEdit({ dx: x - bounds.x }); }} />
2128
+ <NumField label={t('inspector.y')} prefix="Y" value={bounds ? round1(bounds.y) : MIXED} min={-COORD_LIMIT} max={COORD_LIMIT} ctx={{ percent: stage.h }}
2129
+ onCommit={(y) => { if (bounds) groupEdit({ dy: y - bounds.y }); }} />
2130
+ </Row>
2131
+ <div className="grid grid-cols-[1fr_1fr_24px] items-center gap-1.5">
2132
+ <NumField label={t('inspector.width')} prefix="W" value={bounds ? round1(bounds.w) : MIXED} min={1} max={COORD_LIMIT} ctx={{ percent: stage.w }}
2133
+ onCommit={(w) => { if (bounds) scaleBounds(w / bounds.w); }} />
2134
+ <NumField label={t('inspector.height')} prefix="H" value={bounds ? round1(bounds.h) : MIXED} min={1} max={COORD_LIMIT} ctx={{ percent: stage.h }}
2135
+ onCommit={(h) => { if (bounds) scaleBounds(h / bounds.h); }} />
2136
+ <LockToggle t={t} locked />
2137
+ </div>
2138
+ <Row>
2139
+ <NumField label={t('inspector.rotate')} prefix={<RotateGlyph />} value={common((p) => p.value.rotate)} unit="°"
2140
+ onCommit={(_, apply) => commit(boxEdits((p) => ({ rotate: wrapDeg(apply(p.value.rotate)) })))} />
2141
+ <span />
2142
+ </Row>
2143
+ </Section>
2144
+ ) : null}
2145
+ {onEdit && blocks.every((b) => b.clipId && b.loc && parseFilmDocLoc(b.loc) != null) ? <FadeSection t={t} blocks={blocks} fades={fades} commit={commit} /> : null}
2146
+ {look ? (
2147
+ <>
2148
+ <AppearanceSection look={look} t={t} radius />
2149
+ <StrokeSection look={look} t={t} />
2150
+ <EffectsSection look={look} t={t} />
2151
+ <AdjustSection look={look} t={t} />
2152
+ </>
2153
+ ) : null}
2154
+ {volumeAll ? (
2155
+ <Section id="sound" title={t('inspector.sound')}>
2156
+ <VolumeField value={volume} t={t} onCommit={(apply) => commit(volumeEdits(blocks, apply))} />
2157
+ </Section>
2158
+ ) : null}
2159
+ </>
2160
+ );
2161
+ }
2162
+
2163
+ /**
2164
+ * Fade in and out, in seconds: how long the clips' picture and sound take to come up from nothing at their start and
2165
+ * to go down to nothing at their end (film.html keeps them in each clip's `overrides`, see lib/clip-fade). Each fits in
2166
+ * its clip: the other fade gives way only as far as it must.
2167
+ */
2168
+ function FadeSection({ t, blocks, fades, commit }: { t: T; blocks: readonly TimelineBlock[]; fades: ReadonlyMap<string, FadeMs> | undefined; commit: (edits: ClipEdit[]) => void }) {
2169
+ const fps = useTimecodeFps();
2170
+ const of = (b: TimelineBlock): FadeMs => (b.clipId ? fades?.get(b.clipId) : undefined) ?? [0, 0];
2171
+ const common = (i: 0 | 1): number | Mixed => {
2172
+ const v = round3(of(blocks[0]!)[i] / 1000);
2173
+ return blocks.every((b) => round3(of(b)[i] / 1000) === v) ? v : MIXED;
2174
+ };
2175
+ const set = (i: 0 | 1, apply: FieldApply) => commit(blocks.flatMap((b) => {
2176
+ const was = of(b);
2177
+ const len = (b.endMs - b.startMs) / 1000;
2178
+ const own = Math.max(0, Math.min(len, round3(apply(was[i] / 1000))));
2179
+ const other = Math.min(round3(was[1 - i]! / 1000), round3(len - own));
2180
+ const next = i === 0 ? [own, other] : [other, own];
2181
+ return next[0] === round3(was[0] / 1000) && next[1] === round3(was[1] / 1000) ? [] : [{ loc: b.loc!, prop: 'fade', value: next }];
2182
+ }));
2183
+ const longest = Math.min(...blocks.map((b) => (b.endMs - b.startMs) / 1000));
2184
+ return (
2185
+ <Section id="fades" title={t('inspector.fades')}>
2186
+ <Row>
2187
+ <NumField label={t('inspector.fadeIn')} word value={common(0)} min={0} max={round3(longest)} step={1 / fps} unit="s"
2188
+ ctx={{ time: true, fps }} onCommit={(_, apply) => set(0, apply)} />
2189
+ <NumField label={t('inspector.fadeOut')} word value={common(1)} min={0} max={round3(longest)} step={1 / fps} unit="s"
2190
+ ctx={{ time: true, fps }} onCommit={(_, apply) => set(1, apply)} />
2191
+ </Row>
2192
+ </Section>
2193
+ );
2194
+ }
2195
+
2196
+ /** The transition picked on the timeline, for the inspector: its kind and length; a new length, or taken away. */
2197
+ export type TransitionPick = {
2198
+ kind: FoundKind;
2199
+ durationMs: number;
2200
+ onDuration: (ms: number) => Promise<string | null>;
2201
+ onRemove: () => void;
2202
+ };
2203
+
2204
+ /** A transition picked on the timeline: its length, typed, and a button that takes it away. */
2205
+ function TransitionPanel({ t, pick }: { t: T; pick: TransitionPick }) {
2206
+ const fps = useTimecodeFps();
2207
+ const [refusal, setRefusal] = React.useState<string | null>(null);
2208
+ return (
2209
+ <>
2210
+ <Header icon={<Film size={13} />} name={t(`transitions.${pick.kind}`)} meta={t('inspector.transition')} />
2211
+ {refusal ? <RefusalBanner message={refusal} onDismiss={() => setRefusal(null)} t={t} /> : null}
2212
+ <Section id="transition" title={t('inspector.transition')}>
2213
+ <Row>
2214
+ <NumField label={t('inspector.transitionLength')} word value={round3(pick.durationMs / 1000)} min={1 / fps} step={1 / fps} unit="s"
2215
+ ctx={{ time: true, fps }}
2216
+ onCommit={(n) => { void pick.onDuration(Math.round(n * 1000)).then((failed) => setRefusal(failed)); }} />
2217
+ <span />
2218
+ </Row>
2219
+ <button type="button" data-transition-remove="" onClick={pick.onRemove}
2220
+ className="mt-1 flex h-7 w-full items-center justify-center gap-1.5 rounded-md border text-[11.5px] text-[var(--text)] transition hover:bg-[var(--bg-hover)]"
2221
+ style={{ borderColor: 'var(--border)' }}>
2222
+ <X size={12} aria-hidden />
2223
+ {t('inspector.transitionRemove')}
2224
+ </button>
2225
+ </Section>
2226
+ </>
2227
+ );
2228
+ }
2229
+
2230
+ /**
2231
+ * A clip's `box` (film.html only — a code-shaped clip has no such section), its picture's own size `own` known.
2232
+ *
2233
+ * The fields: X / Y, W / H in stage px, the angle, and the align row. W and H keep the picture's
2234
+ * proportions while locked (typing one sets the other, and the box is written with its width alone); unlocked, a
2235
+ * video or still gets a box of its own proportions (its CSS fits or crops the picture in it). A page always shows
2236
+ * whole, so its box keeps its proportions.
2237
+ */
2238
+ function ClipPositionSection({
2239
+ t, loc, page, own, placed, value, geometry, onCommit, onPreview,
2240
+ }: {
2241
+ t: T;
2242
+ loc: string;
2243
+ page: boolean;
2244
+ own: MgBox;
2245
+ /** A box is written (else the clip is where it lands by default, and there is nothing to reset). */
2246
+ placed: boolean;
2247
+ value: MgTransform;
2248
+ geometry: StageGeometry;
2249
+ onCommit: (edits: ClipEdit[]) => void;
2250
+ /** The picture follows a dragged number; letting go / Enter writes (the same feel as a layer's fields). */
2251
+ onPreview?: (patch: Partial<MgTransform> | null) => void;
2252
+ }) {
2253
+ const [locked, setLocked] = React.useState(true);
2254
+ const lockedNow = page || locked;
2255
+ const patch = (part: Partial<MgTransform>) => {
2256
+ onCommit([{ loc, prop: 'box', value: compactBox({ ...value, ...part }, own) }]);
2257
+ };
2258
+ /* the fields show where the clip is now (it follows a drag on the stage); a typed number is worked out from the
2259
+ picture's own size and the written box, never from the drawn place, which already shows this very edit */
2260
+ const box = geometry.box ?? own;
2261
+ const bind = (part: (n: number) => Partial<MgTransform>) => ({
2262
+ onCommit: (n: number) => patch(part(n)),
2263
+ ...(onPreview ? { onPreview: (n: number) => onPreview(part(n)), onRevert: () => onPreview(null) } : {}),
2264
+ });
2265
+ const xPart = (x: number) => clipFieldPart('x', x, box);
2266
+ const yPart = (y: number) => clipFieldPart('y', y, box);
2267
+ const sizePart = (field: 'w' | 'h', n: number): Partial<MgTransform> => {
2268
+ if (lockedNow) return clipFieldPart(field, n, box);
2269
+ return field === 'w' ? { scaleX: n / (box.w || 1) } : { scaleY: n / (box.h || 1) };
2270
+ };
2271
+ const { w: stageW, h: stageH } = geometry.stage;
2272
+ const alignTo = (edge: AlignEdge) => {
2273
+ const { w, h } = clipFields(value, box);
2274
+ const part: Partial<MgTransform> = {};
2275
+ if (edge === 'left') Object.assign(part, xPart(0));
2276
+ if (edge === 'hcenter') Object.assign(part, xPart((stageW - w) / 2));
2277
+ if (edge === 'right') Object.assign(part, xPart(stageW - w));
2278
+ if (edge === 'top') Object.assign(part, yPart(0));
2279
+ if (edge === 'vcenter') Object.assign(part, yPart((stageH - h) / 2));
2280
+ if (edge === 'bottom') Object.assign(part, yPart(stageH - h));
2281
+ if ((part.x ?? value.x) === value.x && (part.y ?? value.y) === value.y) return;
2282
+ patch(part);
2283
+ };
2284
+ return (
2285
+ <Section
2286
+ id="position"
2287
+ title={t('inspector.position')}
2288
+ aside={placed ? (
2289
+ <SectionAction label={t('inspector.resetTransform')} onClick={() => onCommit([{ loc, prop: 'box', value: null }])}>
2290
+ <RotateCcw size={11} />
2291
+ </SectionAction>
2292
+ ) : null}
2293
+ >
2294
+ <AlignBar t={t} onAlign={alignTo} />
2295
+ <Row>
2296
+ <NumField label={t('inspector.x')} prefix="X" value={geometry.x} min={-COORD_LIMIT} max={COORD_LIMIT} ctx={{ percent: stageW }} {...bind(xPart)} />
2297
+ <NumField label={t('inspector.y')} prefix="Y" value={geometry.y} min={-COORD_LIMIT} max={COORD_LIMIT} ctx={{ percent: stageH }} {...bind(yPart)} />
2298
+ </Row>
2299
+ <div className="grid grid-cols-[1fr_1fr_24px] items-center gap-1.5">
2300
+ <NumField label={t('inspector.width')} prefix="W" value={geometry.w} min={1} max={COORD_LIMIT} ctx={{ percent: stageW }} {...bind((w) => sizePart('w', w))} />
2301
+ <NumField label={t('inspector.height')} prefix="H" value={geometry.h} min={1} max={COORD_LIMIT} ctx={{ percent: stageH }} {...bind((h) => sizePart('h', h))} />
2302
+ <LockToggle t={t} locked={lockedNow} {...(page ? {} : { onChange: setLocked })} />
2303
+ </div>
2304
+ <Row>
2305
+ <NumField label={t('inspector.rotate')} prefix={<RotateGlyph />} value={value.rotate} unit="°" {...bind((rotate) => ({ rotate: wrapDeg(rotate) }))} />
2306
+ <span />
2307
+ </Row>
2308
+ </Section>
2309
+ );
2310
+ }