@agent-native/core-corpus 0.201.0-nightly-20261005153421 → 0.201.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/security/SKILL.md +9 -2
  3. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +14 -4
  4. package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +12 -7
  5. package/corpus/templates/analytics/CHANGELOG.md +6 -0
  6. package/corpus/templates/analytics/README.md +2 -2
  7. package/corpus/templates/analytics/changelog/2026-10-02-events-with-a-very-long-name-app-or-page-path-no-longer-caus.md +6 -0
  8. package/corpus/templates/analytics/package.json +1 -0
  9. package/corpus/templates/analytics/server/handlers/first-party-analytics.ts +9 -19
  10. package/corpus/templates/analytics/server/handlers/session-replay.ts +34 -47
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -6
  12. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +57 -36
  13. package/corpus/templates/analytics/server/lib/indexed-text.ts +53 -0
  14. package/corpus/templates/analytics/server/lib/request-errors.ts +73 -0
  15. package/corpus/templates/analytics/server/lib/session-event-index.ts +6 -16
  16. package/corpus/templates/analytics/server/lib/session-replay.ts +12 -2
  17. package/corpus/templates/analytics/server/plugins/auth.ts +2 -1
  18. package/corpus/templates/analytics/server/plugins/otel.ts +6 -0
  19. package/corpus/templates/assets/.agents/skills/security/SKILL.md +9 -2
  20. package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +12 -7
  21. package/corpus/templates/assets/CHANGELOG.md +7 -0
  22. package/corpus/templates/assets/README.md +2 -2
  23. package/corpus/templates/assets/package.json +1 -0
  24. package/corpus/templates/assets/server/plugins/auth.ts +2 -1
  25. package/corpus/templates/assets/server/plugins/otel.ts +6 -0
  26. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +2 -1
  27. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +5 -4
  28. package/corpus/templates/brain/.agents/skills/security/SKILL.md +9 -2
  29. package/corpus/templates/brain/.agents/skills/storing-data/SKILL.md +12 -7
  30. package/corpus/templates/brain/CHANGELOG.md +6 -0
  31. package/corpus/templates/brain/README.md +12 -8
  32. package/corpus/templates/brain/changelog/2026-10-05-private-slack-channels-are-searchable-org-wide.md +6 -0
  33. package/corpus/templates/brain/package.json +1 -0
  34. package/corpus/templates/brain/server/lib/audiences.ts +1 -98
  35. package/corpus/templates/brain/server/lib/connectors.ts +2 -166
  36. package/corpus/templates/brain/server/plugins/otel.ts +6 -0
  37. package/corpus/templates/calendar/.agents/skills/security/SKILL.md +9 -2
  38. package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +12 -7
  39. package/corpus/templates/calendar/CHANGELOG.md +7 -0
  40. package/corpus/templates/calendar/README.md +2 -2
  41. package/corpus/templates/calendar/package.json +1 -0
  42. package/corpus/templates/calendar/server/plugins/auth.ts +2 -1
  43. package/corpus/templates/calendar/server/plugins/otel.ts +6 -0
  44. package/corpus/templates/chat/.agents/skills/security/SKILL.md +9 -2
  45. package/corpus/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
  46. package/corpus/templates/chat/README.md +2 -2
  47. package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +9 -0
  48. package/corpus/templates/clips/.agents/skills/security/SKILL.md +9 -2
  49. package/corpus/templates/clips/CHANGELOG.md +24 -0
  50. package/corpus/templates/clips/README.md +2 -2
  51. package/corpus/templates/clips/actions/lib/audio-chunk-list.ts +23 -0
  52. package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +187 -292
  53. package/corpus/templates/clips/actions/lib/chunked-transcription.ts +151 -0
  54. package/corpus/templates/clips/actions/lib/recording-audio-source.ts +191 -0
  55. package/corpus/templates/clips/actions/request-transcript.ts +81 -135
  56. package/corpus/templates/clips/actions/search-recordings.ts +19 -5
  57. package/corpus/templates/clips/app/components/clips-command-menu.tsx +5 -0
  58. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -1
  59. package/corpus/templates/clips/app/components/library/search-bar.tsx +5 -0
  60. package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
  61. package/corpus/templates/clips/app/lib/local-recording-upload.ts +13 -6
  62. package/corpus/templates/clips/app/lib/recording-section.ts +1 -1
  63. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +7 -5
  64. package/corpus/templates/clips/app/routes/record.tsx +16 -6
  65. package/corpus/templates/clips/changelog/2026-10-02-long-uploads-an-hour-or-more-now-transcribe-reliably-instead.md +6 -0
  66. package/corpus/templates/clips/changelog/2026-10-05-confirming-discard-recording-now-returns-you-to-your-library.md +6 -0
  67. package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recording-now-removes-its-failed-upload-card-fr.md +6 -0
  68. package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recovered-recording-no-longer-warns-that-it-s-o.md +6 -0
  69. package/corpus/templates/clips/changelog/2026-10-05-search-results-identify-recordings-in-trash-and-open-them-wi.md +6 -0
  70. package/corpus/templates/clips/package.json +1 -0
  71. package/corpus/templates/clips/server/lib/builder-cdn.ts +5 -0
  72. package/corpus/templates/clips/server/lib/builder-media-compression.ts +2 -2
  73. package/corpus/templates/clips/server/plugins/auth.ts +2 -1
  74. package/corpus/templates/clips/server/plugins/otel.ts +6 -0
  75. package/corpus/templates/clips/shared/transcript-failure.ts +5 -0
  76. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  77. package/corpus/templates/content/.agents/skills/security/SKILL.md +9 -2
  78. package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +12 -7
  79. package/corpus/templates/content/CHANGELOG.md +41 -0
  80. package/corpus/templates/content/README.md +2 -2
  81. package/corpus/templates/content/actions/_database-navigation.ts +523 -344
  82. package/corpus/templates/content/actions/_database-row-mutation.ts +44 -10
  83. package/corpus/templates/content/actions/_database-setup-discovery.ts +9 -3
  84. package/corpus/templates/content/actions/_database-utils.ts +299 -271
  85. package/corpus/templates/content/actions/_property-utils.ts +293 -190
  86. package/corpus/templates/content/actions/_relation-values.ts +3 -1
  87. package/corpus/templates/content/actions/get-content-database.ts +1 -0
  88. package/corpus/templates/content/actions/query-content-database-items.ts +32 -5
  89. package/corpus/templates/content/actions/set-document-property.ts +17 -11
  90. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +21 -6
  91. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -43
  92. package/corpus/templates/content/app/components/editor/DocumentEditorSkeleton.tsx +159 -50
  93. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +12 -28
  94. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +6 -1
  95. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +139 -70
  96. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +16 -1
  97. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +180 -75
  98. package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +76 -0
  99. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +60 -2
  100. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +41 -42
  101. package/corpus/templates/content/app/components/editor/document-editor-layout.ts +18 -0
  102. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +11 -3
  103. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +15 -7
  104. package/corpus/templates/content/app/components/editor/suggestions/model.ts +13 -0
  105. package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +136 -0
  106. package/corpus/templates/content/app/components/editor/table-view.ts +73 -0
  107. package/corpus/templates/content/app/components/layout/ContentStartupShell.tsx +1 -1
  108. package/corpus/templates/content/app/components/layout/Layout.tsx +5 -1
  109. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +57 -16
  110. package/corpus/templates/content/app/components/sidebar/MovePageDialog.tsx +4 -4
  111. package/corpus/templates/content/app/components/sidebar/SidebarNavigationRow.tsx +34 -10
  112. package/corpus/templates/content/app/global.css +16 -8
  113. package/corpus/templates/content/app/hooks/content-action-refresh.ts +14 -13
  114. package/corpus/templates/content/app/hooks/use-content-database.ts +72 -9
  115. package/corpus/templates/content/app/hooks/use-content-personal-navigation.ts +7 -3
  116. package/corpus/templates/content/app/hooks/use-documents.ts +27 -0
  117. package/corpus/templates/content/app/i18n-data.ts +3 -2
  118. package/corpus/templates/content/app/lib/content-landing.ts +3 -31
  119. package/corpus/templates/content/app/lib/files-navigation.ts +182 -10
  120. package/corpus/templates/content/app/lib/files-root-hint.ts +20 -32
  121. package/corpus/templates/content/app/lib/page-startup-hints.ts +108 -0
  122. package/corpus/templates/content/app/lib/private-link-redirect.ts +25 -0
  123. package/corpus/templates/content/app/lib/sidebar-layout-hint.ts +34 -1
  124. package/corpus/templates/content/app/lib/startup-timing.ts +2 -0
  125. package/corpus/templates/content/app/root.tsx +3 -1
  126. package/corpus/templates/content/app/routes/_app.home.tsx +10 -19
  127. package/corpus/templates/content/app/routes/p.$id.tsx +23 -5
  128. package/corpus/templates/content/changelog/2026-10-02-a-page-link-you-cant-open-now-says-so-instead-of-opening-an.md +6 -0
  129. package/corpus/templates/content/changelog/2026-10-02-after-a-refresh-the-files-sidebar-shows-the-open-page-s-fold.md +6 -0
  130. package/corpus/templates/content/changelog/2026-10-02-large-collections-open-faster.md +6 -0
  131. package/corpus/templates/content/changelog/2026-10-02-pages-stay-inside-the-window-at-every-width-long-inline-code.md +6 -0
  132. package/corpus/templates/content/changelog/2026-10-02-pages-with-open-comments-and-collections-with-sorted-or-filt.md +6 -0
  133. package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +6 -0
  134. package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +6 -0
  135. package/corpus/templates/content/changelog/2026-10-05-collection-view-context-now-survives-quick-page-changes-when.md +6 -0
  136. package/corpus/templates/content/changelog/2026-10-05-collection-views-keep-their-saved-rows-accurate-during-refre.md +6 -0
  137. package/corpus/templates/content/changelog/2026-10-05-exact-saved-view-links-keep-collection-retry-available.md +6 -0
  138. package/corpus/templates/content/changelog/2026-10-05-expanding-a-files-folder-now-shares-one-navigation-read-acro.md +6 -0
  139. package/corpus/templates/content/changelog/2026-10-05-files-navigation-retries-temporary-failures-consistently.md +6 -0
  140. package/corpus/templates/content/changelog/2026-10-05-shared-page-owners-keep-edit-and-manage-controls-in-files-an.md +6 -0
  141. package/corpus/templates/content/changelog/2026-10-05-tagging-an-ai-in-a-comment-works-again-instead-of-failing-ri.md +6 -0
  142. package/corpus/templates/content/changelog/2026-10-05-the-home-loading-shell-keeps-the-layout-of-your-last-opened-.md +6 -0
  143. package/corpus/templates/content/changelog/2026-10-05-typing-and-a-space-or-inserting-a-toggle-or-callout-no-longe.md +6 -0
  144. package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +4 -1
  145. package/corpus/templates/content/e2e/playwright.config.ts +1 -1
  146. package/corpus/templates/content/package.json +2 -1
  147. package/corpus/templates/content/scripts/seed-perf-database.mjs +600 -0
  148. package/corpus/templates/content/scripts/trace-startup.mjs +25 -3
  149. package/corpus/templates/content/server/db/index.ts +1 -1
  150. package/corpus/templates/content/server/lib/suggested-edits.ts +23 -26
  151. package/corpus/templates/content/server/plugins/auth.ts +2 -1
  152. package/corpus/templates/content/server/plugins/otel.ts +6 -0
  153. package/corpus/templates/content/shared/api.ts +17 -0
  154. package/corpus/templates/content/shared/suggestion-diff.ts +124 -38
  155. package/corpus/templates/content/shared/suggestion-formatting.ts +134 -3
  156. package/corpus/templates/crm/.agents/skills/security/SKILL.md +9 -2
  157. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +12 -7
  158. package/corpus/templates/crm/package.json +1 -0
  159. package/corpus/templates/crm/server/plugins/otel.ts +6 -0
  160. package/corpus/templates/design/.agents/skills/security/SKILL.md +9 -2
  161. package/corpus/templates/design/.agents/skills/storing-data/SKILL.md +12 -7
  162. package/corpus/templates/design/CHANGELOG.md +41 -0
  163. package/corpus/templates/design/README.md +2 -2
  164. package/corpus/templates/design/actions/list-designs.ts +11 -4
  165. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +110 -32
  166. package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +28 -0
  167. package/corpus/templates/design/app/components/design/multi-screen/types.ts +1 -0
  168. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +79 -0
  169. package/corpus/templates/design/app/components/layout/Header.tsx +2 -4
  170. package/corpus/templates/design/app/components/ui/tabs.tsx +59 -1
  171. package/corpus/templates/design/app/global.css +1 -34
  172. package/corpus/templates/design/app/i18n/en-US.ts +4 -0
  173. package/corpus/templates/design/app/lib/design-filter.ts +4 -2
  174. package/corpus/templates/design/app/pages/Index.tsx +91 -59
  175. package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +58 -17
  176. package/corpus/templates/design/changelog/2026-10-01-filter-designs-by-owner.md +6 -0
  177. package/corpus/templates/design/changelog/2026-10-03-layers-dropped-into-visible-canvas-space-beyond-a-screen-now.md +6 -0
  178. package/corpus/templates/design/changelog/2026-10-03-visible-screen-overflow-no-longer-routes-drops-to-the-canvas.md +6 -0
  179. package/corpus/templates/design/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-designs.md +6 -0
  180. package/corpus/templates/design/package.json +1 -0
  181. package/corpus/templates/design/server/plugins/auth.ts +2 -1
  182. package/corpus/templates/design/server/plugins/otel.ts +6 -0
  183. package/corpus/templates/dispatch/.agents/skills/security/SKILL.md +9 -2
  184. package/corpus/templates/dispatch/.agents/skills/storing-data/SKILL.md +12 -7
  185. package/corpus/templates/dispatch/README.md +2 -2
  186. package/corpus/templates/dispatch/package.json +1 -0
  187. package/corpus/templates/dispatch/server/plugins/otel.ts +6 -0
  188. package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +2 -1
  189. package/corpus/templates/factory/.agents/skills/security/SKILL.md +9 -2
  190. package/corpus/templates/factory/.agents/skills/storing-data/SKILL.md +12 -7
  191. package/corpus/templates/factory/package.json +1 -0
  192. package/corpus/templates/factory/server/plugins/otel.ts +6 -0
  193. package/corpus/templates/forms/.agents/skills/security/SKILL.md +9 -2
  194. package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +12 -7
  195. package/corpus/templates/forms/README.md +2 -2
  196. package/corpus/templates/forms/package.json +1 -0
  197. package/corpus/templates/forms/server/plugins/auth.ts +2 -1
  198. package/corpus/templates/forms/server/plugins/otel.ts +6 -0
  199. package/corpus/templates/mail/.agents/skills/security/SKILL.md +9 -2
  200. package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +12 -7
  201. package/corpus/templates/mail/CHANGELOG.md +6 -0
  202. package/corpus/templates/mail/README.md +2 -2
  203. package/corpus/templates/mail/package.json +1 -0
  204. package/corpus/templates/mail/server/plugins/auth.ts +2 -1
  205. package/corpus/templates/mail/server/plugins/otel.ts +6 -0
  206. package/corpus/templates/plan/.agents/skills/security/SKILL.md +9 -2
  207. package/corpus/templates/plan/.agents/skills/storing-data/SKILL.md +12 -7
  208. package/corpus/templates/plan/CHANGELOG.md +1 -0
  209. package/corpus/templates/plan/README.md +2 -2
  210. package/corpus/templates/plan/package.json +1 -0
  211. package/corpus/templates/plan/server/plugins/auth.ts +2 -1
  212. package/corpus/templates/plan/server/plugins/otel.ts +6 -0
  213. package/corpus/templates/slides/.agents/skills/security/SKILL.md +9 -2
  214. package/corpus/templates/slides/.agents/skills/storing-data/SKILL.md +12 -7
  215. package/corpus/templates/slides/CHANGELOG.md +23 -0
  216. package/corpus/templates/slides/README.md +2 -2
  217. package/corpus/templates/slides/actions/list-decks.ts +11 -4
  218. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +44 -36
  219. package/corpus/templates/slides/app/components/home/SlidesHomeLibrary.tsx +79 -0
  220. package/corpus/templates/slides/app/components/layout/Header.tsx +2 -15
  221. package/corpus/templates/slides/app/components/ui/tabs.tsx +59 -0
  222. package/corpus/templates/slides/app/global.css +3 -54
  223. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  224. package/corpus/templates/slides/app/lib/deck-filter.ts +5 -2
  225. package/corpus/templates/slides/app/pages/Index.tsx +102 -88
  226. package/corpus/templates/slides/changelog/2026-10-01-filter-decks-by-owner.md +6 -0
  227. package/corpus/templates/slides/changelog/2026-10-02-hide-recent-until-decks-available.md +6 -0
  228. package/corpus/templates/slides/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-decks-a.md +6 -0
  229. package/corpus/templates/slides/package.json +1 -0
  230. package/corpus/templates/slides/server/handlers/request-auth-context.ts +8 -0
  231. package/corpus/templates/slides/server/plugins/auth.ts +2 -1
  232. package/corpus/templates/slides/server/plugins/otel.ts +6 -0
  233. package/corpus/templates/tasks/.agents/skills/security/SKILL.md +9 -2
  234. package/corpus/templates/tasks/.agents/skills/storing-data/SKILL.md +12 -7
  235. package/package.json +1 -1
