cursedbelt-core 1.0.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 (307) hide show
  1. package/LICENSE +15 -0
  2. package/dist/client/wsWire.d.ts +49 -0
  3. package/dist/client/wsWire.js +57 -0
  4. package/dist/config/appConfig.d.ts +361 -0
  5. package/dist/config/appConfig.js +308 -0
  6. package/dist/core/activity/index.d.ts +8 -0
  7. package/dist/core/activity/index.js +7 -0
  8. package/dist/core/activity/model.d.ts +115 -0
  9. package/dist/core/activity/model.js +63 -0
  10. package/dist/core/analytics/index.d.ts +18 -0
  11. package/dist/core/analytics/index.js +18 -0
  12. package/dist/core/analytics/range.d.ts +186 -0
  13. package/dist/core/analytics/range.js +271 -0
  14. package/dist/core/analytics/schedule.d.ts +301 -0
  15. package/dist/core/analytics/schedule.js +372 -0
  16. package/dist/core/category/index.d.ts +3 -0
  17. package/dist/core/category/index.js +6 -0
  18. package/dist/core/category/memoryStore.d.ts +21 -0
  19. package/dist/core/category/memoryStore.js +97 -0
  20. package/dist/core/category/tree.d.ts +26 -0
  21. package/dist/core/category/tree.js +113 -0
  22. package/dist/core/category/types.d.ts +48 -0
  23. package/dist/core/category/types.js +8 -0
  24. package/dist/core/color.d.ts +14 -0
  25. package/dist/core/color.js +32 -0
  26. package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
  27. package/dist/core/colorScheme/colorSchemeStore.js +123 -0
  28. package/dist/core/colorScheme/index.d.ts +7 -0
  29. package/dist/core/colorScheme/index.js +7 -0
  30. package/dist/core/domainOwners.d.ts +19 -0
  31. package/dist/core/domainOwners.js +15 -0
  32. package/dist/core/download.d.ts +14 -0
  33. package/dist/core/download.js +37 -0
  34. package/dist/core/events/ccEventBus.d.ts +44 -0
  35. package/dist/core/events/ccEventBus.js +70 -0
  36. package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
  37. package/dist/core/file-tree/fileTreeModel.js +312 -0
  38. package/dist/core/folder-tree/actions.d.ts +84 -0
  39. package/dist/core/folder-tree/actions.js +119 -0
  40. package/dist/core/folder-tree/index.d.ts +3 -0
  41. package/dist/core/folder-tree/index.js +20 -0
  42. package/dist/core/folder-tree/path.d.ts +76 -0
  43. package/dist/core/folder-tree/path.js +146 -0
  44. package/dist/core/folder-tree/tree.d.ts +86 -0
  45. package/dist/core/folder-tree/tree.js +184 -0
  46. package/dist/core/layout/index.d.ts +1 -0
  47. package/dist/core/layout/index.js +7 -0
  48. package/dist/core/master-lock/index.d.ts +14 -0
  49. package/dist/core/master-lock/index.js +14 -0
  50. package/dist/core/master-lock/kdf.d.ts +63 -0
  51. package/dist/core/master-lock/kdf.js +117 -0
  52. package/dist/core/master-lock/policy.d.ts +32 -0
  53. package/dist/core/master-lock/policy.js +52 -0
  54. package/dist/core/master-lock/presence.d.ts +52 -0
  55. package/dist/core/master-lock/presence.js +61 -0
  56. package/dist/core/master-lock/wire.d.ts +139 -0
  57. package/dist/core/master-lock/wire.js +81 -0
  58. package/dist/core/media/bgRemoval.d.ts +27 -0
  59. package/dist/core/media/bgRemoval.js +101 -0
  60. package/dist/core/media/ffmpeg.d.ts +57 -0
  61. package/dist/core/media/ffmpeg.js +126 -0
  62. package/dist/core/media/format.d.ts +20 -0
  63. package/dist/core/media/format.js +54 -0
  64. package/dist/core/media/index.d.ts +12 -0
  65. package/dist/core/media/index.js +18 -0
  66. package/dist/core/media/mediaItem.d.ts +15 -0
  67. package/dist/core/media/mediaItem.js +34 -0
  68. package/dist/core/media/renderPlan.d.ts +63 -0
  69. package/dist/core/media/renderPlan.js +232 -0
  70. package/dist/core/media/segment.d.ts +79 -0
  71. package/dist/core/media/segment.js +247 -0
  72. package/dist/core/media/size.d.ts +1 -0
  73. package/dist/core/media/size.js +3 -0
  74. package/dist/core/media/trim.d.ts +21 -0
  75. package/dist/core/media/trim.js +40 -0
  76. package/dist/core/media/types.d.ts +148 -0
  77. package/dist/core/media/types.js +11 -0
  78. package/dist/core/media/uploadLimits.d.ts +7 -0
  79. package/dist/core/media/uploadLimits.js +16 -0
  80. package/dist/core/media/validate.d.ts +9 -0
  81. package/dist/core/media/validate.js +121 -0
  82. package/dist/core/media/videoCuts.d.ts +152 -0
  83. package/dist/core/media/videoCuts.js +284 -0
  84. package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
  85. package/dist/core/navigation/breadcrumbCompat.js +21 -0
  86. package/dist/core/navigation/coverageTest.d.ts +24 -0
  87. package/dist/core/navigation/coverageTest.js +48 -0
  88. package/dist/core/navigation/index.d.ts +6 -0
  89. package/dist/core/navigation/index.js +10 -0
  90. package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
  91. package/dist/core/navigation/migrateNavPreferences.js +63 -0
  92. package/dist/core/navigation/navigationModel.d.ts +165 -0
  93. package/dist/core/navigation/navigationModel.js +14 -0
  94. package/dist/core/navigation/navigationSelectors.d.ts +63 -0
  95. package/dist/core/navigation/navigationSelectors.js +167 -0
  96. package/dist/core/navigation/searchCatalogue.d.ts +23 -0
  97. package/dist/core/navigation/searchCatalogue.js +76 -0
  98. package/dist/core/net/reconnectSchedule.d.ts +46 -0
  99. package/dist/core/net/reconnectSchedule.js +38 -0
  100. package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
  101. package/dist/core/palette/ShortcutRegistry.js +52 -0
  102. package/dist/core/palette/fuzzyRank.d.ts +18 -0
  103. package/dist/core/palette/fuzzyRank.js +73 -0
  104. package/dist/core/palette/hotkeys.d.ts +28 -0
  105. package/dist/core/palette/hotkeys.js +118 -0
  106. package/dist/core/palette/index.d.ts +3 -0
  107. package/dist/core/palette/index.js +8 -0
  108. package/dist/core/schema/projection.d.ts +84 -0
  109. package/dist/core/schema/projection.js +184 -0
  110. package/dist/core/session/claims.d.ts +145 -0
  111. package/dist/core/session/claims.js +157 -0
  112. package/dist/core/sharing/index.d.ts +5 -0
  113. package/dist/core/sharing/index.js +4 -0
  114. package/dist/core/sharing/model.d.ts +195 -0
  115. package/dist/core/sharing/model.js +150 -0
  116. package/dist/core/site/siteCopy.d.ts +92 -0
  117. package/dist/core/site/siteCopy.js +91 -0
  118. package/dist/core/slots.d.ts +22 -0
  119. package/dist/core/slots.js +31 -0
  120. package/dist/core/styles/constructableSheet.d.ts +51 -0
  121. package/dist/core/styles/constructableSheet.js +80 -0
  122. package/dist/core/tokens/registry.d.ts +119 -0
  123. package/dist/core/tokens/registry.js +447 -0
  124. package/dist/core/uiScale/index.d.ts +8 -0
  125. package/dist/core/uiScale/index.js +8 -0
  126. package/dist/core/uiScale/scale.d.ts +36 -0
  127. package/dist/core/uiScale/scale.js +47 -0
  128. package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
  129. package/dist/core/uiScale/uiScaleStore.js +63 -0
  130. package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
  131. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
  132. package/dist/core/wire/index.d.ts +17 -0
  133. package/dist/core/wire/index.js +17 -0
  134. package/dist/core/wire/ingress.d.ts +44 -0
  135. package/dist/core/wire/ingress.js +134 -0
  136. package/dist/core/wire/marshalWorker.d.ts +70 -0
  137. package/dist/core/wire/marshalWorker.js +273 -0
  138. package/dist/core/wire/marshalling.d.ts +122 -0
  139. package/dist/core/wire/marshalling.js +126 -0
  140. package/dist/core/wire/wireHub.d.ts +80 -0
  141. package/dist/core/wire/wireHub.js +116 -0
  142. package/dist/ctgr/base64url.d.ts +8 -0
  143. package/dist/ctgr/base64url.js +39 -0
  144. package/dist/ctgr/chunkAccumulator.d.ts +214 -0
  145. package/dist/ctgr/chunkAccumulator.js +307 -0
  146. package/dist/ctgr/chunkStore.d.ts +24 -0
  147. package/dist/ctgr/chunkStore.js +125 -0
  148. package/dist/ctgr/codec.d.ts +44 -0
  149. package/dist/ctgr/codec.js +153 -0
  150. package/dist/ctgr/compress.d.ts +2 -0
  151. package/dist/ctgr/compress.js +20 -0
  152. package/dist/ctgr/crc32.d.ts +2 -0
  153. package/dist/ctgr/crc32.js +23 -0
  154. package/dist/ctgr/digest.d.ts +1 -0
  155. package/dist/ctgr/digest.js +15 -0
  156. package/dist/ctgr/index.d.ts +9 -0
  157. package/dist/ctgr/index.js +17 -0
  158. package/dist/ctgr/types.d.ts +64 -0
  159. package/dist/ctgr/types.js +21 -0
  160. package/dist/ctgr/v0compat.d.ts +27 -0
  161. package/dist/ctgr/v0compat.js +128 -0
  162. package/dist/index.d.ts +15 -0
  163. package/dist/index.js +17 -0
  164. package/dist/shared/errors.d.ts +101 -0
  165. package/dist/shared/errors.js +99 -0
  166. package/dist/testing/bytes.d.ts +32 -0
  167. package/dist/testing/bytes.js +33 -0
  168. package/dist/testing/domInspect.d.ts +59 -0
  169. package/dist/testing/domInspect.js +115 -0
  170. package/dist/testing/fakeFetch.d.ts +23 -0
  171. package/dist/testing/fakeFetch.js +21 -0
  172. package/dist/testing/index.d.ts +3 -0
  173. package/dist/testing/index.js +11 -0
  174. package/package.json +373 -0
  175. package/src/client/wsWire.ts +83 -0
  176. package/src/config/appConfig.spec.ts +158 -0
  177. package/src/config/appConfig.ts +372 -0
  178. package/src/core/activity/index.ts +21 -0
  179. package/src/core/activity/model.ts +160 -0
  180. package/src/core/analytics/index.ts +18 -0
  181. package/src/core/analytics/range.spec.ts +280 -0
  182. package/src/core/analytics/range.ts +383 -0
  183. package/src/core/analytics/schedule.spec.ts +311 -0
  184. package/src/core/analytics/schedule.ts +624 -0
  185. package/src/core/category/category.spec.ts +144 -0
  186. package/src/core/category/index.ts +19 -0
  187. package/src/core/category/memoryStore.ts +104 -0
  188. package/src/core/category/tree.ts +118 -0
  189. package/src/core/category/types.ts +59 -0
  190. package/src/core/color.spec.ts +35 -0
  191. package/src/core/color.ts +35 -0
  192. package/src/core/colorScheme/colorScheme.spec.ts +127 -0
  193. package/src/core/colorScheme/colorSchemeStore.ts +184 -0
  194. package/src/core/colorScheme/index.ts +23 -0
  195. package/src/core/domainOwners.ts +32 -0
  196. package/src/core/download.spec.ts +38 -0
  197. package/src/core/download.ts +35 -0
  198. package/src/core/events/ccEventBus.spec.ts +90 -0
  199. package/src/core/events/ccEventBus.ts +120 -0
  200. package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
  201. package/src/core/file-tree/fileTreeModel.ts +511 -0
  202. package/src/core/folder-tree/actions.spec.ts +103 -0
  203. package/src/core/folder-tree/actions.ts +153 -0
  204. package/src/core/folder-tree/index.ts +46 -0
  205. package/src/core/folder-tree/path.spec.ts +113 -0
  206. package/src/core/folder-tree/path.ts +148 -0
  207. package/src/core/folder-tree/tree.spec.ts +157 -0
  208. package/src/core/folder-tree/tree.ts +244 -0
  209. package/src/core/layout/index.ts +7 -0
  210. package/src/core/master-lock/index.ts +50 -0
  211. package/src/core/master-lock/kdf.spec.ts +95 -0
  212. package/src/core/master-lock/kdf.ts +145 -0
  213. package/src/core/master-lock/policy.ts +55 -0
  214. package/src/core/master-lock/presence.spec.ts +73 -0
  215. package/src/core/master-lock/presence.ts +71 -0
  216. package/src/core/master-lock/wire.ts +155 -0
  217. package/src/core/media/bgRemoval.spec.ts +120 -0
  218. package/src/core/media/bgRemoval.ts +123 -0
  219. package/src/core/media/ffmpeg.spec.ts +135 -0
  220. package/src/core/media/ffmpeg.ts +162 -0
  221. package/src/core/media/format.spec.ts +41 -0
  222. package/src/core/media/format.ts +58 -0
  223. package/src/core/media/index.ts +105 -0
  224. package/src/core/media/mediaItem.spec.ts +46 -0
  225. package/src/core/media/mediaItem.ts +40 -0
  226. package/src/core/media/renderPlan.spec.ts +140 -0
  227. package/src/core/media/renderPlan.ts +318 -0
  228. package/src/core/media/segment.spec.ts +159 -0
  229. package/src/core/media/segment.ts +352 -0
  230. package/src/core/media/size.spec.ts +19 -0
  231. package/src/core/media/size.ts +3 -0
  232. package/src/core/media/trim.spec.ts +75 -0
  233. package/src/core/media/trim.ts +60 -0
  234. package/src/core/media/types.ts +147 -0
  235. package/src/core/media/uploadLimits.ts +18 -0
  236. package/src/core/media/validate.spec.ts +106 -0
  237. package/src/core/media/validate.ts +131 -0
  238. package/src/core/media/videoCuts.spec.ts +327 -0
  239. package/src/core/media/videoCuts.ts +355 -0
  240. package/src/core/navigation/breadcrumbCompat.ts +44 -0
  241. package/src/core/navigation/coverageTest.ts +54 -0
  242. package/src/core/navigation/index.ts +50 -0
  243. package/src/core/navigation/migrateNavPreferences.ts +119 -0
  244. package/src/core/navigation/navigation.spec.ts +388 -0
  245. package/src/core/navigation/navigationModel.ts +185 -0
  246. package/src/core/navigation/navigationSelectors.ts +206 -0
  247. package/src/core/navigation/searchCatalogue.ts +89 -0
  248. package/src/core/net/reconnectSchedule.spec.ts +49 -0
  249. package/src/core/net/reconnectSchedule.ts +78 -0
  250. package/src/core/palette/ShortcutRegistry.ts +69 -0
  251. package/src/core/palette/fuzzyRank.ts +78 -0
  252. package/src/core/palette/hotkeys.ts +132 -0
  253. package/src/core/palette/index.ts +22 -0
  254. package/src/core/palette/palette.spec.ts +160 -0
  255. package/src/core/schema/projection.spec.ts +122 -0
  256. package/src/core/schema/projection.ts +225 -0
  257. package/src/core/session/claims.ts +181 -0
  258. package/src/core/sharing/index.ts +33 -0
  259. package/src/core/sharing/model.spec.ts +104 -0
  260. package/src/core/sharing/model.ts +296 -0
  261. package/src/core/site/siteCopy.ts +115 -0
  262. package/src/core/slots.ts +38 -0
  263. package/src/core/styles/constructableSheet.ts +86 -0
  264. package/src/core/tokens/registry.spec.ts +160 -0
  265. package/src/core/tokens/registry.ts +536 -0
  266. package/src/core/uiScale/index.ts +32 -0
  267. package/src/core/uiScale/scale.ts +63 -0
  268. package/src/core/uiScale/uiScale.spec.ts +77 -0
  269. package/src/core/uiScale/uiScaleStore.ts +100 -0
  270. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
  271. package/src/core/wire/index.ts +56 -0
  272. package/src/core/wire/ingress.spec.ts +276 -0
  273. package/src/core/wire/ingress.ts +182 -0
  274. package/src/core/wire/marshalWorker.spec.ts +196 -0
  275. package/src/core/wire/marshalWorker.ts +361 -0
  276. package/src/core/wire/marshalling.spec.ts +139 -0
  277. package/src/core/wire/marshalling.ts +238 -0
  278. package/src/core/wire/wireHub.spec.ts +137 -0
  279. package/src/core/wire/wireHub.ts +213 -0
  280. package/src/ctgr/base64url.ts +44 -0
  281. package/src/ctgr/chunkAccumulator.ts +403 -0
  282. package/src/ctgr/chunkStore.spec.ts +101 -0
  283. package/src/ctgr/chunkStore.ts +168 -0
  284. package/src/ctgr/codec.spec.ts +208 -0
  285. package/src/ctgr/codec.ts +202 -0
  286. package/src/ctgr/compress.ts +21 -0
  287. package/src/ctgr/crc32.ts +23 -0
  288. package/src/ctgr/digest.ts +14 -0
  289. package/src/ctgr/index.ts +39 -0
  290. package/src/ctgr/types.ts +80 -0
  291. package/src/ctgr/v0compat.spec.ts +73 -0
  292. package/src/ctgr/v0compat.ts +156 -0
  293. package/src/declaredDepsAreImported.spec.ts +87 -0
  294. package/src/index.ts +25 -0
  295. package/src/leafSubpathsImportNothing.spec.ts +287 -0
  296. package/src/lockfileIsTracked.spec.ts +50 -0
  297. package/src/namedSubpathsResolve.spec.ts +122 -0
  298. package/src/noPathDeps.spec.ts +24 -0
  299. package/src/publishShape.spec.ts +89 -0
  300. package/src/shared/errors.ts +153 -0
  301. package/src/skippedTestsAreAnswered.spec.ts +221 -0
  302. package/src/testTmpRoot.spec.ts +102 -0
  303. package/src/testing/bytes.ts +38 -0
  304. package/src/testing/domInspect.spec.ts +79 -0
  305. package/src/testing/domInspect.ts +127 -0
  306. package/src/testing/fakeFetch.ts +26 -0
  307. package/src/testing/index.ts +11 -0
