egaki 0.5.0 → 0.7.0

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 (280) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/cli/cache-utils.d.ts +33 -0
  3. package/dist/cli/cache-utils.d.ts.map +1 -0
  4. package/dist/cli/cache-utils.js +157 -0
  5. package/dist/cli/cached-generate.d.ts +105 -0
  6. package/dist/cli/cached-generate.d.ts.map +1 -0
  7. package/dist/cli/cached-generate.js +208 -0
  8. package/dist/cli/cached-generate.test.d.ts +11 -0
  9. package/dist/cli/cached-generate.test.d.ts.map +1 -0
  10. package/dist/cli/cached-generate.test.js +431 -0
  11. package/dist/cli/cartesia-provider.d.ts +16 -0
  12. package/dist/cli/cartesia-provider.d.ts.map +1 -0
  13. package/dist/cli/cartesia-provider.js +184 -0
  14. package/dist/cli/chatgpt-auth.d.ts.map +1 -0
  15. package/dist/cli/cli.d.ts +4 -0
  16. package/dist/cli/cli.d.ts.map +1 -0
  17. package/dist/cli/cli.js +1329 -0
  18. package/dist/cli/clone-generate.d.ts +26 -0
  19. package/dist/cli/clone-generate.d.ts.map +1 -0
  20. package/dist/cli/clone-generate.js +148 -0
  21. package/dist/{credentials.d.ts → cli/credentials.d.ts} +11 -2
  22. package/dist/cli/credentials.d.ts.map +1 -0
  23. package/dist/{credentials.js → cli/credentials.js} +88 -5
  24. package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
  25. package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
  26. package/dist/cli/elevenlabs-speech-provider.js +199 -0
  27. package/dist/cli/generate.d.ts +140 -0
  28. package/dist/cli/generate.d.ts.map +1 -0
  29. package/dist/cli/generate.js +604 -0
  30. package/dist/cli/generate.test.d.ts +2 -0
  31. package/dist/cli/generate.test.d.ts.map +1 -0
  32. package/dist/cli/generate.test.js +131 -0
  33. package/dist/cli/login.d.ts.map +1 -0
  34. package/dist/{login.js → cli/login.js} +45 -4
  35. package/dist/cli/main.d.ts +3 -0
  36. package/dist/cli/main.d.ts.map +1 -0
  37. package/dist/cli/main.js +7 -0
  38. package/dist/cli/model-catalog.d.ts +141 -0
  39. package/dist/cli/model-catalog.d.ts.map +1 -0
  40. package/dist/cli/model-catalog.js +2135 -0
  41. package/dist/{models.d.ts → cli/models.d.ts} +16 -9
  42. package/dist/cli/models.d.ts.map +1 -0
  43. package/dist/cli/models.js +313 -0
  44. package/dist/cli/open-browser.d.ts.map +1 -0
  45. package/dist/cli/openai-speech-provider.d.ts +3 -0
  46. package/dist/cli/openai-speech-provider.d.ts.map +1 -0
  47. package/dist/cli/openai-speech-provider.js +28 -0
  48. package/dist/cli/separation-generate.d.ts +34 -0
  49. package/dist/cli/separation-generate.d.ts.map +1 -0
  50. package/dist/cli/separation-generate.js +117 -0
  51. package/dist/cli/speech-catalog.d.ts +35 -0
  52. package/dist/cli/speech-catalog.d.ts.map +1 -0
  53. package/dist/cli/speech-catalog.js +195 -0
  54. package/dist/cli/speech-generate.d.ts +86 -0
  55. package/dist/cli/speech-generate.d.ts.map +1 -0
  56. package/dist/cli/speech-generate.js +156 -0
  57. package/dist/cli/speech-models.d.ts +8 -0
  58. package/dist/cli/speech-models.d.ts.map +1 -0
  59. package/dist/cli/speech-models.js +109 -0
  60. package/dist/cli/speech-provider.test.d.ts +2 -0
  61. package/dist/cli/speech-provider.test.d.ts.map +1 -0
  62. package/dist/cli/speech-provider.test.js +197 -0
  63. package/dist/cli/subscription.d.ts.map +1 -0
  64. package/dist/cli/transcription-catalog.d.ts +35 -0
  65. package/dist/cli/transcription-catalog.d.ts.map +1 -0
  66. package/dist/cli/transcription-catalog.js +192 -0
  67. package/dist/cli/transcription-generate.d.ts +62 -0
  68. package/dist/cli/transcription-generate.d.ts.map +1 -0
  69. package/dist/cli/transcription-generate.js +144 -0
  70. package/dist/cli/transcription-models.d.ts +9 -0
  71. package/dist/cli/transcription-models.d.ts.map +1 -0
  72. package/dist/cli/transcription-models.js +100 -0
  73. package/dist/cli/transcription-providers.d.ts +22 -0
  74. package/dist/cli/transcription-providers.d.ts.map +1 -0
  75. package/dist/cli/transcription-providers.js +290 -0
  76. package/dist/cli/video-model-catalog.d.ts +2 -0
  77. package/dist/cli/video-model-catalog.d.ts.map +1 -0
  78. package/dist/cli/video-model-catalog.js +5 -0
  79. package/dist/cli/xai-auth.d.ts +29 -0
  80. package/dist/cli/xai-auth.d.ts.map +1 -0
  81. package/dist/cli/xai-auth.js +325 -0
  82. package/dist/vite/angled-screen.d.ts +57 -0
  83. package/dist/vite/angled-screen.d.ts.map +1 -0
  84. package/dist/vite/angled-screen.js +115 -0
  85. package/dist/vite/app.d.ts +51 -0
  86. package/dist/vite/app.d.ts.map +1 -0
  87. package/dist/vite/app.js +150 -0
  88. package/dist/vite/bands-shader.d.ts +47 -0
  89. package/dist/vite/bands-shader.d.ts.map +1 -0
  90. package/dist/vite/bands-shader.js +267 -0
  91. package/dist/vite/code-block.d.ts +107 -0
  92. package/dist/vite/code-block.d.ts.map +1 -0
  93. package/dist/vite/code-block.js +641 -0
  94. package/dist/vite/components.d.ts +36 -0
  95. package/dist/vite/components.d.ts.map +1 -0
  96. package/dist/vite/components.js +113 -0
  97. package/dist/vite/dispersion-rings-shader.d.ts +44 -0
  98. package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
  99. package/dist/vite/dispersion-rings-shader.js +282 -0
  100. package/dist/vite/easing-curves.d.ts +129 -0
  101. package/dist/vite/easing-curves.d.ts.map +1 -0
  102. package/dist/vite/easing-curves.js +584 -0
  103. package/dist/vite/easing-curves.test.d.ts +2 -0
  104. package/dist/vite/easing-curves.test.d.ts.map +1 -0
  105. package/dist/vite/easing-curves.test.js +200 -0
  106. package/dist/vite/generated-media-client.d.ts +35 -0
  107. package/dist/vite/generated-media-client.d.ts.map +1 -0
  108. package/dist/vite/generated-media-client.js +76 -0
  109. package/dist/vite/keyframes.d.ts +129 -0
  110. package/dist/vite/keyframes.d.ts.map +1 -0
  111. package/dist/vite/keyframes.js +194 -0
  112. package/dist/vite/layout-editor.d.ts +45 -0
  113. package/dist/vite/layout-editor.d.ts.map +1 -0
  114. package/dist/vite/layout-editor.js +587 -0
  115. package/dist/vite/layout-transition.d.ts +136 -0
  116. package/dist/vite/layout-transition.d.ts.map +1 -0
  117. package/dist/vite/layout-transition.js +581 -0
  118. package/dist/vite/liquid-gradient-shader.d.ts +62 -0
  119. package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
  120. package/dist/vite/liquid-gradient-shader.js +413 -0
  121. package/dist/vite/mdx-client.d.ts +15 -0
  122. package/dist/vite/mdx-client.d.ts.map +1 -0
  123. package/dist/vite/mdx-client.js +265 -0
  124. package/dist/vite/mdx-parse.d.ts +102 -0
  125. package/dist/vite/mdx-parse.d.ts.map +1 -0
  126. package/dist/vite/mdx-parse.js +251 -0
  127. package/dist/vite/mdx-provided-components.d.ts +18 -0
  128. package/dist/vite/mdx-provided-components.d.ts.map +1 -0
  129. package/dist/vite/mdx-provided-components.js +14 -0
  130. package/dist/vite/mdx-video.d.ts +327 -0
  131. package/dist/vite/mdx-video.d.ts.map +1 -0
  132. package/dist/vite/mdx-video.js +596 -0
  133. package/dist/vite/mdx-video.test.d.ts +9 -0
  134. package/dist/vite/mdx-video.test.d.ts.map +1 -0
  135. package/dist/vite/mdx-video.test.js +1945 -0
  136. package/dist/vite/media-components.d.ts +45 -0
  137. package/dist/vite/media-components.d.ts.map +1 -0
  138. package/dist/vite/media-components.js +355 -0
  139. package/dist/vite/media-duration-store.d.ts +66 -0
  140. package/dist/vite/media-duration-store.d.ts.map +1 -0
  141. package/dist/vite/media-duration-store.js +204 -0
  142. package/dist/vite/player-page.d.ts +35 -0
  143. package/dist/vite/player-page.d.ts.map +1 -0
  144. package/dist/vite/player-page.js +720 -0
  145. package/dist/vite/render-client.d.ts +18 -0
  146. package/dist/vite/render-client.d.ts.map +1 -0
  147. package/dist/vite/render-client.js +67 -0
  148. package/dist/vite/sdk.d.ts +221 -0
  149. package/dist/vite/sdk.d.ts.map +1 -0
  150. package/dist/vite/sdk.js +355 -0
  151. package/dist/vite/server-components.d.ts +55 -0
  152. package/dist/vite/server-components.d.ts.map +1 -0
  153. package/dist/vite/server-components.js +156 -0
  154. package/dist/vite/server-mdx.d.ts +74 -0
  155. package/dist/vite/server-mdx.d.ts.map +1 -0
  156. package/dist/vite/server-mdx.js +259 -0
  157. package/dist/vite/shader-renderer.d.ts +48 -0
  158. package/dist/vite/shader-renderer.d.ts.map +1 -0
  159. package/dist/vite/shader-renderer.js +411 -0
  160. package/dist/vite/store-hooks.d.ts +12 -0
  161. package/dist/vite/store-hooks.d.ts.map +1 -0
  162. package/dist/vite/store-hooks.js +93 -0
  163. package/dist/vite/store.d.ts +75 -0
  164. package/dist/vite/store.d.ts.map +1 -0
  165. package/dist/vite/store.js +52 -0
  166. package/dist/vite/store.test.d.ts +6 -0
  167. package/dist/vite/store.test.d.ts.map +1 -0
  168. package/dist/vite/store.test.js +172 -0
  169. package/dist/vite/tweakpane-hook.d.ts +94 -0
  170. package/dist/vite/tweakpane-hook.d.ts.map +1 -0
  171. package/dist/vite/tweakpane-hook.js +450 -0
  172. package/dist/vite/vite-plugin.d.ts +28 -0
  173. package/dist/vite/vite-plugin.d.ts.map +1 -0
  174. package/dist/vite/vite-plugin.js +538 -0
  175. package/dist/vite/wave-gradient-shader.d.ts +41 -0
  176. package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
  177. package/dist/vite/wave-gradient-shader.js +207 -0
  178. package/package.json +103 -25
  179. package/skills/egaki/SKILL.md +162 -1
  180. package/src/cli/cache-utils.ts +169 -0
  181. package/src/cli/cached-generate.test.ts +531 -0
  182. package/src/cli/cached-generate.ts +348 -0
  183. package/src/cli/cartesia-provider.ts +249 -0
  184. package/src/cli/cli.ts +1818 -0
  185. package/src/cli/clone-generate.ts +202 -0
  186. package/src/{credentials.ts → cli/credentials.ts} +90 -7
  187. package/src/cli/elevenlabs-speech-provider.ts +243 -0
  188. package/src/cli/generate.test.ts +166 -0
  189. package/src/cli/generate.ts +912 -0
  190. package/src/{login.ts → cli/login.ts} +48 -3
  191. package/src/cli/main.ts +8 -0
  192. package/src/cli/model-catalog.ts +2336 -0
  193. package/src/cli/models.ts +408 -0
  194. package/src/cli/openai-speech-provider.ts +34 -0
  195. package/src/cli/separation-generate.ts +187 -0
  196. package/src/cli/speech-catalog.ts +253 -0
  197. package/src/cli/speech-generate.ts +275 -0
  198. package/src/cli/speech-models.ts +146 -0
  199. package/src/cli/speech-provider.test.ts +226 -0
  200. package/src/cli/transcription-catalog.ts +250 -0
  201. package/src/cli/transcription-generate.ts +214 -0
  202. package/src/cli/transcription-models.ts +136 -0
  203. package/src/cli/transcription-providers.ts +427 -0
  204. package/src/cli/video-model-catalog.ts +14 -0
  205. package/src/cli/xai-auth.ts +396 -0
  206. package/src/vite/angled-screen.tsx +214 -0
  207. package/src/vite/app.tsx +197 -0
  208. package/src/vite/bands-shader.tsx +319 -0
  209. package/src/vite/code-block.tsx +939 -0
  210. package/src/vite/components.tsx +278 -0
  211. package/src/vite/dispersion-rings-shader.tsx +331 -0
  212. package/src/vite/easing-curves.test.ts +232 -0
  213. package/src/vite/easing-curves.ts +743 -0
  214. package/src/vite/generated-media-client.tsx +121 -0
  215. package/src/vite/keyframes.tsx +347 -0
  216. package/src/vite/layout-editor.tsx +709 -0
  217. package/src/vite/layout-transition.tsx +706 -0
  218. package/src/vite/liquid-gradient-shader.tsx +474 -0
  219. package/src/vite/mdx-client.tsx +420 -0
  220. package/src/vite/mdx-parse.ts +343 -0
  221. package/src/vite/mdx-provided-components.ts +19 -0
  222. package/src/vite/mdx-video.test.tsx +2219 -0
  223. package/src/vite/mdx-video.tsx +976 -0
  224. package/src/vite/media-components.tsx +473 -0
  225. package/src/vite/media-duration-store.ts +225 -0
  226. package/src/vite/motion-timing.ts +194 -0
  227. package/src/vite/player-page.tsx +1107 -0
  228. package/src/vite/render-client.ts +84 -0
  229. package/src/vite/sdk.ts +598 -0
  230. package/src/vite/server-components.tsx +241 -0
  231. package/src/vite/server-mdx.ts +285 -0
  232. package/src/vite/shader-renderer.tsx +535 -0
  233. package/src/vite/store-hooks.ts +117 -0
  234. package/src/vite/store.test.ts +202 -0
  235. package/src/vite/store.ts +115 -0
  236. package/src/vite/styles.css +21 -0
  237. package/src/vite/tweakpane-hook.tsx +593 -0
  238. package/src/vite/virtual-modules.d.ts +30 -0
  239. package/src/vite/vite-plugin.ts +584 -0
  240. package/src/vite/wave-gradient-shader.tsx +253 -0
  241. package/README.md +0 -266
  242. package/dist/antigravity-auth.d.ts +0 -15
  243. package/dist/antigravity-auth.d.ts.map +0 -1
  244. package/dist/antigravity-auth.js +0 -544
  245. package/dist/antigravity-image.d.ts +0 -11
  246. package/dist/antigravity-image.d.ts.map +0 -1
  247. package/dist/antigravity-image.js +0 -285
  248. package/dist/chatgpt-auth.d.ts.map +0 -1
  249. package/dist/cli.d.ts +0 -3
  250. package/dist/cli.d.ts.map +0 -1
  251. package/dist/cli.js +0 -968
  252. package/dist/credentials.d.ts.map +0 -1
  253. package/dist/gateway-model-candidates.generated.d.ts +0 -11
  254. package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
  255. package/dist/gateway-model-candidates.generated.js +0 -64
  256. package/dist/login.d.ts.map +0 -1
  257. package/dist/model-catalog.d.ts +0 -42
  258. package/dist/model-catalog.d.ts.map +0 -1
  259. package/dist/model-catalog.js +0 -1001
  260. package/dist/models.d.ts.map +0 -1
  261. package/dist/models.js +0 -246
  262. package/dist/open-browser.d.ts.map +0 -1
  263. package/dist/subscription.d.ts.map +0 -1
  264. package/dist/video-model-catalog.d.ts +0 -52
  265. package/dist/video-model-catalog.d.ts.map +0 -1
  266. package/dist/video-model-catalog.js +0 -306
  267. package/src/cli.ts +0 -1378
  268. package/src/model-catalog.ts +0 -1077
  269. package/src/models.ts +0 -309
  270. package/src/video-model-catalog.ts +0 -363
  271. /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
  272. /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
  273. /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
  274. /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
  275. /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
  276. /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
  277. /package/dist/{subscription.js → cli/subscription.js} +0 -0
  278. /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
  279. /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
  280. /package/src/{subscription.ts → cli/subscription.ts} +0 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Layout editor for the video player.
