@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.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +60 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +23 -0
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-types.ts +75 -1
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1086 -282
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +13 -0
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +70 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +78 -72
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +16 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +159 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +23 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +5 -0
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +471 -369
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +178 -17
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post.test.ts +46 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +278 -19
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1087 -7219
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +282 -289
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +20 -11
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- 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
|
|
199
|
-
const
|
|
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
|
-
|
|
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 {
|
package/src/client/toast.ts
CHANGED
|
@@ -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,
|
|
5
|
-
* and uploads with metadata attached
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
|
195
|
+
const videoTrack = await optional("open video track", () =>
|
|
196
|
+
input.getPrimaryVideoTrack(),
|
|
197
|
+
);
|
|
80
198
|
if (!videoTrack) return {};
|
|
81
199
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
152
|
-
* 1920×1080, and extract poster frame
|
|
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
|
-
//
|
|
163
|
-
// so the
|
|
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
|
-
|
|
404
|
+
videoCodec,
|
|
405
|
+
videoBitrate,
|
|
406
|
+
} = await probeSource(file);
|
|
172
407
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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",
|