@@ -0,0 +1,318 @@
1
+ // Timeline → ffmpeg render plan. Pure compiler from a cwip/timeline document to
2
+ // an ordered list of ffmpeg argv commands + auxiliary files — executor-agnostic:
3
+ // the browser runs the SAME plan on ffmpeg.wasm (`react/media/ffmpeg-wasm`) and a
4
+ // server can spawn native ffmpeg against it. Nothing here touches an FS.
5
+ //
6
+ // Pipeline shape (the two-pass normalize+concat that survives mixed sources):
7
+ // 1. per video clip — extract the source window, apply speed (setpts/atempo),
8
+ // volume, and NORMALIZE to one spec (scale+pad+fps, resampled audio, a
9
+ // silent bed when the source has no audio) → chunk_i
10
+ // 2. concat — all chunks share codec/fps/resolution, so `-c copy`
11
+ // 3. audio mix — audio-track clips are extracted the same way and
12
+ // `adelay`ed to their timeline offsets, then `amix`ed under the video lane
13
+ //
14
+ // v1 model notes: ONE video lane (the first video track) — clips are laid
15
+ // end-to-end in timeline order, so close gaps before exporting if you want cuts
16
+ // where the gaps were. Audio clip offsets are their absolute `timelineStart`.
17
+
18
+ import { clipDuration, roundSeconds, type TimelineClip, type TimelineTrack } from 'cwip/timeline';
19
+ import { videoCodecPlan } from './ffmpeg';
20
+ import type { VideoFormat, VideoQuality } from './types';
21
+
22
+ /** An auxiliary text file the executor writes before running the commands. */
23
+ export interface RenderPlanFile {
24
+ name: string;
25
+ text: string;
26
+ }
27
+
28
+ /** One ffmpeg invocation (argv without the binary). */
29
+ export interface FfmpegCommand {
30
+ args: string[];
31
+ /** The file this command produces. */
32
+ outputName: string;
33
+ /** Output seconds this command encodes — weight for progress reporting
34
+ * (0 for stream-copy steps, which are ~instant). */
35
+ encodeSeconds: number;
36
+ label: string;
37
+ }
38
+
39
+ export interface TimelineRenderPlan {
40
+ files: RenderPlanFile[];
41
+ commands: FfmpegCommand[];
42
+ outputName: string;
43
+ /** Intermediates to delete after the export (NOT the inputs or the output). */
44
+ intermediateNames: string[];
45
+ totalEncodeSeconds: number;
46
+ /** Asset ids the executor must materialize under their mapped file names. */
47
+ requiredAssetIds: string[];
48
+ }
49
+
50
+ export interface NormalizeSpec {
51
+ width: number;
52
+ height: number;
53
+ fps: number;
54
+ audioRate: number;
55
+ }
56
+
57
+ /** 720p30 keeps in-browser (wasm) export times tolerable; hosts can raise it. */
58
+ export const DEFAULT_NORMALIZE: NormalizeSpec = {
59
+ width: 1280,
60
+ height: 720,
61
+ fps: 30,
62
+ audioRate: 44100,
63
+ };
64
+
65
+ export interface TimelineRenderOptions {
66
+ /** assetId → file name in the executor's filesystem. */
67
+ assetFileNames: Record<string, string>;
68
+ /** assetId → whether the file HAS an audio stream (default true). Sources
69
+ * without one get a silent bed so every chunk concats consistently. */
70
+ assetHasAudio?: Record<string, boolean>;
71
+ outputName?: string;
72
+ format?: VideoFormat;
73
+ quality?: VideoQuality;
74
+ normalize?: NormalizeSpec;
75
+ /** Prefix for every intermediate file (default "csrp_"). */
76
+ intermediatePrefix?: string;
77
+ }
78
+
79
+ /**
80
+ * ffmpeg's `atempo` only accepts 0.5–2.0 — chain filters for anything beyond
81
+ * (4× → `atempo=2.0,atempo=2.0`).
82
+ */
83
+ export const buildAtempoChain = (speed: number): string => {
84
+ const filters: string[] = [];
85
+ let remaining = speed;
86
+ while (remaining > 2) {
87
+ filters.push('atempo=2.0');
88
+ remaining /= 2;
89
+ }
90
+ while (remaining < 0.5) {
91
+ filters.push('atempo=0.5');
92
+ remaining /= 0.5;
93
+ }
94
+ filters.push(`atempo=${remaining.toFixed(4)}`);
95
+ return filters.join(',');
96
+ };
97
+
98
+ /** Video/audio speed filter fragments for a clip (null at speed 1). */
99
+ export const speedFilters = (speed: number): { video: string; audio: string } | null =>
100
+ speed === 1
101
+ ? null
102
+ : { video: `setpts=${(1 / speed).toFixed(6)}*PTS`, audio: buildAtempoChain(speed) };
103
+
104
+ const scalePad = (n: NormalizeSpec): string =>
105
+ `scale=${n.width}:${n.height}:force_original_aspect_ratio=decrease,` +
106
+ `pad=${n.width}:${n.height}:(ow-iw)/2:(oh-ih)/2,fps=${n.fps}`;
107
+
108
+ /** The `-vf` chain for one clip: reset timestamps, speed, then normalize.
109
+ * `setpts=PTS-STARTPTS` rebases the extracted window to t=0 — without it a
110
+ * source whose first frame carries a non-zero PTS (common for screen/live
111
+ * recordings) makes the `fps` filter emit a BLACK frame to fill the gap before
112
+ * the real first frame, so the export opens on black instead of the picture. */
113
+ const videoFilter = (clip: TimelineClip, normalize: NormalizeSpec): string => {
114
+ const speed = speedFilters(clip.speed);
115
+ return ['setpts=PTS-STARTPTS', speed?.video, scalePad(normalize)].filter(Boolean).join(',');
116
+ };
117
+
118
+ /** The audio chain for one clip: reset timestamps, speed, volume, resample.
119
+ * `asetpts=PTS-STARTPTS` mirrors the video rebase so audio stays aligned. */
120
+ const audioFilter = (clip: TimelineClip, normalize: NormalizeSpec): string => {
121
+ const speed = speedFilters(clip.speed);
122
+ const volume = clip.volume !== 1 ? `volume=${clip.volume.toFixed(3)}` : null;
123
+ return ['asetpts=PTS-STARTPTS', speed?.audio, volume, `aresample=${normalize.audioRate}`]
124
+ .filter(Boolean)
125
+ .join(',');
126
+ };
127
+
128
+ const BASE_ARGS = ['-hide_banner', '-nostdin', '-y'];
129
+
130
+ interface ChunkContext {
131
+ options: Required<Pick<TimelineRenderOptions, 'assetFileNames'>> & TimelineRenderOptions;
132
+ normalize: NormalizeSpec;
133
+ prefix: string;
134
+ }
135
+
136
+ const sourceFileOf = (ctx: ChunkContext, clip: TimelineClip): string => {
137
+ const file = ctx.options.assetFileNames[clip.sourceAssetId];
138
+ if (!file) {
139
+ throw new Error(
140
+ `[renderPlan] no file name mapped for asset "${clip.sourceAssetId}" (clip "${clip.name}") — add it to assetFileNames`,
141
+ );
142
+ }
143
+ return file;
144
+ };
145
+
146
+ const hasAudio = (ctx: ChunkContext, clip: TimelineClip): boolean =>
147
+ ctx.options.assetHasAudio?.[clip.sourceAssetId] ?? true;
148
+
149
+ /** Extract + normalize one VIDEO clip into a self-contained chunk. */
150
+ const videoChunkCommand = (ctx: ChunkContext, clip: TimelineClip, index: number): FfmpegCommand => {
151
+ const plan = videoCodecPlan(ctx.options.format, ctx.options.quality);
152
+ const outputName = `${ctx.prefix}v${index}.${ctx.options.format === 'webm' ? 'webm' : 'mp4'}`;
153
+ const silent = !hasAudio(ctx, clip);
154
+ const args = [
155
+ ...BASE_ARGS,
156
+ '-ss',
157
+ String(clip.trackIn),
158
+ '-to',
159
+ String(clip.trackOut),
160
+ '-i',
161
+ sourceFileOf(ctx, clip),
162
+ // A silent stereo bed when the source has no audio track, so EVERY chunk
163
+ // carries an audio stream and the concat/copy step stays consistent.
164
+ ...(silent
165
+ ? [
166
+ '-f',
167
+ 'lavfi',
168
+ '-t',
169
+ String(clipDuration(clip)),
170
+ '-i',
171
+ `anullsrc=channel_layout=stereo:sample_rate=${ctx.normalize.audioRate}`,
172
+ ]
173
+ : []),
174
+ '-vf',
175
+ videoFilter(clip, ctx.normalize),
176
+ ...(silent ? ['-map', '0:v:0', '-map', '1:a:0'] : ['-af', audioFilter(clip, ctx.normalize)]),
177
+ '-c:v',
178
+ plan.videoCodec,
179
+ '-preset',
180
+ plan.preset,
181
+ '-crf',
182
+ String(plan.crf),
183
+ '-c:a',
184
+ plan.audioCodec,
185
+ ...plan.extraOutputArgs,
186
+ outputName,
187
+ ];
188
+ return {
189
+ args,
190
+ outputName,
191
+ encodeSeconds: clipDuration(clip),
192
+ label: `video clip ${index + 1} (${clip.name})`,
193
+ };
194
+ };
195
+
196
+ /** Extract one AUDIO-track clip as a lone audio chunk (m4a). */
197
+ const audioChunkCommand = (ctx: ChunkContext, clip: TimelineClip, index: number): FfmpegCommand => {
198
+ const outputName = `${ctx.prefix}a${index}.m4a`;
199
+ const args = [
200
+ ...BASE_ARGS,
201
+ '-ss',
202
+ String(clip.trackIn),
203
+ '-to',
204
+ String(clip.trackOut),
205
+ '-i',
206
+ sourceFileOf(ctx, clip),
207
+ '-vn',
208
+ '-af',
209
+ audioFilter(clip, ctx.normalize),
210
+ '-c:a',
211
+ 'aac',
212
+ outputName,
213
+ ];
214
+ return {
215
+ args,
216
+ outputName,
217
+ encodeSeconds: clipDuration(clip),
218
+ label: `audio clip ${index + 1} (${clip.name})`,
219
+ };
220
+ };
221
+
222
+ /**
223
+ * Compile the timeline into an ordered command list. Throws when the first
224
+ * video track has no clips (nothing to render) or an asset is unmapped.
225
+ */
226
+ export const buildTimelineRenderPlan = (
227
+ tracks: TimelineTrack[],
228
+ options: TimelineRenderOptions,
229
+ ): TimelineRenderPlan => {
230
+ const normalize = options.normalize ?? DEFAULT_NORMALIZE;
231
+ const prefix = options.intermediatePrefix ?? 'csrp_';
232
+ const outputName = options.outputName ?? 'output.mp4';
233
+ const ctx: ChunkContext = { options: options as ChunkContext['options'], normalize, prefix };
234
+
235
+ const videoTrack = tracks.find((t) => t.type === 'video');
236
+ const videoClips = [...(videoTrack?.clips ?? [])].sort(
237
+ (a, b) => a.timelineStart - b.timelineStart,
238
+ );
239
+ if (videoClips.length === 0) {
240
+ throw new Error('[renderPlan] the timeline has no video clips — nothing to render');
241
+ }
242
+ const audioClips = tracks
243
+ .filter((t) => t.type === 'audio')
244
+ .flatMap((t) => t.clips)
245
+ .sort((a, b) => a.timelineStart - b.timelineStart);
246
+
247
+ const commands: FfmpegCommand[] = [];
248
+ const files: RenderPlanFile[] = [];
249
+ const intermediateNames: string[] = [];
250
+
251
+ // 1. video chunks
252
+ const videoChunks = videoClips.map((clip, i) => videoChunkCommand(ctx, clip, i));
253
+ commands.push(...videoChunks);
254
+ intermediateNames.push(...videoChunks.map((c) => c.outputName));
255
+
256
+ // 2. concat (stream copy — chunks are normalized to one spec)
257
+ const listName = `${prefix}concat.txt`;
258
+ files.push({ name: listName, text: videoChunks.map((c) => `file '${c.outputName}'`).join('\n') });
259
+ intermediateNames.push(listName);
260
+ const needsMix = audioClips.length > 0;
261
+ const videoLaneName = needsMix ? `${prefix}lane.mp4` : outputName;
262
+ commands.push({
263
+ args: [...BASE_ARGS, '-f', 'concat', '-safe', '0', '-i', listName, '-c', 'copy', videoLaneName],
264
+ outputName: videoLaneName,
265
+ encodeSeconds: 0,
266
+ label: 'concat video lane',
267
+ });
268
+
269
+ // 3. audio mix under the lane
270
+ if (needsMix) {
271
+ intermediateNames.push(videoLaneName);
272
+ const audioChunks = audioClips.map((clip, i) => audioChunkCommand(ctx, clip, i));
273
+ commands.push(...audioChunks);
274
+ intermediateNames.push(...audioChunks.map((c) => c.outputName));
275
+
276
+ const inputs = [videoLaneName, ...audioChunks.map((c) => c.outputName)];
277
+ const delays = audioClips.map((clip, i) => {
278
+ const ms = Math.max(0, Math.round(clip.timelineStart * 1000));
279
+ return `[${i + 1}:a]adelay=${ms}|${ms}[d${i}]`;
280
+ });
281
+ const mixInputs = ['[0:a]', ...audioClips.map((_, i) => `[d${i}]`)].join('');
282
+ const filterComplex = [
283
+ ...delays,
284
+ `${mixInputs}amix=inputs=${audioClips.length + 1}:duration=first:normalize=0[aout]`,
285
+ ].join(';');
286
+ commands.push({
287
+ args: [
288
+ ...BASE_ARGS,
289
+ ...inputs.flatMap((name) => ['-i', name]),
290
+ '-filter_complex',
291
+ filterComplex,
292
+ '-map',
293
+ '0:v',
294
+ '-map',
295
+ '[aout]',
296
+ '-c:v',
297
+ 'copy',
298
+ '-c:a',
299
+ 'aac',
300
+ outputName,
301
+ ],
302
+ outputName,
303
+ // re-encodes the full audio lane under a copied video stream — cheap but
304
+ // not instant; weight it by the lane length.
305
+ encodeSeconds: roundSeconds(videoClips.reduce((sum, c) => sum + clipDuration(c), 0)),
306
+ label: 'mix audio tracks',
307
+ });
308
+ }
309
+
310
+ return {
311
+ files,
312
+ commands,
313
+ outputName,
314
+ intermediateNames,
315
+ totalEncodeSeconds: roundSeconds(commands.reduce((sum, c) => sum + c.encodeSeconds, 0)),
316
+ requiredAssetIds: [...new Set([...videoClips, ...audioClips].map((c) => c.sourceAssetId))],
317
+ };
318
+ };
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ closeMask,
4
+ createRegionSegmenter,
5
+ dilateMask,
6
+ downscaleRgba,
7
+ erodeMask,
8
+ fitWorkingSize,
9
+ type SegmentSource,
10
+ sensitivityToTolerance,
11
+ upscaleMask,
12
+ } from './segment';
13
+
14
+ /** Build an RGBA source from a per-pixel color function. */
15
+ const makeSource = (
16
+ width: number,
17
+ height: number,
18
+ colorAt: (x: number, y: number) => [number, number, number],
19
+ ): SegmentSource => {
20
+ const data = new Uint8ClampedArray(width * height * 4);
21
+ for (let y = 0; y < height; y++) {
22
+ for (let x = 0; x < width; x++) {
23
+ const [r, g, b] = colorAt(x, y);
24
+ const o = (y * width + x) << 2;
25
+ data[o] = r;
26
+ data[o + 1] = g;
27
+ data[o + 2] = b;
28
+ data[o + 3] = 255;
29
+ }
30
+ }
31
+ return { data, width, height };
32
+ };
33
+
34
+ const at = (m: Uint8ClampedArray, w: number, x: number, y: number) => m[y * w + x];
35
+
36
+ describe('createRegionSegmenter — point (magic wand)', () => {
37
+ // A red square (the object) on a blue field — a clean object/background split.
38
+ const source = makeSource(40, 40, (x, y) =>
39
+ x >= 10 && x < 30 && y >= 10 && y < 30 ? [220, 30, 30] : [30, 30, 220],
40
+ );
41
+ const seg = createRegionSegmenter();
42
+
43
+ test('selects the clicked object and nothing of the background', () => {
44
+ const mask = seg.segment(source, { type: 'point', x: 20, y: 20 }) as Uint8ClampedArray;
45
+ expect(mask.length).toBe(40 * 40);
46
+ // inside the red square → selected
47
+ expect(at(mask, 40, 20, 20)).toBe(255);
48
+ expect(at(mask, 40, 12, 12)).toBe(255);
49
+ expect(at(mask, 40, 28, 28)).toBe(255);
50
+ // out in the blue field → not selected
51
+ expect(at(mask, 40, 2, 2)).toBe(0);
52
+ expect(at(mask, 40, 38, 38)).toBe(0);
53
+ });
54
+
55
+ test('a tight tolerance still selects a flat-color object; output is pure binary', () => {
56
+ const mask = seg.segment(
57
+ source,
58
+ { type: 'point', x: 20, y: 20 },
59
+ { tolerance: 8 },
60
+ ) as Uint8ClampedArray;
61
+ expect(at(mask, 40, 20, 20)).toBe(255);
62
+ for (const v of mask) expect(v === 0 || v === 255).toBe(true);
63
+ });
64
+
65
+ test('clicking the background selects the background region, not the object', () => {
66
+ const mask = seg.segment(source, { type: 'point', x: 2, y: 2 }) as Uint8ClampedArray;
67
+ expect(at(mask, 40, 2, 2)).toBe(255);
68
+ expect(at(mask, 40, 20, 20)).toBe(0); // the red object is excluded
69
+ });
70
+
71
+ test('an out-of-range click is clamped into bounds (no throw, plausible mask)', () => {
72
+ const mask = seg.segment(source, { type: 'point', x: 9999, y: -50 }) as Uint8ClampedArray;
73
+ expect(mask.length).toBe(40 * 40);
74
+ expect(at(mask, 40, 39, 0)).toBe(255); // clamped to the corner (blue field)
75
+ });
76
+ });
77
+
78
+ describe('createRegionSegmenter — box', () => {
79
+ // A green disc-ish object centered in the box, on a gray field.
80
+ const source = makeSource(60, 60, (x, y) => {
81
+ const dx = x - 30;
82
+ const dy = y - 30;
83
+ return dx * dx + dy * dy < 14 * 14 ? [40, 200, 60] : [180, 180, 180];
84
+ });
85
+ const seg = createRegionSegmenter();
86
+
87
+ test('selects the object inside the box and clips to the box bounds', () => {
88
+ const mask = seg.segment(source, {
89
+ type: 'box',
90
+ x0: 16,
91
+ y0: 16,
92
+ x1: 44,
93
+ y1: 44,
94
+ }) as Uint8ClampedArray;
95
+ expect(at(mask, 60, 30, 30)).toBe(255); // object center
96
+ // nothing selected outside the box (clipped)
97
+ expect(at(mask, 60, 2, 2)).toBe(0);
98
+ expect(at(mask, 60, 58, 58)).toBe(0);
99
+ });
100
+
101
+ test('normalizes a reversed/“drag-up-left” box', () => {
102
+ const a = seg.segment(source, {
103
+ type: 'box',
104
+ x0: 44,
105
+ y0: 44,
106
+ x1: 16,
107
+ y1: 16,
108
+ }) as Uint8ClampedArray;
109
+ expect(at(a, 60, 30, 30)).toBe(255);
110
+ });
111
+ });
112
+
113
+ describe('morphology', () => {
114
+ test('dilate grows, erode shrinks, close fills a 1px hole', () => {
115
+ const w = 5;
116
+ const h = 5;
117
+ const m = new Uint8ClampedArray(w * h);
118
+ // a filled 3×3 block with a single-pixel hole at its center
119
+ for (let y = 1; y <= 3; y++) for (let x = 1; x <= 3; x++) m[y * w + x] = 255;
120
+ m[2 * w + 2] = 0; // poke the hole
121
+
122
+ expect(at(closeMask(m, w, h, 1), w, 2, 2)).toBe(255); // hole filled
123
+ expect(at(dilateMask(m, w, h), w, 0, 2)).toBe(255); // grew left edge
124
+ expect(at(erodeMask(m, w, h), w, 1, 1)).toBe(0); // corner eroded away
125
+ });
126
+ });
127
+
128
+ describe('resolution helpers', () => {
129
+ test('fitWorkingSize caps to maxPixels and preserves aspect, never upsizing', () => {
130
+ const small = fitWorkingSize(800, 600, 2_000_000);
131
+ expect(small).toEqual({ width: 800, height: 600, scale: 1 });
132
+
133
+ const big = fitWorkingSize(4000, 3000, 1_000_000);
134
+ expect(big.width * big.height).toBeLessThanOrEqual(1_000_000);
135
+ expect(big.scale).toBeLessThan(1);
136
+ expect(Math.abs(big.width / big.height - 4000 / 3000)).toBeLessThan(0.02);
137
+ });
138
+
139
+ test('downscale then upscale round-trips a region back to natural dims', () => {
140
+ const src = makeSource(8, 8, (x) => (x < 4 ? [255, 0, 0] : [0, 0, 255]));
141
+ const down = downscaleRgba(src.data, 8, 8, 4, 4);
142
+ expect(down.length).toBe(4 * 4 * 4);
143
+ const small = new Uint8ClampedArray(4 * 4).fill(0);
144
+ small[0] = 255; // mark one working-res pixel
145
+ const up = upscaleMask(small, 4, 4, 8, 8);
146
+ expect(up.length).toBe(8 * 8);
147
+ expect(at(up, 8, 0, 0)).toBe(255);
148
+ expect(at(up, 8, 1, 1)).toBe(255); // the 1px block maps to a 2×2 natural block
149
+ });
150
+ });
151
+
152
+ describe('sensitivityToTolerance', () => {
153
+ test('is monotonic and bounded across the slider range', () => {
154
+ expect(sensitivityToTolerance(0)).toBeLessThan(sensitivityToTolerance(50));
155
+ expect(sensitivityToTolerance(50)).toBeLessThan(sensitivityToTolerance(100));
156
+ expect(sensitivityToTolerance(-10)).toBe(sensitivityToTolerance(0)); // clamped
157
+ expect(sensitivityToTolerance(999)).toBe(sensitivityToTolerance(100));
158
+ });
159
+ });