3
+ *
4
+ * Lets users visually drag, scale, and edit text on elements inside the
5
+ * Remotion Player. All spatial changes are applied via CSS transform
6
+ * (translate + scale). Double-click text to edit inline.
7
+ *
8
+ * Changes are tracked per-element using safe-mdx's `data-markdown-line`
9
+ * attribute, then serialized as structured text for an agent to apply.
10
+ *
11
+ * Keyboard: Delete/Backspace = delete selected element, Esc = select parent
12
+ * (deselect at root), Ctrl+Z = undo.
13
+ *
14
+ * Bug-fix notes (from oracle review, commit 1158b916):
15
+ * - Undo stores a snapshot of the previous ElementChange so restoring
16
+ * doesn't delete the change entirely (was: changesRef.current.delete).
17
+ * - attachMoveable uses a generation counter ref to guard against stale
18
+ * async import resolutions (race between disable/new-select and import).
19
+ * - Text edit keydown uses a named handler removed in the blur cleanup.
20
+ * - finishTextEdit() is extracted and called from the !editing cleanup
21
+ * effect so toggling editing off mid-text-edit cleans up properly.
22
+ * - Moveable instance is destroyed on unmount via a dedicated useEffect.
23
+ * - Clipboard "Copied!" timeout is tracked in a ref and cleared on unmount.
24
+ * - Moveable constructor cast cleaned up (kept hand-written interfaces
25
+ * because react-moveable types are not installed as a dependency).
26
+ */
27
+
28
+ import { useCallback, useEffect, useRef, useState } from 'react'
29
+
30
+ // ── Moveable types ─────────────────────────────────────────────────────
31
+ // Hand-written because react-moveable (which moveable re-exports types
32
+ // from) is not installed. These cover the subset we use.
33
+
34
+ interface MoveableEvent { target: HTMLElement | SVGElement; transform: string }
35
+
36
+ interface MoveableScaleEvent {
37
+ target: HTMLElement | SVGElement
38
+ scale: number[]
39
+ drag: { transform: string }
40
+ }
41
+
42
+ interface MoveableInstance {
43
+ on(event: 'dragStart', handler: (e: MoveableEvent) => void): MoveableInstance
44
+ on(event: 'drag', handler: (e: MoveableEvent) => void): MoveableInstance
45
+ on(event: 'scaleStart', handler: (e: MoveableEvent) => void): MoveableInstance
46
+ on(event: 'scale', handler: (e: MoveableScaleEvent) => void): MoveableInstance
47
+ destroy(): void
48
+ }
49
+
50
+ interface MoveableConstructor {
51
+ new(parent: HTMLElement, opts: Record<string, unknown>): MoveableInstance
52
+ }
53
+
54
+ // ── Types ──────────────────────────────────────────────────────────────
55
+
56
+ type ElementKind = 'text' | 'image' | 'video' | 'audio' | 'code' | 'svg' | 'canvas' | 'element'
57
+ type ElementStatus = 'unchanged' | 'deleted'
58
+
59
+ interface ElementChange {
60
+ mdxLine: number | null
61
+ kind: ElementKind
62
+ status: ElementStatus
63
+ translateX: number
64
+ translateY: number
65
+ scaleX: number
66
+ scaleY: number
67
+ textContent: string | null
68
+ originalTextContent: string | null
69
+ textPreview: string
70
+ tagName: string
71
+ /** src/href for images and videos, so the prompt identifies which media changed */
72
+ src: string | null
73
+ }
74
+
75
+ interface UndoEntry {
76
+ el: HTMLElement
77
+ transform: string
78
+ position: string
79
+ display: string
80
+ /** Snapshot of the ElementChange before this action, or null if none existed */
81
+ prevChange: ElementChange | null
82
+ }
83
+
84
+ export interface SectionMeta {
85
+ heading: string | null
86
+ durationInFrames: number
87
+ }
88
+
89
+ // ── Helpers ────────────────────────────────────────────────────────────
90
+
91
+ function isTextElement(el: HTMLElement): boolean {
92
+ return el.children.length === 0 && (el.textContent?.trim().length ?? 0) > 0
93
+ }
94
+
95
+ function findMdxLine(el: HTMLElement, boundary: HTMLElement): number | null {
96
+ let cur: HTMLElement | null = el
97
+ while (cur && boundary.contains(cur)) {
98
+ const v = cur.getAttribute('data-markdown-line')
99
+ if (v) return parseInt(v, 10)
100
+ cur = cur.parentElement
101
+ }
102
+ return null
103
+ }
104
+
105
+ function classifyElement(el: HTMLElement): ElementKind {
106
+ const tag = el.tagName.toLowerCase()
107
+ if (tag === 'img' || tag === 'picture') return 'image'
108
+ if (tag === 'video') return 'video'
109
+ if (tag === 'audio') return 'audio'
110
+ if (tag === 'svg') return 'svg'
111
+ if (tag === 'canvas') return 'canvas'
112
+ if (tag === 'pre' || tag === 'code') return 'code'
113
+ if (el.querySelector('img, picture')) return 'image'
114
+ if (el.querySelector('video')) return 'video'
115
+ if (el.querySelector('canvas')) return 'canvas'
116
+ if (el.querySelector('pre, code')) return 'code'
117
+ // Text: has meaningful text content and no non-text media children
118
+ if ((el.textContent?.trim().length ?? 0) > 0 && !el.querySelector('img, video, canvas, svg:not([aria-hidden])')) {
119
+ return 'text'
120
+ }
121
+ return 'element'
122
+ }
123
+
124
+ /** Extract src for images and videos, checking the element itself and its children */
125
+ function extractMediaSrc(el: HTMLElement): string | null {
126
+ const tag = el.tagName.toLowerCase()
127
+ if (tag === 'img') return el.getAttribute('src')
128
+ if (tag === 'video') return el.getAttribute('src') ?? el.querySelector('source')?.getAttribute('src') ?? null
129
+ const img = el.querySelector('img')
130
+ if (img) return img.getAttribute('src')
131
+ const video = el.querySelector('video')
132
+ if (video) return video.getAttribute('src') ?? video.querySelector('source')?.getAttribute('src') ?? null
133
+ return null
134
+ }
135
+
136
+ function preview(el: HTMLElement): string {
137
+ const t = (el.textContent ?? '').trim()
138
+ return t.length > 60 ? t.slice(0, 60) + '…' : t
139
+ }
140
+
141
+ function hasChange(c: ElementChange): boolean {
142
+ return c.status !== 'unchanged'
143
+ || c.translateX !== 0 || c.translateY !== 0
144
+ || c.scaleX !== 1 || c.scaleY !== 1
145
+ || c.textContent !== null
146
+ }
147
+
148
+ function cloneChange(c: ElementChange): ElementChange {
149
+ return { ...c }
150
+ }
151
+
152
+ /** Derive which section the current frame falls in */
153
+ function getCurrentSection(frame: number, sections: SectionMeta[]): SectionMeta | null {
154
+ let acc = 0
155
+ for (const s of sections) {
156
+ if (frame < acc + s.durationInFrames) return s
157
+ acc += s.durationInFrames
158
+ }
159
+ return sections.length > 0 ? sections[sections.length - 1]! : null
160
+ }
161
+
162
+ // ── Serialization ──────────────────────────────────────────────────────
163
+
164
+ function serializeChanges(
165
+ changes: Map<HTMLElement, ElementChange>,
166
+ currentFrame: number | null,
167
+ fps: number,
168
+ sections: SectionMeta[],
169
+ entryPath: string,
170
+ ): string {
171
+ const entries = Array.from(changes.values()).filter(hasChange)
172
+ if (entries.length === 0) return ''
173
+
174
+ const parts: string[] = ['## Layout changes\n']
175
+
176
+ if (entryPath) {
177
+ parts.push(`File: ${entryPath}`)
178
+ }
179
+
180
+ // Frame + section context
181
+ if (currentFrame !== null) {
182
+ const seconds = (currentFrame / fps).toFixed(1)
183
+ parts.push(`Current frame: ${currentFrame} (${seconds}s at ${fps}fps)`)
184
+
185
+ const section = getCurrentSection(currentFrame, sections)
186
+ if (section?.heading) {
187
+ parts.push(`Section: "${section.heading}"`)
188
+ }
189
+ parts.push('')
190
+ }
191
+
192
+ for (const c of entries) {
193
+ const line = c.mdxLine !== null ? ` (line ${c.mdxLine})` : ''
194
+ const text = c.kind === 'text' && c.textPreview ? `: "${c.textPreview}"` : ''
195
+ const src = c.src && (c.kind === 'image' || c.kind === 'video') ? ` (src: ${c.src})` : ''
196
+ parts.push(`**${c.kind}**${line}${text}${src}`)
197
+
198
+ if (c.status === 'deleted') {
199
+ parts.push(`- Deleted`)
200
+ parts.push('')
201
+ continue
202
+ }
203
+
204
+ if (c.translateX !== 0 || c.translateY !== 0) {
205
+ const x = `${c.translateX > 0 ? '+' : ''}${Math.round(c.translateX)}px`
206
+ const y = `${c.translateY > 0 ? '+' : ''}${Math.round(c.translateY)}px`
207
+ parts.push(`- Move: x=${x}, y=${y}`)
208
+ }
209
+ if (c.scaleX !== 1 || c.scaleY !== 1) {
210
+ parts.push(`- Scale: ${c.scaleX.toFixed(2)}x, ${c.scaleY.toFixed(2)}y`)
211
+ }
212
+ if (c.textContent !== null && c.originalTextContent !== null) {
213
+ parts.push(`- Text: "${c.originalTextContent}" -> "${c.textContent}"`)
214
+ }
215
+ parts.push('')
216
+ }
217
+ return parts.join('\n')
218
+ }
219
+
220
+ // ── Toolbar button ─────────────────────────────────────────────────────
221
+
222
+ function ToolbarButton({ onClick, active, children, title }: {
223
+ onClick: () => void; active?: boolean; children: React.ReactNode; title?: string
224
+ }) {
225
+ return (
226
+ <button
227
+ type='button' onClick={onClick} title={title}
228
+ className={[
229
+ 'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors cursor-pointer',
230
+ active
231
+ ? 'bg-sky-200 text-sky-950'
232
+ : 'text-zinc-300 hover:bg-white/10',
233
+ ].join(' ')}
234
+ >{children}</button>
235
+ )
236
+ }
237
+
238
+ // ── Main component ─────────────────────────────────────────────────────
239
+
240
+ export function LayoutEditor({ playerContainerRef, playerRef, editing, onEditingChange, onReset, sections, fps, entryPath }: {
241
+ playerContainerRef: React.RefObject<HTMLElement | null>
242
+ playerRef: React.RefObject<{ getCurrentFrame: () => number; addEventListener: (name: any, cb: any) => void; removeEventListener: (name: any, cb: any) => void } | null>
243
+ editing: boolean
244
+ onEditingChange: (editing: boolean) => void
245
+ onReset: () => void
246
+ sections: SectionMeta[]
247
+ fps: number
248
+ entryPath: string
249
+ }) {
250
+ const [selectedEl, setSelectedEl] = useState<HTMLElement | null>(null)
251
+ const [changesCount, setChangesCount] = useState(0)
252
+ const [copied, setCopied] = useState(false)
253
+
254
+ const moveableRef = useRef<MoveableInstance | null>(null)
255
+ const changesRef = useRef<Map<HTMLElement, ElementChange>>(new Map())
256
+ const textEditRef = useRef<HTMLElement | null>(null)
257
+ const undoStack = useRef<UndoEntry[]>([])
258
+ /** Generation counter to guard against stale async import resolutions */
259
+ const moveableGenRef = useRef(0)
260
+ /** Timeout ID for the "Copied!" indicator reset */
261
+ const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
262
+
263
+ const getPlayer = useCallback((): HTMLElement | null => {
264
+ return playerContainerRef.current?.querySelector('.__remotion-player') as HTMLElement | null
265
+ }, [playerContainerRef])
266
+
267
+ const isSelectable = useCallback((el: HTMLElement, player: HTMLElement): boolean => {
268
+ if (el.className?.includes?.('moveable-')) return false
269
+ const d = getComputedStyle(el).display
270
+ if (!['block', 'flex', 'grid', 'inline-block'].includes(d)) return false
271
+ if (el.offsetHeight <= 10 || el.offsetWidth <= 20) return false
272
+ const r = el.getBoundingClientRect()
273
+ const p = player.getBoundingClientRect()
274
+ if (Math.abs(r.width - p.width) < 5 && Math.abs(r.height - p.height) < 5) return false
275
+ return true
276
+ }, [])
277
+
278
+ const findTarget = useCallback((el: HTMLElement): HTMLElement | null => {
279
+ const player = getPlayer()
280
+ if (!player?.contains(el)) return null
281
+ let cur: HTMLElement | null = el
282
+ while (cur && player.contains(cur)) {
283
+ if (isSelectable(cur, player)) return cur
284
+ cur = cur.parentElement
285
+ }
286
+ return null
287
+ }, [getPlayer, isSelectable])
288
+
289
+ /** Try all elements at a screen point, skipping transparent overlays like Fill */
290
+ const findTargetAtPoint = useCallback((clientX: number, clientY: number): HTMLElement | null => {
291
+ const elements = document.elementsFromPoint(clientX, clientY)
292
+ for (const el of elements) {
293
+ const hit = findTarget(el as HTMLElement)
294
+ if (hit) return hit
295
+ }
296
+ return null
297
+ }, [findTarget])
298
+
299
+ function countChanges(): number {
300
+ let n = 0
301
+ for (const c of changesRef.current.values()) if (hasChange(c)) n++
302
+ return n
303
+ }
304
+
305
+ // ── Finish text editing (extracted so it can be called from !editing cleanup) ──
306
+
307
+ const finishTextEdit = useCallback(() => {
308
+ const target = textEditRef.current
309
+ if (!target) return
310
+ target.contentEditable = 'false'
311
+ target.style.outline = ''
312
+ target.style.outlineOffset = ''
313
+ const c = changesRef.current.get(target)
314
+ if (c && target.textContent !== c.originalTextContent) {
315
+ c.textContent = target.textContent ?? ''
316
+ }
317
+ textEditRef.current = null
318
+ setChangesCount(countChanges())
319
+ }, [])
320
+
321
+ // ── Attach Moveable (drag + scale, all via transform) ──
322
+
323
+ const attachMoveable = useCallback(async (target: HTMLElement) => {
324
+ moveableRef.current?.destroy()
325
+ moveableRef.current = null
326
+ const container = playerContainerRef.current
327
+
328
+ // Increment generation before the async import
329
+ const gen = ++moveableGenRef.current
330
+
331
+ const mod = await import('moveable')
332
+
333
+ // Bail if editing was disabled or a newer selection happened during import
334
+ if (moveableGenRef.current !== gen) return
335
+
336
+ const Cls = mod.default as unknown as MoveableConstructor
337
+ const m = new Cls(container ?? document.body, {
338
+ target,
339
+ draggable: true,
340
+ scalable: true,
341
+ keepRatio: true,
342
+ origin: false,
343
+ throttleDrag: 1,
344
+ throttleScale: 0.01,
345
+ renderDirections: ['nw', 'ne', 'sw', 'se'],
346
+ edge: false,
347
+ zoom: 1,
348
+ })
349
+
350
+ if (getComputedStyle(target).position === 'static') {
351
+ target.style.position = 'relative'
352
+ }
353
+
354
+ if (!changesRef.current.has(target)) {
355
+ const player = getPlayer()
356
+ changesRef.current.set(target, {
357
+ mdxLine: findMdxLine(target, player ?? document.body),
358
+ kind: classifyElement(target),
359
+ status: 'unchanged',
360
+ translateX: 0, translateY: 0,
361
+ scaleX: 1, scaleY: 1,
362
+ textContent: null, originalTextContent: null,
363
+ textPreview: preview(target),
364
+ tagName: target.tagName.toLowerCase(),
365
+ src: extractMediaSrc(target),
366
+ })
367
+ }
368
+
369
+ m.on('dragStart', ({ target: el }) => {
370
+ const htmlEl = el as HTMLElement
371
+ const existing = changesRef.current.get(htmlEl)
372
+ undoStack.current.push({
373
+ el: htmlEl,
374
+ transform: htmlEl.style.transform,
375
+ position: htmlEl.style.position,
376
+ display: htmlEl.style.display,
377
+ prevChange: existing ? cloneChange(existing) : null,
378
+ })
379
+ })
380
+
381
+ m.on('drag', ({ target: el, transform }) => {
382
+ const htmlEl = el as HTMLElement
383
+ htmlEl.style.transform = transform
384
+ const c = changesRef.current.get(htmlEl)
385
+ if (c) {
386
+ const m = transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/)
387
+ if (m) { c.translateX = parseFloat(m[1]!); c.translateY = parseFloat(m[2]!) }
388
+ setChangesCount(countChanges())
389
+ }
390
+ })
391
+
392
+ m.on('scaleStart', ({ target: el }) => {
393
+ const htmlEl = el as HTMLElement
394
+ const existing = changesRef.current.get(htmlEl)
395
+ undoStack.current.push({
396
+ el: htmlEl,
397
+ transform: htmlEl.style.transform,
398
+ position: htmlEl.style.position,
399
+ display: htmlEl.style.display,
400
+ prevChange: existing ? cloneChange(existing) : null,
401
+ })
402
+ })
403
+
404
+ m.on('scale', ({ target: el, scale, drag }) => {
405
+ const htmlEl = el as HTMLElement
406
+ htmlEl.style.transform = drag.transform
407
+ const c = changesRef.current.get(htmlEl)
408
+ if (c) {
409
+ c.scaleX = scale[0]!
410
+ c.scaleY = scale[1]!
411
+ const mt = drag.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/)
412
+ if (mt) { c.translateX = parseFloat(mt[1]!); c.translateY = parseFloat(mt[2]!) }
413
+ setChangesCount(countChanges())
414
+ }
415
+ })
416
+
417
+ moveableRef.current = m
418
+ setSelectedEl(target)
419
+ }, [playerContainerRef, getPlayer])
420
+
421
+ // ── Double-click to edit text ──
422
+
423
+ const handleDoubleClick = useCallback((e: MouseEvent) => {
424
+ if (!editing) return
425
+ const player = getPlayer()
426
+ if (!player?.contains(e.target as Node)) return
427
+
428
+ // For text editing, try all elements at this point (skipping transparent
429
+ // overlays like Fill), looking for the innermost text-bearing element.
430
+ let target: HTMLElement | null = null
431
+ const elements = document.elementsFromPoint(e.clientX, e.clientY)
432
+ for (const candidate of elements) {
433
+ if (!player.contains(candidate)) continue
434
+ let cur: HTMLElement | null = candidate as HTMLElement
435
+ while (cur && player.contains(cur)) {
436
+ if (isTextElement(cur)) { target = cur; break }
437
+ cur = cur.parentElement
438
+ }
439
+ if (target) break
440
+ }
441
+ // Fall back to selectable parent if no leaf text found
442
+ if (!target) target = findTargetAtPoint(e.clientX, e.clientY)
443
+ if (!target || !isTextElement(target)) return
444
+
445
+ e.preventDefault()
446
+ e.stopPropagation()
447
+
448
+ // Invalidate any in-flight attachMoveable async imports so they bail
449
+ // after resolving. A double-click fires click→click→dblclick; the second
450
+ // click starts attachMoveable whose import may resolve after we enter
451
+ // text edit mode, causing a __CROACT__ null error in Moveable's renderer.
452
+ moveableGenRef.current++
453
+ moveableRef.current?.destroy()
454
+ moveableRef.current = null
455
+ textEditRef.current = target
456
+
457
+ const change = changesRef.current.get(target)
458
+ if (change && change.originalTextContent === null) {
459
+ change.originalTextContent = target.textContent ?? ''
460
+ }
461
+
462
+ target.contentEditable = 'true'
463
+ target.focus()
464
+ target.style.outline = '2px solid #7dd3fc'
465
+ target.style.outlineOffset = '2px'
466
+
467
+ const range = document.createRange()
468
+ range.selectNodeContents(target)
469
+ window.getSelection()?.removeAllRanges()
470
+ window.getSelection()?.addRange(range)
471
+
472
+ // Named handler so we can remove it in cleanup
473
+ const onEscapeKey = (ke: KeyboardEvent) => { if (ke.key === 'Escape') target.blur() }
474
+
475
+ const finish = () => {
476
+ target.removeEventListener('keydown', onEscapeKey)
477
+ finishTextEdit()
478
+ }
479
+ target.addEventListener('blur', finish, { once: true })
480
+ target.addEventListener('keydown', onEscapeKey)
481
+ }, [editing, getPlayer, findTargetAtPoint, finishTextEdit])
482
+
483
+ // ── Click to select ──
484
+
485
+ const handleClick = useCallback((e: MouseEvent) => {
486
+ if (!editing || textEditRef.current) return
487
+ if ((e.target as HTMLElement).closest('[data-layout-editor-toolbar]')) return
488
+ if ((e.target as HTMLElement).className?.includes?.('moveable-')) return
489
+
490
+ // Click outside player container → deselect
491
+ if (!playerContainerRef.current?.contains(e.target as Node)) {
492
+ if (selectedEl) {
493
+ moveableRef.current?.destroy()
494
+ moveableRef.current = null
495
+ setSelectedEl(null)
496
+ }
497
+ return
498
+ }
499
+
500
+ const target = findTargetAtPoint(e.clientX, e.clientY)
501
+ if (target) {
502
+ e.preventDefault()
503
+ e.stopPropagation()
504
+ void attachMoveable(target)
505
+ } else if (selectedEl) {
506
+ // Clicked inside player but not on a selectable element → deselect
507
+ moveableRef.current?.destroy()
508
+ moveableRef.current = null
509
+ setSelectedEl(null)
510
+ }
511
+ }, [editing, selectedEl, findTargetAtPoint, attachMoveable, playerContainerRef])
512
+
513
+ // ── Keyboard: Esc (select parent / deselect), Ctrl+Z (undo) ──
514
+
515
+ useEffect(() => {
516
+ if (!editing) return
517
+ const player = getPlayer()
518
+
519
+ const onKey = (e: KeyboardEvent) => {
520
+ if (textEditRef.current) return
521
+
522
+ if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
523
+ e.preventDefault()
524
+ const entry = undoStack.current.pop()
525
+ if (entry) {
526
+ entry.el.style.transform = entry.transform
527
+ entry.el.style.position = entry.position
528
+ entry.el.style.display = entry.display
529
+ // Restore previous change snapshot instead of deleting entirely
530
+ if (entry.prevChange) {
531
+ changesRef.current.set(entry.el, entry.prevChange)
532
+ } else {
533
+ changesRef.current.delete(entry.el)
534
+ }
535
+ setChangesCount(countChanges())
536
+ if (selectedEl === entry.el) void attachMoveable(entry.el)
537
+ }
538
+ return
539
+ }
540
+
541
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEl) {
542
+ e.preventDefault()
543
+ // Invalidate any in-flight attachMoveable async imports
544
+ moveableGenRef.current++
545
+ const player = getPlayer()
546
+ // Ensure a change entry exists for this element
547
+ if (!changesRef.current.has(selectedEl)) {
548
+ changesRef.current.set(selectedEl, {
549
+ mdxLine: findMdxLine(selectedEl, player ?? document.body),
550
+ kind: classifyElement(selectedEl),
551
+ status: 'unchanged',
552
+ translateX: 0, translateY: 0,
553
+ scaleX: 1, scaleY: 1,
554
+ textContent: null, originalTextContent: null,
555
+ textPreview: preview(selectedEl),
556
+ tagName: selectedEl.tagName.toLowerCase(),
557
+ src: extractMediaSrc(selectedEl),
558
+ })
559
+ }
560
+ const existing = changesRef.current.get(selectedEl)!
561
+ undoStack.current.push({
562
+ el: selectedEl,
563
+ transform: selectedEl.style.transform,
564
+ position: selectedEl.style.position,
565
+ display: selectedEl.style.display,
566
+ prevChange: cloneChange(existing),
567
+ })
568
+ existing.status = 'deleted'
569
+ selectedEl.style.display = 'none'
570
+ moveableRef.current?.destroy()
571
+ moveableRef.current = null
572
+ setSelectedEl(null)
573
+ setChangesCount(countChanges())
574
+ return
575
+ }
576
+
577
+ if (e.key === 'Escape') {
578
+ if (!selectedEl || !player) {
579
+ moveableRef.current?.destroy()
580
+ moveableRef.current = null
581
+ setSelectedEl(null)
582
+ return
583
+ }
584
+ let parent: HTMLElement | null = selectedEl.parentElement
585
+ while (parent && player.contains(parent)) {
586
+ if (isSelectable(parent, player)) { void attachMoveable(parent); return }
587
+ parent = parent.parentElement
588
+ }
589
+ moveableRef.current?.destroy()
590
+ moveableRef.current = null
591
+ setSelectedEl(null)
592
+ }
593
+ }
594
+
595
+ document.addEventListener('keydown', onKey)
596
+ return () => document.removeEventListener('keydown', onKey)
597
+ }, [editing, selectedEl, getPlayer, isSelectable, attachMoveable])
598
+
599
+ // ── Deselect on player seek ──
600
+
601
+ useEffect(() => {
602
+ if (!editing) return
603
+ const player = playerRef.current
604
+ if (!player) return
605
+ const onSeek = () => {
606
+ moveableRef.current?.destroy()
607
+ moveableRef.current = null
608
+ setSelectedEl(null)
609
+ }
610
+ player.addEventListener('seeked', onSeek)
611
+ return () => player.removeEventListener('seeked', onSeek)
612
+ }, [editing, playerRef])
613
+
614
+ // ── Listeners lifecycle ──
615
+
616
+ useEffect(() => {
617
+ if (!editing) return
618
+ document.addEventListener('click', handleClick, true)
619
+ document.addEventListener('dblclick', handleDoubleClick, true)
620
+ return () => {
621
+ document.removeEventListener('click', handleClick, true)
622
+ document.removeEventListener('dblclick', handleDoubleClick, true)
623
+ }
624
+ }, [editing, handleClick, handleDoubleClick])
625
+
626
+ // ── Cleanup when editing is toggled off ──
627
+
628
+ useEffect(() => {
629
+ if (!editing) {
630
+ // Increment generation to invalidate any in-flight attachMoveable
631
+ moveableGenRef.current++
632
+ moveableRef.current?.destroy()
633
+ moveableRef.current = null
634
+ setSelectedEl(null)
635
+ // Clean up any active text edit
636
+ finishTextEdit()
637
+ }
638
+ }, [editing, finishTextEdit])
639
+
640
+ // ── Destroy moveable on unmount ──
641
+
642
+ useEffect(() => {
643
+ return () => {
644
+ moveableRef.current?.destroy()
645
+ moveableRef.current = null
646
+ }
647
+ }, [])
648
+
649
+ // ── Clear copied timeout on unmount ──
650
+
651
+ useEffect(() => {
652
+ return () => {
653
+ if (copiedTimeoutRef.current !== null) {
654
+ clearTimeout(copiedTimeoutRef.current)
655
+ }
656
+ }
657
+ }, [])
658
+
659
+ // ── Actions ──
660
+
661
+ const handleCopy = useCallback(async () => {
662
+ const currentFrame = playerRef.current?.getCurrentFrame() ?? null
663
+ const text = serializeChanges(changesRef.current, currentFrame, fps, sections, entryPath)
664
+ if (!text) return
665
+ await navigator.clipboard.writeText(text)
666
+ setCopied(true)
667
+ if (copiedTimeoutRef.current !== null) {
668
+ clearTimeout(copiedTimeoutRef.current)
669
+ }
670
+ copiedTimeoutRef.current = setTimeout(() => {
671
+ setCopied(false)
672
+ copiedTimeoutRef.current = null
673
+ }, 2000)
674
+ }, [playerRef, fps, sections])
675
+
676
+ const handleReset = useCallback(() => {
677
+ moveableRef.current?.destroy()
678
+ moveableRef.current = null
679
+ changesRef.current.clear()
680
+ undoStack.current = []
681
+ setChangesCount(0)
682
+ setSelectedEl(null)
683
+ onReset()
684
+ }, [onReset])
685
+
686
+ return (
687
+ <div data-layout-editor-toolbar className='flex items-center gap-1.5 text-[13px]'>
688
+ <ToolbarButton onClick={() => onEditingChange(!editing)} active={editing}
689
+ title={editing ? 'Exit editing mode' : 'Edit layout — click to select, drag to move, corners to scale, double-click text to edit'}>
690
+ {editing ? '✦ Editing' : '✦ Edit Layout'}
691
+ </ToolbarButton>
692
+
693
+ {editing && changesCount > 0 && (
694
+ <>
695
+ <ToolbarButton onClick={handleCopy} title='Copy changes for an AI agent'>
696
+ {copied ? 'Copied!' : `Copy ${changesCount} change${changesCount > 1 ? 's' : ''}`}
697
+ </ToolbarButton>
698
+ <ToolbarButton onClick={handleReset} title='Revert all changes'>
699
+ Reset
700
+ </ToolbarButton>
701
+ </>
702
+ )}
703
+
704
+ {editing && !selectedEl && (
705
+ <span className='text-zinc-600 italic'>Click an element</span>
706
+ )}
707
+ </div>
708
+ )
709
+ }