@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +14 -4
- package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/analytics/CHANGELOG.md +6 -0
- package/corpus/templates/analytics/README.md +2 -2
- package/corpus/templates/analytics/changelog/2026-10-02-events-with-a-very-long-name-app-or-page-path-no-longer-caus.md +6 -0
- package/corpus/templates/analytics/package.json +1 -0
- package/corpus/templates/analytics/server/handlers/first-party-analytics.ts +9 -19
- package/corpus/templates/analytics/server/handlers/session-replay.ts +34 -47
- package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -6
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +57 -36
- package/corpus/templates/analytics/server/lib/indexed-text.ts +53 -0
- package/corpus/templates/analytics/server/lib/request-errors.ts +73 -0
- package/corpus/templates/analytics/server/lib/session-event-index.ts +6 -16
- package/corpus/templates/analytics/server/lib/session-replay.ts +12 -2
- package/corpus/templates/analytics/server/plugins/auth.ts +2 -1
- package/corpus/templates/analytics/server/plugins/otel.ts +6 -0
- package/corpus/templates/assets/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/assets/CHANGELOG.md +7 -0
- package/corpus/templates/assets/README.md +2 -2
- package/corpus/templates/assets/package.json +1 -0
- package/corpus/templates/assets/server/plugins/auth.ts +2 -1
- package/corpus/templates/assets/server/plugins/otel.ts +6 -0
- package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +2 -1
- package/corpus/templates/brain/.agents/skills/brain/SKILL.md +5 -4
- package/corpus/templates/brain/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/brain/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/brain/CHANGELOG.md +6 -0
- package/corpus/templates/brain/README.md +12 -8
- package/corpus/templates/brain/changelog/2026-10-05-private-slack-channels-are-searchable-org-wide.md +6 -0
- package/corpus/templates/brain/package.json +1 -0
- package/corpus/templates/brain/server/lib/audiences.ts +1 -98
- package/corpus/templates/brain/server/lib/connectors.ts +2 -166
- package/corpus/templates/brain/server/plugins/otel.ts +6 -0
- package/corpus/templates/calendar/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/calendar/CHANGELOG.md +7 -0
- package/corpus/templates/calendar/README.md +2 -2
- package/corpus/templates/calendar/package.json +1 -0
- package/corpus/templates/calendar/server/plugins/auth.ts +2 -1
- package/corpus/templates/calendar/server/plugins/otel.ts +6 -0
- package/corpus/templates/chat/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/chat/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/chat/README.md +2 -2
- package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +9 -0
- package/corpus/templates/clips/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/clips/CHANGELOG.md +24 -0
- package/corpus/templates/clips/README.md +2 -2
- package/corpus/templates/clips/actions/lib/audio-chunk-list.ts +23 -0
- package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +187 -292
- package/corpus/templates/clips/actions/lib/chunked-transcription.ts +151 -0
- package/corpus/templates/clips/actions/lib/recording-audio-source.ts +191 -0
- package/corpus/templates/clips/actions/request-transcript.ts +81 -135
- package/corpus/templates/clips/actions/search-recordings.ts +19 -5
- package/corpus/templates/clips/app/components/clips-command-menu.tsx +5 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -1
- package/corpus/templates/clips/app/components/library/search-bar.tsx +5 -0
- package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
- package/corpus/templates/clips/app/lib/local-recording-upload.ts +13 -6
- package/corpus/templates/clips/app/lib/recording-section.ts +1 -1
- package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +7 -5
- package/corpus/templates/clips/app/routes/record.tsx +16 -6
- package/corpus/templates/clips/changelog/2026-10-02-long-uploads-an-hour-or-more-now-transcribe-reliably-instead.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-confirming-discard-recording-now-returns-you-to-your-library.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recording-now-removes-its-failed-upload-card-fr.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-discarding-a-recovered-recording-no-longer-warns-that-it-s-o.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-05-search-results-identify-recordings-in-trash-and-open-them-wi.md +6 -0
- package/corpus/templates/clips/package.json +1 -0
- package/corpus/templates/clips/server/lib/builder-cdn.ts +5 -0
- package/corpus/templates/clips/server/lib/builder-media-compression.ts +2 -2
- package/corpus/templates/clips/server/plugins/auth.ts +2 -1
- package/corpus/templates/clips/server/plugins/otel.ts +6 -0
- package/corpus/templates/clips/shared/transcript-failure.ts +5 -0
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
- package/corpus/templates/content/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/content/CHANGELOG.md +41 -0
- package/corpus/templates/content/README.md +2 -2
- package/corpus/templates/content/actions/_database-navigation.ts +523 -344
- package/corpus/templates/content/actions/_database-row-mutation.ts +44 -10
- package/corpus/templates/content/actions/_database-setup-discovery.ts +9 -3
- package/corpus/templates/content/actions/_database-utils.ts +299 -271
- package/corpus/templates/content/actions/_property-utils.ts +293 -190
- package/corpus/templates/content/actions/_relation-values.ts +3 -1
- package/corpus/templates/content/actions/get-content-database.ts +1 -0
- package/corpus/templates/content/actions/query-content-database-items.ts +32 -5
- package/corpus/templates/content/actions/set-document-property.ts +17 -11
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +21 -6
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -43
- package/corpus/templates/content/app/components/editor/DocumentEditorSkeleton.tsx +159 -50
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +12 -28
- package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +6 -1
- package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +139 -70
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +16 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +180 -75
- package/corpus/templates/content/app/components/editor/database/DatabaseViewSkeleton.tsx +76 -0
- package/corpus/templates/content/app/components/editor/database/navigation-state.ts +60 -2
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +41 -42
- package/corpus/templates/content/app/components/editor/document-editor-layout.ts +18 -0
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +11 -3
- package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +15 -7
- package/corpus/templates/content/app/components/editor/suggestions/model.ts +13 -0
- package/corpus/templates/content/app/components/editor/suggestions/read-only-blocks.ts +136 -0
- package/corpus/templates/content/app/components/editor/table-view.ts +73 -0
- package/corpus/templates/content/app/components/layout/ContentStartupShell.tsx +1 -1
- package/corpus/templates/content/app/components/layout/Layout.tsx +5 -1
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +57 -16
- package/corpus/templates/content/app/components/sidebar/MovePageDialog.tsx +4 -4
- package/corpus/templates/content/app/components/sidebar/SidebarNavigationRow.tsx +34 -10
- package/corpus/templates/content/app/global.css +16 -8
- package/corpus/templates/content/app/hooks/content-action-refresh.ts +14 -13
- package/corpus/templates/content/app/hooks/use-content-database.ts +72 -9
- package/corpus/templates/content/app/hooks/use-content-personal-navigation.ts +7 -3
- package/corpus/templates/content/app/hooks/use-documents.ts +27 -0
- package/corpus/templates/content/app/i18n-data.ts +3 -2
- package/corpus/templates/content/app/lib/content-landing.ts +3 -31
- package/corpus/templates/content/app/lib/files-navigation.ts +182 -10
- package/corpus/templates/content/app/lib/files-root-hint.ts +20 -32
- package/corpus/templates/content/app/lib/page-startup-hints.ts +108 -0
- package/corpus/templates/content/app/lib/private-link-redirect.ts +25 -0
- package/corpus/templates/content/app/lib/sidebar-layout-hint.ts +34 -1
- package/corpus/templates/content/app/lib/startup-timing.ts +2 -0
- package/corpus/templates/content/app/root.tsx +3 -1
- package/corpus/templates/content/app/routes/_app.home.tsx +10 -19
- package/corpus/templates/content/app/routes/p.$id.tsx +23 -5
- package/corpus/templates/content/changelog/2026-10-02-a-page-link-you-cant-open-now-says-so-instead-of-opening-an.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-after-a-refresh-the-files-sidebar-shows-the-open-page-s-fold.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-large-collections-open-faster.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-pages-stay-inside-the-window-at-every-width-long-inline-code.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-pages-with-open-comments-and-collections-with-sorted-or-filt.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-suggest-edits-works-on-pages-written-by-agents-including-pag.md +6 -0
- package/corpus/templates/content/changelog/2026-10-02-while-you-suggest-edits-tables-and-other-blocks-that-suggest.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-collection-view-context-now-survives-quick-page-changes-when.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-collection-views-keep-their-saved-rows-accurate-during-refre.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-exact-saved-view-links-keep-collection-retry-available.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-expanding-a-files-folder-now-shares-one-navigation-read-acro.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-files-navigation-retries-temporary-failures-consistently.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-shared-page-owners-keep-edit-and-manage-controls-in-files-an.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-tagging-an-ai-in-a-comment-works-again-instead-of-failing-ri.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-the-home-loading-shell-keeps-the-layout-of-your-last-opened-.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-typing-and-a-space-or-inserting-a-toggle-or-callout-no-longe.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +4 -1
- package/corpus/templates/content/e2e/playwright.config.ts +1 -1
- package/corpus/templates/content/package.json +2 -1
- package/corpus/templates/content/scripts/seed-perf-database.mjs +600 -0
- package/corpus/templates/content/scripts/trace-startup.mjs +25 -3
- package/corpus/templates/content/server/db/index.ts +1 -1
- package/corpus/templates/content/server/lib/suggested-edits.ts +23 -26
- package/corpus/templates/content/server/plugins/auth.ts +2 -1
- package/corpus/templates/content/server/plugins/otel.ts +6 -0
- package/corpus/templates/content/shared/api.ts +17 -0
- package/corpus/templates/content/shared/suggestion-diff.ts +124 -38
- package/corpus/templates/content/shared/suggestion-formatting.ts +134 -3
- package/corpus/templates/crm/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/crm/package.json +1 -0
- package/corpus/templates/crm/server/plugins/otel.ts +6 -0
- package/corpus/templates/design/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/design/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/design/CHANGELOG.md +41 -0
- package/corpus/templates/design/README.md +2 -2
- package/corpus/templates/design/actions/list-designs.ts +11 -4
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +110 -32
- package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +28 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +1 -0
- package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +79 -0
- package/corpus/templates/design/app/components/layout/Header.tsx +2 -4
- package/corpus/templates/design/app/components/ui/tabs.tsx +59 -1
- package/corpus/templates/design/app/global.css +1 -34
- package/corpus/templates/design/app/i18n/en-US.ts +4 -0
- package/corpus/templates/design/app/lib/design-filter.ts +4 -2
- package/corpus/templates/design/app/pages/Index.tsx +91 -59
- package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +58 -17
- package/corpus/templates/design/changelog/2026-10-01-filter-designs-by-owner.md +6 -0
- package/corpus/templates/design/changelog/2026-10-03-layers-dropped-into-visible-canvas-space-beyond-a-screen-now.md +6 -0
- package/corpus/templates/design/changelog/2026-10-03-visible-screen-overflow-no-longer-routes-drops-to-the-canvas.md +6 -0
- package/corpus/templates/design/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-designs.md +6 -0
- package/corpus/templates/design/package.json +1 -0
- package/corpus/templates/design/server/plugins/auth.ts +2 -1
- package/corpus/templates/design/server/plugins/otel.ts +6 -0
- package/corpus/templates/dispatch/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/dispatch/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/dispatch/README.md +2 -2
- package/corpus/templates/dispatch/package.json +1 -0
- package/corpus/templates/dispatch/server/plugins/otel.ts +6 -0
- package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +2 -1
- package/corpus/templates/factory/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/factory/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/factory/package.json +1 -0
- package/corpus/templates/factory/server/plugins/otel.ts +6 -0
- package/corpus/templates/forms/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/forms/README.md +2 -2
- package/corpus/templates/forms/package.json +1 -0
- package/corpus/templates/forms/server/plugins/auth.ts +2 -1
- package/corpus/templates/forms/server/plugins/otel.ts +6 -0
- package/corpus/templates/mail/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/mail/CHANGELOG.md +6 -0
- package/corpus/templates/mail/README.md +2 -2
- package/corpus/templates/mail/package.json +1 -0
- package/corpus/templates/mail/server/plugins/auth.ts +2 -1
- package/corpus/templates/mail/server/plugins/otel.ts +6 -0
- package/corpus/templates/plan/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/plan/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/plan/CHANGELOG.md +1 -0
- package/corpus/templates/plan/README.md +2 -2
- package/corpus/templates/plan/package.json +1 -0
- package/corpus/templates/plan/server/plugins/auth.ts +2 -1
- package/corpus/templates/plan/server/plugins/otel.ts +6 -0
- package/corpus/templates/slides/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/slides/.agents/skills/storing-data/SKILL.md +12 -7
- package/corpus/templates/slides/CHANGELOG.md +23 -0
- package/corpus/templates/slides/README.md +2 -2
- package/corpus/templates/slides/actions/list-decks.ts +11 -4
- package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +44 -36
- package/corpus/templates/slides/app/components/home/SlidesHomeLibrary.tsx +79 -0
- package/corpus/templates/slides/app/components/layout/Header.tsx +2 -15
- package/corpus/templates/slides/app/components/ui/tabs.tsx +59 -0
- package/corpus/templates/slides/app/global.css +3 -54
- package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
- package/corpus/templates/slides/app/lib/deck-filter.ts +5 -2
- package/corpus/templates/slides/app/pages/Index.tsx +102 -88
- package/corpus/templates/slides/changelog/2026-10-01-filter-decks-by-owner.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-02-hide-recent-until-decks-available.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-05-the-recent-tab-shows-loading-placeholders-until-your-decks-a.md +6 -0
- package/corpus/templates/slides/package.json +1 -0
- package/corpus/templates/slides/server/handlers/request-auth-context.ts +8 -0
- package/corpus/templates/slides/server/plugins/auth.ts +2 -1
- package/corpus/templates/slides/server/plugins/otel.ts +6 -0
- package/corpus/templates/tasks/.agents/skills/security/SKILL.md +9 -2
- package/corpus/templates/tasks/.agents/skills/storing-data/SKILL.md +12 -7
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { normalizeProviderTranscript } from "./provider-transcript.js";
|
|
2
|
+
|
|
3
|
+
const CHUNK_TRANSCRIPTION_CONCURRENCY = 3;
|
|
4
|
+
|
|
5
|
+
export interface TimedChunk {
|
|
6
|
+
startMs: number;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProviderTranscriptResult {
|
|
11
|
+
text: string;
|
|
12
|
+
language?: string | null;
|
|
13
|
+
segments?: Array<{
|
|
14
|
+
startMs: number;
|
|
15
|
+
endMs: number;
|
|
16
|
+
text: string;
|
|
17
|
+
speakerLabel?: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface JoinedTranscriptSegment {
|
|
22
|
+
startMs: number;
|
|
23
|
+
endMs: number;
|
|
24
|
+
text: string;
|
|
25
|
+
speaker?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface JoinedTranscript {
|
|
29
|
+
fullText: string;
|
|
30
|
+
language: string | null;
|
|
31
|
+
segments: JoinedTranscriptSegment[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ChunkTranscriptionError extends Error {
|
|
35
|
+
readonly chunkIndex: number;
|
|
36
|
+
readonly chunkCount: number;
|
|
37
|
+
readonly cause: unknown;
|
|
38
|
+
|
|
39
|
+
constructor(chunkIndex: number, chunkCount: number, cause: unknown) {
|
|
40
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
41
|
+
super(
|
|
42
|
+
`Transcribing part ${chunkIndex + 1} of ${chunkCount} failed: ${reason}`,
|
|
43
|
+
);
|
|
44
|
+
this.name = "ChunkTranscriptionError";
|
|
45
|
+
this.chunkIndex = chunkIndex;
|
|
46
|
+
this.chunkCount = chunkCount;
|
|
47
|
+
this.cause = cause;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Transcribes every chunk, at most `concurrency` at a time, retrying each
|
|
53
|
+
* chunk once when `isRetryable` accepts its error. Any chunk that still fails
|
|
54
|
+
* fails the whole run: a transcript missing a part must never be saved as if
|
|
55
|
+
* it were complete. A single-chunk run rethrows the provider error unchanged.
|
|
56
|
+
*/
|
|
57
|
+
export async function transcribeAudioChunks<C extends TimedChunk>({
|
|
58
|
+
chunks,
|
|
59
|
+
transcribe,
|
|
60
|
+
isRetryable,
|
|
61
|
+
concurrency = CHUNK_TRANSCRIPTION_CONCURRENCY,
|
|
62
|
+
}: {
|
|
63
|
+
chunks: C[];
|
|
64
|
+
transcribe: (chunk: C, index: number) => Promise<ProviderTranscriptResult>;
|
|
65
|
+
isRetryable: (err: unknown) => boolean;
|
|
66
|
+
concurrency?: number;
|
|
67
|
+
}): Promise<ProviderTranscriptResult[]> {
|
|
68
|
+
const results = new Array<ProviderTranscriptResult>(chunks.length);
|
|
69
|
+
let next = 0;
|
|
70
|
+
let failure: { index: number; error: unknown } | null = null;
|
|
71
|
+
|
|
72
|
+
const transcribeWithRetry = async (index: number) => {
|
|
73
|
+
try {
|
|
74
|
+
return await transcribe(chunks[index], index);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (!isRetryable(err)) throw err;
|
|
77
|
+
return transcribe(chunks[index], index);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const worker = async () => {
|
|
82
|
+
while (!failure && next < chunks.length) {
|
|
83
|
+
const index = next++;
|
|
84
|
+
try {
|
|
85
|
+
results[index] = await transcribeWithRetry(index);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
failure ??= { index, error };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
await Promise.all(
|
|
93
|
+
Array.from({ length: Math.min(concurrency, chunks.length) }, worker),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (failure) {
|
|
97
|
+
const { index, error } = failure;
|
|
98
|
+
if (chunks.length === 1) throw error;
|
|
99
|
+
throw new ChunkTranscriptionError(index, chunks.length, error);
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clamp(value: number, min: number, max: number): number {
|
|
105
|
+
return Math.min(Math.max(value, min), max);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Stitches per-chunk results into one transcript on the recording timeline.
|
|
110
|
+
* Provider timestamps drift past the end of long audio, so each segment is
|
|
111
|
+
* clamped to its own chunk before the chunk's offset is added.
|
|
112
|
+
*/
|
|
113
|
+
export function joinChunkTranscripts(
|
|
114
|
+
chunks: TimedChunk[],
|
|
115
|
+
results: ProviderTranscriptResult[],
|
|
116
|
+
): JoinedTranscript {
|
|
117
|
+
const texts: string[] = [];
|
|
118
|
+
const segments: JoinedTranscriptSegment[] = [];
|
|
119
|
+
let language: string | null = null;
|
|
120
|
+
|
|
121
|
+
chunks.forEach((chunk, index) => {
|
|
122
|
+
const result = results[index];
|
|
123
|
+
const chunkSegments = (result.segments ?? [])
|
|
124
|
+
.map((segment) => {
|
|
125
|
+
const startMs = clamp(segment.startMs, 0, chunk.durationMs);
|
|
126
|
+
const endMs = clamp(segment.endMs, startMs, chunk.durationMs);
|
|
127
|
+
const speaker = segment.speakerLabel?.trim();
|
|
128
|
+
return {
|
|
129
|
+
startMs: chunk.startMs + startMs,
|
|
130
|
+
endMs: chunk.startMs + endMs,
|
|
131
|
+
text: segment.text.trim(),
|
|
132
|
+
...(speaker ? { speaker } : {}),
|
|
133
|
+
};
|
|
134
|
+
})
|
|
135
|
+
.filter((segment) => segment.text)
|
|
136
|
+
.sort((a, b) => a.startMs - b.startMs);
|
|
137
|
+
|
|
138
|
+
const normalized = normalizeProviderTranscript(result.text, chunkSegments);
|
|
139
|
+
if (normalized.fullText) {
|
|
140
|
+
texts.push(normalized.fullText);
|
|
141
|
+
language ??= result.language?.trim() || null;
|
|
142
|
+
}
|
|
143
|
+
segments.push(...normalized.segments);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
fullText: texts.join("\n\n"),
|
|
148
|
+
language,
|
|
149
|
+
segments,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Readable, Transform } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
|
8
|
+
|
|
9
|
+
import { readAppState } from "@agent-native/core/application-state";
|
|
10
|
+
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
11
|
+
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
12
|
+
|
|
13
|
+
import { isBuilderCdnHost } from "../../server/lib/builder-cdn.js";
|
|
14
|
+
import {
|
|
15
|
+
AudioOnlyExtractionError,
|
|
16
|
+
extractAudioOnlyWithFfmpeg,
|
|
17
|
+
type AudioExtractor,
|
|
18
|
+
type AudioOnlyTranscriptionMedia,
|
|
19
|
+
} from "./audio-only-transcription.js";
|
|
20
|
+
|
|
21
|
+
interface RecordingAudioTimeouts {
|
|
22
|
+
downloadMs: number;
|
|
23
|
+
extractionMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type MediaFetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
type RecordingMediaTarget =
|
|
29
|
+
| { kind: "local-blob" }
|
|
30
|
+
| { kind: "stream"; url: string; origin: "app" | "builder-cdn" }
|
|
31
|
+
| { kind: "download"; url: string };
|
|
32
|
+
|
|
33
|
+
function appOrigin(): string {
|
|
34
|
+
const port = process.env.NITRO_PORT || process.env.PORT || "3000";
|
|
35
|
+
return (
|
|
36
|
+
process.env.PUBLIC_URL ??
|
|
37
|
+
process.env.NITRO_PUBLIC_URL ??
|
|
38
|
+
`http://localhost:${port}`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only URLs the server controls may be streamed by ffmpeg directly: ffmpeg
|
|
43
|
+
// follows redirects and resolves hosts without the SSRF guard, so any other
|
|
44
|
+
// URL is downloaded through ssrfSafeFetch instead.
|
|
45
|
+
export function resolveRecordingMediaTarget(
|
|
46
|
+
videoUrl: string,
|
|
47
|
+
): RecordingMediaTarget {
|
|
48
|
+
if (
|
|
49
|
+
videoUrl.startsWith("/api/video/") ||
|
|
50
|
+
(videoUrl.startsWith("/api/uploads/") && videoUrl.endsWith("/blob"))
|
|
51
|
+
) {
|
|
52
|
+
return { kind: "local-blob" };
|
|
53
|
+
}
|
|
54
|
+
if (videoUrl.startsWith("/") && !videoUrl.startsWith("//")) {
|
|
55
|
+
return { kind: "stream", url: `${appOrigin()}${videoUrl}`, origin: "app" };
|
|
56
|
+
}
|
|
57
|
+
let parsed: URL;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(videoUrl);
|
|
60
|
+
} catch {
|
|
61
|
+
return { kind: "download", url: videoUrl };
|
|
62
|
+
}
|
|
63
|
+
if (parsed.protocol === "https:" && isBuilderCdnHost(parsed.hostname)) {
|
|
64
|
+
return { kind: "stream", url: parsed.toString(), origin: "builder-cdn" };
|
|
65
|
+
}
|
|
66
|
+
return { kind: "download", url: videoUrl };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const safeFetch: MediaFetcher = (url, init) =>
|
|
70
|
+
ssrfSafeFetch(url, init, { maxRedirects: 3 });
|
|
71
|
+
|
|
72
|
+
function tooLargeError(bytes: number): Error {
|
|
73
|
+
return new Error(
|
|
74
|
+
`Recording media is too large to transcribe (${bytes} bytes, max ${MAX_UPLOAD_BYTES}).`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function withTempDir<T>(work: (dir: string) => Promise<T>): Promise<T> {
|
|
79
|
+
const dir = await mkdtemp(join(tmpdir(), "clips-transcription-source-"));
|
|
80
|
+
try {
|
|
81
|
+
return await work(dir);
|
|
82
|
+
} finally {
|
|
83
|
+
await rm(dir, { recursive: true, force: true }).catch((err) => {
|
|
84
|
+
console.warn(
|
|
85
|
+
`[clips] failed to remove transcription temp dir ${dir}:`,
|
|
86
|
+
(err as Error)?.message ?? String(err),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function downloadToFile(
|
|
93
|
+
url: string,
|
|
94
|
+
path: string,
|
|
95
|
+
fetcher: MediaFetcher,
|
|
96
|
+
timeoutMs: number,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const res = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
// guard:allow-bare-error — invariant: request-transcript records media preparation errors as a failed transcript.
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Failed to fetch videoUrl: HTTP ${res.status} ${res.statusText}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const declaredBytes = Number(res.headers.get("content-length") ?? "0");
|
|
106
|
+
if (declaredBytes > MAX_UPLOAD_BYTES) throw tooLargeError(declaredBytes);
|
|
107
|
+
// guard:allow-bare-error — invariant: request-transcript catches media preparation errors and records them as a failed transcript.
|
|
108
|
+
if (!res.body) throw new Error("Failed to fetch videoUrl: empty body");
|
|
109
|
+
|
|
110
|
+
let receivedBytes = 0;
|
|
111
|
+
const limit = new Transform({
|
|
112
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
113
|
+
receivedBytes += chunk.length;
|
|
114
|
+
if (receivedBytes > MAX_UPLOAD_BYTES) {
|
|
115
|
+
callback(tooLargeError(receivedBytes));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
callback(null, chunk);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
await pipeline(
|
|
122
|
+
Readable.fromWeb(res.body as unknown as NodeReadableStream),
|
|
123
|
+
limit,
|
|
124
|
+
createWriteStream(path),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function prepareRecordingTranscriptionAudio({
|
|
129
|
+
recordingId,
|
|
130
|
+
videoUrl,
|
|
131
|
+
timeouts,
|
|
132
|
+
extractor = extractAudioOnlyWithFfmpeg,
|
|
133
|
+
fetchers = { app: fetch, safe: safeFetch },
|
|
134
|
+
}: {
|
|
135
|
+
recordingId: string;
|
|
136
|
+
videoUrl: string;
|
|
137
|
+
timeouts: RecordingAudioTimeouts;
|
|
138
|
+
extractor?: AudioExtractor;
|
|
139
|
+
fetchers?: { app: MediaFetcher; safe: MediaFetcher };
|
|
140
|
+
}): Promise<AudioOnlyTranscriptionMedia> {
|
|
141
|
+
const extractFromFile = (write: (path: string) => Promise<void>) =>
|
|
142
|
+
withTempDir(async (dir) => {
|
|
143
|
+
const path = join(dir, "input");
|
|
144
|
+
await write(path);
|
|
145
|
+
return extractor({
|
|
146
|
+
source: { kind: "file", path },
|
|
147
|
+
timeoutMs: timeouts.extractionMs,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
const downloadAndExtract = (url: string, fetcher: MediaFetcher) =>
|
|
151
|
+
extractFromFile((path) =>
|
|
152
|
+
downloadToFile(url, path, fetcher, timeouts.downloadMs),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const target = resolveRecordingMediaTarget(videoUrl);
|
|
156
|
+
switch (target.kind) {
|
|
157
|
+
case "local-blob":
|
|
158
|
+
return extractFromFile(async (path) => {
|
|
159
|
+
const stash = await readAppState(`recording-blob-${recordingId}`);
|
|
160
|
+
const b64 = typeof stash?.data === "string" ? stash.data : null;
|
|
161
|
+
// guard:allow-bare-error — invariant: request-transcript catches media preparation errors and records them as a failed transcript.
|
|
162
|
+
if (!b64) throw new Error("recording-blob app-state missing");
|
|
163
|
+
await writeFile(path, Buffer.from(b64, "base64"));
|
|
164
|
+
});
|
|
165
|
+
case "download":
|
|
166
|
+
return downloadAndExtract(target.url, fetchers.safe);
|
|
167
|
+
case "stream":
|
|
168
|
+
try {
|
|
169
|
+
return await extractor({
|
|
170
|
+
source: { kind: "url", url: target.url },
|
|
171
|
+
timeoutMs: timeouts.downloadMs + timeouts.extractionMs,
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// A stream can fail where a full download succeeds, e.g. a server
|
|
175
|
+
// without range support for an MP4 whose index sits at the end.
|
|
176
|
+
if (
|
|
177
|
+
!(err instanceof AudioOnlyExtractionError) ||
|
|
178
|
+
err.code !== "EXTRACTION_FAILED"
|
|
179
|
+
) {
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
console.warn(
|
|
183
|
+
`[clips] streaming audio extraction failed for ${recordingId}; retrying from a downloaded copy: ${err.message}`,
|
|
184
|
+
);
|
|
185
|
+
return downloadAndExtract(
|
|
186
|
+
target.url,
|
|
187
|
+
target.origin === "app" ? fetchers.app : fetchers.safe,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { defineAction } from "@agent-native/core/action";
|
|
2
2
|
import type { ActionRunContext } from "@agent-native/core/action";
|
|
3
|
-
import {
|
|
4
|
-
readAppState,
|
|
5
|
-
writeAppState,
|
|
6
|
-
} from "@agent-native/core/application-state";
|
|
7
|
-
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
3
|
+
import { writeAppState } from "@agent-native/core/application-state";
|
|
8
4
|
import { resolveHasBuilderGatewayCredential } from "@agent-native/core/server";
|
|
9
5
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
10
6
|
import { track } from "@agent-native/core/tracking";
|
|
@@ -37,25 +33,28 @@ import { PENDING_TRANSCRIPT_HEARTBEAT_MS } from "../shared/transcript-status.js"
|
|
|
37
33
|
import {
|
|
38
34
|
AudioOnlyExtractionError,
|
|
39
35
|
assertAudioHasAudibleSignal,
|
|
36
|
+
audioExtractionTimeoutMs,
|
|
40
37
|
isNoExtractableAudioError,
|
|
41
38
|
isTransientExtractionError,
|
|
42
|
-
prepareAudioOnlyTranscriptionMedia,
|
|
43
39
|
type AudioOnlyTranscriptionMedia,
|
|
44
40
|
} from "./lib/audio-only-transcription.js";
|
|
45
41
|
import {
|
|
46
42
|
clearBuilderCreditsExhausted,
|
|
47
43
|
noteBuilderCreditsExhausted,
|
|
48
44
|
} from "./lib/builder-credits-state.js";
|
|
45
|
+
import {
|
|
46
|
+
ChunkTranscriptionError,
|
|
47
|
+
joinChunkTranscripts,
|
|
48
|
+
transcribeAudioChunks,
|
|
49
|
+
} from "./lib/chunked-transcription.js";
|
|
49
50
|
import { finalizeEndedMeetingsForRecording } from "./lib/finalize-ended-meetings.js";
|
|
50
51
|
import {
|
|
51
52
|
fetchLoomTranscript,
|
|
52
53
|
loomTranscriptUnavailableMessage,
|
|
53
54
|
} from "./lib/loom-transcript.js";
|
|
54
55
|
import { isLoomRecording } from "./lib/native-media.js";
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
normalizeProviderTranscript,
|
|
58
|
-
} from "./lib/provider-transcript.js";
|
|
56
|
+
import { isLikelyMismatchedTranscriptLanguage } from "./lib/provider-transcript.js";
|
|
57
|
+
import { prepareRecordingTranscriptionAudio } from "./lib/recording-audio-source.js";
|
|
59
58
|
import regenerateSummary from "./regenerate-summary.js";
|
|
60
59
|
import regenerateTitle from "./regenerate-title.js";
|
|
61
60
|
|
|
@@ -158,6 +157,24 @@ export function builderTranscriptionTimeoutMs(
|
|
|
158
157
|
);
|
|
159
158
|
}
|
|
160
159
|
|
|
160
|
+
// The stored size may describe the original upload or its compressed copy,
|
|
161
|
+
// depending on whether compression finished, so budget for the larger of it
|
|
162
|
+
// and a duration-based estimate.
|
|
163
|
+
export function estimatedRecordingMediaBytes(
|
|
164
|
+
videoSizeBytes: number | null | undefined,
|
|
165
|
+
durationMs: number | null | undefined,
|
|
166
|
+
): number {
|
|
167
|
+
const sizeBytes =
|
|
168
|
+
videoSizeBytes && Number.isFinite(videoSizeBytes) && videoSizeBytes > 0
|
|
169
|
+
? videoSizeBytes
|
|
170
|
+
: 0;
|
|
171
|
+
const durationBytes =
|
|
172
|
+
durationMs && Number.isFinite(durationMs) && durationMs > 0
|
|
173
|
+
? Math.ceil(durationMs / 60_000) * ESTIMATED_VIDEO_BYTES_PER_MINUTE
|
|
174
|
+
: 0;
|
|
175
|
+
return Math.max(sizeBytes, durationBytes);
|
|
176
|
+
}
|
|
177
|
+
|
|
161
178
|
export function recordingMediaFetchTimeoutMs(
|
|
162
179
|
videoSizeBytes: number | null | undefined,
|
|
163
180
|
durationMs: number | null | undefined,
|
|
@@ -172,12 +189,10 @@ export function recordingMediaFetchTimeoutMs(
|
|
|
172
189
|
);
|
|
173
190
|
}
|
|
174
191
|
|
|
175
|
-
const estimatedBytes =
|
|
176
|
-
videoSizeBytes
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
? Math.ceil(durationMs / 60_000) * ESTIMATED_VIDEO_BYTES_PER_MINUTE
|
|
180
|
-
: 0;
|
|
192
|
+
const estimatedBytes = estimatedRecordingMediaBytes(
|
|
193
|
+
videoSizeBytes,
|
|
194
|
+
durationMs,
|
|
195
|
+
);
|
|
181
196
|
if (!estimatedBytes) return MEDIA_FETCH_MIN_TIMEOUT_MS;
|
|
182
197
|
|
|
183
198
|
const fiftyMbUnits = Math.ceil(estimatedBytes / (50 * 1024 * 1024));
|
|
@@ -301,78 +316,6 @@ function rootCause(err: Error): Error {
|
|
|
301
316
|
return cause instanceof Error ? rootCause(cause) : err;
|
|
302
317
|
}
|
|
303
318
|
|
|
304
|
-
function recordingFallbackMimeType(
|
|
305
|
-
rec: Pick<RecordingMediaRow, "videoFormat">,
|
|
306
|
-
): string {
|
|
307
|
-
return rec.videoFormat === "mp4" ? "video/mp4" : "video/webm";
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function pickSourceMimeType(
|
|
311
|
-
actual: string | null | undefined,
|
|
312
|
-
fallback: string,
|
|
313
|
-
): string {
|
|
314
|
-
const base = (actual ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
315
|
-
if (!base || base === "application/octet-stream") return fallback;
|
|
316
|
-
return actual ?? fallback;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async function loadRecordingMediaBlob({
|
|
320
|
-
recordingId,
|
|
321
|
-
videoUrl,
|
|
322
|
-
fallbackMimeType,
|
|
323
|
-
timeoutMs,
|
|
324
|
-
}: {
|
|
325
|
-
recordingId: string;
|
|
326
|
-
videoUrl: string;
|
|
327
|
-
fallbackMimeType: string;
|
|
328
|
-
timeoutMs: number;
|
|
329
|
-
}): Promise<{ blob: Blob; sourceMimeType: string }> {
|
|
330
|
-
const isLocalBlob =
|
|
331
|
-
videoUrl.startsWith("/api/video/") ||
|
|
332
|
-
(videoUrl.startsWith("/api/uploads/") && videoUrl.endsWith("/blob"));
|
|
333
|
-
if (isLocalBlob) {
|
|
334
|
-
const stash = await readAppState(`recording-blob-${recordingId}`);
|
|
335
|
-
const b64 = typeof stash?.data === "string" ? stash.data : null;
|
|
336
|
-
if (!b64) throw new Error("recording-blob app-state missing");
|
|
337
|
-
const bytes = Buffer.from(b64, "base64");
|
|
338
|
-
const mime =
|
|
339
|
-
typeof stash?.mimeType === "string" ? stash.mimeType : fallbackMimeType;
|
|
340
|
-
return {
|
|
341
|
-
blob: new Blob([bytes], { type: mime }),
|
|
342
|
-
sourceMimeType: pickSourceMimeType(mime, fallbackMimeType),
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
let resolvedVideoUrl = videoUrl;
|
|
347
|
-
const isAppRelativeUrl =
|
|
348
|
-
resolvedVideoUrl.startsWith("/") && !resolvedVideoUrl.startsWith("//");
|
|
349
|
-
if (isAppRelativeUrl) {
|
|
350
|
-
const port = process.env.NITRO_PORT || process.env.PORT || "3000";
|
|
351
|
-
const origin =
|
|
352
|
-
process.env.PUBLIC_URL ??
|
|
353
|
-
process.env.NITRO_PUBLIC_URL ??
|
|
354
|
-
`http://localhost:${port}`;
|
|
355
|
-
resolvedVideoUrl = `${origin}${resolvedVideoUrl}`;
|
|
356
|
-
}
|
|
357
|
-
const vidRes = isAppRelativeUrl
|
|
358
|
-
? await fetch(resolvedVideoUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
|
359
|
-
: await ssrfSafeFetch(
|
|
360
|
-
resolvedVideoUrl,
|
|
361
|
-
{ signal: AbortSignal.timeout(timeoutMs) },
|
|
362
|
-
{ maxRedirects: 3 },
|
|
363
|
-
);
|
|
364
|
-
if (!vidRes.ok) {
|
|
365
|
-
throw new Error(
|
|
366
|
-
`Failed to fetch videoUrl: HTTP ${vidRes.status} ${vidRes.statusText}`,
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
const blob = await vidRes.blob();
|
|
370
|
-
return {
|
|
371
|
-
blob,
|
|
372
|
-
sourceMimeType: pickSourceMimeType(blob.type, fallbackMimeType),
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
319
|
function isRecentlyPendingTranscript(transcript: {
|
|
377
320
|
status: string | null;
|
|
378
321
|
updatedAt: string | null;
|
|
@@ -1069,8 +1012,8 @@ const requestTranscriptAction = defineAction({
|
|
|
1069
1012
|
const now = new Date().toISOString();
|
|
1070
1013
|
|
|
1071
1014
|
let builderError: string | null = null;
|
|
1015
|
+
let builderFailureCode: TranscriptFailureCode = "CLOUD_FAILED";
|
|
1072
1016
|
let audioMediaPromise: Promise<AudioOnlyTranscriptionMedia> | null = null;
|
|
1073
|
-
let audioSignalPromise: Promise<void> | null = null;
|
|
1074
1017
|
|
|
1075
1018
|
const getAudioMedia = (
|
|
1076
1019
|
rec: RecordingMediaRow,
|
|
@@ -1083,31 +1026,21 @@ const requestTranscriptAction = defineAction({
|
|
|
1083
1026
|
transcriptFailureMessage("NO_AUDIO_SAVED"),
|
|
1084
1027
|
);
|
|
1085
1028
|
}
|
|
1086
|
-
audioMediaPromise ??= (
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
fallbackMimeType,
|
|
1092
|
-
timeoutMs: recordingMediaFetchTimeoutMs(
|
|
1029
|
+
audioMediaPromise ??= prepareRecordingTranscriptionAudio({
|
|
1030
|
+
recordingId: args.recordingId,
|
|
1031
|
+
videoUrl,
|
|
1032
|
+
timeouts: {
|
|
1033
|
+
downloadMs: recordingMediaFetchTimeoutMs(
|
|
1093
1034
|
rec.videoSizeBytes,
|
|
1094
1035
|
rec.durationMs,
|
|
1095
1036
|
),
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
});
|
|
1102
|
-
})();
|
|
1037
|
+
extractionMs: audioExtractionTimeoutMs(
|
|
1038
|
+
estimatedRecordingMediaBytes(rec.videoSizeBytes, rec.durationMs),
|
|
1039
|
+
),
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1103
1042
|
return audioMediaPromise;
|
|
1104
1043
|
};
|
|
1105
|
-
const ensureAudioHasSignal = (
|
|
1106
|
-
media: AudioOnlyTranscriptionMedia,
|
|
1107
|
-
): Promise<void> => {
|
|
1108
|
-
audioSignalPromise ??= assertAudioHasAudibleSignal(media);
|
|
1109
|
-
return audioSignalPromise;
|
|
1110
|
-
};
|
|
1111
1044
|
|
|
1112
1045
|
const [existingNativeTranscript] = await db
|
|
1113
1046
|
.select({
|
|
@@ -1231,13 +1164,14 @@ const requestTranscriptAction = defineAction({
|
|
|
1231
1164
|
}
|
|
1232
1165
|
|
|
1233
1166
|
let audioMedia: AudioOnlyTranscriptionMedia;
|
|
1167
|
+
const extractionStartedAt = Date.now();
|
|
1234
1168
|
try {
|
|
1235
1169
|
audioMedia = await withPendingTranscriptHeartbeat(
|
|
1236
1170
|
db,
|
|
1237
1171
|
args.recordingId,
|
|
1238
1172
|
async () => {
|
|
1239
1173
|
const media = await getAudioMedia(rec);
|
|
1240
|
-
|
|
1174
|
+
assertAudioHasAudibleSignal(media);
|
|
1241
1175
|
return media;
|
|
1242
1176
|
},
|
|
1243
1177
|
);
|
|
@@ -1252,35 +1186,39 @@ const requestTranscriptAction = defineAction({
|
|
|
1252
1186
|
});
|
|
1253
1187
|
}
|
|
1254
1188
|
|
|
1189
|
+
console.log("[clips] transcription audio prepared", {
|
|
1190
|
+
recordingId: args.recordingId,
|
|
1191
|
+
chunks: audioMedia.chunks.length,
|
|
1192
|
+
audioBytes: audioMedia.chunks.reduce(
|
|
1193
|
+
(total, chunk) => total + chunk.audioBytes.byteLength,
|
|
1194
|
+
0,
|
|
1195
|
+
),
|
|
1196
|
+
elapsedMs: Date.now() - extractionStartedAt,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1255
1199
|
try {
|
|
1256
1200
|
const startedAt = Date.now();
|
|
1257
|
-
const
|
|
1201
|
+
const chunkResults = await withPendingTranscriptHeartbeat(
|
|
1258
1202
|
db,
|
|
1259
1203
|
args.recordingId,
|
|
1260
1204
|
() =>
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1205
|
+
transcribeAudioChunks({
|
|
1206
|
+
chunks: audioMedia.chunks,
|
|
1207
|
+
isRetryable: isTransientTranscriptionError,
|
|
1208
|
+
transcribe: (chunk) =>
|
|
1209
|
+
transcribeWithBuilderModelFallback({
|
|
1210
|
+
audioBytes: chunk.audioBytes,
|
|
1211
|
+
mimeType: audioMedia.mimeType,
|
|
1212
|
+
diarize: true,
|
|
1213
|
+
instructions: SPEECH_ONLY_TRANSCRIPTION_INSTRUCTIONS,
|
|
1214
|
+
timeoutMs: builderTranscriptionTimeoutMs(chunk.durationMs),
|
|
1215
|
+
}),
|
|
1267
1216
|
}),
|
|
1268
1217
|
);
|
|
1269
1218
|
|
|
1270
|
-
const
|
|
1271
|
-
.
|
|
1272
|
-
|
|
1273
|
-
return {
|
|
1274
|
-
startMs: s.startMs,
|
|
1275
|
-
endMs: s.endMs,
|
|
1276
|
-
text: s.text.trim(),
|
|
1277
|
-
...(speaker ? { speaker } : {}),
|
|
1278
|
-
};
|
|
1279
|
-
})
|
|
1280
|
-
.filter((segment) => segment.text);
|
|
1281
|
-
const normalizedTranscript = normalizeProviderTranscript(
|
|
1282
|
-
builderResult.text,
|
|
1283
|
-
segments,
|
|
1219
|
+
const normalizedTranscript = joinChunkTranscripts(
|
|
1220
|
+
audioMedia.chunks,
|
|
1221
|
+
chunkResults,
|
|
1284
1222
|
);
|
|
1285
1223
|
const fullText = normalizedTranscript.fullText;
|
|
1286
1224
|
|
|
@@ -1309,7 +1247,7 @@ const requestTranscriptAction = defineAction({
|
|
|
1309
1247
|
ownerEmail,
|
|
1310
1248
|
status: "ready",
|
|
1311
1249
|
failureReason: null,
|
|
1312
|
-
language:
|
|
1250
|
+
language: normalizedTranscript.language ?? "en",
|
|
1313
1251
|
segmentsJson: JSON.stringify(normalizedTranscript.segments),
|
|
1314
1252
|
fullText,
|
|
1315
1253
|
now,
|
|
@@ -1363,7 +1301,7 @@ const requestTranscriptAction = defineAction({
|
|
|
1363
1301
|
|
|
1364
1302
|
const elapsedMs = Date.now() - startedAt;
|
|
1365
1303
|
console.log(
|
|
1366
|
-
`Transcribed recording ${args.recordingId} via builder in ${elapsedMs}ms (${normalizedTranscript.segments.length} segments)`,
|
|
1304
|
+
`Transcribed recording ${args.recordingId} via builder in ${elapsedMs}ms (${audioMedia.chunks.length} parts, ${normalizedTranscript.segments.length} segments)`,
|
|
1367
1305
|
);
|
|
1368
1306
|
return {
|
|
1369
1307
|
recordingId: args.recordingId,
|
|
@@ -1375,6 +1313,8 @@ const requestTranscriptAction = defineAction({
|
|
|
1375
1313
|
} catch (err) {
|
|
1376
1314
|
const reason = (err as Error).message;
|
|
1377
1315
|
const details = serializeError(err);
|
|
1316
|
+
const chunkErr = err instanceof ChunkTranscriptionError ? err : null;
|
|
1317
|
+
if (chunkErr) builderFailureCode = "CHUNK_FAILED";
|
|
1378
1318
|
if (isBuilderCreditsExhaustedMessage(reason)) {
|
|
1379
1319
|
await noteBuilderCreditsExhausted({
|
|
1380
1320
|
source: "transcription",
|
|
@@ -1401,6 +1341,12 @@ const requestTranscriptAction = defineAction({
|
|
|
1401
1341
|
provider: BUILDER_GEMINI_TRANSCRIPTION_MODEL,
|
|
1402
1342
|
failureReason: reason,
|
|
1403
1343
|
details,
|
|
1344
|
+
...(chunkErr
|
|
1345
|
+
? {
|
|
1346
|
+
chunkIndex: chunkErr.chunkIndex,
|
|
1347
|
+
chunkCount: chunkErr.chunkCount,
|
|
1348
|
+
}
|
|
1349
|
+
: {}),
|
|
1404
1350
|
});
|
|
1405
1351
|
}
|
|
1406
1352
|
}
|
|
@@ -1424,14 +1370,14 @@ const requestTranscriptAction = defineAction({
|
|
|
1424
1370
|
ownerEmail,
|
|
1425
1371
|
status: "failed",
|
|
1426
1372
|
failureReason: reason,
|
|
1427
|
-
failureCode: builderError ?
|
|
1373
|
+
failureCode: builderError ? builderFailureCode : "CLOUD_UNCONFIGURED",
|
|
1428
1374
|
now,
|
|
1429
1375
|
...(cloudTransient ? { retryCount: cloudNextRetryCount } : {}),
|
|
1430
1376
|
});
|
|
1431
1377
|
track(
|
|
1432
1378
|
"recording_transcription_failed",
|
|
1433
1379
|
{
|
|
1434
|
-
failure_code: builderError ?
|
|
1380
|
+
failure_code: builderError ? builderFailureCode : "CLOUD_UNCONFIGURED",
|
|
1435
1381
|
stage: "transcription",
|
|
1436
1382
|
retryable: cloudTransient,
|
|
1437
1383
|
output_id: args.recordingId,
|