@@ -1,15 +1,21 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
 
8
+ import {
9
+ parseAudioChunkList,
10
+ type AudioChunkListEntry,
11
+ } from "./audio-chunk-list.js";
12
+
8
13
  const AUDIO_EXTRACTION_MIN_TIMEOUT_MS = 30_000;
9
14
  const AUDIO_EXTRACTION_MAX_TIMEOUT_MS = 90_000;
10
15
  const AUDIO_EXTRACTION_BASE_TIMEOUT_MS = 25_000;
11
16
  const AUDIO_EXTRACTION_PER_50MB_MS = 10_000;
12
17
  const SILENCE_MAX_VOLUME_DB = -60;
18
+ export const TRANSCRIPTION_CHUNK_SECONDS = 15 * 60;
13
19
  const STDERR_LIMIT = 16 * 1024;
14
20
  const requireFromThisFile = createRequire(import.meta.url);
15
21
  let cachedFfmpegStaticPath: string | null | undefined;
@@ -32,28 +38,32 @@ export class AudioOnlyExtractionError extends Error {
32
38
  }
33
39
  }
34
40
 
35
- export interface AudioOnlyTranscriptionMedia {
41
+ export interface AudioChunk {
36
42
  audioBytes: Uint8Array;
37
- mimeType: string;
38
- filename: string;
39
- source: "audio-input" | "extracted-audio" | "raw-media-fallback";
43
+ startMs: number;
44
+ durationMs: number;
40
45
  }
41
46
 
42
- export interface AudioExtractionInput {
43
- mediaBytes: Uint8Array;
47
+ export interface AudioOnlyTranscriptionMedia {
44
48
  mimeType: string;
45
- recordingId: string;
49
+ maxVolumeDb: number | null;
50
+ chunks: AudioChunk[];
46
51
  }
47
52
 
48
- export interface AudioExtractionOutput {
49
- audioBytes: Uint8Array;
50
- mimeType: string;
51
- extension: string;
53
+ // A `url` source is handed to ffmpeg as-is, so only pass URLs the server
54
+ // already trusts; untrusted URLs must be downloaded via ssrfSafeFetch first.
55
+ export type AudioExtractionSource =
56
+ | { kind: "url"; url: string }
57
+ | { kind: "file"; path: string };
58
+
59
+ export interface AudioExtractionInput {
60
+ source: AudioExtractionSource;
61
+ timeoutMs: number;
52
62
  }
53
63
 
54
64
  export type AudioExtractor = (
55
65
  input: AudioExtractionInput,
56
- ) => Promise<AudioExtractionOutput>;
66
+ ) => Promise<AudioOnlyTranscriptionMedia>;
57
67
 
58
68
  class FfmpegRunError extends Error {
59
69
  stderr: string;
@@ -65,14 +75,6 @@ class FfmpegRunError extends Error {
65
75
  }
66
76
  }
67
77
 
68
- function baseMimeType(mimeType: string | null | undefined): string {
69
- return (mimeType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
70
- }
71
-
72
- export function isAudioMimeType(mimeType: string | null | undefined): boolean {
73
- return baseMimeType(mimeType).startsWith("audio/");
74
- }
75
-
76
78
  export function isNoExtractableAudioError(err: unknown): boolean {
77
79
  return (
78
80
  err instanceof AudioOnlyExtractionError &&
@@ -82,39 +84,10 @@ export function isNoExtractableAudioError(err: unknown): boolean {
82
84
  );
83
85
  }
84
86
 
85
- export function isFfmpegUnavailableError(err: unknown): boolean {
86
- return (
87
- err instanceof AudioOnlyExtractionError && err.code === "FFMPEG_UNAVAILABLE"
88
- );
89
- }
90
-
91
87
  export function isTransientExtractionError(err: unknown): boolean {
92
88
  return err instanceof AudioOnlyExtractionError && err.code === "TIMEOUT";
93
89
  }
94
90
 
95
- export function audioExtensionForMimeType(
96
- mimeType: string | null | undefined,
97
- ): string {
98
- switch (baseMimeType(mimeType)) {
99
- case "audio/mp4":
100
- case "audio/m4a":
101
- case "audio/x-m4a":
102
- return "m4a";
103
- case "audio/mpeg":
104
- case "audio/mp3":
105
- return "mp3";
106
- case "audio/ogg":
107
- return "ogg";
108
- case "audio/wav":
109
- case "audio/wave":
110
- case "audio/x-wav":
111
- return "wav";
112
- case "audio/webm":
113
- default:
114
- return "webm";
115
- }
116
- }
117
-
118
91
  function clampAudioExtractionTimeoutMs(value: number): number {
119
92
  return Math.max(
120
93
  AUDIO_EXTRACTION_MIN_TIMEOUT_MS,
@@ -145,82 +118,60 @@ export function audioExtractionTimeoutMs(
145
118
  );
146
119
  }
147
120
 
148
- function mediaExtensionForMimeType(mimeType: string): string {
149
- switch (baseMimeType(mimeType)) {
150
- case "video/mp4":
151
- case "video/quicktime":
152
- case "audio/mp4":
153
- return "mp4";
154
- case "audio/m4a":
155
- case "audio/x-m4a":
156
- return "m4a";
157
- case "audio/mpeg":
158
- case "audio/mp3":
159
- return "mp3";
160
- case "audio/ogg":
161
- return "ogg";
162
- case "audio/wav":
163
- case "audio/wave":
164
- case "audio/x-wav":
165
- return "wav";
166
- case "video/webm":
167
- case "audio/webm":
168
- return "webm";
169
- default:
170
- return "bin";
171
- }
172
- }
173
-
174
- function outputForSourceMimeType(mimeType: string): {
175
- mimeType: string;
176
- extension: string;
177
- copyArgs: string[];
178
- transcodeArgs: string[];
179
- } {
180
- const base = baseMimeType(mimeType);
181
- if (base.includes("mp4") || base === "video/quicktime") {
182
- return {
183
- mimeType: "audio/mp4",
184
- extension: "m4a",
185
- copyArgs: ["-map", "0:a:0", "-vn", "-c:a", "copy", "-f", "mp4"],
186
- transcodeArgs: [
187
- "-map",
188
- "0:a:0",
189
- "-vn",
190
- "-ac",
191
- "1",
192
- "-ar",
193
- "16000",
194
- "-b:a",
195
- "48k",
196
- "-c:a",
197
- "aac",
198
- "-f",
199
- "mp4",
200
- ],
201
- };
121
+ // Always re-encode. Copying the source track keeps its bitrate, and an hour of
122
+ // copied audio (~60-140 MB) exceeds the transcription gateway's request size
123
+ // limit; mono 16 kHz 48 kbps is ~22 MB per hour.
124
+ const TRANSCRIPTION_AUDIO = {
125
+ mimeType: "audio/mp4",
126
+ extension: "m4a",
127
+ args: [
128
+ "-map",
129
+ "0:a:0",
130
+ "-vn",
131
+ "-ac",
132
+ "1",
133
+ "-ar",
134
+ "16000",
135
+ "-b:a",
136
+ "48k",
137
+ "-c:a",
138
+ "aac",
139
+ "-f",
140
+ "mp4",
141
+ ],
142
+ };
143
+
144
+ // Recording bytes are user-supplied. Playlist and concat formats would let a
145
+ // crafted file make ffmpeg open other local files or arbitrary URLs.
146
+ const INPUT_FORMAT_ALLOWLIST = ["-format_whitelist", "mov,matroska,wav"];
147
+
148
+ function ffmpegInputArgs(source: AudioExtractionSource): string[] {
149
+ if (source.kind === "file") {
150
+ return [
151
+ ...INPUT_FORMAT_ALLOWLIST,
152
+ "-protocol_whitelist",
153
+ "file",
154
+ "-i",
155
+ source.path,
156
+ ];
202
157
  }
203
-
204
- return {
205
- mimeType: "audio/webm",
206
- extension: "webm",
207
- copyArgs: ["-map", "0:a:0", "-vn", "-c:a", "copy", "-f", "webm"],
208
- transcodeArgs: [
209
- "-map",
210
- "0:a:0",
211
- "-vn",
212
- "-ac",
213
- "1",
214
- "-ar",
215
- "16000",
216
- "-b:a",
217
- "48k",
218
- "-c:a",
219
- "libopus",
220
- "-f",
221
- "webm",
222
- ],
223
- };
158
+ const protocols =
159
+ new URL(source.url).protocol === "https:" ? "https,tls,tcp" : "http,tcp";
160
+ return [
161
+ ...INPUT_FORMAT_ALLOWLIST,
162
+ "-protocol_whitelist",
163
+ protocols,
164
+ "-reconnect",
165
+ "1",
166
+ "-reconnect_on_network_error",
167
+ "1",
168
+ "-reconnect_delay_max",
169
+ "5",
170
+ "-rw_timeout",
171
+ "30000000",
172
+ "-i",
173
+ source.url,
174
+ ];
224
175
  }
225
176
 
226
177
  function ffmpegCommand(): string {
@@ -340,81 +291,37 @@ async function runFfmpegForStderr(
340
291
  });
341
292
  }
342
293
 
343
- function parseVolumeDb(stderr: string, field: "mean" | "max"): number | null {
344
- const match = stderr.match(
345
- new RegExp(`${field}_volume:\\s*(-?inf|-?\\d+(?:\\.\\d+)?) dB`, "i"),
346
- );
294
+ function parseMaxVolumeDb(stderr: string): number | null {
295
+ const match = stderr.match(/max_volume:\s*(-?inf|-?\d+(?:\.\d+)?) dB/i);
347
296
  if (!match) return null;
348
297
  return match[1] === "-inf" ? Number.NEGATIVE_INFINITY : Number(match[1]);
349
298
  }
350
299
 
351
- export async function analyzeAudioSignal({
352
- audioBytes,
353
- mimeType,
354
- }: AudioOnlyTranscriptionMedia): Promise<{
355
- meanVolumeDb: number | null;
356
- maxVolumeDb: number | null;
357
- }> {
358
- if (audioBytes.byteLength === 0) {
359
- throw new AudioOnlyExtractionError(
360
- "NO_AUDIO_TRACK",
361
- "No speech was detected because the recording media is empty.",
362
- );
363
- }
364
-
365
- const dir = await mkdtemp(join(tmpdir(), "clips-transcription-"));
366
- const inputPath = join(dir, `input.${audioExtensionForMimeType(mimeType)}`);
367
- const timeoutMs = audioExtractionTimeoutMs(audioBytes.byteLength);
368
-
369
- try {
370
- await writeFile(inputPath, audioBytes);
371
- const stderr = await runFfmpegForStderr(
372
- [
373
- "-hide_banner",
374
- "-nostdin",
375
- "-i",
376
- inputPath,
377
- "-vn",
378
- "-af",
379
- "volumedetect",
380
- "-f",
381
- "null",
382
- "-",
383
- ],
384
- timeoutMs,
385
- ).catch((err) => {
386
- throw mapFfmpegError(err);
387
- });
388
-
389
- return {
390
- meanVolumeDb: parseVolumeDb(stderr, "mean"),
391
- maxVolumeDb: parseVolumeDb(stderr, "max"),
392
- };
393
- } finally {
394
- await rm(dir, { recursive: true, force: true }).catch(() => {});
395
- }
300
+ async function detectMaxVolumeDb(
301
+ audioPath: string,
302
+ timeoutMs: number,
303
+ ): Promise<number | null> {
304
+ const stderr = await runFfmpegForStderr(
305
+ [
306
+ "-hide_banner",
307
+ "-nostdin",
308
+ "-i",
309
+ audioPath,
310
+ "-vn",
311
+ "-af",
312
+ "volumedetect",
313
+ "-f",
314
+ "null",
315
+ "-",
316
+ ],
317
+ timeoutMs,
318
+ );
319
+ return parseMaxVolumeDb(stderr);
396
320
  }
397
321
 
398
- export async function assertAudioHasAudibleSignal(
399
- media: AudioOnlyTranscriptionMedia,
400
- ): Promise<void> {
401
- if (media.source === "raw-media-fallback") {
402
- return;
403
- }
404
-
405
- let signal: { meanVolumeDb: number | null; maxVolumeDb: number | null };
406
- try {
407
- signal = await analyzeAudioSignal(media);
408
- } catch (err) {
409
- if (isFfmpegUnavailableError(err)) {
410
- console.warn(
411
- "[clips] ffmpeg unavailable; skipping silence detection and proceeding to transcription.",
412
- );
413
- return;
414
- }
415
- throw err;
416
- }
417
- const maxVolumeDb = signal.maxVolumeDb;
322
+ export function assertAudioHasAudibleSignal({
323
+ maxVolumeDb,
324
+ }: AudioOnlyTranscriptionMedia): void {
418
325
  if (maxVolumeDb === null || maxVolumeDb <= SILENCE_MAX_VOLUME_DB) {
419
326
  throw new AudioOnlyExtractionError(
420
327
  "NO_SPEECH_DETECTED",
@@ -423,45 +330,93 @@ export async function assertAudioHasAudibleSignal(
423
330
  }
424
331
  }
425
332
 
426
- export async function extractAudioOnlyWithFfmpeg({
427
- mediaBytes,
428
- mimeType,
429
- }: AudioExtractionInput): Promise<AudioExtractionOutput> {
430
- if (mediaBytes.byteLength === 0) {
333
+ function readChunkList(csv: string): AudioChunkListEntry[] {
334
+ let entries: AudioChunkListEntry[];
335
+ try {
336
+ entries = parseAudioChunkList(csv);
337
+ } catch (err) {
431
338
  throw new AudioOnlyExtractionError(
432
- "NO_AUDIO_TRACK",
433
- "No speech was detected because the recording media is empty.",
339
+ "EXTRACTION_FAILED",
340
+ `Splitting the audio for transcription failed: ${(err as Error)?.message ?? String(err)}`,
434
341
  );
435
342
  }
343
+ if (entries.length === 0) {
344
+ throw new AudioOnlyExtractionError(
345
+ "EXTRACTION_FAILED",
346
+ "Splitting the audio for transcription produced no chunks.",
347
+ );
348
+ }
349
+ return entries;
350
+ }
351
+
352
+ // Copy-only split of the already re-encoded audio. ffmpeg cuts on packet
353
+ // boundaries, so chunk lengths come from the segment list, not the target.
354
+ async function splitAudioIntoChunks(
355
+ audioPath: string,
356
+ dir: string,
357
+ ): Promise<AudioChunk[]> {
358
+ const listPath = join(dir, "chunks.csv");
359
+ await runFfmpeg(
360
+ [
361
+ "-hide_banner",
362
+ "-loglevel",
363
+ "error",
364
+ "-nostdin",
365
+ "-y",
366
+ "-i",
367
+ audioPath,
368
+ "-map",
369
+ "0:a:0",
370
+ "-c",
371
+ "copy",
372
+ "-f",
373
+ "segment",
374
+ "-segment_time",
375
+ String(TRANSCRIPTION_CHUNK_SECONDS),
376
+ "-segment_format",
377
+ "mp4",
378
+ "-reset_timestamps",
379
+ "1",
380
+ "-segment_list",
381
+ listPath,
382
+ "-segment_list_type",
383
+ "csv",
384
+ join(dir, `chunk-%04d.${TRANSCRIPTION_AUDIO.extension}`),
385
+ ],
386
+ AUDIO_EXTRACTION_MIN_TIMEOUT_MS,
387
+ );
436
388
 
389
+ const entries = readChunkList(await readFile(listPath, "utf8"));
390
+ return Promise.all(
391
+ entries.map(async (entry) => ({
392
+ audioBytes: new Uint8Array(await readFile(join(dir, entry.file))),
393
+ startMs: entry.startMs,
394
+ durationMs: entry.durationMs,
395
+ })),
396
+ );
397
+ }
398
+
399
+ export async function extractAudioOnlyWithFfmpeg({
400
+ source,
401
+ timeoutMs,
402
+ }: AudioExtractionInput): Promise<AudioOnlyTranscriptionMedia> {
437
403
  const dir = await mkdtemp(join(tmpdir(), "clips-transcription-"));
438
- const inputPath = join(dir, `input.${mediaExtensionForMimeType(mimeType)}`);
439
- const output = outputForSourceMimeType(mimeType);
440
- const outputPath = join(dir, `audio.${output.extension}`);
441
- const baseArgs = ["-hide_banner", "-loglevel", "error", "-nostdin", "-y"];
442
- const timeoutMs = audioExtractionTimeoutMs(mediaBytes.byteLength);
404
+ const outputPath = join(dir, `audio.${TRANSCRIPTION_AUDIO.extension}`);
443
405
 
444
406
  try {
445
- await writeFile(inputPath, mediaBytes);
446
- try {
447
- await runFfmpeg(
448
- [...baseArgs, "-i", inputPath, ...output.copyArgs, outputPath],
449
- timeoutMs,
450
- );
451
- } catch (copyErr) {
452
- if (
453
- copyErr instanceof FfmpegRunError &&
454
- isMissingAudioTrack(copyErr.stderr)
455
- ) {
456
- throw copyErr;
457
- }
458
- await runFfmpeg(
459
- [...baseArgs, "-i", inputPath, ...output.transcodeArgs, outputPath],
460
- timeoutMs,
461
- ).catch((transcodeErr) => {
462
- throw mapFfmpegError(transcodeErr);
463
- });
464
- }
407
+ await runFfmpeg(
408
+ [
409
+ "-hide_banner",
410
+ "-loglevel",
411
+ "error",
412
+ "-nostdin",
413
+ "-y",
414
+ ...ffmpegInputArgs(source),
415
+ ...TRANSCRIPTION_AUDIO.args,
416
+ outputPath,
417
+ ],
418
+ timeoutMs,
419
+ );
465
420
 
466
421
  const info = await stat(outputPath).catch(() => null);
467
422
  if (!info || info.size === 0) {
@@ -472,9 +427,12 @@ export async function extractAudioOnlyWithFfmpeg({
472
427
  }
473
428
 
474
429
  return {
475
- audioBytes: new Uint8Array(await readFile(outputPath)),
476
- mimeType: output.mimeType,
477
- extension: output.extension,
430
+ mimeType: TRANSCRIPTION_AUDIO.mimeType,
431
+ maxVolumeDb: await detectMaxVolumeDb(
432
+ outputPath,
433
+ audioExtractionTimeoutMs(info.size),
434
+ ),
435
+ chunks: await splitAudioIntoChunks(outputPath, dir),
478
436
  };
479
437
  } catch (err) {
480
438
  if (err instanceof AudioOnlyExtractionError) throw err;
@@ -483,66 +441,3 @@ export async function extractAudioOnlyWithFfmpeg({
483
441
  await rm(dir, { recursive: true, force: true }).catch(() => {});
484
442
  }
485
443
  }
486
-
487
- export async function prepareAudioOnlyTranscriptionMedia({
488
- blob,
489
- recordingId,
490
- sourceMimeType,
491
- extractor = extractAudioOnlyWithFfmpeg,
492
- }: {
493
- blob: Blob;
494
- recordingId: string;
495
- sourceMimeType?: string | null;
496
- extractor?: AudioExtractor;
497
- }): Promise<AudioOnlyTranscriptionMedia> {
498
- const mimeType =
499
- baseMimeType(sourceMimeType) || baseMimeType(blob.type) || "audio/webm";
500
- const mediaBytes = new Uint8Array(await blob.arrayBuffer());
501
-
502
- if (mediaBytes.byteLength === 0) {
503
- throw new AudioOnlyExtractionError(
504
- "NO_AUDIO_TRACK",
505
- "No speech was detected because the recording media is empty.",
506
- );
507
- }
508
-
509
- if (isAudioMimeType(mimeType)) {
510
- return {
511
- audioBytes: mediaBytes,
512
- mimeType,
513
- filename: `${recordingId}.${audioExtensionForMimeType(mimeType)}`,
514
- source: "audio-input",
515
- };
516
- }
517
-
518
- let extracted: AudioExtractionOutput;
519
- try {
520
- extracted = await extractor({
521
- mediaBytes,
522
- mimeType,
523
- recordingId,
524
- });
525
- } catch (err) {
526
- if (
527
- isFfmpegUnavailableError(err) ||
528
- (err instanceof AudioOnlyExtractionError && err.code === "TIMEOUT")
529
- ) {
530
- console.warn(
531
- "[clips] ffmpeg could not prepare audio-only media; sending original media to the transcription provider.",
532
- );
533
- return {
534
- audioBytes: mediaBytes,
535
- mimeType,
536
- filename: `${recordingId}.${mediaExtensionForMimeType(mimeType)}`,
537
- source: "raw-media-fallback",
538
- };
539
- }
540
- throw err;
541
- }
542
- return {
543
- audioBytes: extracted.audioBytes,
544
- mimeType: extracted.mimeType,
545
- filename: `${recordingId}.${extracted.extension}`,
546
- source: "extracted-audio",
547
- };
548
- }