@agent-native/core 0.100.2 → 0.101.2
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +65 -0
- package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/ar-SA/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/de-DE/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/es-ES/template-plan.mdx +14 -0
- package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/fr-FR/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/hi-IN/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/ja-JP/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/ko-KR/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/pt-BR/template-plan.mdx +14 -0
- package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/zh-CN/template-plan.mdx +7 -0
- package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +48 -30
- package/corpus/core/docs/content/locales/zh-TW/template-plan.mdx +7 -0
- package/corpus/core/docs/content/pr-visual-recap.mdx +50 -32
- package/corpus/core/docs/content/template-plan.mdx +14 -0
- package/corpus/core/docs/content/toolkit-comments-review.mdx +16 -2
- package/corpus/core/package.json +4 -1
- package/corpus/core/src/a2a/index.ts +2 -1
- package/corpus/core/src/a2a/server.ts +34 -9
- package/corpus/core/src/agent/production-agent.ts +56 -10
- package/corpus/core/src/cli/create.ts +47 -19
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +2 -3
- package/corpus/core/src/cli/recap.ts +3 -4932
- package/corpus/core/src/cli/skills-content/visual-plan-skill.ts +6 -4
- package/corpus/core/src/cli/skills-content/visual-recap-skill.ts +7 -1
- package/corpus/core/src/client/AgentPanel.tsx +19 -17
- package/corpus/core/src/client/analytics.ts +8 -1
- package/corpus/core/src/client/chat/markdown-renderer.tsx +54 -8
- package/corpus/core/src/client/chat/message-components.tsx +8 -2
- package/corpus/core/src/client/clipboard.ts +28 -1
- package/corpus/core/src/client/guided-questions.tsx +4 -0
- package/corpus/core/src/client/i18n.tsx +2 -1
- package/corpus/core/src/client/index.ts +4 -0
- package/corpus/core/src/client/review/ReviewCommentComposer.tsx +108 -0
- package/corpus/core/src/client/review/ReviewThreadPanel.tsx +482 -150
- package/corpus/core/src/client/review/index.ts +8 -0
- package/corpus/core/src/client/review/use-review.ts +35 -1
- package/corpus/core/src/client/use-agent-chat-context.ts +3 -2
- package/corpus/core/src/review/actions/create-review-comment.ts +2 -1
- package/corpus/core/src/review/actions/get-review-feedback.ts +6 -11
- package/corpus/core/src/review/actions/list-review-comments.ts +38 -4
- package/corpus/core/src/review/actions/reply-review-comment.ts +33 -22
- package/corpus/core/src/review/actions/resolve-review-thread.ts +29 -2
- package/corpus/core/src/review/actions/send-review-thread-to-agent.ts +62 -0
- package/corpus/core/src/review/identity.ts +101 -0
- package/corpus/core/src/review/index.ts +14 -0
- package/corpus/core/src/review/store.ts +315 -8
- package/corpus/core/src/review/types.ts +4 -0
- package/corpus/core/src/server/action-discovery.ts +4 -0
- package/corpus/core/src/server/action-routes.ts +125 -4
- package/corpus/core/src/server/agent-chat/plugin-options.ts +16 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +1 -0
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/core/src/vite/action-types-plugin.ts +4 -0
- package/corpus/templates/analytics/changelog/2026-07-14-daily-dashboard-email-captures-authenticate-with-a-sessi.md +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +156 -3
- package/corpus/templates/assets/app/components/generation/GenerationResults.tsx +402 -142
- package/corpus/templates/assets/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/assets/app/i18n-data.ts +1 -0
- package/corpus/templates/assets/app/routes/library.tsx +187 -48
- package/corpus/templates/assets/changelog/2026-07-13-merged-the-candidates-panel-into-the-library-as-a-drafts-tab.md +6 -0
- package/corpus/templates/chat/app/lib/agent-page.tsx +40 -0
- package/corpus/templates/chat/app/routes/agent.tsx +5 -3
- package/corpus/templates/chat/changelog/2026-07-14-fixed-chat-template-startup-with-older-core-versions.md +6 -0
- package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +5 -0
- package/corpus/templates/clips/AGENTS.md +4 -0
- package/corpus/templates/clips/actions/get-feature-flags.ts +30 -0
- package/corpus/templates/clips/actions/lib/transcript-preview.ts +48 -0
- package/corpus/templates/clips/actions/request-transcript.ts +41 -1
- package/corpus/templates/clips/actions/view-screen.ts +5 -9
- package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
- package/corpus/templates/clips/app/components/player/video-player.tsx +39 -1
- package/corpus/templates/clips/app/hooks/use-desktop-promo.ts +6 -12
- package/corpus/templates/clips/app/hooks/use-mse-video-source.ts +146 -0
- package/corpus/templates/clips/app/lib/capture-install-options.ts +21 -0
- package/corpus/templates/clips/app/lib/fmp4.ts +271 -0
- package/corpus/templates/clips/app/lib/mse-video-loader.ts +585 -0
- package/corpus/templates/clips/app/routes/_app.dictate.tsx +0 -4
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +5 -13
- package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +2 -37
- package/corpus/templates/clips/app/routes/download.tsx +3 -2
- package/corpus/templates/clips/changelog/2026-07-10-shared-clips-now-start-playing-instantly-instead-of-showing-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-14-clips-now-distinguishes-transcript-previews-from-incomplete-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-14-desktop-app-prompts-stay-hidden-after-you-download-the-insta.md +6 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +5 -0
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +10 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +4 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +2518 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +353 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +558 -67
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +11 -1
- package/corpus/templates/clips/desktop/src-tauri/src/remote_flags.rs +188 -0
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +1 -1
- package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +1 -0
- package/corpus/templates/clips/server/plugins/agent-chat.ts +5 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +5 -1
- package/corpus/templates/clips/shared/feature-flags.ts +41 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +3 -0
- package/corpus/templates/design/.agents/skills/design-review-feedback/SKILL.md +42 -0
- package/corpus/templates/design/AGENTS.md +6 -0
- package/corpus/templates/design/README.md +1 -0
- package/corpus/templates/design/actions/navigate.ts +4 -4
- package/corpus/templates/design/actions/view-screen.ts +116 -2
- package/corpus/templates/design/agent-native.app-skill.json +5 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +52 -59
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +13 -9
- package/corpus/templates/design/app/components/design/EditPanel.tsx +79 -11
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +78 -65
- package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +161 -0
- package/corpus/templates/design/app/components/design/ReviewStatusControl.tsx +96 -0
- package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +166 -0
- package/corpus/templates/design/app/components/layout/Layout.tsx +1 -0
- package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +1077 -0
- package/corpus/templates/design/app/components/visual-editor/index.ts +1 -0
- package/corpus/templates/design/app/components/visual-editor/review-canvas-state.ts +42 -0
- package/corpus/templates/design/app/hooks/use-design-systems.ts +2 -2
- package/corpus/templates/design/app/hooks/use-navigation-state.ts +9 -6
- package/corpus/templates/design/app/hooks/use-question-flow.ts +7 -1
- package/corpus/templates/design/app/i18n/ar-SA.ts +56 -1
- package/corpus/templates/design/app/i18n/de-DE.ts +57 -1
- package/corpus/templates/design/app/i18n/en-US.ts +56 -1
- package/corpus/templates/design/app/i18n/es-ES.ts +56 -1
- package/corpus/templates/design/app/i18n/fr-FR.ts +57 -1
- package/corpus/templates/design/app/i18n/hi-IN.ts +56 -1
- package/corpus/templates/design/app/i18n/ja-JP.ts +56 -1
- package/corpus/templates/design/app/i18n/ko-KR.ts +56 -1
- package/corpus/templates/design/app/i18n/pt-BR.ts +57 -1
- package/corpus/templates/design/app/i18n/zh-CN.ts +56 -1
- package/corpus/templates/design/app/i18n/zh-TW.ts +51 -0
- package/corpus/templates/design/app/pages/Present.tsx +172 -14
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +23 -0
- package/corpus/templates/design/app/pages/design-editor/tool-state.ts +10 -0
- package/corpus/templates/design/app/pages/present-review-state.ts +18 -0
- package/corpus/templates/design/app/public-routes.ts +8 -0
- package/corpus/templates/design/app/root.tsx +7 -6
- package/corpus/templates/design/changelog/2026-07-13-dragged-design-assets-now-appear-at-the-visible-drop-locatio.md +6 -0
- package/corpus/templates/design/changelog/2026-07-13-reviewers-can-pin-comments-on-shared-designs-and-apply-verif.md +6 -0
- package/corpus/templates/design/server/plugins/agent-chat.ts +10 -0
- package/corpus/templates/design/server/plugins/auth.ts +7 -5
- package/corpus/templates/design/server/plugins/review.ts +9 -0
- package/corpus/templates/design/shared/review-anchor.ts +83 -0
- package/corpus/templates/design/shared/review-summary.ts +25 -0
- package/corpus/templates/forms/.agents/skills/form-publishing/SKILL.md +2 -0
- package/corpus/templates/forms/AGENTS.md +4 -0
- package/corpus/templates/forms/actions/create-form.ts +2 -1
- package/corpus/templates/forms/actions/update-form.ts +5 -2
- package/corpus/templates/forms/app/i18n/ar-SA.ts +3 -0
- package/corpus/templates/forms/app/i18n/de-DE.ts +3 -0
- package/corpus/templates/forms/app/i18n/en-US.ts +3 -0
- package/corpus/templates/forms/app/i18n/es-ES.ts +3 -0
- package/corpus/templates/forms/app/i18n/fr-FR.ts +3 -0
- package/corpus/templates/forms/app/i18n/hi-IN.ts +3 -0
- package/corpus/templates/forms/app/i18n/ja-JP.ts +3 -0
- package/corpus/templates/forms/app/i18n/ko-KR.ts +3 -0
- package/corpus/templates/forms/app/i18n/pt-BR.ts +3 -0
- package/corpus/templates/forms/app/i18n/zh-CN.ts +3 -0
- package/corpus/templates/forms/app/i18n/zh-TW.ts +3 -0
- package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +19 -0
- package/corpus/templates/forms/changelog/2026-07-14-fixed-the-ask-forms-loading-input-appearing-above-the-welcom.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-14-form-social-previews-now-show-the-form-title-description-and.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-14-forms-can-email-the-form-owner-when-new-responses-arrive.md +6 -0
- package/corpus/templates/forms/server/handlers/submissions.ts +17 -0
- package/corpus/templates/forms/server/lib/form-og-image.ts +64 -6
- package/corpus/templates/forms/server/lib/public-form-ssr.ts +6 -2
- package/corpus/templates/forms/server/lib/response-email.ts +65 -0
- package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -0
- package/corpus/templates/forms/server/routes/api/forms/og/[...slug]/og.png.get.ts +184 -0
- package/corpus/templates/forms/shared/types.ts +2 -0
- package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +6 -4
- package/corpus/templates/plan/.agents/skills/visual-recap/SKILL.md +7 -1
- package/dist/a2a/index.d.ts +2 -1
- package/dist/a2a/index.d.ts.map +1 -1
- package/dist/a2a/index.js +1 -1
- package/dist/a2a/index.js.map +1 -1
- package/dist/a2a/server.d.ts +25 -0
- package/dist/a2a/server.d.ts.map +1 -1
- package/dist/a2a/server.js +30 -6
- package/dist/a2a/server.js.map +1 -1
- package/dist/agent/production-agent.d.ts +2 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +37 -12
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/create.d.ts +5 -3
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +44 -19
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +2 -2
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +2 -2
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/cli/recap.d.ts +3 -562
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +3 -3875
- package/dist/cli/recap.js.map +1 -1
- package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
- package/dist/cli/skills-content/visual-plan-skill.d.ts.map +1 -1
- package/dist/cli/skills-content/visual-plan-skill.js +6 -4
- package/dist/cli/skills-content/visual-plan-skill.js.map +1 -1
- package/dist/cli/skills-content/visual-recap-skill.d.ts +1 -1
- package/dist/cli/skills-content/visual-recap-skill.d.ts.map +1 -1
- package/dist/cli/skills-content/visual-recap-skill.js +7 -1
- package/dist/cli/skills-content/visual-recap-skill.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +3 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/analytics.d.ts +5 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +3 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/chat/markdown-renderer.d.ts +1 -0
- package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
- package/dist/client/chat/markdown-renderer.js +36 -1
- package/dist/client/chat/markdown-renderer.js.map +1 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +5 -2
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/clipboard.d.ts +3 -1
- package/dist/client/clipboard.d.ts.map +1 -1
- package/dist/client/clipboard.js +24 -1
- package/dist/client/clipboard.js.map +1 -1
- package/dist/client/guided-questions.d.ts +3 -1
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +2 -1
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/client/i18n.d.ts.map +1 -1
- package/dist/client/i18n.js +3 -1
- package/dist/client/i18n.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/review/ReviewCommentComposer.d.ts +18 -0
- package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -0
- package/dist/client/review/ReviewCommentComposer.js +30 -0
- package/dist/client/review/ReviewCommentComposer.js.map +1 -0
- package/dist/client/review/ReviewThreadPanel.d.ts +28 -1
- package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
- package/dist/client/review/ReviewThreadPanel.js +151 -39
- package/dist/client/review/ReviewThreadPanel.js.map +1 -1
- package/dist/client/review/index.d.ts +3 -2
- package/dist/client/review/index.d.ts.map +1 -1
- package/dist/client/review/index.js +2 -1
- package/dist/client/review/index.js.map +1 -1
- package/dist/client/review/use-review.d.ts +23 -0
- package/dist/client/review/use-review.d.ts.map +1 -1
- package/dist/client/review/use-review.js +3 -0
- package/dist/client/review/use-review.js.map +1 -1
- package/dist/client/use-agent-chat-context.d.ts +1 -1
- package/dist/client/use-agent-chat-context.d.ts.map +1 -1
- package/dist/client/use-agent-chat-context.js +4 -2
- package/dist/client/use-agent-chat-context.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/review/actions/create-review-comment.js +2 -1
- package/dist/review/actions/create-review-comment.js.map +1 -1
- package/dist/review/actions/get-review-feedback.js +6 -11
- package/dist/review/actions/get-review-feedback.js.map +1 -1
- package/dist/review/actions/list-review-comments.d.ts +1 -0
- package/dist/review/actions/list-review-comments.js +25 -4
- package/dist/review/actions/list-review-comments.js.map +1 -1
- package/dist/review/actions/reply-review-comment.js +12 -5
- package/dist/review/actions/reply-review-comment.js.map +1 -1
- package/dist/review/actions/resolve-review-thread.d.ts +3 -0
- package/dist/review/actions/resolve-review-thread.js +20 -3
- package/dist/review/actions/resolve-review-thread.js.map +1 -1
- package/dist/review/actions/send-review-thread-to-agent.d.ts +17 -0
- package/dist/review/actions/send-review-thread-to-agent.d.ts.map +1 -0
- package/dist/review/actions/send-review-thread-to-agent.js +49 -0
- package/dist/review/actions/send-review-thread-to-agent.js.map +1 -0
- package/dist/review/identity.d.ts +6 -0
- package/dist/review/identity.d.ts.map +1 -0
- package/dist/review/identity.js +68 -0
- package/dist/review/identity.js.map +1 -0
- package/dist/review/index.d.ts +3 -1
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/index.js +2 -1
- package/dist/review/index.js.map +1 -1
- package/dist/review/store.d.ts +36 -0
- package/dist/review/store.d.ts.map +1 -1
- package/dist/review/store.js +223 -8
- package/dist/review/store.js.map +1 -1
- package/dist/review/types.d.ts +4 -0
- package/dist/review/types.d.ts.map +1 -1
- package/dist/review/types.js.map +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +4 -0
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/action-routes.d.ts +45 -0
- package/dist/server/action-routes.d.ts.map +1 -1
- package/dist/server/action-routes.js +71 -5
- package/dist/server/action-routes.js.map +1 -1
- package/dist/server/agent-chat/plugin-options.d.ts +16 -0
- package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
- package/dist/server/agent-chat/plugin-options.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +1 -0
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/vite/action-types-plugin.d.ts.map +1 -1
- package/dist/vite/action-types-plugin.js +4 -0
- package/dist/vite/action-types-plugin.js.map +1 -1
- package/docs/content/locales/ar-SA/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/ar-SA/template-plan.mdx +7 -0
- package/docs/content/locales/de-DE/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/de-DE/template-plan.mdx +7 -0
- package/docs/content/locales/es-ES/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/es-ES/template-plan.mdx +14 -0
- package/docs/content/locales/fr-FR/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/fr-FR/template-plan.mdx +7 -0
- package/docs/content/locales/hi-IN/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/hi-IN/template-plan.mdx +7 -0
- package/docs/content/locales/ja-JP/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/ja-JP/template-plan.mdx +7 -0
- package/docs/content/locales/ko-KR/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/ko-KR/template-plan.mdx +7 -0
- package/docs/content/locales/pt-BR/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/pt-BR/template-plan.mdx +14 -0
- package/docs/content/locales/zh-CN/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/zh-CN/template-plan.mdx +7 -0
- package/docs/content/locales/zh-TW/pr-visual-recap.mdx +48 -30
- package/docs/content/locales/zh-TW/template-plan.mdx +7 -0
- package/docs/content/pr-visual-recap.mdx +50 -32
- package/docs/content/template-plan.mdx +14 -0
- package/docs/content/toolkit-comments-review.mdx +16 -2
- package/package.json +4 -1
- package/corpus/templates/forms/server/routes/api/forms/og/[slug]/og.png.get.ts +0 -45
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
DropdownMenuTrigger,
|
|
29
29
|
} from "@/components/ui/dropdown-menu";
|
|
30
30
|
import { Spinner } from "@/components/ui/spinner";
|
|
31
|
+
import { useMseVideoSource } from "@/hooks/use-mse-video-source";
|
|
31
32
|
import {
|
|
32
33
|
parsePlaybackSpeed,
|
|
33
34
|
readPlaybackSpeedPreference,
|
|
@@ -365,6 +366,32 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
365
366
|
[resolvedVideoSrc],
|
|
366
367
|
);
|
|
367
368
|
|
|
369
|
+
// Media Source Extensions path for raw fragmented-MP4 recordings (desktop
|
|
370
|
+
// live-stream uploads). Those files declare no up-front duration, so the
|
|
371
|
+
// native progressive pipeline scans the whole file before it can play from
|
|
372
|
+
// a CDN. When the asset sniffs as fragmented, `mse.mode === "mse"` and we
|
|
373
|
+
// hand the element a MediaSource object URL instead of the raw URL, with the
|
|
374
|
+
// duration supplied from the DB. Everything else (classic MP4, WebM, Loom,
|
|
375
|
+
// browsers without MediaSource) stays on the native `<video src>` path,
|
|
376
|
+
// byte-for-byte unchanged.
|
|
377
|
+
const mse = useMseVideoSource({
|
|
378
|
+
videoRef,
|
|
379
|
+
sourceUrl: resolvedVideoSrc,
|
|
380
|
+
durationMs,
|
|
381
|
+
videoFormat,
|
|
382
|
+
disabled: isLoomEmbed || unsupportedFormat,
|
|
383
|
+
});
|
|
384
|
+
const mseActive = mse.mode === "mse" && Boolean(mse.objectUrl);
|
|
385
|
+
// The URL actually put on the <video> element: the MediaSource object URL
|
|
386
|
+
// while MSE drives playback, nothing while we're still sniffing an eligible
|
|
387
|
+
// asset (so the browser never starts the slow native scan), otherwise the
|
|
388
|
+
// normal resolved/cache-busted source.
|
|
389
|
+
const domVideoSrc = mseActive
|
|
390
|
+
? mse.objectUrl
|
|
391
|
+
: mse.mode === "pending"
|
|
392
|
+
? undefined
|
|
393
|
+
: activeVideoSrc;
|
|
394
|
+
|
|
368
395
|
useEffect(() => {
|
|
369
396
|
if (!resolvedVideoSrc) {
|
|
370
397
|
setActiveVideoSrc(undefined);
|
|
@@ -1164,7 +1191,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
1164
1191
|
) : activeVideoSrc ? (
|
|
1165
1192
|
<video
|
|
1166
1193
|
ref={videoRef}
|
|
1167
|
-
src={
|
|
1194
|
+
src={domVideoSrc}
|
|
1168
1195
|
poster={resolveLocalUrl(thumbnailUrl)}
|
|
1169
1196
|
// `crossOrigin` is only needed so the owner's canvas thumbnail
|
|
1170
1197
|
// capture isn't tainted. For everyone else (viewers, and the Slack
|
|
@@ -1328,6 +1355,17 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|
|
1328
1355
|
playAttemptPendingRef.current = false;
|
|
1329
1356
|
setIsPlayPending(false);
|
|
1330
1357
|
|
|
1358
|
+
// If the MSE pipeline surfaced a media error, tear it down and let
|
|
1359
|
+
// the native <video src> path take over the raw asset URL instead
|
|
1360
|
+
// of running the cache-bust retry against a MediaSource blob URL.
|
|
1361
|
+
if (mseActive) {
|
|
1362
|
+
mse.fallbackToNative();
|
|
1363
|
+
setIsBuffering(false);
|
|
1364
|
+
setIsPreparing(true);
|
|
1365
|
+
setCanPlay(false);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1331
1369
|
// Most "format not supported" / decode errors reported here are
|
|
1332
1370
|
// transient — e.g. the share page's video element started
|
|
1333
1371
|
// fetching a moment before a background seekable-remux pass
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
hasDownloadedDesktopApp,
|
|
6
|
+
markDesktopAppDownloaded,
|
|
7
|
+
} from "@/lib/capture-install-options";
|
|
6
8
|
|
|
7
9
|
function detectDesktopApp(): boolean {
|
|
8
10
|
if (typeof navigator === "undefined") return false;
|
|
@@ -25,20 +27,12 @@ export function useDesktopPromo() {
|
|
|
25
27
|
|
|
26
28
|
useEffect(() => {
|
|
27
29
|
setIsDesktopApp(detectDesktopApp());
|
|
28
|
-
setDismissed(
|
|
29
|
-
typeof window !== "undefined" &&
|
|
30
|
-
window.localStorage?.getItem(DISMISSED_KEY) === "1",
|
|
31
|
-
);
|
|
30
|
+
setDismissed(hasDownloadedDesktopApp());
|
|
32
31
|
}, []);
|
|
33
32
|
|
|
34
33
|
const dismiss = useCallback(() => {
|
|
35
34
|
setDismissed(true);
|
|
36
|
-
|
|
37
|
-
window.localStorage?.setItem(DISMISSED_KEY, "1");
|
|
38
|
-
} catch {
|
|
39
|
-
// localStorage can throw in private browsing — ignore, dismissal
|
|
40
|
-
// still holds for the session via React state.
|
|
41
|
-
}
|
|
35
|
+
markDesktopAppDownloaded();
|
|
42
36
|
}, []);
|
|
43
37
|
|
|
44
38
|
return {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether a recording should play through Media Source Extensions and,
|
|
3
|
+
* when it should, owns the `MseVideoLoader` lifecycle.
|
|
4
|
+
*
|
|
5
|
+
* Raw fragmented-MP4 recordings (from the desktop live-streaming pipeline)
|
|
6
|
+
* declare no up-front duration, so the browser's progressive `<video src>`
|
|
7
|
+
* pipeline stalls when they're served from a CDN. This hook sniffs the asset,
|
|
8
|
+
* and for fragmented files hands the video element a `MediaSource` object URL
|
|
9
|
+
* driven by range requests with the duration supplied from the DB. Classic MP4,
|
|
10
|
+
* WebM, Loom, and anything the browser can't MSE fall back to the native path
|
|
11
|
+
* unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type RefObject,
|
|
16
|
+
useCallback,
|
|
17
|
+
useEffect,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
} from "react";
|
|
21
|
+
|
|
22
|
+
import { sniffFragmentedMp4 } from "@/lib/fmp4";
|
|
23
|
+
import { MseVideoLoader, isMediaSourceSupported } from "@/lib/mse-video-loader";
|
|
24
|
+
|
|
25
|
+
export type MseSourceMode = "pending" | "native" | "mse";
|
|
26
|
+
|
|
27
|
+
export interface UseMseVideoSourceParams {
|
|
28
|
+
videoRef: RefObject<HTMLVideoElement | null>;
|
|
29
|
+
/** The resolved asset URL (proxied same-origin or provider URL). */
|
|
30
|
+
sourceUrl: string | undefined;
|
|
31
|
+
/** Authoritative duration in ms from the DB. */
|
|
32
|
+
durationMs: number;
|
|
33
|
+
/** Container format hint. WebM is never eligible for this MP4-only loader. */
|
|
34
|
+
videoFormat?: "webm" | "mp4" | null;
|
|
35
|
+
/** True for Loom embeds / formats the browser cannot decode — never MSE. */
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseMseVideoSourceResult {
|
|
40
|
+
mode: MseSourceMode;
|
|
41
|
+
/** MediaSource object URL to use as `<video src>` when `mode === "mse"`. */
|
|
42
|
+
objectUrl: string | undefined;
|
|
43
|
+
/** Drop MSE and revert to the plain `<video src>` path. */
|
|
44
|
+
fallbackToNative: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useMseVideoSource({
|
|
48
|
+
videoRef,
|
|
49
|
+
sourceUrl,
|
|
50
|
+
durationMs,
|
|
51
|
+
videoFormat,
|
|
52
|
+
disabled,
|
|
53
|
+
}: UseMseVideoSourceParams): UseMseVideoSourceResult {
|
|
54
|
+
// WebM recordings use the native Infinity-duration workaround; only MP4 (and
|
|
55
|
+
// unknown-format, which the sniff will confirm) are candidates.
|
|
56
|
+
const eligible =
|
|
57
|
+
!disabled &&
|
|
58
|
+
!!sourceUrl &&
|
|
59
|
+
videoFormat !== "webm" &&
|
|
60
|
+
isMediaSourceSupported();
|
|
61
|
+
|
|
62
|
+
const [mode, setMode] = useState<MseSourceMode>(() =>
|
|
63
|
+
eligible ? "pending" : "native",
|
|
64
|
+
);
|
|
65
|
+
const [objectUrl, setObjectUrl] = useState<string | undefined>(undefined);
|
|
66
|
+
const loaderRef = useRef<MseVideoLoader | null>(null);
|
|
67
|
+
|
|
68
|
+
// Latest duration, kept in a ref so the (deliberately duration-independent)
|
|
69
|
+
// rebuild effect below seeds a freshly created loader with the current value
|
|
70
|
+
// even if it changed during the async sniff. Updating a ref in render is
|
|
71
|
+
// side-effect-free and safe.
|
|
72
|
+
const durationMsRef = useRef(durationMs);
|
|
73
|
+
durationMsRef.current = durationMs;
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
loaderRef.current?.destroy();
|
|
77
|
+
loaderRef.current = null;
|
|
78
|
+
setObjectUrl(undefined);
|
|
79
|
+
|
|
80
|
+
if (!eligible || !sourceUrl) {
|
|
81
|
+
setMode("native");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setMode("pending");
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
|
|
88
|
+
void sniffFragmentedMp4(sourceUrl)
|
|
89
|
+
.then((isFragmented) => {
|
|
90
|
+
if (cancelled) return;
|
|
91
|
+
const video = videoRef.current;
|
|
92
|
+
if (!isFragmented || !video) {
|
|
93
|
+
setMode("native");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const loader = new MseVideoLoader({
|
|
98
|
+
url: sourceUrl,
|
|
99
|
+
durationMs: durationMsRef.current,
|
|
100
|
+
video,
|
|
101
|
+
onFatal: () => {
|
|
102
|
+
if (cancelled) return;
|
|
103
|
+
loaderRef.current?.destroy();
|
|
104
|
+
loaderRef.current = null;
|
|
105
|
+
setObjectUrl(undefined);
|
|
106
|
+
setMode("native");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
loaderRef.current = loader;
|
|
110
|
+
setObjectUrl(loader.objectUrl);
|
|
111
|
+
setMode("mse");
|
|
112
|
+
} catch {
|
|
113
|
+
setMode("native");
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
.catch(() => {
|
|
117
|
+
if (!cancelled) setMode("native");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
cancelled = true;
|
|
122
|
+
loaderRef.current?.destroy();
|
|
123
|
+
loaderRef.current = null;
|
|
124
|
+
};
|
|
125
|
+
// videoRef is a stable ref object. `durationMs` is intentionally excluded:
|
|
126
|
+
// it only seeds the timeline and is pushed to the live loader by the effect
|
|
127
|
+
// below, so a metadata-poll update must not rebuild the loader (which would
|
|
128
|
+
// revoke the object URL and restart playback from byte zero).
|
|
129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
130
|
+
}, [eligible, sourceUrl]);
|
|
131
|
+
|
|
132
|
+
// Recording metadata polling can raise the DB duration while the same
|
|
133
|
+
// fragmented asset is still playing; apply it to the live loader in place.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
loaderRef.current?.setDuration(durationMs);
|
|
136
|
+
}, [durationMs]);
|
|
137
|
+
|
|
138
|
+
const fallbackToNative = useCallback(() => {
|
|
139
|
+
loaderRef.current?.destroy();
|
|
140
|
+
loaderRef.current = null;
|
|
141
|
+
setObjectUrl(undefined);
|
|
142
|
+
setMode("native");
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
return { mode, objectUrl, fallbackToNative };
|
|
146
|
+
}
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
const DESKTOP_DOWNLOAD_STORAGE_KEY = "clips.desktop-promo.dismissed";
|
|
2
|
+
|
|
3
|
+
export function hasDownloadedDesktopApp(): boolean {
|
|
4
|
+
try {
|
|
5
|
+
return (
|
|
6
|
+
typeof window !== "undefined" &&
|
|
7
|
+
window.localStorage?.getItem(DESKTOP_DOWNLOAD_STORAGE_KEY) === "1"
|
|
8
|
+
);
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function markDesktopAppDownloaded(): void {
|
|
15
|
+
try {
|
|
16
|
+
window.localStorage?.setItem(DESKTOP_DOWNLOAD_STORAGE_KEY, "1");
|
|
17
|
+
} catch {
|
|
18
|
+
// Download tracking is best-effort and must not block the installer.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
function isFalsy(value: unknown): boolean {
|
|
2
23
|
if (typeof value !== "string") return false;
|
|
3
24
|
return ["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fragmented-MP4 helpers for the Media Source Extensions player path.
|
|
3
|
+
*
|
|
4
|
+
* The desktop custom recording pipeline live-streams captures as fragmented
|
|
5
|
+
* MP4 (an `ftyp`+`moov` init segment followed by ~1s `moof`/`mdat` fragments,
|
|
6
|
+
* brands `isom iso5 hlsf`). Those files declare no up-front duration
|
|
7
|
+
* (`mvhd duration=0`, no `mehd`), so Chrome's progressive `<video src>`
|
|
8
|
+
* pipeline scans the whole file over the network before firing
|
|
9
|
+
* `loadedmetadata`. We instead drive playback through MSE and supply the
|
|
10
|
+
* duration ourselves. These pure helpers detect that file shape and parse the
|
|
11
|
+
* bits of the init segment MSE needs.
|
|
12
|
+
*
|
|
13
|
+
* Everything here operates on raw bytes so it can be unit-tested without a
|
|
14
|
+
* browser.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Read a 4-char ASCII box type at `offset`. */
|
|
18
|
+
function readType(bytes: Uint8Array, offset: number): string {
|
|
19
|
+
return String.fromCharCode(
|
|
20
|
+
bytes[offset],
|
|
21
|
+
bytes[offset + 1],
|
|
22
|
+
bytes[offset + 2],
|
|
23
|
+
bytes[offset + 3],
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read a big-endian uint32 at `offset`. */
|
|
28
|
+
function readU32(bytes: Uint8Array, offset: number): number {
|
|
29
|
+
return (
|
|
30
|
+
((bytes[offset] << 24) |
|
|
31
|
+
(bytes[offset + 1] << 16) |
|
|
32
|
+
(bytes[offset + 2] << 8) |
|
|
33
|
+
bytes[offset + 3]) >>>
|
|
34
|
+
0
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Find the first index of an ASCII marker at or after `from`, or -1. */
|
|
39
|
+
export function indexOfAscii(
|
|
40
|
+
bytes: Uint8Array,
|
|
41
|
+
marker: string,
|
|
42
|
+
from = 0,
|
|
43
|
+
): number {
|
|
44
|
+
const len = marker.length;
|
|
45
|
+
const last = bytes.byteLength - len;
|
|
46
|
+
for (let i = Math.max(0, from); i <= last; i++) {
|
|
47
|
+
let match = true;
|
|
48
|
+
for (let j = 0; j < len; j++) {
|
|
49
|
+
if (bytes[i + j] !== marker.charCodeAt(j)) {
|
|
50
|
+
match = false;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (match) return i;
|
|
55
|
+
}
|
|
56
|
+
return -1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TopLevelBox {
|
|
60
|
+
type: string;
|
|
61
|
+
/** Absolute offset of the box (its size field). */
|
|
62
|
+
start: number;
|
|
63
|
+
/** Total box size in bytes, or 0 when the box runs to the end of input. */
|
|
64
|
+
size: number;
|
|
65
|
+
/** Bytes from `start` to the first byte of box payload. */
|
|
66
|
+
headerSize: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk the top-level MP4 boxes contained in `bytes`. Stops when a box header
|
|
71
|
+
* would run past the buffer (i.e. the buffer is a truncated head), returning
|
|
72
|
+
* whatever complete boxes were found.
|
|
73
|
+
*/
|
|
74
|
+
export function readTopLevelBoxes(bytes: Uint8Array): TopLevelBox[] {
|
|
75
|
+
const boxes: TopLevelBox[] = [];
|
|
76
|
+
let offset = 0;
|
|
77
|
+
const total = bytes.byteLength;
|
|
78
|
+
|
|
79
|
+
while (offset + 8 <= total) {
|
|
80
|
+
let size = readU32(bytes, offset);
|
|
81
|
+
let headerSize = 8;
|
|
82
|
+
const type = readType(bytes, offset + 4);
|
|
83
|
+
|
|
84
|
+
if (size === 1) {
|
|
85
|
+
// 64-bit largesize. We only need the low 32 bits in practice (recordings
|
|
86
|
+
// never exceed 4GB), and JS bitwise math is 32-bit, so read the low word.
|
|
87
|
+
if (offset + 16 > total) break;
|
|
88
|
+
headerSize = 16;
|
|
89
|
+
size = readU32(bytes, offset + 12);
|
|
90
|
+
} else if (size === 0) {
|
|
91
|
+
// Box extends to end of file.
|
|
92
|
+
boxes.push({ type, start: offset, size: 0, headerSize });
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (size < headerSize) break;
|
|
97
|
+
boxes.push({ type, start: offset, size, headerSize });
|
|
98
|
+
offset += size;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return boxes;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ParsedInitSegment {
|
|
105
|
+
/** Length in bytes of the init segment (end of the `moov` box). */
|
|
106
|
+
initLength: number;
|
|
107
|
+
/** `codecs` string for `video/mp4; codecs="..."`. */
|
|
108
|
+
codecs: string;
|
|
109
|
+
hasVideo: boolean;
|
|
110
|
+
hasAudio: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse the init segment from the head of a fragmented MP4. `bytes` must
|
|
115
|
+
* contain the whole `ftyp`+`moov` prefix (a few hundred KB is always enough —
|
|
116
|
+
* the init segment carries no per-fragment data). Returns null when `moov` is
|
|
117
|
+
* incomplete or no usable video codec was found.
|
|
118
|
+
*/
|
|
119
|
+
export function parseInitSegment(bytes: Uint8Array): ParsedInitSegment | null {
|
|
120
|
+
const boxes = readTopLevelBoxes(bytes);
|
|
121
|
+
const moov = boxes.find((b) => b.type === "moov");
|
|
122
|
+
if (!moov || moov.size === 0) return null;
|
|
123
|
+
const moovEnd = moov.start + moov.size;
|
|
124
|
+
if (moovEnd > bytes.byteLength) return null; // moov not fully present yet
|
|
125
|
+
|
|
126
|
+
const moovRegion = bytes.subarray(moov.start, moovEnd);
|
|
127
|
+
const videoCodec = parseAvcCodec(moovRegion);
|
|
128
|
+
const hasAudio = indexOfAscii(moovRegion, "mp4a") !== -1;
|
|
129
|
+
const hasVideo = Boolean(videoCodec);
|
|
130
|
+
|
|
131
|
+
if (!hasVideo && !hasAudio) return null;
|
|
132
|
+
|
|
133
|
+
const codecParts: string[] = [];
|
|
134
|
+
if (videoCodec) codecParts.push(videoCodec);
|
|
135
|
+
// Clips fMP4 audio is always AAC-LC 48kHz stereo → mp4a.40.2.
|
|
136
|
+
if (hasAudio) codecParts.push("mp4a.40.2");
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
initLength: moovEnd,
|
|
140
|
+
codecs: codecParts.join(","),
|
|
141
|
+
hasVideo,
|
|
142
|
+
hasAudio,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hex2(value: number): string {
|
|
147
|
+
return value.toString(16).padStart(2, "0");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the `avc1.PPCCLL` codec string from the `avcC` configuration box within
|
|
152
|
+
* `moovRegion` (PP=profile, CC=profile-compatibility, LL=level). Returns null
|
|
153
|
+
* when no `avcC` box is present.
|
|
154
|
+
*/
|
|
155
|
+
export function parseAvcCodec(moovRegion: Uint8Array): string | null {
|
|
156
|
+
const marker = indexOfAscii(moovRegion, "avcC");
|
|
157
|
+
if (marker === -1) return null;
|
|
158
|
+
// avcC payload: [0] configurationVersion, [1] AVCProfileIndication,
|
|
159
|
+
// [2] profile_compatibility, [3] AVCLevelIndication.
|
|
160
|
+
const config = marker + 4;
|
|
161
|
+
if (config + 4 > moovRegion.byteLength) return null;
|
|
162
|
+
const profile = moovRegion[config + 1];
|
|
163
|
+
const compat = moovRegion[config + 2];
|
|
164
|
+
const level = moovRegion[config + 3];
|
|
165
|
+
return `avc1.${hex2(profile)}${hex2(compat)}${hex2(level)}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Find the byte offset (relative to `bytes`) of the first `moof` box start — the
|
|
170
|
+
* fragment boundary MSE must begin an append at. `bytes` is a chunk fetched at
|
|
171
|
+
* an arbitrary byte position, so this scans for the `moof` type marker and
|
|
172
|
+
* backs up 4 bytes to the box size field. Returns -1 when none is found.
|
|
173
|
+
*/
|
|
174
|
+
export function findMoofOffset(bytes: Uint8Array): number {
|
|
175
|
+
let from = 0;
|
|
176
|
+
for (;;) {
|
|
177
|
+
const marker = indexOfAscii(bytes, "moof", from);
|
|
178
|
+
if (marker === -1) return -1;
|
|
179
|
+
const boxStart = marker - 4;
|
|
180
|
+
from = marker + 4;
|
|
181
|
+
if (boxStart < 0) continue;
|
|
182
|
+
|
|
183
|
+
const size = readU32(bytes, boxStart);
|
|
184
|
+
// A moof box is small; a bogus size means we hit the ASCII "moof" inside
|
|
185
|
+
// mdat payload rather than a real box header.
|
|
186
|
+
if (size < 16 || size > 16 * 1024 * 1024) continue;
|
|
187
|
+
|
|
188
|
+
// A real moof always begins with an `mfhd` child box (payload starts at
|
|
189
|
+
// boxStart+8, whose first child type sits at boxStart+12).
|
|
190
|
+
if (boxStart + 16 > bytes.byteLength) continue;
|
|
191
|
+
if (readType(bytes, boxStart + 12) !== "mfhd") continue;
|
|
192
|
+
|
|
193
|
+
// ...and is immediately followed by its `mdat`. Require this when the
|
|
194
|
+
// follower is within the buffer; near the tail we accept the validated moof.
|
|
195
|
+
const nextBox = boxStart + size;
|
|
196
|
+
if (
|
|
197
|
+
nextBox + 8 <= bytes.byteLength &&
|
|
198
|
+
readType(bytes, nextBox + 4) !== "mdat"
|
|
199
|
+
) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return boxStart;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* True when the head of an MP4 shows the fragmented shape: the `hlsf` brand in
|
|
209
|
+
* `ftyp`, or an `mvex` box inside `moov` (present only in fragmented files).
|
|
210
|
+
* `headBytes` should be the first few KB of the file.
|
|
211
|
+
*/
|
|
212
|
+
export function isFragmentedMp4Head(headBytes: Uint8Array): boolean {
|
|
213
|
+
if (headBytes.byteLength < 8) return false;
|
|
214
|
+
// Must look like an MP4 at all.
|
|
215
|
+
if (indexOfAscii(headBytes, "ftyp") !== 4) return false;
|
|
216
|
+
if (indexOfAscii(headBytes, "hlsf") !== -1) return true;
|
|
217
|
+
if (indexOfAscii(headBytes, "mvex") !== -1) return true;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Cache detection by URL identity so we sniff each asset only once. */
|
|
222
|
+
const detectionCache = new Map<string, Promise<boolean>>();
|
|
223
|
+
|
|
224
|
+
function detectionKey(url: string): string {
|
|
225
|
+
try {
|
|
226
|
+
const base =
|
|
227
|
+
typeof window === "undefined"
|
|
228
|
+
? "http://clips.local"
|
|
229
|
+
: window.location.href;
|
|
230
|
+
const parsed = new URL(url, base);
|
|
231
|
+
// Strip volatile auth/cache-bust params so the same asset shares one entry.
|
|
232
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
233
|
+
} catch {
|
|
234
|
+
return url;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const SNIFF_BYTES = 4096;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Range-fetch the first few KB of an asset and report whether it is a raw
|
|
242
|
+
* fragmented MP4 that needs the MSE path. Cached per asset; resolves false on
|
|
243
|
+
* any network/parse error so callers fall back to the native `<video src>`.
|
|
244
|
+
*/
|
|
245
|
+
export async function sniffFragmentedMp4(url: string): Promise<boolean> {
|
|
246
|
+
const key = detectionKey(url);
|
|
247
|
+
const cached = detectionCache.get(key);
|
|
248
|
+
if (cached) return cached;
|
|
249
|
+
|
|
250
|
+
const promise = (async () => {
|
|
251
|
+
try {
|
|
252
|
+
const res = await fetch(url, {
|
|
253
|
+
headers: { Range: `bytes=0-${SNIFF_BYTES - 1}` },
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) return false;
|
|
256
|
+
const head = new Uint8Array(await res.arrayBuffer());
|
|
257
|
+
return isFragmentedMp4Head(head);
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
|
|
263
|
+
detectionCache.set(key, promise);
|
|
264
|
+
// Don't cache a rejected/false-by-error result forever if it was a transient
|
|
265
|
+
// network blip: drop the entry when it resolves false so a later retry can
|
|
266
|
+
// re-sniff, but keep positive detections cached.
|
|
267
|
+
void promise.then((isFrag) => {
|
|
268
|
+
if (!isFrag) detectionCache.delete(key);
|
|
269
|
+
});
|
|
270
|
+
return promise;
|
|
271
|
+
}
|