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,162 @@
1
+ // Pure ffmpeg/ffprobe argument builders + output parsers. No Bun.spawn, no I/O — the ONE
2
+ // source of truth for ffmpeg argv construction, shared by the Bun server adapter
3
+ // (`server/media-bun/video.ts` spawns these against native ffmpeg) and the browser editor
4
+ // (`react/media` executes the same argv against ffmpeg.wasm). Unit tested without ffmpeg.
5
+
6
+ import type { VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec } from './types';
7
+
8
+ /** Codec + rate-control plan for an output container at a quality preset. */
9
+ export interface VideoCodecPlan {
10
+ videoCodec: string;
11
+ audioCodec: string;
12
+ /** Constant Rate Factor — lower is higher quality (x264 ~18 visually lossless). */
13
+ crf: number;
14
+ preset: string;
15
+ /** Container-specific extra output flags (e.g. faststart for mp4). */
16
+ extraOutputArgs: string[];
17
+ }
18
+
19
+ const X264_CRF: Record<VideoQuality, number> = { high: 18, medium: 23, low: 28 };
20
+ const VP9_CRF: Record<VideoQuality, number> = { high: 24, medium: 31, low: 37 };
21
+
22
+ /**
23
+ * Resolve the codec plan for a trim. mp4 → H.264/AAC (universal playback) with
24
+ * `+faststart` so the moov atom is up front (web streaming); webm → VP9/Opus.
25
+ */
26
+ export function videoCodecPlan(
27
+ format: VideoFormat = 'mp4',
28
+ quality: VideoQuality = 'medium',
29
+ ): VideoCodecPlan {
30
+ if (format === 'webm') {
31
+ return {
32
+ videoCodec: 'libvpx-vp9',
33
+ audioCodec: 'libopus',
34
+ crf: VP9_CRF[quality],
35
+ preset: 'good',
36
+ extraOutputArgs: ['-b:v', '0', '-row-mt', '1'],
37
+ };
38
+ }
39
+ return {
40
+ videoCodec: 'libx264',
41
+ audioCodec: 'aac',
42
+ crf: X264_CRF[quality],
43
+ preset: 'fast',
44
+ // yuv420p = broad decoder compatibility; faststart = progressive web playback.
45
+ extraOutputArgs: ['-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Build the ffmpeg argv (without the `ffmpeg` binary) for a re-encoding trim.
51
+ * Uses accurate input seeking (`-ss` before `-i`, accurate when transcoding) plus
52
+ * `-t duration` so the clip is exactly `end - start` long regardless of keyframes.
53
+ */
54
+ export function buildTrimArgs(spec: VideoTrimSpec): string[] {
55
+ const plan = videoCodecPlan(spec.format, spec.quality);
56
+ const duration = Math.max(0, spec.endSeconds - spec.startSeconds);
57
+ return [
58
+ '-hide_banner',
59
+ '-nostdin',
60
+ '-y',
61
+ '-ss',
62
+ String(spec.startSeconds),
63
+ '-i',
64
+ spec.inputPath,
65
+ '-t',
66
+ String(duration),
67
+ '-c:v',
68
+ plan.videoCodec,
69
+ '-preset',
70
+ plan.preset,
71
+ '-crf',
72
+ String(plan.crf),
73
+ '-c:a',
74
+ plan.audioCodec,
75
+ ...plan.extraOutputArgs,
76
+ spec.outputPath,
77
+ ];
78
+ }
79
+
80
+ /** ffprobe argv for `probeMedia` — quiet JSON with streams + format. */
81
+ export function buildProbeArgs(inputPath: string): string[] {
82
+ return ['-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', inputPath];
83
+ }
84
+
85
+ /** ffmpeg argv to grab a single frame at `atSeconds` and write it to `output`. */
86
+ export function buildThumbnailArgs(inputPath: string, atSeconds: number, output: string): string[] {
87
+ return [
88
+ '-hide_banner',
89
+ '-nostdin',
90
+ '-y',
91
+ '-ss',
92
+ String(Math.max(0, atSeconds)),
93
+ '-i',
94
+ inputPath,
95
+ '-frames:v',
96
+ '1',
97
+ '-q:v',
98
+ '3',
99
+ output,
100
+ ];
101
+ }
102
+
103
+ /** Parse an ffmpeg `HH:MM:SS.ss` (or bare seconds) timestamp into seconds. */
104
+ export function ffmpegTimeToSeconds(time: string): number {
105
+ const t = time.trim();
106
+ if (t === 'N/A' || t === '') return Number.NaN;
107
+ const parts = t.split(':');
108
+ if (parts.length === 1) return Number.parseFloat(parts[0]);
109
+ let seconds = 0;
110
+ for (const part of parts) seconds = seconds * 60 + Number.parseFloat(part);
111
+ return seconds;
112
+ }
113
+
114
+ const TIME_RE = /time=\s*([0-9:.]+)/;
115
+
116
+ /**
117
+ * Parse one line/chunk of ffmpeg stderr into a progress event. ffmpeg emits
118
+ * `frame=… time=00:00:03.00 …` (carriage-return-updated). Returns an `encode`
119
+ * event with a clamped percent when `totalSeconds` is known, else just the phase;
120
+ * `null` when the line carries no timestamp.
121
+ */
122
+ export function parseFfmpegProgress(
123
+ line: string,
124
+ totalSeconds?: number,
125
+ ): VideoProgressEvent | null {
126
+ const m = TIME_RE.exec(line);
127
+ if (!m) return null;
128
+ const elapsed = ffmpegTimeToSeconds(m[1]);
129
+ if (!Number.isFinite(elapsed)) return null;
130
+ if (totalSeconds && totalSeconds > 0) {
131
+ const pct = Math.min(100, Math.max(0, (elapsed / totalSeconds) * 100));
132
+ return { phase: 'encode', progressPercent: Math.round(pct) };
133
+ }
134
+ return { phase: 'encode' };
135
+ }
136
+
137
+ export interface ProbeResult {
138
+ width: number;
139
+ height: number;
140
+ durationSeconds: number;
141
+ format: string;
142
+ }
143
+
144
+ /** Shape of the bits of ffprobe JSON we read. */
145
+ interface FfprobeJson {
146
+ streams?: Array<{ codec_type?: string; width?: number; height?: number; duration?: string }>;
147
+ format?: { duration?: string; format_name?: string };
148
+ }
149
+
150
+ /** Extract width/height/duration/format from parsed ffprobe JSON. */
151
+ export function parseFfprobe(json: FfprobeJson): ProbeResult {
152
+ const streams = json.streams ?? [];
153
+ const video = streams.find((s) => s.codec_type === 'video');
154
+ const durationStr = json.format?.duration ?? video?.duration ?? '';
155
+ const durationSeconds = durationStr ? Number.parseFloat(durationStr) : Number.NaN;
156
+ return {
157
+ width: video?.width ?? 0,
158
+ height: video?.height ?? 0,
159
+ durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
160
+ format: json.format?.format_name ?? '',
161
+ };
162
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ clampQuality,
4
+ imageFormatToExtension,
5
+ imageFormatToMime,
6
+ mimeToImageFormat,
7
+ pickThumbnailFormat,
8
+ } from './format';
9
+
10
+ describe('format helpers', () => {
11
+ test('mime + extension lookup', () => {
12
+ expect(imageFormatToMime('jpeg')).toBe('image/jpeg');
13
+ expect(imageFormatToMime('webp')).toBe('image/webp');
14
+ expect(imageFormatToExtension('jpeg')).toBe('jpg');
15
+ expect(imageFormatToExtension('avif')).toBe('avif');
16
+ });
17
+
18
+ test('mimeToImageFormat is forgiving', () => {
19
+ expect(mimeToImageFormat('image/jpeg')).toBe('jpeg');
20
+ expect(mimeToImageFormat('image/jpg')).toBe('jpeg');
21
+ expect(mimeToImageFormat('IMAGE/PNG')).toBe('png');
22
+ expect(mimeToImageFormat('application/octet-stream')).toBeUndefined();
23
+ expect(mimeToImageFormat(undefined)).toBeUndefined();
24
+ });
25
+
26
+ test('clampQuality coerces to a 1..100 integer with a fallback', () => {
27
+ expect(clampQuality(80)).toBe(80);
28
+ expect(clampQuality(0)).toBe(1);
29
+ expect(clampQuality(250)).toBe(100);
30
+ expect(clampQuality(72.6)).toBe(73);
31
+ expect(clampQuality(undefined)).toBe(80);
32
+ expect(clampQuality(undefined, 60)).toBe(60);
33
+ expect(clampQuality(Number.NaN, 50)).toBe(50);
34
+ });
35
+
36
+ test('thumbnail format prefers webp, png when alpha matters', () => {
37
+ expect(pickThumbnailFormat()).toBe('webp');
38
+ expect(pickThumbnailFormat({ hasAlpha: false })).toBe('webp');
39
+ expect(pickThumbnailFormat({ hasAlpha: true })).toBe('png');
40
+ });
41
+ });
@@ -0,0 +1,58 @@
1
+ // Pure format helpers — MIME/extension lookup, quality clamping, thumbnail-format
2
+ // selection. No npm deps; shared by the server adapter, the React layer, and tests.
3
+
4
+ import type { ImageOutputFormat } from './types';
5
+
6
+ export const IMAGE_OUTPUT_FORMATS: readonly ImageOutputFormat[] = ['jpeg', 'png', 'webp', 'avif'];
7
+
8
+ const IMAGE_MIME: Record<ImageOutputFormat, string> = {
9
+ jpeg: 'image/jpeg',
10
+ png: 'image/png',
11
+ webp: 'image/webp',
12
+ avif: 'image/avif',
13
+ };
14
+
15
+ const IMAGE_EXT: Record<ImageOutputFormat, string> = {
16
+ jpeg: 'jpg',
17
+ png: 'png',
18
+ webp: 'webp',
19
+ avif: 'avif',
20
+ };
21
+
22
+ export function imageFormatToMime(format: ImageOutputFormat): string {
23
+ return IMAGE_MIME[format];
24
+ }
25
+
26
+ export function imageFormatToExtension(format: ImageOutputFormat): string {
27
+ return IMAGE_EXT[format];
28
+ }
29
+
30
+ /** Best-effort MIME → output format (e.g. `image/jpeg` → `jpeg`). */
31
+ export function mimeToImageFormat(mime: string | undefined): ImageOutputFormat | undefined {
32
+ if (!mime) return undefined;
33
+ const m = mime.toLowerCase();
34
+ if (m.includes('jpeg') || m.includes('jpg')) return 'jpeg';
35
+ if (m.includes('png')) return 'png';
36
+ if (m.includes('webp')) return 'webp';
37
+ if (m.includes('avif')) return 'avif';
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * Clamp an image quality to a sane 1–100 integer. A missing/NaN/out-of-range value
43
+ * falls back to `fallback` (default 80), so a malformed spec never yields a 0-quality
44
+ * (blank) or >100 (sharp-throwing) encode.
45
+ */
46
+ export function clampQuality(quality: number | undefined, fallback = 80): number {
47
+ if (quality == null || !Number.isFinite(quality)) return fallback;
48
+ return Math.min(100, Math.max(1, Math.round(quality)));
49
+ }
50
+
51
+ /**
52
+ * The thumbnail encode format: webp by default (small + wide support); png when
53
+ * transparency must survive (the source already has alpha). Keeps thumbnail policy
54
+ * in one tested place rather than scattered across server handlers.
55
+ */
56
+ export function pickThumbnailFormat(opts?: { hasAlpha?: boolean }): ImageOutputFormat {
57
+ return opts?.hasAlpha ? 'png' : 'webp';
58
+ }
@@ -0,0 +1,105 @@
1
+ // cursedbelt-core/media — pure, dependency-free media contract + utilities shared by
2
+ // the Bun server adapter (`src/server/media-bun`), the React components
3
+ // (`cursedbelt/react/media`), and any consuming app/test. No React, no Bun, no npm
4
+ // dep — the same module loads identically everywhere.
5
+
6
+ export { type FloodFillBGOptions, floodFillBGS } from './bgRemoval';
7
+ export {
8
+ buildProbeArgs,
9
+ buildThumbnailArgs,
10
+ buildTrimArgs,
11
+ ffmpegTimeToSeconds,
12
+ type ProbeResult,
13
+ parseFfmpegProgress,
14
+ parseFfprobe,
15
+ type VideoCodecPlan,
16
+ videoCodecPlan,
17
+ } from './ffmpeg';
18
+ export {
19
+ clampQuality,
20
+ IMAGE_OUTPUT_FORMATS,
21
+ imageFormatToExtension,
22
+ imageFormatToMime,
23
+ mimeToImageFormat,
24
+ pickThumbnailFormat,
25
+ } from './format';
26
+ export {
27
+ isAudioItem,
28
+ isImageItem,
29
+ isVideoItem,
30
+ mediaItemAspect,
31
+ mediaItemDimensions,
32
+ } from './mediaItem';
33
+ export {
34
+ buildAtempoChain,
35
+ buildTimelineRenderPlan,
36
+ DEFAULT_NORMALIZE,
37
+ type FfmpegCommand,
38
+ type NormalizeSpec,
39
+ type RenderPlanFile,
40
+ speedFilters,
41
+ type TimelineRenderOptions,
42
+ type TimelineRenderPlan,
43
+ } from './renderPlan';
44
+ export {
45
+ closeMask,
46
+ createRegionSegmenter,
47
+ DEFAULT_CLOSE_RADIUS,
48
+ DEFAULT_TOLERANCE,
49
+ dilateMask,
50
+ downscaleRgba,
51
+ erodeMask,
52
+ fitWorkingSize,
53
+ type ImageSegmenter,
54
+ type SegmentOptions,
55
+ type SegmentPrompt,
56
+ type SegmentSource,
57
+ sensitivityToTolerance,
58
+ upscaleMask,
59
+ } from './segment';
60
+ export { formatBytes } from './size';
61
+ export {
62
+ type ClampTrimOptions,
63
+ clampTrimRange,
64
+ formatTimecode,
65
+ type TrimRange,
66
+ trimDurationSeconds,
67
+ } from './trim';
68
+ export type {
69
+ GalleryMedia,
70
+ GalleryPhoto,
71
+ ImageOperation,
72
+ ImageOutputFormat,
73
+ ImagePipelineSpec,
74
+ MediaItem,
75
+ MediaKind,
76
+ MediaKindFilter,
77
+ PlayableVideo,
78
+ ProcessingStatus,
79
+ VideoFormat,
80
+ VideoProgressEvent,
81
+ VideoQuality,
82
+ VideoTrimSpec,
83
+ } from './types';
84
+ export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits';
85
+ export {
86
+ assertValidImagePipelineSpec,
87
+ assertValidVideoTrimSpec,
88
+ type ValidationResult,
89
+ validateImagePipelineSpec,
90
+ validateVideoTrimSpec,
91
+ } from './validate';
92
+
93
+ // Marking sections of a video to CUT OUT. Framework-free on purpose: the dialog
94
+ // (`react/media/VideoCutEditor`), the app route that refuses an unexecutable list and the
95
+ // binary-server job that runs it all read the same rules from here.
96
+ export {
97
+ cutListProblem,
98
+ cutSeconds,
99
+ cutTimecode,
100
+ keptSeconds,
101
+ keptSegments,
102
+ MIN_CUT_SECONDS,
103
+ normalizeCuts,
104
+ type VideoCut,
105
+ } from './videoCuts';
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ isAudioItem,
4
+ isImageItem,
5
+ isVideoItem,
6
+ mediaItemAspect,
7
+ mediaItemDimensions,
8
+ } from './mediaItem';
9
+ import type { MediaItem } from './types';
10
+
11
+ const item = (over: Partial<MediaItem> = {}): MediaItem => ({
12
+ id: '1',
13
+ type: 'image',
14
+ url: '/downloads/1',
15
+ name: 'one',
16
+ ...over,
17
+ });
18
+
19
+ describe('mediaItem helpers', () => {
20
+ test('kind predicates', () => {
21
+ expect(isImageItem(item({ type: 'image' }))).toBe(true);
22
+ expect(isVideoItem(item({ type: 'video' }))).toBe(true);
23
+ expect(isAudioItem(item({ type: 'audio' }))).toBe(true);
24
+ expect(isVideoItem(item({ type: 'image' }))).toBe(false);
25
+ });
26
+
27
+ test('uses intrinsic dimensions when present', () => {
28
+ expect(mediaItemDimensions(item({ width: 800, height: 600 }))).toEqual({
29
+ width: 800,
30
+ height: 600,
31
+ });
32
+ expect(mediaItemAspect(item({ width: 800, height: 600 }))).toBeCloseTo(4 / 3, 5);
33
+ });
34
+
35
+ test('falls back per-kind when dimensions are missing', () => {
36
+ expect(mediaItemDimensions(item({ type: 'image' }))).toEqual({ width: 4, height: 3 });
37
+ expect(mediaItemDimensions(item({ type: 'video' }))).toEqual({ width: 16, height: 9 });
38
+ expect(mediaItemAspect(item({ type: 'video' }))).toBeCloseTo(16 / 9, 5);
39
+ });
40
+
41
+ test('zero/partial dimensions fall back to the per-kind default ratio', () => {
42
+ // Neither both-positive → image default 4:3.
43
+ expect(mediaItemAspect(item({ type: 'image', width: 0, height: 0 }))).toBeCloseTo(4 / 3, 5);
44
+ expect(mediaItemAspect(item({ type: 'image', width: 10, height: 0 }))).toBeCloseTo(4 / 3, 5);
45
+ });
46
+ });
@@ -0,0 +1,40 @@
1
+ // Pure MediaItem helpers — kind predicates + dimension/aspect resolution. The React
2
+ // catalogue maps items to layout photos through these, so the "what size is this
3
+ // thumbnail" logic is tested here, not buried in a component.
4
+
5
+ import type { MediaItem } from './types';
6
+
7
+ export const isImageItem = (item: MediaItem): boolean => item.type === 'image';
8
+ export const isVideoItem = (item: MediaItem): boolean => item.type === 'video';
9
+ export const isAudioItem = (item: MediaItem): boolean => item.type === 'audio';
10
+
11
+ /** Default cell shape when an item carries no intrinsic dimensions. */
12
+ const FALLBACK_DIMS: Record<MediaItem['type'], { width: number; height: number }> = {
13
+ image: { width: 4, height: 3 },
14
+ video: { width: 16, height: 9 },
15
+ audio: { width: 1, height: 1 },
16
+ };
17
+
18
+ /**
19
+ * Resolve renderable dimensions for an item. Real `width`/`height` win; otherwise a
20
+ * per-kind default (4:3 image, 16:9 video, 1:1 audio) keeps the justified/masonry
21
+ * layout from collapsing a dimensionless item to zero height.
22
+ */
23
+ export function mediaItemDimensions(item: MediaItem): { width: number; height: number } {
24
+ if (isPositive(item.width) && isPositive(item.height)) {
25
+ return { width: item.width, height: item.height };
26
+ }
27
+ return FALLBACK_DIMS[item.type];
28
+ }
29
+
30
+ /** width / height (clamped to a sane range), falling back to `fallback` (default 1). */
31
+ export function mediaItemAspect(item: MediaItem, fallback = 1): number {
32
+ const { width, height } = mediaItemDimensions(item);
33
+ if (!isPositive(width) || !isPositive(height)) return fallback;
34
+ const ratio = width / height;
35
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : fallback;
36
+ }
37
+
38
+ function isPositive(n: number | undefined): n is number {
39
+ return typeof n === 'number' && Number.isFinite(n) && n > 0;
40
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { TimelineClip, TimelineTrack } from 'cwip/timeline';
3
+ import {
4
+ buildAtempoChain,
5
+ buildTimelineRenderPlan,
6
+ DEFAULT_NORMALIZE,
7
+ speedFilters,
8
+ } from './renderPlan';
9
+
10
+ const clip = (partial: Partial<TimelineClip> & { id: string }): TimelineClip => ({
11
+ name: partial.id,
12
+ sourceAssetId: 'a1',
13
+ trackIn: 0,
14
+ trackOut: 10,
15
+ timelineStart: 0,
16
+ timelineEnd: 10,
17
+ speed: 1,
18
+ volume: 1,
19
+ ...partial,
20
+ });
21
+
22
+ const track = (id: string, type: 'video' | 'audio', clips: TimelineClip[]): TimelineTrack => ({
23
+ id,
24
+ name: id,
25
+ type,
26
+ clips,
27
+ });
28
+
29
+ const FILES = { a1: 'a1.mp4', a2: 'a2.mov', m1: 'music.mp3' };
30
+
31
+ describe('buildAtempoChain / speedFilters', () => {
32
+ test('chains beyond the 0.5–2.0 window', () => {
33
+ expect(buildAtempoChain(1.5)).toBe('atempo=1.5000');
34
+ expect(buildAtempoChain(4)).toBe('atempo=2.0,atempo=2.0000');
35
+ expect(buildAtempoChain(0.25)).toBe('atempo=0.5,atempo=0.5000');
36
+ });
37
+
38
+ test('speed 1 needs no filters', () => {
39
+ expect(speedFilters(1)).toBeNull();
40
+ expect(speedFilters(2)?.video).toBe('setpts=0.500000*PTS');
41
+ });
42
+ });
43
+
44
+ describe('buildTimelineRenderPlan', () => {
45
+ test('single-source trim+split plan: chunks then copy-concat straight to output', () => {
46
+ const tracks = [
47
+ track('v1', 'video', [
48
+ clip({ id: 'c1', trackIn: 2, trackOut: 6, timelineStart: 0, timelineEnd: 4 }),
49
+ clip({ id: 'c2', trackIn: 8, trackOut: 12, timelineStart: 4, timelineEnd: 8 }),
50
+ ]),
51
+ ];
52
+ const plan = buildTimelineRenderPlan(tracks, { assetFileNames: FILES });
53
+ expect(plan.commands).toHaveLength(3);
54
+ const [chunk1, chunk2, concat] = plan.commands;
55
+ expect(chunk1.args).toContain('-ss');
56
+ expect(chunk1.args[chunk1.args.indexOf('-ss') + 1]).toBe('2');
57
+ expect(chunk1.args[chunk1.args.indexOf('-to') + 1]).toBe('6');
58
+ expect(chunk2.args[chunk2.args.indexOf('-i') + 1]).toBe('a1.mp4');
59
+ // normalize spec is embedded in the -vf chain
60
+ const vf = chunk1.args[chunk1.args.indexOf('-vf') + 1];
61
+ expect(vf).toContain(`scale=${DEFAULT_NORMALIZE.width}:${DEFAULT_NORMALIZE.height}`);
62
+ expect(vf).toContain(`fps=${DEFAULT_NORMALIZE.fps}`);
63
+ // concat is stream-copy to the FINAL output (no audio mix step needed)
64
+ expect(concat.args).toContain('concat');
65
+ expect(concat.args).toContain('copy');
66
+ expect(concat.outputName).toBe('output.mp4');
67
+ expect(plan.files[0].text).toBe("file 'csrp_v0.mp4'\nfile 'csrp_v1.mp4'");
68
+ expect(plan.totalEncodeSeconds).toBe(8);
69
+ expect(plan.requiredAssetIds).toEqual(['a1']);
70
+ // intermediates: 2 chunks + concat list — never the output
71
+ expect(plan.intermediateNames).not.toContain('output.mp4');
72
+ });
73
+
74
+ test('speed + volume land in the filter chains and shorten the encode weight', () => {
75
+ const tracks = [
76
+ track('v1', 'video', [
77
+ clip({ id: 'c1', trackOut: 8, timelineEnd: 4, speed: 2, volume: 0.5 }),
78
+ ]),
79
+ ];
80
+ const plan = buildTimelineRenderPlan(tracks, { assetFileNames: FILES });
81
+ const chunk = plan.commands[0];
82
+ const vf = chunk.args[chunk.args.indexOf('-vf') + 1];
83
+ const af = chunk.args[chunk.args.indexOf('-af') + 1];
84
+ expect(vf).toContain('setpts=0.500000*PTS');
85
+ expect(af).toContain('atempo=2.0000');
86
+ expect(af).toContain('volume=0.500');
87
+ expect(chunk.encodeSeconds).toBe(4); // 8 source seconds at 2×
88
+ });
89
+
90
+ test('multi-source + audio track: silent bed, adelay offsets, amix under the lane', () => {
91
+ const tracks = [
92
+ track('v1', 'video', [
93
+ clip({ id: 'c1' }),
94
+ clip({ id: 'c2', sourceAssetId: 'a2', timelineStart: 10, timelineEnd: 20 }),
95
+ ]),
96
+ track('t-audio', 'audio', [
97
+ clip({ id: 'm1', sourceAssetId: 'm1', trackOut: 8, timelineStart: 2.5, timelineEnd: 10.5 }),
98
+ ]),
99
+ ];
100
+ const plan = buildTimelineRenderPlan(tracks, {
101
+ assetFileNames: FILES,
102
+ assetHasAudio: { a2: false },
103
+ });
104
+ // v-chunk for the silent source pulls in an anullsrc bed + explicit maps
105
+ const silentChunk = plan.commands[1];
106
+ expect(silentChunk.args.join(' ')).toContain('anullsrc');
107
+ expect(silentChunk.args).toContain('0:v:0');
108
+ // the mix step delays the music to 2500ms and amixes 2 inputs
109
+ const mix = plan.commands.at(-1);
110
+ const filter = mix?.args[mix.args.indexOf('-filter_complex') + 1];
111
+ expect(filter).toContain('adelay=2500|2500');
112
+ expect(filter).toContain('amix=inputs=2:duration=first');
113
+ expect(mix?.outputName).toBe('output.mp4');
114
+ // the intermediate lane is cleaned up, output is not
115
+ expect(plan.intermediateNames).toContain('csrp_lane.mp4');
116
+ expect(plan.requiredAssetIds).toEqual(['a1', 'a2', 'm1']);
117
+ });
118
+
119
+ test('fails loudly on an unmapped asset and an empty video lane', () => {
120
+ expect(() =>
121
+ buildTimelineRenderPlan([track('v1', 'video', [clip({ id: 'c1', sourceAssetId: 'nope' })])], {
122
+ assetFileNames: FILES,
123
+ }),
124
+ ).toThrow(/no file name mapped/);
125
+ expect(() =>
126
+ buildTimelineRenderPlan([track('v1', 'video', [])], { assetFileNames: FILES }),
127
+ ).toThrow(/no video clips/);
128
+ });
129
+
130
+ test('webm format flows through the codec plan and chunk extensions', () => {
131
+ const tracks = [track('v1', 'video', [clip({ id: 'c1' })])];
132
+ const plan = buildTimelineRenderPlan(tracks, {
133
+ assetFileNames: FILES,
134
+ format: 'webm',
135
+ outputName: 'out.webm',
136
+ });
137
+ expect(plan.commands[0].outputName).toBe('csrp_v0.webm');
138
+ expect(plan.commands[0].args).toContain('libvpx-vp9');
139
+ });
140
+ });