@jant/core 0.6.16 → 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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -15,7 +15,10 @@ import type { EditorState } from "@tiptap/pm/state";
15
15
  import type { EditorView } from "@tiptap/pm/view";
16
16
  import {
17
17
  getFixedFloatingContainerRect,
18
+ getFloatingArrowOffset,
18
19
  getFloatingPosition,
20
+ getRangeAnchorRect,
21
+ getVisibleClipRect,
19
22
  } from "./floating-position.js";
20
23
 
21
24
  const linkToolbarKey = new PluginKey("linkToolbar");
@@ -195,17 +198,17 @@ export const LinkToolbar = Extension.create({
195
198
  el.style.display = "flex";
196
199
 
197
200
  const dialog = view.dom.closest("dialog");
198
- const start = view.coordsAtPos(from);
199
- const end = view.coordsAtPos(to);
201
+ const containerRect = getFixedFloatingContainerRect(dialog);
202
+ const anchorRect = getRangeAnchorRect(
203
+ view,
204
+ from,
205
+ to,
206
+ getVisibleClipRect(view.dom, containerRect),
207
+ );
200
208
  const rect = el.getBoundingClientRect();
201
209
  const layout = getFloatingPosition({
202
- anchorRect: {
203
- left: start.left,
204
- right: end.right,
205
- top: Math.min(start.top, end.top),
206
- bottom: Math.max(start.bottom, end.bottom),
207
- },
208
- containerRect: getFixedFloatingContainerRect(dialog),
210
+ anchorRect,
211
+ containerRect,
209
212
  floatingWidth: rect.width,
210
213
  floatingHeight: rect.height,
211
214
  preferredPlacement: "top",
@@ -215,6 +218,16 @@ export const LinkToolbar = Extension.create({
215
218
 
216
219
  el.style.left = `${layout.left}px`;
217
220
  el.style.top = `${layout.top}px`;
221
+ el.dataset.placement = layout.placement;
222
+ el.style.setProperty(
223
+ "--floating-arrow-x",
224
+ `${getFloatingArrowOffset({
225
+ anchorRect,
226
+ containerRect,
227
+ floatingLeft: layout.left,
228
+ floatingWidth: rect.width,
229
+ })}px`,
230
+ );
218
231
  }
219
232
 
220
233
  interface ShowInputOptions {
@@ -337,6 +337,17 @@ export function dismissToast(id: string): void {
337
337
  toast.addEventListener("animationend", () => toast.remove());
338
338
  }
339
339
 
340
+ /**
341
+ * Re-promote the container holding a persistent toast before its message
342
+ * changes. A dialog may have opened since the toast appeared — a failed
343
+ * publish reopens the composer — and the container would otherwise be left
344
+ * behind in the inert tree, painted under the dialog and unclickable.
345
+ */
346
+ function promoteAgain(toast: HTMLElement): void {
347
+ const container = toast.parentElement;
348
+ if (container) ensureTopLayer(container);
349
+ }
350
+
340
351
  /**
341
352
  * Replace a persistent toast with an auto-dismissing one.
342
353
  *
@@ -358,6 +369,7 @@ export function replaceWithAutoClose(
358
369
  return;
359
370
  }
360
371
 
372
+ promoteAgain(toast);
361
373
  toast.className = `toast toast-${type}`;
362
374
  toast.replaceChildren();
363
375
  setToastContent(toast, type, message);
@@ -388,6 +400,7 @@ export function replaceWithAutoCloseAction(
388
400
  return;
389
401
  }
390
402
 
403
+ promoteAgain(toast);
391
404
  toast.className = `toast toast-${type}`;
392
405
  toast.replaceChildren();
393
406
  setToastContent(toast, type, message, action);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Vite's `?worker&inline` import: the worker script bundled into the importing
3
+ * chunk and started from a `blob:` URL. Declared here rather than by pulling
4
+ * in all of `vite/client`, which also types `import.meta.env` and every asset
5
+ * suffix — none of which the client uses.
6
+ */
7
+ declare module "*?worker&inline" {
8
+ const workerConstructor: {
9
+ new (options?: { name?: string }): Worker;
10
+ };
11
+ export default workerConstructor;
12
+ }
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Shared Upload Helper with Metadata
3
3
  *
4
- * Processes images via ImageProcessor, extracts dimensions + blurhash,
5
- * and uploads with metadata attached to the FormData.
4
+ * Processes images via ImageProcessor — resize, WebP, dimensions, blurhash —
5
+ * and uploads them with that metadata attached.
6
6
  * Used by paste-media, image-node replace, and inline compose editors.
7
7
  */
8
8
 
9
9
  import { ImageProcessor } from "./image-processor.js";
10
- import { extractImageMetadata } from "./media-metadata.js";
11
10
  import { uploadViaSession } from "./upload-session.js";
12
11
 
13
12
  /**
@@ -18,22 +17,13 @@ import { uploadViaSession } from "./upload-session.js";
18
17
  export async function uploadWithMetadata(
19
18
  file: File,
20
19
  ): Promise<{ url: string; id: string }> {
21
- // Process image (resize, convert to WebP)
22
20
  const {
23
21
  file: processed,
24
22
  width,
25
23
  height,
24
+ blurhash,
26
25
  } = await ImageProcessor.processToFile(file);
27
26
 
28
- // Extract blurhash from the processed file
29
- let blurhash: string | undefined;
30
- try {
31
- const meta = await extractImageMetadata(processed);
32
- blurhash = meta.blurhash;
33
- } catch {
34
- // Blurhash extraction failed — upload without it
35
- }
36
-
37
27
  const result = await uploadViaSession(processed, {
38
28
  width,
39
29
  height,
@@ -2,7 +2,10 @@
2
2
  * Client-side Video Processor
3
3
  *
4
4
  * Processes videos before upload using mediabunny:
5
- * - Transcodes to H.264/AAC MP4 (universal playback)
5
+ * - Remuxes to MP4, transcoding to H.264/AAC only when the source is not
6
+ * already in those codecs or does not fit the size caps. A source that
7
+ * needs nothing has its encoded packets copied straight across, so it
8
+ * reaches the server without a generation of re-encoding loss
6
9
  * - Resizes to max 1920px long edge / 1080px short edge
7
10
  * - Strips spurious rotation metadata from the output (mediabunny may
8
11
  * bake rotation into pixels AND write a display matrix, causing the
@@ -14,17 +17,8 @@
14
17
  * Requires WebCodecs API support — check `isSupported()` before use.
15
18
  */
16
19
 
17
- import {
18
- Input,
19
- Output,
20
- Mp4OutputFormat,
21
- BufferTarget,
22
- BlobSource,
23
- CanvasSink,
24
- Conversion,
25
- QUALITY_HIGH,
26
- ALL_FORMATS,
27
- } from "mediabunny";
20
+ import type { VideoCodec } from "mediabunny";
21
+ import { loadMediabunny } from "./mediabunny.js";
28
22
  import { encode } from "blurhash";
29
23
  import { normalizeDurationSeconds } from "../lib/video-playback.js";
30
24
  import { zeroTrackAlternateGroups } from "../lib/mp4-track-flags.js";
@@ -36,6 +30,66 @@ const MAX_SHORT_EDGE = 1080;
36
30
  const POSTER_WIDTH = 640;
37
31
  const BLURHASH_SIZE = 32;
38
32
 
33
+ /**
34
+ * Bitrate targeted for a 1080p H.264 re-encode; every other size scales from
35
+ * here. Around 8 Mbps — enough to hold up against the source on a phone or a
36
+ * laptop, low enough that a clip stays servable over the web.
37
+ *
38
+ * This is expressed as a bitrate on purpose. Given a quality *level*,
39
+ * mediabunny prefers quantizer-based encoding, which has no size ceiling at
40
+ * all: a frugally-encoded source gets faithfully reproduced — its own
41
+ * compression artifacts included — at several times its original size. A
42
+ * bitrate keeps the output predictable.
43
+ */
44
+ const REFERENCE_BITRATE = 7_900_000;
45
+ const REFERENCE_PIXELS = 1920 * 1080;
46
+
47
+ /** Bitrate scales slightly sub-linearly with pixel count. */
48
+ const PIXEL_SCALE_EXPONENT = 0.95;
49
+
50
+ /**
51
+ * Bits H.264 needs to carry one bit of the source codec at the same quality.
52
+ * H.264 is the baseline; HEVC and VP9 fit roughly 40% more picture into a
53
+ * bit, AV1 around 60%. Used to cap the target at what the source content
54
+ * actually carries — spending more than that reproduces the source's
55
+ * artifacts in high fidelity and buys nothing, because the detail was never
56
+ * there to begin with.
57
+ */
58
+ const CODEC_BITRATE_RATIO: Partial<Record<VideoCodec, number>> = {
59
+ avc: 1,
60
+ hevc: 1 / 0.6,
61
+ vp9: 1 / 0.6,
62
+ av1: 1 / 0.4,
63
+ vp8: 1 / 1.2,
64
+ };
65
+
66
+ /**
67
+ * Pick the bitrate for a re-encode: the size-appropriate target, but never
68
+ * more than the source content itself warrants.
69
+ */
70
+ function reencodeBitrate(
71
+ source: {
72
+ pixels: number;
73
+ bitrate: number | undefined;
74
+ codec: VideoCodec | null | undefined;
75
+ },
76
+ targetPixels: number,
77
+ ): number {
78
+ const target =
79
+ REFERENCE_BITRATE *
80
+ (targetPixels / REFERENCE_PIXELS) ** PIXEL_SCALE_EXPONENT;
81
+
82
+ if (!source.bitrate || !source.pixels) return Math.round(target);
83
+
84
+ const ratio = (source.codec && CODEC_BITRATE_RATIO[source.codec]) ?? 1;
85
+ const sourceCap =
86
+ source.bitrate *
87
+ ratio *
88
+ (targetPixels / source.pixels) ** PIXEL_SCALE_EXPONENT;
89
+
90
+ return Math.round(Math.min(target, sourceCap));
91
+ }
92
+
39
93
  export interface VideoProcessResult {
40
94
  file: File;
41
95
  width: number;
@@ -54,102 +108,281 @@ function isSupported(): boolean {
54
108
  return typeof VideoEncoder !== "undefined";
55
109
  }
56
110
 
57
- /**
58
- * Extract a poster frame, blurhash, and source dimensions from a video file.
59
- * Seeks to `min(duration × 0.1, 1s)` and captures the frame.
60
- * Also returns the original video dimensions so the caller can compute
61
- * the correct output size without opening a second Input instance.
62
- *
63
- * @param file - Source video file
64
- * @returns Poster blob (640px-wide WebP), blurhash string, and source dimensions
65
- */
66
- async function extractPoster(file: File): Promise<{
111
+ interface SourceProbe {
67
112
  poster?: Blob;
68
113
  blurhash?: string;
69
114
  sourceWidth?: number;
70
115
  sourceHeight?: number;
71
116
  rotation?: number;
72
117
  durationSeconds?: number;
73
- }> {
118
+ /** Source video codec, or `null` when it could not be determined. */
119
+ videoCodec?: VideoCodec | null;
120
+ /** Average bitrate of the source video track, in bits per second. */
121
+ videoBitrate?: number;
122
+ }
123
+
124
+ /**
125
+ * Report a probe step that failed. Every step here is optional — the upload
126
+ * proceeds without it — so the failure has to be visible somewhere, or a
127
+ * broken decoder reads as a video that merely has no thumbnail.
128
+ */
129
+ function warnProbeFailure(step: string, error: unknown): void {
130
+ // eslint-disable-next-line no-console -- an optional step failing is silent by design; say so
131
+ console.warn(`[jant] video probe: ${step} failed`, error);
132
+ }
133
+
134
+ /**
135
+ * Run one optional probe step, returning `undefined` when it fails.
136
+ *
137
+ * Each step feeds a different part of the result, and none of them is worth
138
+ * the others: an unreadable bitrate should not cost the poster, and an
139
+ * undecodable frame should not cost the codec that decides whether the file
140
+ * needs re-encoding at all.
141
+ */
142
+ async function optional<T>(
143
+ step: string,
144
+ run: () => Promise<T>,
145
+ ): Promise<T | undefined> {
146
+ try {
147
+ return await run();
148
+ } catch (error) {
149
+ warnProbeFailure(step, error);
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Draw the captured frame into a canvas of the given size.
156
+ * Returns null when the browser refuses a 2D context.
157
+ */
158
+ function drawFrame(
159
+ source: HTMLCanvasElement,
160
+ width: number,
161
+ height: number,
162
+ ): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
163
+ const canvas = document.createElement("canvas");
164
+ canvas.width = width;
165
+ canvas.height = height;
166
+ const ctx = canvas.getContext("2d");
167
+ if (!ctx) return null;
168
+ ctx.drawImage(source, 0, 0, width, height);
169
+ return { canvas, ctx };
170
+ }
171
+
172
+ /**
173
+ * Probe a video file and extract its poster frame and blurhash in one pass.
174
+ * Seeks to `min(duration × 0.1, 1s)` and captures the frame.
175
+ *
176
+ * Returns the source dimensions, codec, and bitrate alongside, so the caller
177
+ * can decide whether a re-encode is needed at all — and at what bitrate —
178
+ * without opening a second Input instance.
179
+ *
180
+ * Every field is optional and probed independently. A file whose frames will
181
+ * not decode still reports its codec and duration; one whose bitrate cannot be
182
+ * read still yields a poster. Only the container being unreadable — no video
183
+ * track at all — returns nothing, because then there is nothing to report.
184
+ *
185
+ * @param file - Source video file
186
+ * @returns Poster blob (640px-wide WebP), blurhash, dimensions, codec, bitrate
187
+ */
188
+ async function probeSource(file: File): Promise<SourceProbe> {
189
+ const { Input, BlobSource, ALL_FORMATS, CanvasSink } = await loadMediabunny();
74
190
  const input = new Input({
75
191
  source: new BlobSource(file),
76
192
  formats: ALL_FORMATS,
77
193
  });
78
194
  try {
79
- const videoTrack = await input.getPrimaryVideoTrack();
195
+ const videoTrack = await optional("open video track", () =>
196
+ input.getPrimaryVideoTrack(),
197
+ );
80
198
  if (!videoTrack) return {};
81
199
 
82
- const sourceWidth = videoTrack.displayWidth;
83
- const sourceHeight = videoTrack.displayHeight;
84
- const rotation = videoTrack.rotation;
200
+ const videoCodec = await optional("read codec", () =>
201
+ videoTrack.getCodec(),
202
+ );
203
+ // Metadata-only scan of the sample table — milliseconds even on a large
204
+ // file, and exact, unlike dividing file size by duration. Only an input
205
+ // to the re-encode bitrate, so `undefined` degrades to the size-based
206
+ // target rather than failing the probe.
207
+ const videoBitrate = await optional(
208
+ "compute bitrate",
209
+ async () => (await videoTrack.computePacketStats()).averageBitrate,
210
+ );
211
+ const duration = await optional("compute duration", () =>
212
+ input.computeDuration(),
213
+ );
85
214
 
86
- const duration = await input.computeDuration();
87
- const durationSeconds = normalizeDurationSeconds(duration);
88
- const seekTime = Math.min(duration * 0.1, 1);
215
+ // Everything the caller needs to plan the conversion.
216
+ const base: SourceProbe = {
217
+ sourceWidth: videoTrack.displayWidth,
218
+ sourceHeight: videoTrack.displayHeight,
219
+ rotation: videoTrack.rotation,
220
+ durationSeconds:
221
+ duration === undefined ? undefined : normalizeDurationSeconds(duration),
222
+ videoCodec,
223
+ videoBitrate,
224
+ };
89
225
 
90
- const sink = new CanvasSink(videoTrack);
91
- const wrapped = await sink.getCanvas(seekTime);
92
- if (!wrapped) {
93
- return { sourceWidth, sourceHeight, rotation, durationSeconds };
94
- }
226
+ // A tenth of the way in, so the still is not the fade-from-black most
227
+ // clips open on. With no duration to take a tenth of, the opening frame
228
+ // is the only one we can name.
229
+ const seekTime = duration === undefined ? 0 : Math.min(duration * 0.1, 1);
230
+ const wrapped = await optional("capture frame", () =>
231
+ new CanvasSink(videoTrack).getCanvas(seekTime),
232
+ );
233
+ if (!wrapped) return base;
95
234
 
96
235
  const canvas = wrapped.canvas as HTMLCanvasElement;
97
-
98
- // Poster: 640px wide WebP
99
236
  const srcW = canvas.width;
100
237
  const srcH = canvas.height;
238
+
239
+ // Poster: 640px wide WebP
101
240
  const posterScale = Math.min(POSTER_WIDTH / srcW, 1);
102
- const pw = Math.round(srcW * posterScale);
103
- const ph = Math.round(srcH * posterScale);
104
-
105
- const posterCanvas = document.createElement("canvas");
106
- posterCanvas.width = pw;
107
- posterCanvas.height = ph;
108
- const pCtx = posterCanvas.getContext("2d");
109
- if (!pCtx) return { sourceWidth, sourceHeight };
110
- pCtx.drawImage(canvas, 0, 0, pw, ph);
111
-
112
- const poster = await new Promise<Blob | undefined>((resolve) => {
113
- posterCanvas.toBlob(
114
- (blob) => resolve(blob ?? undefined),
115
- "image/webp",
116
- 0.8,
117
- );
118
- });
241
+ const posterFrame = drawFrame(
242
+ canvas,
243
+ Math.round(srcW * posterScale),
244
+ Math.round(srcH * posterScale),
245
+ );
246
+ const poster = posterFrame
247
+ ? await optional(
248
+ "encode poster",
249
+ () =>
250
+ new Promise<Blob | undefined>((resolve) => {
251
+ posterFrame.canvas.toBlob(
252
+ (blob) => resolve(blob ?? undefined),
253
+ "image/webp",
254
+ 0.8,
255
+ );
256
+ }),
257
+ )
258
+ : undefined;
119
259
 
120
260
  // Blurhash: 32px canvas, 4×3 components
121
261
  const bhScale = Math.min(BLURHASH_SIZE / srcW, BLURHASH_SIZE / srcH, 1);
122
262
  const bw = Math.max(Math.round(srcW * bhScale), 1);
123
263
  const bh = Math.max(Math.round(srcH * bhScale), 1);
264
+ const bhFrame = drawFrame(canvas, bw, bh);
265
+ const blurhash = bhFrame
266
+ ? await optional("encode blurhash", async () =>
267
+ encode(bhFrame.ctx.getImageData(0, 0, bw, bh).data, bw, bh, 4, 3),
268
+ )
269
+ : undefined;
270
+
271
+ return { ...base, poster, blurhash };
272
+ } finally {
273
+ input.dispose();
274
+ }
275
+ }
124
276
 
125
- const bhCanvas = document.createElement("canvas");
126
- bhCanvas.width = bw;
127
- bhCanvas.height = bh;
128
- const bhCtx = bhCanvas.getContext("2d");
129
- if (!bhCtx) return { poster, sourceWidth, sourceHeight };
130
- bhCtx.drawImage(canvas, 0, 0, bw, bh);
277
+ /** What a given source needs done to it before upload. */
278
+ export interface VideoProcessPlan {
279
+ /** True when the video must be scaled down to fit the size caps. */
280
+ needsResize: boolean;
281
+ /** Target dimensions — equal to the source dimensions when not resizing. */
282
+ width: number;
283
+ height: number;
284
+ /** True when the video track must be decoded and re-encoded. */
285
+ videoNeedsReencode: boolean;
286
+ /**
287
+ * Target video bitrate in bits per second, or `undefined` when the track
288
+ * is copied rather than re-encoded.
289
+ */
290
+ videoBitrate?: number;
291
+ }
131
292
 
132
- const imageData = bhCtx.getImageData(0, 0, bw, bh);
133
- const blurhash = encode(imageData.data, bw, bh, 4, 3);
293
+ /**
294
+ * Decide what a source video needs: a resize, a re-encode, or nothing at all.
295
+ *
296
+ * Asking for a size or a quality is what disables mediabunny's copy fast
297
+ * path, so anything already within the caps and already in H.264 must come
298
+ * back with `videoNeedsReencode` false — its encoded packets are then copied
299
+ * across untouched, sparing it a generation of quality. An unknown codec
300
+ * (`null`) counts as needing a re-encode, since we can't vouch for it playing
301
+ * everywhere.
302
+ *
303
+ * When a re-encode is unavoidable, the bitrate is the smaller of what the
304
+ * output size warrants and what the source content actually carries. The
305
+ * second half matters: an efficiently-encoded source re-encoded at constant
306
+ * quality can come out several times larger than it went in, all of those
307
+ * bits spent preserving its own compression artifacts.
308
+ *
309
+ * Dimensions are the source's *display* dimensions, i.e. post-rotation, so
310
+ * the caps apply orientation-agnostically.
311
+ *
312
+ * @param source - Display dimensions, codec, and bitrate of the source
313
+ * @param options - `maxLongEdge` and `maxShortEdge` caps
314
+ * @returns The processing plan
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * // Already H.264 and within the caps — copied, not re-encoded
319
+ * planVideoProcessing(
320
+ * { width: 1280, height: 720, videoCodec: "avc" },
321
+ * { maxLongEdge: 1920, maxShortEdge: 1080 },
322
+ * );
323
+ * // { needsResize: false, width: 1280, height: 720,
324
+ * // videoNeedsReencode: false, videoBitrate: undefined }
325
+ * ```
326
+ */
327
+ export function planVideoProcessing(
328
+ source: {
329
+ width?: number;
330
+ height?: number;
331
+ videoCodec?: VideoCodec | null;
332
+ videoBitrate?: number;
333
+ },
334
+ options: { maxLongEdge: number; maxShortEdge: number },
335
+ ): VideoProcessPlan {
336
+ const { width: sourceWidth, height: sourceHeight } = source;
337
+
338
+ // Fall back to the caps when the probe could not read the dimensions;
339
+ // without a source size there is nothing to scale against.
340
+ let width = sourceWidth || options.maxLongEdge;
341
+ let height = sourceHeight || options.maxShortEdge;
342
+ let needsResize = false;
134
343
 
135
- return {
136
- poster,
137
- blurhash,
138
- sourceWidth,
139
- sourceHeight,
140
- rotation,
141
- durationSeconds,
142
- };
143
- } catch {
144
- return {};
145
- } finally {
146
- input.dispose();
344
+ if (sourceWidth && sourceHeight) {
345
+ const longSide = Math.max(sourceWidth, sourceHeight);
346
+ const shortSide = Math.min(sourceWidth, sourceHeight);
347
+ const scale = Math.min(
348
+ options.maxLongEdge / longSide,
349
+ options.maxShortEdge / shortSide,
350
+ 1,
351
+ );
352
+ needsResize = scale < 1;
353
+ if (needsResize) {
354
+ width = Math.round(sourceWidth * scale);
355
+ height = Math.round(sourceHeight * scale);
356
+ // H.264 requires even dimensions
357
+ width += width % 2;
358
+ height += height % 2;
359
+ }
147
360
  }
361
+
362
+ const videoNeedsReencode = needsResize || source.videoCodec !== "avc";
363
+
364
+ return {
365
+ needsResize,
366
+ width,
367
+ height,
368
+ videoNeedsReencode,
369
+ videoBitrate: videoNeedsReencode
370
+ ? reencodeBitrate(
371
+ {
372
+ pixels: (sourceWidth ?? 0) * (sourceHeight ?? 0),
373
+ bitrate: source.videoBitrate,
374
+ codec: source.videoCodec,
375
+ },
376
+ width * height,
377
+ )
378
+ : undefined,
379
+ };
148
380
  }
149
381
 
150
382
  /**
151
- * Process a video file: transcode to H.264/AAC MP4, resize to fit within
152
- * 1920×1080, and extract poster frame + blurhash.
383
+ * Process a video file: remux to MP4, re-encoding to H.264/AAC only when the
384
+ * source needs it, resize to fit within 1920×1080, and extract poster frame
385
+ * + blurhash.
153
386
  *
154
387
  * @param file - Source video file
155
388
  * @param onProgress - Optional callback receiving progress from 0 to 1
@@ -159,8 +392,8 @@ async function processToFile(
159
392
  file: File,
160
393
  onProgress?: (progress: number) => void,
161
394
  ): Promise<VideoProcessResult> {
162
- // Extract poster + blurhash + source dimensions (separate Input instance,
163
- // so the transcoding Input below starts with clean demuxer state).
395
+ // Probe the source and grab poster + blurhash (separate Input instance,
396
+ // so the conversion Input below starts with clean demuxer state).
164
397
  const {
165
398
  poster,
166
399
  blurhash,
@@ -168,28 +401,36 @@ async function processToFile(
168
401
  sourceHeight,
169
402
  rotation,
170
403
  durationSeconds,
171
- } = await extractPoster(file);
404
+ videoCodec,
405
+ videoBitrate,
406
+ } = await probeSource(file);
172
407
 
173
- // Compute output size from display dimensions (post-rotation).
174
- // Orientation-agnostic: long edge ≤ 1920, short edge ≤ 1080.
175
- let targetW = sourceWidth || MAX_LONG_EDGE;
176
- let targetH = sourceHeight || MAX_SHORT_EDGE;
177
- if (sourceWidth && sourceHeight) {
178
- const longSide = Math.max(sourceWidth, sourceHeight);
179
- const shortSide = Math.min(sourceWidth, sourceHeight);
180
- const scale = Math.min(
181
- MAX_LONG_EDGE / longSide,
182
- MAX_SHORT_EDGE / shortSide,
183
- 1,
184
- );
185
- targetW = Math.round(sourceWidth * scale);
186
- targetH = Math.round(sourceHeight * scale);
187
- }
188
- // H.264 requires even dimensions
189
- targetW += targetW % 2;
190
- targetH += targetH % 2;
191
-
192
- // Transcode to MP4 H.264/AAC (fresh Input — not shared with extractPoster)
408
+ const {
409
+ needsResize,
410
+ width: targetW,
411
+ height: targetH,
412
+ videoBitrate: targetBitrate,
413
+ } = planVideoProcessing(
414
+ {
415
+ width: sourceWidth,
416
+ height: sourceHeight,
417
+ videoCodec,
418
+ videoBitrate,
419
+ },
420
+ { maxLongEdge: MAX_LONG_EDGE, maxShortEdge: MAX_SHORT_EDGE },
421
+ );
422
+
423
+ // Convert to MP4 (fresh Input — not shared with probeSource)
424
+ const {
425
+ Input,
426
+ Output,
427
+ Mp4OutputFormat,
428
+ BufferTarget,
429
+ BlobSource,
430
+ Conversion,
431
+ Quality,
432
+ ALL_FORMATS,
433
+ } = await loadMediabunny();
193
434
  const input = new Input({
194
435
  source: new BlobSource(file),
195
436
  formats: ALL_FORMATS,
@@ -206,14 +447,16 @@ async function processToFile(
206
447
  output,
207
448
  video: {
208
449
  codec: "avc",
209
- width: targetW,
210
- height: targetH,
211
- fit: "contain",
212
- bitrate: QUALITY_HIGH,
213
- },
214
- audio: {
215
- codec: "aac",
450
+ ...(needsResize
451
+ ? { width: targetW, height: targetH, fit: "contain" as const }
452
+ : {}),
453
+ ...(targetBitrate !== undefined
454
+ ? { quality: new Quality({ bitrate: targetBitrate }) }
455
+ : {}),
216
456
  },
457
+ // No quality set: an AAC track is copied, and anything else falls back
458
+ // to mediabunny's default, which for AAC lands on 192 kbps either way.
459
+ audio: { codec: "aac" },
217
460
  });
218
461
 
219
462
  if (onProgress) {
@@ -237,7 +480,8 @@ async function processToFile(
237
480
  // Safari's WebCodecs does NOT bake rotation, so the matrix is needed.
238
481
  // Strategy: probe the output as-is; if the dimensions already match
239
482
  // the expected display size, leave the file alone. Otherwise strip
240
- // the matrix and re-probe.
483
+ // the matrix and re-probe. A copied (not re-encoded) track never hits
484
+ // this — no encoder touched the pixels — so it falls out via dimsMatch.
241
485
  const originalName = file.name.replace(/\.[^.]+$/, "");
242
486
  let mp4File = new File([buffer], `${originalName}.mp4`, {
243
487
  type: "video/mp4",