@larkup/workspace-server 0.0.0-stage → 0.2.1
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/LICENSE +176 -0
- package/README.md +2 -2
- package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
- package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/route.d.ts +3 -0
- package/dist-types/app/api/chat/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/tools.d.ts +67 -0
- package/dist-types/app/api/chat/tools.d.ts.map +1 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
- package/dist-types/app/api/config/verify/route.d.ts +3 -0
- package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/describe-image/route.d.ts +4 -0
- package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
- package/dist-types/app/api/document/sign/route.d.ts +2 -0
- package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
- package/dist-types/app/api/files/remote/route.d.ts +5 -0
- package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/route.d.ts +4 -0
- package/dist-types/app/api/mcp/route.d.ts.map +1 -0
- package/dist-types/app/api/parse-file/route.d.ts +6 -0
- package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
- package/dist-types/app/api/pdf-page/route.d.ts +4 -0
- package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
- package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
- package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/search/bing/route.d.ts +5 -0
- package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
- package/dist-types/app/api/search/brave/route.d.ts +5 -0
- package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
- package/dist-types/app/api/search/exa/route.d.ts +5 -0
- package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
- package/dist-types/app/api/search/google/route.d.ts +30 -0
- package/dist-types/app/api/search/google/route.d.ts.map +1 -0
- package/dist-types/app/api/search/route.d.ts +7 -0
- package/dist-types/app/api/search/route.d.ts.map +1 -0
- package/dist-types/app/api/search/tavily/route.d.ts +5 -0
- package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
- package/dist-types/app/api/search/verify/route.d.ts +4 -0
- package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/tabular/route.d.ts +9 -0
- package/dist-types/app/api/tabular/route.d.ts.map +1 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
- package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
- package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
- package/dist-types/lib/chat/image-description.d.ts +10 -0
- package/dist-types/lib/chat/image-description.d.ts.map +1 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
- package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/message-input.d.ts +8 -0
- package/dist-types/lib/chat/message-input.d.ts.map +1 -0
- package/dist-types/lib/chat/model-provider.d.ts +23 -0
- package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
- package/dist-types/lib/chat/response-style.d.ts +2 -0
- package/dist-types/lib/chat/response-style.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
- package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-context.d.ts +72 -0
- package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-registry.d.ts +3 -0
- package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/visual-routing.d.ts +53 -0
- package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
- package/dist-types/lib/enterprise-client.d.ts +31 -0
- package/dist-types/lib/enterprise-client.d.ts.map +1 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
- package/dist-types/lib/media/knowledge.d.ts +47 -0
- package/dist-types/lib/media/knowledge.d.ts.map +1 -0
- package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
- package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
- package/dist-types/lib/media/source-identity.d.ts +12 -0
- package/dist-types/lib/media/source-identity.d.ts.map +1 -0
- package/dist-types/lib/media/source-utils.d.ts +157 -0
- package/dist-types/lib/media/source-utils.d.ts.map +1 -0
- package/dist-types/lib/media/video/quick-look.d.ts +60 -0
- package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
- package/dist-types/lib/remote-files.d.ts +12 -0
- package/dist-types/lib/remote-files.d.ts.map +1 -0
- package/dist-types/lib/search/provider-verification.d.ts +5 -0
- package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
- package/dist-types/runtime-host.d.ts +20 -0
- package/dist-types/runtime-host.d.ts.map +1 -0
- package/package.json +338 -3
- package/src/app/api/chat/feedback/route.ts +77 -0
- package/src/app/api/chat/route.ts +2277 -0
- package/src/app/api/chat/tools.ts +3563 -0
- package/src/app/api/config/test-embedding/route.ts +39 -0
- package/src/app/api/config/verify/route.ts +309 -0
- package/src/app/api/describe-image/route.ts +108 -0
- package/src/app/api/doc-editor/edit/route.ts +87 -0
- package/src/app/api/doc-editor/export/route.ts +32 -0
- package/src/app/api/doc-editor/parse/route.ts +74 -0
- package/src/app/api/doc-editor/restore/route.ts +70 -0
- package/src/app/api/document/sign/route.ts +55 -0
- package/src/app/api/files/remote/route.ts +32 -0
- package/src/app/api/mcp/[connectionId]/route.ts +29 -0
- package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
- package/src/app/api/mcp/route.ts +22 -0
- package/src/app/api/parse-file/route.ts +86 -0
- package/src/app/api/pdf-page/route.ts +40 -0
- package/src/app/api/proxy/verify/route.ts +78 -0
- package/src/app/api/search/bing/route.ts +86 -0
- package/src/app/api/search/brave/route.ts +88 -0
- package/src/app/api/search/exa/route.ts +100 -0
- package/src/app/api/search/google/route.ts +147 -0
- package/src/app/api/search/route.ts +28 -0
- package/src/app/api/search/tavily/route.ts +93 -0
- package/src/app/api/search/verify/route.ts +27 -0
- package/src/app/api/tabular/route.ts +68 -0
- package/src/lib/chat/assistant-source-scope.ts +32 -0
- package/src/lib/chat/conversation-memory.ts +621 -0
- package/src/lib/chat/gateway-fallbacks.ts +12 -0
- package/src/lib/chat/image-description.ts +21 -0
- package/src/lib/chat/media-retrieval-routing.ts +37 -0
- package/src/lib/chat/media-source-routing.ts +100 -0
- package/src/lib/chat/message-input.ts +48 -0
- package/src/lib/chat/model-provider.ts +157 -0
- package/src/lib/chat/response-style.ts +3 -0
- package/src/lib/chat/retrieval-ranking.ts +126 -0
- package/src/lib/chat/retrieval-routing.ts +221 -0
- package/src/lib/chat/tabular-dataset-routing.ts +93 -0
- package/src/lib/chat/tabular-query-plan.ts +195 -0
- package/src/lib/chat/tabular-routing.ts +122 -0
- package/src/lib/chat/tabular-sandbox-files.ts +78 -0
- package/src/lib/chat/tabular-visualization.ts +165 -0
- package/src/lib/chat/tool-call-repair.ts +84 -0
- package/src/lib/chat/tool-context.ts +1222 -0
- package/src/lib/chat/tool-registry.ts +6 -0
- package/src/lib/chat/video-rag-routing.ts +149 -0
- package/src/lib/chat/visual-routing.ts +225 -0
- package/src/lib/enterprise-client.ts +96 -0
- package/src/lib/marketplace/tool-runtime-config.ts +155 -0
- package/src/lib/media/knowledge.ts +489 -0
- package/src/lib/media/pdf-inspection.ts +280 -0
- package/src/lib/media/source-identity.ts +50 -0
- package/src/lib/media/source-utils.ts +1156 -0
- package/src/lib/media/video/quick-look.ts +368 -0
- package/src/lib/media/video-intelligence-adapter.ts +1946 -0
- package/src/lib/media/video-intelligence-connection.ts +151 -0
- package/src/lib/remote-files.ts +182 -0
- package/src/lib/search/provider-verification.ts +95 -0
- package/src/runtime-host.ts +31 -0
|
@@ -0,0 +1,1946 @@
|
|
|
1
|
+
import { workspaceHost } from '@larkup/core/workspace-host';
|
|
2
|
+
import { openAsBlob } from 'node:fs';
|
|
3
|
+
import { getInstalledTool } from '@larkup/marketplace/installer';
|
|
4
|
+
import { loadToolExtension } from '@larkup/marketplace/extension';
|
|
5
|
+
import type { MediaAsset, MediaPipelineStage } from '@larkup/core/types';
|
|
6
|
+
import { trackUsageEvent } from '@larkup/core/analytics-store';
|
|
7
|
+
import type { OfflineKnowledgeEvidenceInput } from '@larkup/core/video-knowledge/knowledge-builder';
|
|
8
|
+
import type { MediaEvidenceSegment } from './knowledge';
|
|
9
|
+
import { resolveVideoIntelligenceConnection } from './video-intelligence-connection';
|
|
10
|
+
import {
|
|
11
|
+
reconcileVideoIntelligenceCapacity,
|
|
12
|
+
type VideoIntelligenceUsageSnapshot,
|
|
13
|
+
} from '@larkup/core/video-intelligence-capacity';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Distinguishes "the GPU provider never picked this job up in time" from
|
|
17
|
+
* every other failure -- callers use this to tell the model/user the
|
|
18
|
+
* analysis service didn't respond, instead of implying the video itself
|
|
19
|
+
* doesn't show the answer.
|
|
20
|
+
*/
|
|
21
|
+
export class VideoWorkerTimeoutError extends Error {
|
|
22
|
+
constructor(public readonly lastJobStatus: string) {
|
|
23
|
+
super(`The video analysis worker did not respond in time (last status: ${lastJobStatus}).`);
|
|
24
|
+
this.name = 'VideoWorkerTimeoutError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A terminal managed-worker failure has already released its concurrency and
|
|
30
|
+
* source-minute reservation, so a fresh worker can safely retry it. Keep this
|
|
31
|
+
* deliberately narrow: transport errors may leave the original job running,
|
|
32
|
+
* while configuration, quota, and evidence errors need user action instead.
|
|
33
|
+
*
|
|
34
|
+
* Safe to retry:
|
|
35
|
+
* - Larkup Cloud wrapper messages (previous-generation worker responses)
|
|
36
|
+
* - Model API overload / capacity errors from the upstream vision/LLM gateway
|
|
37
|
+
* - Stall-timeout: the job was explicitly cancelled before throwing, so no
|
|
38
|
+
* duplicate run is left alive on the worker
|
|
39
|
+
* - Source ingestion failures where the worker dropped the job before ingesting
|
|
40
|
+
* (status === 'failed' at the prepare stage, not a user-data error)
|
|
41
|
+
*/
|
|
42
|
+
export function isRetryableManagedVideoWorkerFailure(error: unknown): boolean {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
44
|
+
if (error instanceof VideoWorkerTimeoutError) return true;
|
|
45
|
+
return (
|
|
46
|
+
// Legacy Larkup Cloud wrapper messages
|
|
47
|
+
message.includes('Larkup Cloud could not complete this video') ||
|
|
48
|
+
message.includes('Larkup Cloud completed without a result') ||
|
|
49
|
+
// Modal GPU worker: upstream model capacity error
|
|
50
|
+
message.includes('model API is currently overloaded') ||
|
|
51
|
+
message.includes('may experience intermittent errors') ||
|
|
52
|
+
// Stall-timeout: job was cancelled before throwing — safe to resubmit
|
|
53
|
+
message.includes('stopped reporting progress for') ||
|
|
54
|
+
// Generic transient source ingestion drop (before any user data is processed)
|
|
55
|
+
message.includes('Video indexing ended with status failed')
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface VideoClient {
|
|
60
|
+
health(): Promise<unknown>;
|
|
61
|
+
getUsage(): Promise<{
|
|
62
|
+
sourceMinutesLimit: number | null;
|
|
63
|
+
sourceMinutesUsed: number;
|
|
64
|
+
activeJobs: number;
|
|
65
|
+
concurrentJobsLimit: number;
|
|
66
|
+
activeJobIds?: string[];
|
|
67
|
+
}>;
|
|
68
|
+
provisionDeviceAccess(installationId: string): Promise<{
|
|
69
|
+
apiKey: string;
|
|
70
|
+
entitlement: {
|
|
71
|
+
plan: string;
|
|
72
|
+
sourceMinutesPerMonth: number | null;
|
|
73
|
+
maxConcurrentJobs: number;
|
|
74
|
+
};
|
|
75
|
+
}>;
|
|
76
|
+
upload(file: Blob, fileName: string): Promise<{ uploadId: string }>;
|
|
77
|
+
submitJob(request: Record<string, unknown>): Promise<VideoJob>;
|
|
78
|
+
getJob(jobId: string): Promise<VideoJob>;
|
|
79
|
+
acknowledgeJobResult(jobId: string): Promise<{ status: string }>;
|
|
80
|
+
cancelJob(jobId: string): Promise<VideoJob>;
|
|
81
|
+
purgeJobData?: (jobId: string) => Promise<void>;
|
|
82
|
+
cancelOnlyActiveJob?: () => Promise<{ status: string; alreadyStopped?: boolean }>;
|
|
83
|
+
submitMaterialization?: (request: {
|
|
84
|
+
sourceUrl: string;
|
|
85
|
+
fileName?: string;
|
|
86
|
+
durationSecs?: number;
|
|
87
|
+
}) => Promise<VideoMaterializationJob>;
|
|
88
|
+
getMaterialization?: (jobId: string) => Promise<VideoMaterializationJob>;
|
|
89
|
+
acknowledgeMaterialization?: (jobId: string) => Promise<{ status: string }>;
|
|
90
|
+
cancelMaterialization?: (jobId: string) => Promise<VideoMaterializationJob>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface VideoMaterializationJob {
|
|
94
|
+
id: string;
|
|
95
|
+
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
|
96
|
+
progress: {
|
|
97
|
+
stage: string;
|
|
98
|
+
percent: number;
|
|
99
|
+
message: string;
|
|
100
|
+
current?: number;
|
|
101
|
+
total?: number;
|
|
102
|
+
unit?: string;
|
|
103
|
+
elapsedSeconds?: number;
|
|
104
|
+
estimatedRemainingSeconds?: number;
|
|
105
|
+
};
|
|
106
|
+
source: {
|
|
107
|
+
url: string;
|
|
108
|
+
fileName: string;
|
|
109
|
+
mimeType: string;
|
|
110
|
+
sizeBytes: number;
|
|
111
|
+
durationSecs: number;
|
|
112
|
+
} | null;
|
|
113
|
+
error: string | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The control plane owns the source-minute allowance. A managed entitlement
|
|
118
|
+
* with remaining capacity may run its bounded inspection directly; a second
|
|
119
|
+
* host-side budget must not turn a valid paid allowance into an unnecessary
|
|
120
|
+
* browser approval flow.
|
|
121
|
+
*/
|
|
122
|
+
export async function hasVideoIntelligenceCapacity(): Promise<boolean> {
|
|
123
|
+
if (workspaceHost()) return false;
|
|
124
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
125
|
+
if (!installed) return false;
|
|
126
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
127
|
+
if (!extension) return false;
|
|
128
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
129
|
+
const client = extension.createClient({ config, fetch: globalThis.fetch });
|
|
130
|
+
const usage = await client.getUsage();
|
|
131
|
+
return (
|
|
132
|
+
usage.concurrentJobsLimit > 0 &&
|
|
133
|
+
usage.activeJobs < usage.concurrentJobsLimit &&
|
|
134
|
+
(usage.sourceMinutesLimit === null || usage.sourceMinutesUsed < usage.sourceMinutesLimit)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function getVideoIntelligenceUsage(): Promise<{
|
|
139
|
+
sourceMinutesLimit: number | null;
|
|
140
|
+
sourceMinutesUsed: number;
|
|
141
|
+
activeJobs: number;
|
|
142
|
+
concurrentJobsLimit: number;
|
|
143
|
+
activeJobIds?: string[];
|
|
144
|
+
}> {
|
|
145
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
146
|
+
if (!installed) throw new Error('Video Intelligence is not installed.');
|
|
147
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
148
|
+
if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
|
|
149
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
150
|
+
return extension.createClient({ config, fetch: globalThis.fetch }).getUsage();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function getReconciledVideoIntelligenceUsage(
|
|
154
|
+
assets: MediaAsset[],
|
|
155
|
+
): Promise<VideoIntelligenceUsageSnapshot> {
|
|
156
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
157
|
+
if (!installed) throw new Error('Video Intelligence is not installed.');
|
|
158
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
159
|
+
if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
|
|
160
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
161
|
+
const client = extension.createClient({ config, fetch: globalThis.fetch });
|
|
162
|
+
return reconcileVideoIntelligenceCapacity({
|
|
163
|
+
assets,
|
|
164
|
+
getUsage: () => client.getUsage(),
|
|
165
|
+
getJob: (jobId) => client.getJob(jobId),
|
|
166
|
+
cancelJob: (jobId) => client.cancelJob(jobId),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks credentials and a video-capable model before a source is uploaded,
|
|
172
|
+
* downloaded, or queued. This keeps configuration mistakes out of long-running
|
|
173
|
+
* media jobs and gives the settings screen a useful, actionable error.
|
|
174
|
+
*/
|
|
175
|
+
export async function validateVideoIntelligenceConfiguration(): Promise<void> {
|
|
176
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
177
|
+
if (!installed) throw new Error('Install Video Intelligence before indexing this video.');
|
|
178
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
179
|
+
if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
|
|
180
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
181
|
+
assertVideoIntelligenceConfiguration(config);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Uses the configured managed service only as a short-lived remote-source
|
|
186
|
+
* bridge. The host must immediately copy the returned URL into its own
|
|
187
|
+
* canonical storage, then acknowledge it so the managed copy is deleted.
|
|
188
|
+
*/
|
|
189
|
+
export async function materializeInstalledRemoteVideo(input: {
|
|
190
|
+
sourceUrl: string;
|
|
191
|
+
fileName?: string;
|
|
192
|
+
durationSecs?: number;
|
|
193
|
+
maxWaitMs?: number;
|
|
194
|
+
assertStillActive?: () => Promise<void>;
|
|
195
|
+
reportProgress?: (progress: VideoMaterializationJob['progress']) => Promise<void>;
|
|
196
|
+
}): Promise<{ jobId: string; source: NonNullable<VideoMaterializationJob['source']> }> {
|
|
197
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
198
|
+
if (!installed) throw new Error('Install Video Intelligence before importing this video.');
|
|
199
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
200
|
+
if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
|
|
201
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
202
|
+
const context = { config, fetch: globalThis.fetch };
|
|
203
|
+
await extension.ensureRuntime?.(context);
|
|
204
|
+
const client = extension.createClient(context);
|
|
205
|
+
if (
|
|
206
|
+
!client.submitMaterialization ||
|
|
207
|
+
!client.getMaterialization ||
|
|
208
|
+
!client.acknowledgeMaterialization ||
|
|
209
|
+
!client.cancelMaterialization
|
|
210
|
+
) {
|
|
211
|
+
throw new Error('Update Video Intelligence before importing a remote video.');
|
|
212
|
+
}
|
|
213
|
+
await client.health();
|
|
214
|
+
await input.assertStillActive?.();
|
|
215
|
+
let job = await client.submitMaterialization({
|
|
216
|
+
sourceUrl: input.sourceUrl,
|
|
217
|
+
...(input.fileName ? { fileName: input.fileName } : {}),
|
|
218
|
+
...(Number.isFinite(input.durationSecs) && Number(input.durationSecs) > 0
|
|
219
|
+
? { durationSecs: Number(input.durationSecs) }
|
|
220
|
+
: {}),
|
|
221
|
+
});
|
|
222
|
+
const deadline = Date.now() + (input.maxWaitMs ?? 2 * 60 * 60_000);
|
|
223
|
+
while (job.status === 'queued' || job.status === 'running') {
|
|
224
|
+
if (Date.now() >= deadline) {
|
|
225
|
+
await client.cancelMaterialization(job.id).catch(() => undefined);
|
|
226
|
+
throw new VideoWorkerTimeoutError(job.status);
|
|
227
|
+
}
|
|
228
|
+
await input.assertStillActive?.().catch(async (error) => {
|
|
229
|
+
await client.cancelMaterialization!(job.id).catch(() => undefined);
|
|
230
|
+
throw error;
|
|
231
|
+
});
|
|
232
|
+
await input.reportProgress?.(job.progress);
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
|
234
|
+
job = await client.getMaterialization(job.id);
|
|
235
|
+
}
|
|
236
|
+
if (job.status !== 'completed' || !job.source) {
|
|
237
|
+
throw new Error(job.error || `Remote video import ended with status ${job.status}.`);
|
|
238
|
+
}
|
|
239
|
+
return { jobId: job.id, source: job.source };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function acknowledgeInstalledRemoteVideoMaterialization(jobId: string): Promise<void> {
|
|
243
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
244
|
+
if (!installed) return;
|
|
245
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
246
|
+
if (!extension) return;
|
|
247
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
248
|
+
const client = extension.createClient({ config, fetch: globalThis.fetch });
|
|
249
|
+
await client.acknowledgeMaterialization?.(jobId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Cancels the managed job before its MediaAsset is deleted. */
|
|
253
|
+
export async function cancelVideoIntelligenceJob(jobId: string): Promise<void> {
|
|
254
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
255
|
+
if (!installed) return;
|
|
256
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
257
|
+
if (!extension) return;
|
|
258
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
259
|
+
const client = extension.createClient({ config, fetch: globalThis.fetch });
|
|
260
|
+
const job = await client.getJob(jobId);
|
|
261
|
+
if (job.status === 'queued' || job.status === 'running') await client.cancelJob(jobId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Removes the local runtime's retained source/result after its host asset is deleted. */
|
|
265
|
+
export async function purgeLocalVideoIntelligenceJobData(jobId: string): Promise<void> {
|
|
266
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
267
|
+
if (!installed) return;
|
|
268
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
269
|
+
if (!extension) return;
|
|
270
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
271
|
+
if (runtimeModeFor(config) !== 'local') return;
|
|
272
|
+
const client = extension.createClient({ config, fetch: globalThis.fetch });
|
|
273
|
+
if (typeof client.purgeJobData !== 'function') return;
|
|
274
|
+
await client.purgeJobData(jobId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Stops exactly one orphaned cloud job. The service rejects multiple jobs. */
|
|
278
|
+
export async function cancelOnlyActiveVideoIntelligenceJob(): Promise<void> {
|
|
279
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
280
|
+
if (!installed) throw new Error('Video Intelligence is not installed.');
|
|
281
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
282
|
+
if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
|
|
283
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
284
|
+
const client = extension.createClient({ config, fetch: globalThis.fetch });
|
|
285
|
+
if (typeof client.cancelOnlyActiveJob !== 'function') {
|
|
286
|
+
throw new Error('Update Video Intelligence before stopping an orphaned cloud job.');
|
|
287
|
+
}
|
|
288
|
+
await client.cancelOnlyActiveJob();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface VideoJob {
|
|
292
|
+
id: string;
|
|
293
|
+
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
|
294
|
+
progress: {
|
|
295
|
+
stage: string;
|
|
296
|
+
percent: number;
|
|
297
|
+
message: string;
|
|
298
|
+
stagePercent?: number;
|
|
299
|
+
sequence?: number;
|
|
300
|
+
elapsedSeconds?: number;
|
|
301
|
+
estimatedRemainingSeconds?: number;
|
|
302
|
+
current?: number;
|
|
303
|
+
total?: number;
|
|
304
|
+
unit?: string;
|
|
305
|
+
};
|
|
306
|
+
result: VideoEvidence | null;
|
|
307
|
+
error: string | null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface CloudProgressCounter {
|
|
311
|
+
current: number;
|
|
312
|
+
total: number;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface CloudProgressCounters {
|
|
316
|
+
captions?: CloudProgressCounter;
|
|
317
|
+
searchClips?: CloudProgressCounter;
|
|
318
|
+
combined?: CloudProgressCounter;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface VideoEvidence {
|
|
322
|
+
durationMs: number;
|
|
323
|
+
video: { width: number; height: number; fps: number };
|
|
324
|
+
transcript: Array<{ startMs: number; endMs: number; text: string }>;
|
|
325
|
+
visualObservations: Array<{
|
|
326
|
+
timeMs: number;
|
|
327
|
+
objects: Array<{ label: string; trackId: number; confidence: number }>;
|
|
328
|
+
ocr: Array<{ text: string; confidence: number }>;
|
|
329
|
+
}>;
|
|
330
|
+
tracks: Array<{
|
|
331
|
+
trackId: number;
|
|
332
|
+
label: string;
|
|
333
|
+
startMs: number;
|
|
334
|
+
endMs: number;
|
|
335
|
+
observations: number;
|
|
336
|
+
confidence?: number;
|
|
337
|
+
}>;
|
|
338
|
+
anonymousPresenceLedger?: {
|
|
339
|
+
method: 'object-detection-anonymous-tracking';
|
|
340
|
+
sampledFrames: number;
|
|
341
|
+
sampleIntervalSecs?: number;
|
|
342
|
+
tracks: Array<{
|
|
343
|
+
trackId: number;
|
|
344
|
+
label: string;
|
|
345
|
+
startMs: number;
|
|
346
|
+
endMs: number;
|
|
347
|
+
observations: number;
|
|
348
|
+
confidence?: number;
|
|
349
|
+
timestampsMs: number[];
|
|
350
|
+
}>;
|
|
351
|
+
labels: Array<{
|
|
352
|
+
label: string;
|
|
353
|
+
distinctTrackCount: number;
|
|
354
|
+
maximumSimultaneous: number;
|
|
355
|
+
simultaneousTimestampsMs: number[];
|
|
356
|
+
}>;
|
|
357
|
+
};
|
|
358
|
+
recurringOverlayText?: Array<{
|
|
359
|
+
text: string;
|
|
360
|
+
firstSeenMs: number;
|
|
361
|
+
lastSeenMs: number;
|
|
362
|
+
observations: number;
|
|
363
|
+
timestampsMs?: number[];
|
|
364
|
+
confidence: number;
|
|
365
|
+
}>;
|
|
366
|
+
semanticObservations?: Array<{
|
|
367
|
+
startMs: number;
|
|
368
|
+
endMs: number;
|
|
369
|
+
text: string;
|
|
370
|
+
confidence: number;
|
|
371
|
+
}>;
|
|
372
|
+
semanticDiagnostics?: {
|
|
373
|
+
attempted?: boolean;
|
|
374
|
+
error?: string | null;
|
|
375
|
+
};
|
|
376
|
+
agentDiagnostics?: {
|
|
377
|
+
attempted?: boolean;
|
|
378
|
+
provider?: string;
|
|
379
|
+
model?: string;
|
|
380
|
+
requests?: number;
|
|
381
|
+
latencyMs?: number;
|
|
382
|
+
fallback?: boolean;
|
|
383
|
+
error?: string | null;
|
|
384
|
+
promptTokens?: number;
|
|
385
|
+
completionTokens?: number;
|
|
386
|
+
};
|
|
387
|
+
processingDiagnostics?: {
|
|
388
|
+
estimatedTotalSeconds?: number;
|
|
389
|
+
elapsedSeconds?: number;
|
|
390
|
+
estimateErrorSeconds?: number;
|
|
391
|
+
};
|
|
392
|
+
transcriptionDiagnostics?: {
|
|
393
|
+
provider?: string | null;
|
|
394
|
+
fallbackProvider?: string | null;
|
|
395
|
+
fallbackUsed?: boolean;
|
|
396
|
+
chunkCount?: number;
|
|
397
|
+
completedChunks?: number;
|
|
398
|
+
chunkErrors?: number;
|
|
399
|
+
error?: string | null;
|
|
400
|
+
};
|
|
401
|
+
knowledgeSummary?: {
|
|
402
|
+
overview?: string;
|
|
403
|
+
participants?: Array<{
|
|
404
|
+
name: string;
|
|
405
|
+
role: string;
|
|
406
|
+
evidence: Array<{ startMs: number; endMs: number }>;
|
|
407
|
+
}>;
|
|
408
|
+
visibleSubjects?: Array<{
|
|
409
|
+
identity: string;
|
|
410
|
+
identityBasis: 'source-named' | 'source-described' | 'unresolved';
|
|
411
|
+
appearances: Array<{
|
|
412
|
+
startMs: number;
|
|
413
|
+
endMs: number;
|
|
414
|
+
confidence: 'direct' | 'partial';
|
|
415
|
+
}>;
|
|
416
|
+
}>;
|
|
417
|
+
stateHistory?: Array<{
|
|
418
|
+
startMs: number;
|
|
419
|
+
endMs: number;
|
|
420
|
+
state: string;
|
|
421
|
+
confidence: 'direct' | 'partial';
|
|
422
|
+
}>;
|
|
423
|
+
keyEvents?: Array<{
|
|
424
|
+
startMs: number;
|
|
425
|
+
endMs: number;
|
|
426
|
+
event: string;
|
|
427
|
+
confidence: 'direct' | 'partial';
|
|
428
|
+
}>;
|
|
429
|
+
narrative?: Array<{
|
|
430
|
+
startMs: number;
|
|
431
|
+
endMs: number;
|
|
432
|
+
text: string;
|
|
433
|
+
confidence: 'direct' | 'partial';
|
|
434
|
+
}>;
|
|
435
|
+
context?: Array<{
|
|
436
|
+
fact: string;
|
|
437
|
+
evidence: Array<{ startMs: number; endMs: number }>;
|
|
438
|
+
}>;
|
|
439
|
+
sourceItems?: Array<{
|
|
440
|
+
kind: 'question' | 'clue' | 'heading' | 'slide-item' | 'board-item' | 'list-item';
|
|
441
|
+
channel: 'spoken' | 'visible';
|
|
442
|
+
questionRole?: 'primary' | 'interactional' | 'rhetorical' | 'not-question';
|
|
443
|
+
text: string;
|
|
444
|
+
answer: string;
|
|
445
|
+
respondent?: string;
|
|
446
|
+
startMs: number;
|
|
447
|
+
endMs: number;
|
|
448
|
+
}>;
|
|
449
|
+
sourceActivityStructures?: Array<{
|
|
450
|
+
kind: 'grid' | 'sequence' | 'collection';
|
|
451
|
+
label: string;
|
|
452
|
+
dimensions: Array<{ label: string; values: string[] }>;
|
|
453
|
+
promptCount?: number;
|
|
454
|
+
startMs: number;
|
|
455
|
+
endMs: number;
|
|
456
|
+
}>;
|
|
457
|
+
sourceTaskInstances?: Array<{
|
|
458
|
+
id: string;
|
|
459
|
+
prompt: string;
|
|
460
|
+
channel: 'spoken' | 'visible';
|
|
461
|
+
answer: string;
|
|
462
|
+
respondent?: string;
|
|
463
|
+
startMs: number;
|
|
464
|
+
endMs: number;
|
|
465
|
+
}>;
|
|
466
|
+
sourceInventoryCoverage?: {
|
|
467
|
+
complete: boolean;
|
|
468
|
+
totalWindows: number;
|
|
469
|
+
processedWindows: number;
|
|
470
|
+
reason?: string;
|
|
471
|
+
};
|
|
472
|
+
uncertainties?: string[];
|
|
473
|
+
};
|
|
474
|
+
coverage?: {
|
|
475
|
+
requested?: 'fast' | 'balanced' | 'thorough';
|
|
476
|
+
sourceFrames?: number;
|
|
477
|
+
decodedFrames?: number;
|
|
478
|
+
analyzedFrames?: number;
|
|
479
|
+
semanticClips?: number;
|
|
480
|
+
};
|
|
481
|
+
videoEmbeddings?: Array<{
|
|
482
|
+
clipId: string;
|
|
483
|
+
startMs: number;
|
|
484
|
+
endMs: number;
|
|
485
|
+
vector: number[];
|
|
486
|
+
dimensions: number;
|
|
487
|
+
provider: string;
|
|
488
|
+
}>;
|
|
489
|
+
videoEmbeddingDiagnostics?: {
|
|
490
|
+
attempted?: boolean;
|
|
491
|
+
provider?: string;
|
|
492
|
+
error?: string | null;
|
|
493
|
+
};
|
|
494
|
+
detectedLanguage?: string;
|
|
495
|
+
answeringGuide: {
|
|
496
|
+
goal?: string;
|
|
497
|
+
importantEntities: string[];
|
|
498
|
+
questionsToPrepareFor: string[];
|
|
499
|
+
instruction: string;
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function createVideoIntelligenceSubmitRequest(input: {
|
|
504
|
+
source: Record<string, unknown>;
|
|
505
|
+
brief: Record<string, unknown>;
|
|
506
|
+
config?: Record<string, unknown>;
|
|
507
|
+
}): Record<string, unknown> {
|
|
508
|
+
return {
|
|
509
|
+
source: input.source,
|
|
510
|
+
brief: input.brief,
|
|
511
|
+
...(input.config && runtimeModeFor(input.config) !== 'local'
|
|
512
|
+
? { modelConfiguration: resolveVideoJobModelConfiguration(input.config) }
|
|
513
|
+
: {}),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
type ReportStage = (
|
|
518
|
+
stage: MediaPipelineStage,
|
|
519
|
+
patch: {
|
|
520
|
+
status: 'running' | 'completed';
|
|
521
|
+
percent?: number;
|
|
522
|
+
current?: number;
|
|
523
|
+
total?: number;
|
|
524
|
+
unit?: string;
|
|
525
|
+
sequence?: number;
|
|
526
|
+
elapsedSeconds?: number;
|
|
527
|
+
estimatedRemainingSeconds?: number;
|
|
528
|
+
message: string;
|
|
529
|
+
startedAt?: string;
|
|
530
|
+
/**
|
|
531
|
+
* The dispatched job's own status, when known. 'queued' means the GPU
|
|
532
|
+
* worker hasn't picked the job up yet (cold start) -- distinct from
|
|
533
|
+
* `status` above, which is about this reporting call's own stage, not
|
|
534
|
+
* the underlying job.
|
|
535
|
+
*/
|
|
536
|
+
jobStatus?: 'queued' | 'running';
|
|
537
|
+
},
|
|
538
|
+
) => Promise<void>;
|
|
539
|
+
|
|
540
|
+
export async function runInstalledVideoIntelligence(input: {
|
|
541
|
+
asset: MediaAsset;
|
|
542
|
+
mediaPath: string;
|
|
543
|
+
reportStage: ReportStage;
|
|
544
|
+
briefOverride?: Record<string, unknown>;
|
|
545
|
+
/** Bounded cloud inspection reserves only the requested source range. */
|
|
546
|
+
sourceDurationSecs?: number;
|
|
547
|
+
/**
|
|
548
|
+
* A presigned HTTPS URL to an already-durable canonical copy (the web
|
|
549
|
+
* app's own S3StorageProvider). When set, this skips openAsBlob + the
|
|
550
|
+
* client.upload() multipart round-trip entirely -- the control plane
|
|
551
|
+
* dispatches the GPU worker straight at this URL, which is what lets
|
|
552
|
+
* watch_original re-inspect a video long after its original upload
|
|
553
|
+
* expired without ever re-transferring the file through this process.
|
|
554
|
+
*/
|
|
555
|
+
sourceUrl?: string;
|
|
556
|
+
/** Persists the managed job ID immediately so deletion can cancel it. */
|
|
557
|
+
onJobSubmitted?: (jobId: string) => Promise<void>;
|
|
558
|
+
/** Prevents a removed asset from submitting a new managed job. */
|
|
559
|
+
assertStillActive?: () => Promise<void>;
|
|
560
|
+
/**
|
|
561
|
+
* How long to wait for the dispatched job before giving up. Full-video
|
|
562
|
+
* indexing runs unattended, so it keeps the long default; a chat-triggered
|
|
563
|
+
* bounded inspection has a live user waiting on a response and must fail
|
|
564
|
+
* fast (with a clear timeout error the caller can distinguish from "the
|
|
565
|
+
* video doesn't show this") if the GPU provider never picks the job up,
|
|
566
|
+
* rather than hang for hours.
|
|
567
|
+
*/
|
|
568
|
+
maxWaitMs?: number;
|
|
569
|
+
}): Promise<{
|
|
570
|
+
evidence: VideoEvidence;
|
|
571
|
+
segments: MediaEvidenceSegment[];
|
|
572
|
+
}> {
|
|
573
|
+
const installed = await getInstalledTool('video-intelligence');
|
|
574
|
+
if (!installed) throw new Error('Install Video Intelligence (New) before indexing this video.');
|
|
575
|
+
const extension = await loadToolExtension<VideoClient>('video-intelligence');
|
|
576
|
+
if (!extension) throw new Error('The installed Video Intelligence tool could not be loaded.');
|
|
577
|
+
const { config } = await resolveVideoIntelligenceConnection(extension, installed.config);
|
|
578
|
+
assertVideoIntelligenceConfiguration(config);
|
|
579
|
+
const context = { config, fetch: globalThis.fetch };
|
|
580
|
+
await input.reportStage('extract', {
|
|
581
|
+
status: 'running',
|
|
582
|
+
percent: 1,
|
|
583
|
+
message: 'Connecting to the configured Video Intelligence runtime...',
|
|
584
|
+
});
|
|
585
|
+
const client = extension.createClient(context);
|
|
586
|
+
try {
|
|
587
|
+
await extension.ensureRuntime?.(context);
|
|
588
|
+
await client.health();
|
|
589
|
+
} catch (error) {
|
|
590
|
+
throw new Error(
|
|
591
|
+
`Video Intelligence connection check failed: ${
|
|
592
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
593
|
+
}`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const uploaded = input.sourceUrl
|
|
597
|
+
? null
|
|
598
|
+
: await client.upload(
|
|
599
|
+
await openAsBlob(input.mediaPath, { type: input.asset.mimeType }),
|
|
600
|
+
input.asset.fileName,
|
|
601
|
+
);
|
|
602
|
+
const brief: Record<string, unknown> = enforceManagedSemanticBrief(config, {
|
|
603
|
+
...createAssetVideoIndexingBrief(input.asset),
|
|
604
|
+
...(input.briefOverride ?? {}),
|
|
605
|
+
// Managed indexing has a semantic reader for normal visual understanding.
|
|
606
|
+
// Local indexing is offline-only, so it must retain OCR and detection as
|
|
607
|
+
// the source of visual evidence rather than producing a speech-only index.
|
|
608
|
+
skipHeavyOperators: shouldSkipHeavyVideoOperators(config, input.briefOverride),
|
|
609
|
+
// Fast Cloud coverage is explicitly a visual overview. Avoid making its
|
|
610
|
+
// completion wait on a full Whisper pass, which can dominate a short
|
|
611
|
+
// video after a worker cold start; balanced/deep indexing still includes
|
|
612
|
+
// timestamped speech by default.
|
|
613
|
+
...(shouldSkipCloudTranscription(config, input.briefOverride, input.asset)
|
|
614
|
+
? { skipTranscription: true }
|
|
615
|
+
: {}),
|
|
616
|
+
// Fast is an evidence-backed overview: semantic captions are the source
|
|
617
|
+
// for chat, while video-vector embeddings are an optional secondary
|
|
618
|
+
// retrieval index. Do not make a user wait for that expensive remote
|
|
619
|
+
// index (or its provider queue) before a Fast video is searchable.
|
|
620
|
+
...(shouldSkipCloudVideoEmbeddings(config, input.briefOverride, input.asset)
|
|
621
|
+
? { skipVideoEmbeddings: true }
|
|
622
|
+
: {}),
|
|
623
|
+
});
|
|
624
|
+
// Normalizing an unbounded source through the worker's single-range path
|
|
625
|
+
// guarantees an H.264 analysis file. Some browser codecs transcribe fine
|
|
626
|
+
// but OpenCV cannot yield frames after a stream-copy materialization, which
|
|
627
|
+
// otherwise silently drops all OCR/object/VLM evidence from a full index.
|
|
628
|
+
const effectiveDurationSecs = input.sourceDurationSecs ?? input.asset.durationSecs;
|
|
629
|
+
if (
|
|
630
|
+
(!Array.isArray(brief.importantRanges) || brief.importantRanges.length === 0) &&
|
|
631
|
+
Number.isFinite(effectiveDurationSecs) &&
|
|
632
|
+
Number(effectiveDurationSecs) > 0
|
|
633
|
+
) {
|
|
634
|
+
brief.importantRanges = [
|
|
635
|
+
{ startSecs: 0, endSecs: Number(effectiveDurationSecs), note: 'full-index-normalization' },
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
await input.assertStillActive?.();
|
|
639
|
+
let job: VideoJob;
|
|
640
|
+
try {
|
|
641
|
+
job = await client.submitJob(
|
|
642
|
+
createVideoIntelligenceSubmitRequest({
|
|
643
|
+
source: {
|
|
644
|
+
...(input.sourceUrl ? { url: input.sourceUrl } : { uploadId: uploaded!.uploadId }),
|
|
645
|
+
fileName: input.asset.fileName,
|
|
646
|
+
mimeType: input.asset.mimeType,
|
|
647
|
+
// `durationSecs` is the billable bounded clip duration for a live
|
|
648
|
+
// inspection. The worker still needs the original timeline length
|
|
649
|
+
// to distinguish a 0:00–0:20 inspection from an entire 20-second
|
|
650
|
+
// source, otherwise it downloads and plans the full recording.
|
|
651
|
+
...(Number.isFinite(input.asset.durationSecs) && Number(input.asset.durationSecs) > 0
|
|
652
|
+
? { timelineDurationSecs: Number(input.asset.durationSecs) }
|
|
653
|
+
: {}),
|
|
654
|
+
durationSecs: effectiveDurationSecs,
|
|
655
|
+
},
|
|
656
|
+
brief,
|
|
657
|
+
config,
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Video Intelligence job submission failed: ${
|
|
663
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
664
|
+
}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
await input.onJobSubmitted?.(job.id);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
await client.cancelJob(job.id).catch(() => undefined);
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
const maxWaitMs = input.maxWaitMs ?? 6 * 60 * 60_000;
|
|
674
|
+
// Queuing and execution are distinct provider phases. A worker can become
|
|
675
|
+
// available near the end of a cold-start allowance; do not cancel a job
|
|
676
|
+
// that has already started processing solely because it spent time queued.
|
|
677
|
+
let deadline = Date.now() + maxWaitMs;
|
|
678
|
+
let previousJobStatus = job.status;
|
|
679
|
+
let reportedCloudStage = '';
|
|
680
|
+
let cloudStageStartedAt = Date.now();
|
|
681
|
+
let lastRemoteProgressSignature = '';
|
|
682
|
+
let lastRemoteProgressAt = Date.now();
|
|
683
|
+
const runningStallTimeoutMs = Math.min(maxWaitMs, 3 * 60_000);
|
|
684
|
+
while (job.status === 'queued' || job.status === 'running') {
|
|
685
|
+
if (Date.now() >= deadline) {
|
|
686
|
+
await client.cancelJob(job.id).catch(() => undefined);
|
|
687
|
+
throw new VideoWorkerTimeoutError(job.status);
|
|
688
|
+
}
|
|
689
|
+
const remoteProgressSignature = JSON.stringify({
|
|
690
|
+
status: job.status,
|
|
691
|
+
stage: job.progress.stage,
|
|
692
|
+
percent: job.progress.percent,
|
|
693
|
+
stagePercent: job.progress.stagePercent,
|
|
694
|
+
sequence: job.progress.sequence,
|
|
695
|
+
current: job.progress.current,
|
|
696
|
+
total: job.progress.total,
|
|
697
|
+
message: job.progress.message,
|
|
698
|
+
});
|
|
699
|
+
const hasFreshRemoteProgress = remoteProgressSignature !== lastRemoteProgressSignature;
|
|
700
|
+
if (hasFreshRemoteProgress) {
|
|
701
|
+
lastRemoteProgressSignature = remoteProgressSignature;
|
|
702
|
+
lastRemoteProgressAt = Date.now();
|
|
703
|
+
} else if (
|
|
704
|
+
job.status === 'running' &&
|
|
705
|
+
Date.now() - lastRemoteProgressAt >= runningStallTimeoutMs
|
|
706
|
+
) {
|
|
707
|
+
await client.cancelJob(job.id).catch(() => undefined);
|
|
708
|
+
throw new Error(
|
|
709
|
+
'The video analysis worker stopped reporting progress for 3 minutes. The job was cancelled safely; retry to use a fresh worker.',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (job.progress.stage !== reportedCloudStage) {
|
|
713
|
+
await completePreviousCloudStage(reportedCloudStage, input.reportStage);
|
|
714
|
+
reportedCloudStage = job.progress.stage;
|
|
715
|
+
cloudStageStartedAt = Date.now();
|
|
716
|
+
}
|
|
717
|
+
if (hasFreshRemoteProgress) {
|
|
718
|
+
const progress = cloudStageProgress(job.progress);
|
|
719
|
+
await input.reportStage(mapStage(job.progress.stage), {
|
|
720
|
+
status: 'running',
|
|
721
|
+
jobStatus: job.status === 'queued' ? 'queued' : 'running',
|
|
722
|
+
...progress,
|
|
723
|
+
// The semantic-reader and embedding counters begin after the local
|
|
724
|
+
// decode/detect work. Reset the clock here so its ETA is based on the
|
|
725
|
+
// actual remote work, not the entire video job.
|
|
726
|
+
...(job.progress.stage === 'synthesize'
|
|
727
|
+
? { startedAt: new Date(cloudStageStartedAt).toISOString() }
|
|
728
|
+
: {}),
|
|
729
|
+
message: job.progress.message,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
// Worker telemetry arrives every few seconds. Poll below the managed
|
|
733
|
+
// control-plane rate limit while keeping every heartbeat visible.
|
|
734
|
+
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
|
735
|
+
job = await client.getJob(job.id);
|
|
736
|
+
if (job.status === 'running' && previousJobStatus !== 'running') {
|
|
737
|
+
deadline = Date.now() + maxWaitMs;
|
|
738
|
+
}
|
|
739
|
+
previousJobStatus = job.status;
|
|
740
|
+
}
|
|
741
|
+
if (job.status !== 'completed' || !job.result) {
|
|
742
|
+
throw new Error(job.error || `Video indexing ended with status ${job.status}.`);
|
|
743
|
+
}
|
|
744
|
+
const semanticDiagnostics = job.result.semanticDiagnostics;
|
|
745
|
+
const semanticFailure = shouldRequireSemanticVideoEvidence(
|
|
746
|
+
config,
|
|
747
|
+
semanticDiagnostics,
|
|
748
|
+
job.result.semanticObservations,
|
|
749
|
+
);
|
|
750
|
+
if (semanticFailure) {
|
|
751
|
+
const detail = semanticDiagnostics?.error
|
|
752
|
+
? `: ${semanticDiagnostics.error}`
|
|
753
|
+
: !semanticDiagnostics
|
|
754
|
+
? ': the Larkup Cloud worker returned an incomplete semantic-evidence result. Retry after updating the Cloud worker.'
|
|
755
|
+
: '';
|
|
756
|
+
throw new Error(`Video semantic analysis did not return validated evidence${detail}`);
|
|
757
|
+
}
|
|
758
|
+
const agent = job.result.agentDiagnostics;
|
|
759
|
+
if (agent?.attempted && agent.model) {
|
|
760
|
+
const promptTokens = agent.promptTokens ?? 0;
|
|
761
|
+
const completionTokens = agent.completionTokens ?? 0;
|
|
762
|
+
void trackUsageEvent({
|
|
763
|
+
type: 'media_processing',
|
|
764
|
+
mediaType: 'video',
|
|
765
|
+
mediaOperation: 'extraction',
|
|
766
|
+
modelId: agent.model,
|
|
767
|
+
provider: agent.provider,
|
|
768
|
+
promptTokens,
|
|
769
|
+
completionTokens,
|
|
770
|
+
totalTokens: promptTokens + completionTokens,
|
|
771
|
+
latencyMs: agent.latencyMs,
|
|
772
|
+
durationSecs: job.result.durationMs / 1_000,
|
|
773
|
+
frameCount: job.result.coverage?.analyzedFrames,
|
|
774
|
+
observationCount:
|
|
775
|
+
job.result.visualObservations.length + (job.result.semanticObservations?.length ?? 0),
|
|
776
|
+
runtime: runtimeModeFor(config) === 'local' ? 'local' : 'remote',
|
|
777
|
+
timestamp: new Date().toISOString(),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
// The control plane retains source/result data only until this explicit
|
|
781
|
+
// acknowledgement. Do this only after validation, so a failed evidence
|
|
782
|
+
// check remains diagnosable and can be recovered instead of deleting the
|
|
783
|
+
// worker's result before the user sees the reason.
|
|
784
|
+
// Installed marketplace extensions are hot-loaded. Older still-supported
|
|
785
|
+
// clients acknowledge from getJob(), while newer ones expose the explicit
|
|
786
|
+
// acknowledgement method above. Support both during the rollout.
|
|
787
|
+
if (typeof client.acknowledgeJobResult === 'function') {
|
|
788
|
+
await client.acknowledgeJobResult(job.id).catch(() => undefined);
|
|
789
|
+
}
|
|
790
|
+
await input.reportStage('vision', {
|
|
791
|
+
status: 'completed',
|
|
792
|
+
percent: 100,
|
|
793
|
+
message: `Analyzed ${job.result.visualObservations.length} timestamped visual observations.`,
|
|
794
|
+
});
|
|
795
|
+
await input.reportStage('transcribe', {
|
|
796
|
+
status: 'completed',
|
|
797
|
+
percent: 100,
|
|
798
|
+
message: `Transcribed ${job.result.transcript.length} timestamped speech sections.`,
|
|
799
|
+
});
|
|
800
|
+
await input.reportStage('extract', {
|
|
801
|
+
status: 'completed',
|
|
802
|
+
percent: 100,
|
|
803
|
+
message: 'Video evidence selected.',
|
|
804
|
+
});
|
|
805
|
+
return { evidence: job.result, segments: evidenceToSegments(job.result) };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function shouldRequireSemanticVideoEvidence(
|
|
809
|
+
config: Record<string, unknown>,
|
|
810
|
+
diagnostics: VideoEvidence['semanticDiagnostics'],
|
|
811
|
+
observations: VideoEvidence['semanticObservations'] = [],
|
|
812
|
+
): boolean {
|
|
813
|
+
const hasUsableEvidence = observations.some((observation) => observation.text.trim());
|
|
814
|
+
return runtimeModeFor(config) !== 'local' && (!diagnostics?.attempted || !hasUsableEvidence);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Managed indexes must contain actual visual-language evidence, not speech/OCR alone. */
|
|
818
|
+
export function enforceManagedSemanticBrief(
|
|
819
|
+
config: Record<string, unknown>,
|
|
820
|
+
brief: Record<string, unknown>,
|
|
821
|
+
): Record<string, unknown> {
|
|
822
|
+
return runtimeModeFor(config) === 'local' ? brief : { ...brief, requireSemanticVision: true };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export function shouldSkipHeavyVideoOperators(
|
|
826
|
+
_config: Record<string, unknown>,
|
|
827
|
+
briefOverride?: Record<string, unknown>,
|
|
828
|
+
): boolean {
|
|
829
|
+
if (typeof briefOverride?.skipHeavyOperators === 'boolean') {
|
|
830
|
+
return briefOverride.skipHeavyOperators;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function shouldSkipCloudTranscription(
|
|
836
|
+
_config: Record<string, unknown>,
|
|
837
|
+
briefOverride: Record<string, unknown> | undefined,
|
|
838
|
+
_asset: MediaAsset,
|
|
839
|
+
): boolean {
|
|
840
|
+
return briefOverride?.skipTranscription === true;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function shouldSkipCloudVideoEmbeddings(
|
|
844
|
+
_config: Record<string, unknown>,
|
|
845
|
+
briefOverride: Record<string, unknown> | undefined,
|
|
846
|
+
_asset: MediaAsset,
|
|
847
|
+
): boolean {
|
|
848
|
+
return briefOverride?.skipVideoEmbeddings === true;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function assertVideoIntelligenceConfiguration(config: Record<string, unknown>): void {
|
|
852
|
+
const models = resolveVideoJobModelConfiguration(config);
|
|
853
|
+
if (!/gemini.*(?:flash|vision)|qwen.*vl|gpt-4o|gpt-4\.1/i.test(models.vision.model)) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
`Before indexing starts, choose a video-capable vision model. "${models.vision.model}" is not supported for video analysis.`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function resolveVideoJobModelConfiguration(config: Record<string, unknown>) {
|
|
861
|
+
const value = (candidate: unknown) =>
|
|
862
|
+
typeof candidate === 'string' && candidate.trim() ? candidate.trim() : undefined;
|
|
863
|
+
|
|
864
|
+
const supportedVisionProviders = new Set(['vercel_ai_gateway', 'google', 'openai']);
|
|
865
|
+
const supportedBrainProviders = new Set([
|
|
866
|
+
'vercel_ai_gateway',
|
|
867
|
+
'google',
|
|
868
|
+
'openai',
|
|
869
|
+
'deepseek',
|
|
870
|
+
'mistral',
|
|
871
|
+
'cohere',
|
|
872
|
+
'anthropic',
|
|
873
|
+
]);
|
|
874
|
+
|
|
875
|
+
const brainOverride = value(config.videoAgentProvider);
|
|
876
|
+
const globalBrainProvider = value(config.larkupAgentProvider);
|
|
877
|
+
const brainProvider =
|
|
878
|
+
brainOverride && brainOverride !== 'auto'
|
|
879
|
+
? brainOverride
|
|
880
|
+
: (globalBrainProvider ?? 'vercel_ai_gateway');
|
|
881
|
+
|
|
882
|
+
const rawBrainApiKey =
|
|
883
|
+
(brainOverride && brainOverride !== 'auto' ? value(config.videoAgentApiKey) : undefined) ??
|
|
884
|
+
(globalBrainProvider === brainProvider ? value(config.larkupAgentApiKey) : undefined);
|
|
885
|
+
|
|
886
|
+
const visionOverride = value(config.videoVisionProvider);
|
|
887
|
+
const globalVisionProvider = value(config.larkupVisionProvider);
|
|
888
|
+
const visionProvider =
|
|
889
|
+
visionOverride && visionOverride !== 'auto'
|
|
890
|
+
? visionOverride
|
|
891
|
+
: (globalVisionProvider ??
|
|
892
|
+
(supportedVisionProviders.has(brainProvider) ? brainProvider : 'vercel_ai_gateway'));
|
|
893
|
+
|
|
894
|
+
const visionApiKey =
|
|
895
|
+
(visionOverride && visionOverride !== 'auto' ? value(config.videoVisionApiKey) : undefined) ??
|
|
896
|
+
(globalVisionProvider === visionProvider ? value(config.larkupVisionApiKey) : undefined) ??
|
|
897
|
+
value(config.visionGatewayApiKey) ??
|
|
898
|
+
(visionProvider === brainProvider ? rawBrainApiKey : undefined);
|
|
899
|
+
|
|
900
|
+
const visionModelOverride = value(config.semanticVisionModel);
|
|
901
|
+
const visionModel =
|
|
902
|
+
(visionModelOverride && visionModelOverride !== 'auto' ? visionModelOverride : undefined) ??
|
|
903
|
+
(globalVisionProvider === visionProvider ? value(config.larkupVisionModel) : undefined) ??
|
|
904
|
+
(visionProvider === 'google'
|
|
905
|
+
? 'google/gemini-3.6-flash'
|
|
906
|
+
: visionProvider === 'openai'
|
|
907
|
+
? 'openai/gpt-4o-mini'
|
|
908
|
+
: 'google/gemini-3.6-flash');
|
|
909
|
+
|
|
910
|
+
const brainApiKey =
|
|
911
|
+
rawBrainApiKey ?? (brainProvider === visionProvider ? visionApiKey : undefined);
|
|
912
|
+
const brainModelOverride = value(config.agentModel);
|
|
913
|
+
const brainModel =
|
|
914
|
+
(brainModelOverride && brainModelOverride !== 'auto' ? brainModelOverride : undefined) ??
|
|
915
|
+
(globalBrainProvider === brainProvider ? value(config.larkupAgentModel) : undefined) ??
|
|
916
|
+
(brainProvider === 'google' ? 'google/gemini-3.5-flash-lite' : 'openai/gpt-5-mini');
|
|
917
|
+
|
|
918
|
+
const audioProvider = value(config.audioProvider) ?? 'deepgram';
|
|
919
|
+
const audioApiKey =
|
|
920
|
+
value(config.audioApiKey) ?? (audioProvider === brainProvider ? rawBrainApiKey : undefined);
|
|
921
|
+
const audioModel = {
|
|
922
|
+
openai: 'whisper-1',
|
|
923
|
+
groq: 'whisper-large-v3-turbo',
|
|
924
|
+
deepgram: 'nova-3',
|
|
925
|
+
elevenlabs: 'scribe_v2',
|
|
926
|
+
}[audioProvider] as string | undefined;
|
|
927
|
+
|
|
928
|
+
if (!supportedVisionProviders.has(visionProvider)) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
`Video vision cannot use "${visionProvider}". Choose Google, OpenAI, or Vercel AI Gateway under Settings → AI Models → Vision Model.`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (!supportedBrainProviders.has(brainProvider)) {
|
|
934
|
+
throw new Error(`Agent / tool-brain provider "${brainProvider}" is not supported.`);
|
|
935
|
+
}
|
|
936
|
+
if (!new Set(['openai', 'groq', 'deepgram', 'elevenlabs']).has(audioProvider)) {
|
|
937
|
+
throw new Error(`Audio provider "${audioProvider}" is not supported.`);
|
|
938
|
+
}
|
|
939
|
+
if (!visionApiKey) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
'Before indexing starts, configure a vision provider and API key in Video Intelligence or under Settings → AI Models → Vision Model. Text-only providers such as DeepSeek need a separate vision provider. The video was not uploaded or downloaded.',
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (!brainApiKey) {
|
|
945
|
+
throw new Error(
|
|
946
|
+
'Before indexing starts, add the selected agent provider API key in Video Intelligence or under Settings → AI Models. The video was not uploaded or downloaded.',
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
if (!audioApiKey) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
'Before indexing starts, add the selected audio provider API key in Video Intelligence settings. The video was not uploaded or downloaded.',
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
if (!audioModel) {
|
|
955
|
+
throw new Error(`Audio provider "${audioProvider}" has no automatic transcription model.`);
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
audio: { provider: audioProvider, apiKey: audioApiKey!, model: audioModel! },
|
|
959
|
+
brain: { provider: brainProvider, apiKey: brainApiKey!, model: brainModel },
|
|
960
|
+
vision: { provider: visionProvider, apiKey: visionApiKey!, model: visionModel },
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function runtimeModeFor(config: Record<string, unknown>): string {
|
|
965
|
+
const value = typeof config.runtimeMode === 'string' ? config.runtimeMode : '';
|
|
966
|
+
if (value === 'local' || value === 'local-docker' || value === 'local-process') return 'local';
|
|
967
|
+
return value === 'custom-remote' ? 'custom-remote' : 'managed-cloud';
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Pull all independently-completed remote units out of the worker message. */
|
|
971
|
+
function cloudProgressCounters(message: string): CloudProgressCounters {
|
|
972
|
+
const counters = [
|
|
973
|
+
...message.matchAll(
|
|
974
|
+
/(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+(captions|described|indexed|search clips|clips)\b/gi,
|
|
975
|
+
),
|
|
976
|
+
]
|
|
977
|
+
.map((match) => ({
|
|
978
|
+
current: Number(match[1].replaceAll(',', '')),
|
|
979
|
+
total: Number(match[2].replaceAll(',', '')),
|
|
980
|
+
kind: match[3].toLowerCase(),
|
|
981
|
+
}))
|
|
982
|
+
.filter((counter) => Number.isFinite(counter.current) && Number.isFinite(counter.total));
|
|
983
|
+
const combined = counters.reduce<CloudProgressCounter | undefined>(
|
|
984
|
+
(combined, counter) => ({
|
|
985
|
+
current: (combined?.current ?? 0) + Math.min(counter.current, counter.total),
|
|
986
|
+
total: (combined?.total ?? 0) + counter.total,
|
|
987
|
+
}),
|
|
988
|
+
undefined,
|
|
989
|
+
);
|
|
990
|
+
const captions = counters.find(
|
|
991
|
+
(counter) => counter.kind === 'captions' || counter.kind === 'described',
|
|
992
|
+
);
|
|
993
|
+
const searchClips = counters.find(
|
|
994
|
+
(counter) =>
|
|
995
|
+
counter.kind === 'search clips' || counter.kind === 'clips' || counter.kind === 'indexed',
|
|
996
|
+
);
|
|
997
|
+
return {
|
|
998
|
+
captions: captions && { current: captions.current, total: captions.total },
|
|
999
|
+
searchClips: searchClips && { current: searchClips.current, total: searchClips.total },
|
|
1000
|
+
combined,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Translate the worker's overall percent into the mapped UI step's own band,
|
|
1006
|
+
* preferring its true completed/total clip units whenever it reports them.
|
|
1007
|
+
*/
|
|
1008
|
+
export function cloudStageProgress(progress: VideoJob['progress']): {
|
|
1009
|
+
percent: number;
|
|
1010
|
+
current?: number;
|
|
1011
|
+
total?: number;
|
|
1012
|
+
unit?: string;
|
|
1013
|
+
sequence?: number;
|
|
1014
|
+
elapsedSeconds?: number;
|
|
1015
|
+
estimatedRemainingSeconds?: number;
|
|
1016
|
+
} {
|
|
1017
|
+
const runtimeDetails = {
|
|
1018
|
+
...(Number.isFinite(progress.sequence) ? { sequence: progress.sequence } : {}),
|
|
1019
|
+
...(Number.isFinite(progress.elapsedSeconds)
|
|
1020
|
+
? { elapsedSeconds: progress.elapsedSeconds }
|
|
1021
|
+
: {}),
|
|
1022
|
+
...(Number.isFinite(progress.estimatedRemainingSeconds)
|
|
1023
|
+
? { estimatedRemainingSeconds: progress.estimatedRemainingSeconds }
|
|
1024
|
+
: {}),
|
|
1025
|
+
};
|
|
1026
|
+
// Unlike a queue placeholder, this is FFmpeg's measured timestamp while
|
|
1027
|
+
// the Cloud worker copies/normalizes the source. Keep it as a normal,
|
|
1028
|
+
// determinate step so a long source never appears stuck at 5%.
|
|
1029
|
+
if (progress.stage === 'prepare') {
|
|
1030
|
+
return {
|
|
1031
|
+
percent: cloudOverallToStagePercent('prepare', progress.percent, progress.stagePercent),
|
|
1032
|
+
...(Number.isFinite(progress.current) ? { current: progress.current } : {}),
|
|
1033
|
+
...(Number.isFinite(progress.total) ? { total: progress.total } : {}),
|
|
1034
|
+
...(progress.unit ? { unit: progress.unit } : {}),
|
|
1035
|
+
...runtimeDetails,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
if (progress.stage === 'synthesize') {
|
|
1039
|
+
const counters = cloudProgressCounters(progress.message);
|
|
1040
|
+
if (counters.combined && counters.combined.total > 0) {
|
|
1041
|
+
const etaCounter =
|
|
1042
|
+
counters.captions && counters.captions.current < counters.captions.total
|
|
1043
|
+
? counters.captions
|
|
1044
|
+
: counters.searchClips;
|
|
1045
|
+
return {
|
|
1046
|
+
percent: cloudOverallToStagePercent(
|
|
1047
|
+
progress.stage,
|
|
1048
|
+
progress.percent,
|
|
1049
|
+
progress.stagePercent,
|
|
1050
|
+
),
|
|
1051
|
+
current: etaCounter?.current ?? counters.combined.current,
|
|
1052
|
+
total: etaCounter?.total ?? counters.combined.total,
|
|
1053
|
+
unit: etaCounter === counters.captions ? 'captions' : 'search clips',
|
|
1054
|
+
...runtimeDetails,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
return {
|
|
1058
|
+
percent: cloudOverallToStagePercent(progress.stage, progress.percent, progress.stagePercent),
|
|
1059
|
+
...runtimeDetails,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
percent: cloudOverallToStagePercent(progress.stage, progress.percent, progress.stagePercent),
|
|
1064
|
+
...(Number.isFinite(progress.current) ? { current: progress.current } : {}),
|
|
1065
|
+
...(Number.isFinite(progress.total) ? { total: progress.total } : {}),
|
|
1066
|
+
...(progress.unit ? { unit: progress.unit } : {}),
|
|
1067
|
+
...runtimeDetails,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Convert the worker's overall percent into progress within the mapped UI
|
|
1073
|
+
* step, from the phase table the worker itself reports against. The worker
|
|
1074
|
+
* already advances that percent smoothly and monotonically, so there is
|
|
1075
|
+
* nothing left for the host to smooth or guess at.
|
|
1076
|
+
*/
|
|
1077
|
+
export function cloudOverallToStagePercent(
|
|
1078
|
+
stage: string,
|
|
1079
|
+
overallPercent: number,
|
|
1080
|
+
stagePercent?: number,
|
|
1081
|
+
): number {
|
|
1082
|
+
// The runtime knows which slice of its bar the current stage owns, so it
|
|
1083
|
+
// reports how far through that stage it is. Taking it at its word keeps a
|
|
1084
|
+
// per-step bar correct through any change to the pipeline's shape; the
|
|
1085
|
+
// fallback only covers a runtime old enough not to send it.
|
|
1086
|
+
if (typeof stagePercent === 'number' && Number.isFinite(stagePercent)) {
|
|
1087
|
+
return Math.max(0, Math.min(99, Math.round(stagePercent)));
|
|
1088
|
+
}
|
|
1089
|
+
// Preparation runs before the pipeline and reports its own share directly.
|
|
1090
|
+
if (stage === 'prepare') return Math.min(99, Math.round((overallPercent / 25) * 100));
|
|
1091
|
+
return Math.max(0, Math.min(99, Math.round(overallPercent)));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Preserve source-level cloud observations for the investigation engine. The
|
|
1096
|
+
* 30-second RAG segments above are useful navigation projections, but they
|
|
1097
|
+
* must never be the only evidence used for an exact score or count.
|
|
1098
|
+
*/
|
|
1099
|
+
export function evidenceToKnowledgeInputs(evidence: VideoEvidence) {
|
|
1100
|
+
const visualObservations = [
|
|
1101
|
+
...evidence.visualObservations.flatMap((observation) => {
|
|
1102
|
+
if (!observation.objects.length) return [];
|
|
1103
|
+
const timestampSecs = observation.timeMs / 1_000;
|
|
1104
|
+
const objects = observation.objects.map((object) => ({
|
|
1105
|
+
label: object.label,
|
|
1106
|
+
trackId: object.trackId,
|
|
1107
|
+
confidence: object.confidence,
|
|
1108
|
+
}));
|
|
1109
|
+
return [
|
|
1110
|
+
{
|
|
1111
|
+
startSecs: timestampSecs,
|
|
1112
|
+
endSecs: timestampSecs,
|
|
1113
|
+
observations: [
|
|
1114
|
+
{
|
|
1115
|
+
kind: 'object' as const,
|
|
1116
|
+
value: `Detected objects: ${objects
|
|
1117
|
+
.map((object) => `${object.label} (track ${object.trackId})`)
|
|
1118
|
+
.join(', ')}`,
|
|
1119
|
+
frameTimestamps: [timestampSecs],
|
|
1120
|
+
confidence:
|
|
1121
|
+
objects.reduce((total, object) => total + object.confidence, 0) /
|
|
1122
|
+
Math.max(1, objects.length),
|
|
1123
|
+
uncertaintyReasons: ['Object labels are produced by the video detector.'],
|
|
1124
|
+
},
|
|
1125
|
+
],
|
|
1126
|
+
},
|
|
1127
|
+
];
|
|
1128
|
+
}),
|
|
1129
|
+
...(evidence.recurringOverlayText ?? []).map((overlay) => ({
|
|
1130
|
+
startSecs: overlay.firstSeenMs / 1_000,
|
|
1131
|
+
endSecs: overlay.lastSeenMs / 1_000,
|
|
1132
|
+
observations: [
|
|
1133
|
+
{
|
|
1134
|
+
kind: 'state' as const,
|
|
1135
|
+
// Recurring on-screen text says a display was present over a span
|
|
1136
|
+
// and when it changed. The distinct schema keeps it a locator: what
|
|
1137
|
+
// the text means is established by reading the frames.
|
|
1138
|
+
value: {
|
|
1139
|
+
subject: 'on-screen-text',
|
|
1140
|
+
property: 'recurring-overlay',
|
|
1141
|
+
value: overlay.text,
|
|
1142
|
+
observations: overlay.observations,
|
|
1143
|
+
firstSeenMs: overlay.firstSeenMs,
|
|
1144
|
+
lastSeenMs: overlay.lastSeenMs,
|
|
1145
|
+
},
|
|
1146
|
+
frameTimestamps: (overlay.timestampsMs ?? [overlay.firstSeenMs]).map(
|
|
1147
|
+
(timestamp) => timestamp / 1_000,
|
|
1148
|
+
),
|
|
1149
|
+
confidence: overlay.confidence,
|
|
1150
|
+
uncertaintyReasons: [
|
|
1151
|
+
'Recurring on-screen text read by OCR. Use it to locate a source range for visual verification; it does not establish what the text refers to.',
|
|
1152
|
+
],
|
|
1153
|
+
},
|
|
1154
|
+
],
|
|
1155
|
+
})),
|
|
1156
|
+
...(evidence.semanticObservations ?? [])
|
|
1157
|
+
.filter((observation) => observation.text.trim())
|
|
1158
|
+
.map((observation) => ({
|
|
1159
|
+
startSecs: observation.startMs / 1_000,
|
|
1160
|
+
endSecs: observation.endMs / 1_000,
|
|
1161
|
+
observations: [
|
|
1162
|
+
{
|
|
1163
|
+
kind: 'action' as const,
|
|
1164
|
+
value: normalizeVisibleSubjectProtocolTimestamps(
|
|
1165
|
+
observation.text,
|
|
1166
|
+
observation.startMs,
|
|
1167
|
+
observation.endMs,
|
|
1168
|
+
),
|
|
1169
|
+
frameTimestamps: [observation.startMs / 1_000, observation.endMs / 1_000],
|
|
1170
|
+
confidence: Math.min(1, Math.max(0, observation.confidence)),
|
|
1171
|
+
uncertaintyReasons: [
|
|
1172
|
+
'Semantic VLM interpretation is grounded in a bounded sequence of cloud-analyzed frames.',
|
|
1173
|
+
],
|
|
1174
|
+
},
|
|
1175
|
+
],
|
|
1176
|
+
})),
|
|
1177
|
+
];
|
|
1178
|
+
const ocrEvidence = evidence.visualObservations.flatMap((observation) => {
|
|
1179
|
+
const timestampSecs = observation.timeMs / 1_000;
|
|
1180
|
+
return observation.ocr
|
|
1181
|
+
.filter((line) => line.text.trim() && line.confidence >= 0.35)
|
|
1182
|
+
.map((line) => ({
|
|
1183
|
+
modality: 'ocr' as const,
|
|
1184
|
+
timeRange: {
|
|
1185
|
+
startSecs: timestampSecs,
|
|
1186
|
+
endSecs: timestampSecs,
|
|
1187
|
+
precision: 'estimated' as const,
|
|
1188
|
+
},
|
|
1189
|
+
payload: {
|
|
1190
|
+
text: line.text,
|
|
1191
|
+
blocks: [{ text: line.text, confidence: line.confidence }],
|
|
1192
|
+
},
|
|
1193
|
+
source: { kind: 'provider' as const, provider: 'video-intelligence-ocr' },
|
|
1194
|
+
confidence: {
|
|
1195
|
+
score: Math.min(1, Math.max(0, line.confidence)),
|
|
1196
|
+
source: 'provider' as const,
|
|
1197
|
+
calibrationStatus: 'uncalibrated' as const,
|
|
1198
|
+
uncertaintyReasons: ['OCR derived from a timestamped cloud analysis frame.'],
|
|
1199
|
+
},
|
|
1200
|
+
observation: { kind: 'ocr' as const, value: { text: line.text } },
|
|
1201
|
+
}));
|
|
1202
|
+
});
|
|
1203
|
+
return {
|
|
1204
|
+
transcriptChunks: evidence.transcript.map((segment) => ({
|
|
1205
|
+
text: segment.text,
|
|
1206
|
+
startSecs: segment.startMs / 1_000,
|
|
1207
|
+
endSecs: segment.endMs / 1_000,
|
|
1208
|
+
})),
|
|
1209
|
+
visualObservations,
|
|
1210
|
+
ocrEvidence,
|
|
1211
|
+
reconciledEvidence: reconciledAccount(evidence),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* A bounded worker decodes an excerpt as a new media stream, while its
|
|
1217
|
+
* enclosing semantic observation is anchored to the original source. Some
|
|
1218
|
+
* vision providers consequently return `Visible subject` protocol times
|
|
1219
|
+
* relative to that excerpt. Canonicalize only that typed protocol back to the
|
|
1220
|
+
* enclosing source interval before it is persisted; ordinary prose and
|
|
1221
|
+
* already-source-absolute protocol values remain unchanged.
|
|
1222
|
+
*/
|
|
1223
|
+
function normalizeVisibleSubjectProtocolTimestamps(
|
|
1224
|
+
text: string,
|
|
1225
|
+
observationStartMs: number,
|
|
1226
|
+
observationEndMs: number,
|
|
1227
|
+
): string {
|
|
1228
|
+
if (!text.includes('Visible subject: ')) return text;
|
|
1229
|
+
const startMs = Math.max(0, Math.floor(observationStartMs));
|
|
1230
|
+
const endMs = Math.max(startMs, Math.floor(observationEndMs));
|
|
1231
|
+
const spanMs = endMs - startMs;
|
|
1232
|
+
return text
|
|
1233
|
+
.split(/(\r?\n)/u)
|
|
1234
|
+
.map((line) => {
|
|
1235
|
+
const prefix = 'Visible subject: ';
|
|
1236
|
+
if (!line.startsWith(prefix)) return line;
|
|
1237
|
+
try {
|
|
1238
|
+
const subject = JSON.parse(line.slice(prefix.length)) as Record<string, unknown>;
|
|
1239
|
+
const subjectStartMs = Number(subject.startMs);
|
|
1240
|
+
const subjectEndMs = Number(subject.endMs);
|
|
1241
|
+
if (
|
|
1242
|
+
!Number.isFinite(subjectStartMs) ||
|
|
1243
|
+
!Number.isFinite(subjectEndMs) ||
|
|
1244
|
+
subjectEndMs < subjectStartMs ||
|
|
1245
|
+
(subjectStartMs >= startMs && subjectEndMs <= endMs)
|
|
1246
|
+
) {
|
|
1247
|
+
return line;
|
|
1248
|
+
}
|
|
1249
|
+
// Relative timestamps may have a tiny negative value when the first
|
|
1250
|
+
// sampled frame precedes the decoded excerpt's zero point. Values far
|
|
1251
|
+
// beyond the excerpt are neither reliable relative times nor safe to
|
|
1252
|
+
// reinterpret, so leave them for the protocol validator to reject.
|
|
1253
|
+
if (subjectStartMs < -1_000 || subjectEndMs > spanMs + 1_000) return line;
|
|
1254
|
+
return (
|
|
1255
|
+
prefix +
|
|
1256
|
+
JSON.stringify({
|
|
1257
|
+
...subject,
|
|
1258
|
+
startMs: Math.max(startMs, Math.min(endMs, Math.round(startMs + subjectStartMs))),
|
|
1259
|
+
endMs: Math.max(
|
|
1260
|
+
startMs,
|
|
1261
|
+
Math.min(endMs, Math.round(startMs + Math.max(subjectStartMs, subjectEndMs))),
|
|
1262
|
+
),
|
|
1263
|
+
})
|
|
1264
|
+
);
|
|
1265
|
+
} catch {
|
|
1266
|
+
return line;
|
|
1267
|
+
}
|
|
1268
|
+
})
|
|
1269
|
+
.join('');
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Converts a bounded cloud re-analysis result into immutable refinement evidence. */
|
|
1273
|
+
export function evidenceToRefinementInputs(
|
|
1274
|
+
evidence: VideoEvidence,
|
|
1275
|
+
): OfflineKnowledgeEvidenceInput[] {
|
|
1276
|
+
const inputs = evidenceToKnowledgeInputs(evidence);
|
|
1277
|
+
const transcript: OfflineKnowledgeEvidenceInput[] = inputs.transcriptChunks.map((chunk) => ({
|
|
1278
|
+
modality: 'transcript',
|
|
1279
|
+
timeRange: { startSecs: chunk.startSecs, endSecs: chunk.endSecs, precision: 'segment' },
|
|
1280
|
+
payload: { text: chunk.text },
|
|
1281
|
+
source: { kind: 'provider', provider: 'video-intelligence-stt' },
|
|
1282
|
+
confidence: {
|
|
1283
|
+
score: 0.8,
|
|
1284
|
+
source: 'provider',
|
|
1285
|
+
calibrationStatus: 'uncalibrated',
|
|
1286
|
+
uncertaintyReasons: ['Timestamped transcript returned by bounded cloud analysis.'],
|
|
1287
|
+
},
|
|
1288
|
+
observation: { kind: 'speech', value: { text: chunk.text } },
|
|
1289
|
+
}));
|
|
1290
|
+
const visual: OfflineKnowledgeEvidenceInput[] = inputs.visualObservations.flatMap((sequence) =>
|
|
1291
|
+
(sequence.observations ?? []).map((observation) => ({
|
|
1292
|
+
modality: 'visual' as const,
|
|
1293
|
+
timeRange: {
|
|
1294
|
+
startSecs: sequence.startSecs,
|
|
1295
|
+
endSecs: sequence.endSecs,
|
|
1296
|
+
precision: 'estimated' as const,
|
|
1297
|
+
},
|
|
1298
|
+
payload: { text: observation.value },
|
|
1299
|
+
source: { kind: 'provider' as const, provider: 'video-intelligence-vision' },
|
|
1300
|
+
confidence: {
|
|
1301
|
+
score: observation.confidence,
|
|
1302
|
+
source: 'provider' as const,
|
|
1303
|
+
calibrationStatus: 'uncalibrated' as const,
|
|
1304
|
+
uncertaintyReasons: observation.uncertaintyReasons,
|
|
1305
|
+
},
|
|
1306
|
+
observation: { kind: observation.kind, value: observation.value },
|
|
1307
|
+
})),
|
|
1308
|
+
);
|
|
1309
|
+
const trackedPeople = evidence.tracks.filter((track) => track.label.toLowerCase() === 'person');
|
|
1310
|
+
const tracking: OfflineKnowledgeEvidenceInput[] = trackedPeople.length
|
|
1311
|
+
? [
|
|
1312
|
+
{
|
|
1313
|
+
modality: 'computed',
|
|
1314
|
+
timeRange: {
|
|
1315
|
+
startSecs: Math.min(...trackedPeople.map((track) => track.startMs / 1_000)),
|
|
1316
|
+
endSecs: Math.max(...trackedPeople.map((track) => track.endMs / 1_000)),
|
|
1317
|
+
precision: 'estimated',
|
|
1318
|
+
},
|
|
1319
|
+
payload: {
|
|
1320
|
+
method: 'video-intelligence-anonymous-person-tracking',
|
|
1321
|
+
tracks: trackedPeople.map((track) => ({
|
|
1322
|
+
id: track.trackId,
|
|
1323
|
+
startSecs: track.startMs / 1_000,
|
|
1324
|
+
endSecs: track.endMs / 1_000,
|
|
1325
|
+
observations: track.observations,
|
|
1326
|
+
confidence: track.confidence ?? null,
|
|
1327
|
+
})),
|
|
1328
|
+
},
|
|
1329
|
+
source: { kind: 'provider', provider: 'video-intelligence-tracking' },
|
|
1330
|
+
confidence: {
|
|
1331
|
+
score:
|
|
1332
|
+
trackedPeople.reduce((total, track) => total + (track.confidence ?? 0.65), 0) /
|
|
1333
|
+
trackedPeople.length,
|
|
1334
|
+
source: 'provider',
|
|
1335
|
+
calibrationStatus: 'uncalibrated',
|
|
1336
|
+
uncertaintyReasons: [
|
|
1337
|
+
'This is an anonymous track count within the inspected range, not person identity.',
|
|
1338
|
+
'Occlusion and re-entry can cause an undercount or duplicate track.',
|
|
1339
|
+
],
|
|
1340
|
+
},
|
|
1341
|
+
observation: {
|
|
1342
|
+
kind: 'computed',
|
|
1343
|
+
value: {
|
|
1344
|
+
count: trackedPeople.length,
|
|
1345
|
+
label: 'person',
|
|
1346
|
+
method: 'anonymous-bounded-tracking',
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
]
|
|
1351
|
+
: [];
|
|
1352
|
+
const anonymousPresence: OfflineKnowledgeEvidenceInput[] = evidence.anonymousPresenceLedger
|
|
1353
|
+
? [
|
|
1354
|
+
{
|
|
1355
|
+
modality: 'computed',
|
|
1356
|
+
timeRange: {
|
|
1357
|
+
startSecs: 0,
|
|
1358
|
+
endSecs: evidence.durationMs / 1_000,
|
|
1359
|
+
precision: 'estimated',
|
|
1360
|
+
},
|
|
1361
|
+
payload: {
|
|
1362
|
+
text:
|
|
1363
|
+
'Anonymous presence ledger from every indexed detection sample. It reports concurrent ' +
|
|
1364
|
+
'subjects and continuous anonymous tracks, but does not claim that tracks across edits ' +
|
|
1365
|
+
'belong to the same identity.',
|
|
1366
|
+
...evidence.anonymousPresenceLedger,
|
|
1367
|
+
},
|
|
1368
|
+
source: { kind: 'provider', provider: 'video-intelligence-tracking' },
|
|
1369
|
+
confidence: {
|
|
1370
|
+
score: 0.72,
|
|
1371
|
+
source: 'provider',
|
|
1372
|
+
calibrationStatus: 'uncalibrated',
|
|
1373
|
+
uncertaintyReasons: [
|
|
1374
|
+
'The ledger measures detections at indexed sample times, not every decoded frame.',
|
|
1375
|
+
'Anonymous tracks can split when a subject is occluded, exits, re-enters, or a cut occurs.',
|
|
1376
|
+
],
|
|
1377
|
+
},
|
|
1378
|
+
observation: {
|
|
1379
|
+
kind: 'computed',
|
|
1380
|
+
value: evidence.anonymousPresenceLedger,
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1383
|
+
]
|
|
1384
|
+
: [];
|
|
1385
|
+
return [
|
|
1386
|
+
...transcript,
|
|
1387
|
+
...visual,
|
|
1388
|
+
...inputs.ocrEvidence,
|
|
1389
|
+
...tracking,
|
|
1390
|
+
...anonymousPresence,
|
|
1391
|
+
...reconciledAccount(evidence),
|
|
1392
|
+
];
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* The index's own reconciled account of the source, as retrievable evidence.
|
|
1397
|
+
*
|
|
1398
|
+
* Individual readings of the same moment disagree -- one clip reads a display
|
|
1399
|
+
* one way and the next reads it the other. The runtime already resolves that:
|
|
1400
|
+
* it cross-checks every claim against the rest of the timeline and keeps only
|
|
1401
|
+
* what survives. Without this, retrieval only ever sees the raw readings and
|
|
1402
|
+
* can surface the one that lost, so the answer contradicts the index that
|
|
1403
|
+
* produced it. Each item carries the span it was drawn from and the
|
|
1404
|
+
* uncertainties the check left open.
|
|
1405
|
+
*/
|
|
1406
|
+
function reconciledAccount(evidence: VideoEvidence): OfflineKnowledgeEvidenceInput[] {
|
|
1407
|
+
const summary = evidence.knowledgeSummary;
|
|
1408
|
+
if (!summary) return [];
|
|
1409
|
+
const durationSecs = evidence.durationMs / 1_000;
|
|
1410
|
+
const entry = (
|
|
1411
|
+
startSecs: number,
|
|
1412
|
+
endSecs: number,
|
|
1413
|
+
text: string,
|
|
1414
|
+
confidence: number,
|
|
1415
|
+
): OfflineKnowledgeEvidenceInput => ({
|
|
1416
|
+
modality: 'computed',
|
|
1417
|
+
timeRange: {
|
|
1418
|
+
startSecs: Math.max(0, Math.min(durationSecs, startSecs)),
|
|
1419
|
+
endSecs: Math.max(0, Math.min(durationSecs, Math.max(startSecs, endSecs))),
|
|
1420
|
+
precision: 'estimated',
|
|
1421
|
+
},
|
|
1422
|
+
payload: { text },
|
|
1423
|
+
source: { kind: 'provider', provider: 'video-intelligence-index' },
|
|
1424
|
+
confidence: {
|
|
1425
|
+
score: confidence,
|
|
1426
|
+
source: 'heuristic',
|
|
1427
|
+
calibrationStatus: 'uncalibrated',
|
|
1428
|
+
uncertaintyReasons: [
|
|
1429
|
+
'The index reconciled this across the whole timeline; it is not a single reading.',
|
|
1430
|
+
...(summary.uncertainties ?? []).slice(0, 4),
|
|
1431
|
+
],
|
|
1432
|
+
},
|
|
1433
|
+
observation: { kind: 'computed', value: { text } },
|
|
1434
|
+
});
|
|
1435
|
+
const score = (confidence: 'direct' | 'partial') => (confidence === 'direct' ? 0.82 : 0.6);
|
|
1436
|
+
return [
|
|
1437
|
+
...(summary.overview
|
|
1438
|
+
? [entry(0, durationSecs, `Reconciled overview: ${summary.overview}`, 0.75)]
|
|
1439
|
+
: []),
|
|
1440
|
+
...(summary.stateHistory ?? []).map((state) =>
|
|
1441
|
+
entry(
|
|
1442
|
+
state.startMs / 1_000,
|
|
1443
|
+
state.endMs / 1_000,
|
|
1444
|
+
`Reconciled state: ${state.state}`,
|
|
1445
|
+
score(state.confidence),
|
|
1446
|
+
),
|
|
1447
|
+
),
|
|
1448
|
+
...(summary.keyEvents ?? []).map((event) =>
|
|
1449
|
+
entry(
|
|
1450
|
+
event.startMs / 1_000,
|
|
1451
|
+
event.endMs / 1_000,
|
|
1452
|
+
`Reconciled event: ${event.event}`,
|
|
1453
|
+
score(event.confidence),
|
|
1454
|
+
),
|
|
1455
|
+
),
|
|
1456
|
+
...(summary.narrative ?? []).map((note) =>
|
|
1457
|
+
entry(
|
|
1458
|
+
note.startMs / 1_000,
|
|
1459
|
+
note.endMs / 1_000,
|
|
1460
|
+
`Chronological note: ${note.text}`,
|
|
1461
|
+
score(note.confidence),
|
|
1462
|
+
),
|
|
1463
|
+
),
|
|
1464
|
+
...(summary.participants ?? []).flatMap((participant) =>
|
|
1465
|
+
participant.evidence
|
|
1466
|
+
.slice(0, 1)
|
|
1467
|
+
.map((range) =>
|
|
1468
|
+
entry(
|
|
1469
|
+
range.startMs / 1_000,
|
|
1470
|
+
range.endMs / 1_000,
|
|
1471
|
+
`Reconciled participant: ${participant.name} — ${participant.role}`,
|
|
1472
|
+
0.7,
|
|
1473
|
+
),
|
|
1474
|
+
),
|
|
1475
|
+
),
|
|
1476
|
+
...(summary.visibleSubjects ?? []).flatMap((subject) => {
|
|
1477
|
+
const appearances = subject.appearances
|
|
1478
|
+
.filter((appearance) => appearance.endMs >= appearance.startMs)
|
|
1479
|
+
.slice(0, 256);
|
|
1480
|
+
if (!appearances.length) return [];
|
|
1481
|
+
const startMs = Math.min(...appearances.map((appearance) => appearance.startMs));
|
|
1482
|
+
const endMs = Math.max(...appearances.map((appearance) => appearance.endMs));
|
|
1483
|
+
const observedDurationMs = appearances.reduce(
|
|
1484
|
+
(total, appearance) => total + appearance.endMs - appearance.startMs,
|
|
1485
|
+
0,
|
|
1486
|
+
);
|
|
1487
|
+
const direct = appearances.every((appearance) => appearance.confidence === 'direct');
|
|
1488
|
+
return [
|
|
1489
|
+
entry(
|
|
1490
|
+
startMs / 1_000,
|
|
1491
|
+
endMs / 1_000,
|
|
1492
|
+
`Reconciled visible subject: ${subject.identity}\nIdentity basis: ${subject.identityBasis}\n` +
|
|
1493
|
+
`Observed appearances: ${appearances
|
|
1494
|
+
.map(
|
|
1495
|
+
(appearance) =>
|
|
1496
|
+
`${appearance.startMs}-${appearance.endMs}ms (${appearance.confidence})`,
|
|
1497
|
+
)
|
|
1498
|
+
.join('; ')}\nObserved visible duration: ${observedDurationMs}ms`,
|
|
1499
|
+
direct ? 0.8 : 0.6,
|
|
1500
|
+
),
|
|
1501
|
+
];
|
|
1502
|
+
}),
|
|
1503
|
+
...(summary.context ?? []).flatMap((context) =>
|
|
1504
|
+
context.evidence
|
|
1505
|
+
.slice(0, 1)
|
|
1506
|
+
.map((range) =>
|
|
1507
|
+
entry(
|
|
1508
|
+
range.startMs / 1_000,
|
|
1509
|
+
range.endMs / 1_000,
|
|
1510
|
+
`Reconciled context: ${context.fact}`,
|
|
1511
|
+
0.7,
|
|
1512
|
+
),
|
|
1513
|
+
),
|
|
1514
|
+
),
|
|
1515
|
+
...(summary.sourceInventoryCoverage
|
|
1516
|
+
? [
|
|
1517
|
+
entry(
|
|
1518
|
+
0,
|
|
1519
|
+
durationSecs,
|
|
1520
|
+
`Source inventory coverage: ${
|
|
1521
|
+
summary.sourceInventoryCoverage.complete ? 'complete' : 'partial'
|
|
1522
|
+
}${
|
|
1523
|
+
summary.sourceInventoryCoverage.reason
|
|
1524
|
+
? ` (${summary.sourceInventoryCoverage.reason})`
|
|
1525
|
+
: ''
|
|
1526
|
+
}`,
|
|
1527
|
+
summary.sourceInventoryCoverage.complete ? 0.9 : 0.5,
|
|
1528
|
+
),
|
|
1529
|
+
]
|
|
1530
|
+
: []),
|
|
1531
|
+
...(summary.sourceItems ?? []).map((item) =>
|
|
1532
|
+
entry(
|
|
1533
|
+
item.startMs / 1_000,
|
|
1534
|
+
item.endMs / 1_000,
|
|
1535
|
+
item.kind === 'question'
|
|
1536
|
+
? `Source question (${item.channel}${
|
|
1537
|
+
item.questionRole ? `, ${item.questionRole}` : ''
|
|
1538
|
+
}): ${item.text}${
|
|
1539
|
+
item.answer ? `\nSource answer: ${item.answer}` : ''
|
|
1540
|
+
}${item.respondent ? `\nSource respondent: ${item.respondent}` : ''}`
|
|
1541
|
+
: `Source item (${item.kind}, ${item.channel}): ${item.text}`,
|
|
1542
|
+
0.82,
|
|
1543
|
+
),
|
|
1544
|
+
),
|
|
1545
|
+
...(summary.sourceTaskInstances ?? []).map((task) =>
|
|
1546
|
+
entry(
|
|
1547
|
+
task.startMs / 1_000,
|
|
1548
|
+
task.endMs / 1_000,
|
|
1549
|
+
`Source question (${task.channel}, primary): ${task.prompt}\nSource task id: ${task.id}${
|
|
1550
|
+
task.answer ? `\nSource answer: ${task.answer}` : ''
|
|
1551
|
+
}${task.respondent ? `\nSource respondent: ${task.respondent}` : ''}`,
|
|
1552
|
+
0.82,
|
|
1553
|
+
),
|
|
1554
|
+
),
|
|
1555
|
+
...(summary.sourceActivityStructures ?? []).map((structure) =>
|
|
1556
|
+
entry(
|
|
1557
|
+
structure.startMs / 1_000,
|
|
1558
|
+
structure.endMs / 1_000,
|
|
1559
|
+
`Source structure (${structure.kind}): ${
|
|
1560
|
+
structure.label ||
|
|
1561
|
+
structure.dimensions
|
|
1562
|
+
.map((dimension) => `${dimension.label}: ${dimension.values.join(', ')}`)
|
|
1563
|
+
.join(' | ')
|
|
1564
|
+
}\nSource dimensions: ${structure.dimensions
|
|
1565
|
+
.map((dimension) => `${dimension.label} = ${dimension.values.join(', ')}`)
|
|
1566
|
+
.join(' | ')}${
|
|
1567
|
+
structure.promptCount === undefined
|
|
1568
|
+
? ''
|
|
1569
|
+
: `\nSource prompt slots: ${structure.promptCount}`
|
|
1570
|
+
}`,
|
|
1571
|
+
0.82,
|
|
1572
|
+
),
|
|
1573
|
+
),
|
|
1574
|
+
];
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Resolve the uploader's free-form focus once, then use it everywhere that
|
|
1579
|
+
* creates or audits a video index. It is deliberately opaque text: neither
|
|
1580
|
+
* the host nor the worker infers a genre or applies subject-specific rules.
|
|
1581
|
+
*/
|
|
1582
|
+
export function resolveVideoIndexingHint(asset: MediaAsset): string | undefined {
|
|
1583
|
+
const candidate = asset.toolInputs?.['video-intelligence'];
|
|
1584
|
+
const input =
|
|
1585
|
+
candidate && typeof candidate === 'object' ? (candidate as Record<string, unknown>) : {};
|
|
1586
|
+
const surfaceGoal = typeof input.goal === 'string' ? input.goal.trim() : '';
|
|
1587
|
+
const persistedInstructions = asset.indexingInstructions?.trim() || '';
|
|
1588
|
+
const hint = surfaceGoal || persistedInstructions;
|
|
1589
|
+
return hint ? hint.slice(0, 4_000) : undefined;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
export function createAssetVideoIndexingBrief(asset: MediaAsset): Record<string, unknown> {
|
|
1593
|
+
const candidate = asset.toolInputs?.['video-intelligence'];
|
|
1594
|
+
const input =
|
|
1595
|
+
candidate && typeof candidate === 'object' ? (candidate as Record<string, unknown>) : {};
|
|
1596
|
+
const rawIndexingMode = String(input.indexingMode);
|
|
1597
|
+
const indexingMode = ['fast', 'balanced', 'thorough'].includes(rawIndexingMode)
|
|
1598
|
+
? rawIndexingMode
|
|
1599
|
+
: 'balanced';
|
|
1600
|
+
return {
|
|
1601
|
+
goal: resolveVideoIndexingHint(asset),
|
|
1602
|
+
contentType:
|
|
1603
|
+
typeof input.contentType === 'string' && input.contentType.trim()
|
|
1604
|
+
? input.contentType.trim().slice(0, 120)
|
|
1605
|
+
: 'general',
|
|
1606
|
+
knownEntities: stringList(input.knownEntities, 50),
|
|
1607
|
+
expectedQuestions: stringList(input.expectedQuestions, 20),
|
|
1608
|
+
language:
|
|
1609
|
+
typeof input.language === 'string' && input.language.trim() && input.language !== 'auto'
|
|
1610
|
+
? input.language.slice(0, 32)
|
|
1611
|
+
: (inferLanguageHintFromTitle(asset.fileName) ?? 'auto'),
|
|
1612
|
+
importantRanges: Array.isArray(input.importantRanges) ? input.importantRanges.slice(0, 20) : [],
|
|
1613
|
+
indexingMode,
|
|
1614
|
+
processingAuthorityConfirmed: input.processingAuthorityConfirmed === true,
|
|
1615
|
+
retainSourceHours: retainSourceHours(input.retainSourceHours),
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Infer only languages whose writing system makes the hint high-confidence.
|
|
1621
|
+
* Latin titles stay automatic because their script is shared by too many
|
|
1622
|
+
* languages. This avoids a second AI request and remains content-agnostic.
|
|
1623
|
+
*/
|
|
1624
|
+
export function inferLanguageHintFromTitle(title: string): string | undefined {
|
|
1625
|
+
const count = (pattern: RegExp) => title.match(pattern)?.length ?? 0;
|
|
1626
|
+
const arabic = count(/\p{Script=Arabic}/gu);
|
|
1627
|
+
const cyrillic = count(/\p{Script=Cyrillic}/gu);
|
|
1628
|
+
const candidates: Array<[string, number]> = [
|
|
1629
|
+
[/[ٹڈڑںھ]/u.test(title) ? 'ur' : /[گچپژ]/u.test(title) ? 'fa' : 'ar', arabic],
|
|
1630
|
+
[/[іїєґ]/iu.test(title) ? 'uk' : 'ru', cyrillic],
|
|
1631
|
+
['he', count(/\p{Script=Hebrew}/gu)],
|
|
1632
|
+
['ko', count(/\p{Script=Hangul}/gu)],
|
|
1633
|
+
['ja', count(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu)],
|
|
1634
|
+
['th', count(/\p{Script=Thai}/gu)],
|
|
1635
|
+
['el', count(/\p{Script=Greek}/gu)],
|
|
1636
|
+
['hi', count(/\p{Script=Devanagari}/gu)],
|
|
1637
|
+
];
|
|
1638
|
+
const [language, characters] = candidates.sort((left, right) => right[1] - left[1])[0] ?? [];
|
|
1639
|
+
return characters >= 3 ? language : undefined;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* A bounded cloud re-analysis (`watch_original` / inspection-policy's
|
|
1644
|
+
* required/optional decisions) reseeks the original source video, so it
|
|
1645
|
+
* cannot work once the source is deleted. Defaults to 72 hours -- long
|
|
1646
|
+
* enough for an active chat session to still verify uncertain evidence,
|
|
1647
|
+
* short enough to bound retained-video exposure -- and stays overridable
|
|
1648
|
+
* per install, clamped to the runtime brief's [0, 720] schema range.
|
|
1649
|
+
*/
|
|
1650
|
+
function retainSourceHours(value: unknown): number {
|
|
1651
|
+
const configured = Number(value);
|
|
1652
|
+
if (!Number.isFinite(configured)) return 72;
|
|
1653
|
+
return Math.max(0, Math.min(720, Math.round(configured)));
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function stringList(value: unknown, limit: number): string[] {
|
|
1657
|
+
if (!Array.isArray(value)) return [];
|
|
1658
|
+
return [
|
|
1659
|
+
...new Set(
|
|
1660
|
+
value
|
|
1661
|
+
.filter((item): item is string => typeof item === 'string')
|
|
1662
|
+
.map((item) => item.trim())
|
|
1663
|
+
.filter(Boolean),
|
|
1664
|
+
),
|
|
1665
|
+
].slice(0, limit);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function mapStage(stage: string): MediaPipelineStage {
|
|
1669
|
+
if (stage === 'prepare') return 'prepare';
|
|
1670
|
+
if (stage === 'transcribe') return 'transcribe';
|
|
1671
|
+
// The worker's final phase is distinct from visual frame analysis. Preserve
|
|
1672
|
+
// it so the UI can truthfully say it is finishing rather than implying that
|
|
1673
|
+
// it is still selecting frames.
|
|
1674
|
+
if (stage === 'synthesize') return 'synthesize';
|
|
1675
|
+
if (stage === 'complete') return 'vision';
|
|
1676
|
+
if (stage === 'detect' || stage === 'ocr') return 'vision';
|
|
1677
|
+
return 'extract';
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
async function completePreviousCloudStage(stage: string, reportStage: ReportStage): Promise<void> {
|
|
1681
|
+
if (stage === 'prepare') {
|
|
1682
|
+
await reportStage('prepare', {
|
|
1683
|
+
status: 'completed',
|
|
1684
|
+
percent: 100,
|
|
1685
|
+
message: 'Cloud video ready.',
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
if (stage === 'transcribe') {
|
|
1689
|
+
await reportStage('transcribe', {
|
|
1690
|
+
status: 'completed',
|
|
1691
|
+
percent: 100,
|
|
1692
|
+
message: 'Speech timeline ready.',
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
if (stage === 'decode') {
|
|
1696
|
+
await reportStage('extract', {
|
|
1697
|
+
status: 'completed',
|
|
1698
|
+
percent: 100,
|
|
1699
|
+
message: 'Video evidence selected.',
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
export function evidenceToSegments(evidence: VideoEvidence): MediaEvidenceSegment[] {
|
|
1705
|
+
const buckets = new Map<
|
|
1706
|
+
number,
|
|
1707
|
+
{ speech: string[]; story: string[]; visual: string[]; startSecs: number; endSecs: number }
|
|
1708
|
+
>();
|
|
1709
|
+
const bucketFor = (seconds: number) => {
|
|
1710
|
+
const index = Math.floor(seconds / 30);
|
|
1711
|
+
const existing = buckets.get(index);
|
|
1712
|
+
if (existing) return existing;
|
|
1713
|
+
const created = {
|
|
1714
|
+
speech: [] as string[],
|
|
1715
|
+
story: [] as string[],
|
|
1716
|
+
visual: [] as string[],
|
|
1717
|
+
startSecs: index * 30,
|
|
1718
|
+
endSecs: Math.min((index + 1) * 30, evidence.durationMs / 1_000),
|
|
1719
|
+
};
|
|
1720
|
+
buckets.set(index, created);
|
|
1721
|
+
return created;
|
|
1722
|
+
};
|
|
1723
|
+
for (const transcript of evidence.transcript) {
|
|
1724
|
+
bucketFor(transcript.startMs / 1_000).speech.push(transcript.text);
|
|
1725
|
+
}
|
|
1726
|
+
// A detector's class list ("person, chair, couch") and a raw OCR dump say
|
|
1727
|
+
// nothing a reader can use, but they are numerous enough to dominate every
|
|
1728
|
+
// retrieved excerpt and to be what a user sees when they open the indexed
|
|
1729
|
+
// text. Both remain queryable as their own evidence records
|
|
1730
|
+
// (`evidenceToKnowledgeInputs`); they just no longer pass for notes. Only
|
|
1731
|
+
// confidently-read on-screen text survives here, because an exact label or
|
|
1732
|
+
// readout is often the thing a later question turns on.
|
|
1733
|
+
for (const observation of evidence.visualObservations) {
|
|
1734
|
+
const visibleText = observation.ocr
|
|
1735
|
+
.filter((line) => line.confidence >= 0.75 && line.text.trim().length >= 2)
|
|
1736
|
+
.map((line) => line.text.trim());
|
|
1737
|
+
if (visibleText.length) {
|
|
1738
|
+
bucketFor(observation.timeMs / 1_000).visual.push(
|
|
1739
|
+
`On screen: ${[...new Set(visibleText)].join(' | ')}`,
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
for (const observation of evidence.semanticObservations ?? []) {
|
|
1744
|
+
const interpretation = observation.text.trim();
|
|
1745
|
+
if (!interpretation) continue;
|
|
1746
|
+
const bucket = bucketFor(observation.startMs / 1_000);
|
|
1747
|
+
bucket.endSecs = Math.max(
|
|
1748
|
+
bucket.endSecs,
|
|
1749
|
+
Math.min(evidence.durationMs / 1_000, observation.endMs / 1_000),
|
|
1750
|
+
);
|
|
1751
|
+
bucket.visual.push(interpretation);
|
|
1752
|
+
}
|
|
1753
|
+
const summary = evidence.knowledgeSummary;
|
|
1754
|
+
if (summary?.overview) bucketFor(0).visual.push(`Indexed overview: ${summary.overview}`);
|
|
1755
|
+
for (const participant of summary?.participants ?? []) {
|
|
1756
|
+
for (const source of participant.evidence) {
|
|
1757
|
+
bucketFor(source.startMs / 1_000).visual.push(
|
|
1758
|
+
`Indexed participant: ${participant.name} — ${participant.role}.`,
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
for (const state of summary?.stateHistory ?? []) {
|
|
1763
|
+
bucketFor(state.startMs / 1_000).visual.push(
|
|
1764
|
+
`Indexed state (${state.confidence}): ${state.state}`,
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
for (const event of summary?.keyEvents ?? []) {
|
|
1768
|
+
bucketFor(event.startMs / 1_000).visual.push(
|
|
1769
|
+
`Indexed event (${event.confidence}): ${event.event}`,
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
for (const note of summary?.narrative ?? []) {
|
|
1773
|
+
bucketFor(note.startMs / 1_000).story.push(note.text);
|
|
1774
|
+
}
|
|
1775
|
+
for (const context of summary?.context ?? []) {
|
|
1776
|
+
for (const source of context.evidence) {
|
|
1777
|
+
bucketFor(source.startMs / 1_000).visual.push(`Indexed context: ${context.fact}`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
for (const item of summary?.sourceItems ?? []) {
|
|
1781
|
+
bucketFor(item.startMs / 1_000).visual.push(
|
|
1782
|
+
item.kind === 'question'
|
|
1783
|
+
? `Source question (${item.channel}${
|
|
1784
|
+
item.questionRole ? `, ${item.questionRole}` : ''
|
|
1785
|
+
}): ${item.text}${
|
|
1786
|
+
item.answer ? `\nSource answer: ${item.answer}` : ''
|
|
1787
|
+
}${item.respondent ? `\nSource respondent: ${item.respondent}` : ''}`
|
|
1788
|
+
: `Source item (${item.kind}, ${item.channel}): ${item.text}`,
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
for (const task of summary?.sourceTaskInstances ?? []) {
|
|
1792
|
+
bucketFor(task.startMs / 1_000).visual.push(
|
|
1793
|
+
`Source question (${task.channel}, primary): ${task.prompt}\nSource task id: ${task.id}${
|
|
1794
|
+
task.answer ? `\nSource answer: ${task.answer}` : ''
|
|
1795
|
+
}${task.respondent ? `\nSource respondent: ${task.respondent}` : ''}`,
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
for (const structure of summary?.sourceActivityStructures ?? []) {
|
|
1799
|
+
bucketFor(structure.startMs / 1_000).visual.push(
|
|
1800
|
+
`Source structure (${structure.kind}): ${
|
|
1801
|
+
structure.label ||
|
|
1802
|
+
structure.dimensions
|
|
1803
|
+
.map((dimension) => `${dimension.label}: ${dimension.values.join(', ')}`)
|
|
1804
|
+
.join(' | ')
|
|
1805
|
+
}\nSource dimensions: ${structure.dimensions
|
|
1806
|
+
.map((dimension) => `${dimension.label} = ${dimension.values.join(', ')}`)
|
|
1807
|
+
.join(' | ')}${
|
|
1808
|
+
structure.promptCount === undefined ? '' : `\nSource prompt slots: ${structure.promptCount}`
|
|
1809
|
+
}`,
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
if (summary?.sourceInventoryCoverage) {
|
|
1813
|
+
bucketFor(0).visual.push(
|
|
1814
|
+
`Source inventory coverage: ${
|
|
1815
|
+
summary.sourceInventoryCoverage.complete ? 'complete' : 'partial'
|
|
1816
|
+
}${
|
|
1817
|
+
summary.sourceInventoryCoverage.reason ? ` (${summary.sourceInventoryCoverage.reason})` : ''
|
|
1818
|
+
}`,
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
const guide = [
|
|
1822
|
+
evidence.answeringGuide.goal ? `User indexing goal: ${evidence.answeringGuide.goal}` : '',
|
|
1823
|
+
evidence.answeringGuide.importantEntities.length
|
|
1824
|
+
? `Important entities: ${evidence.answeringGuide.importantEntities.join(', ')}`
|
|
1825
|
+
: '',
|
|
1826
|
+
evidence.answeringGuide.questionsToPrepareFor.length
|
|
1827
|
+
? `Expected questions: ${evidence.answeringGuide.questionsToPrepareFor.join(' | ')}`
|
|
1828
|
+
: '',
|
|
1829
|
+
]
|
|
1830
|
+
.filter(Boolean)
|
|
1831
|
+
.join('\n');
|
|
1832
|
+
return [...buckets.entries()]
|
|
1833
|
+
.sort(([left], [right]) => left - right)
|
|
1834
|
+
.map(([, bucket], sequence) => {
|
|
1835
|
+
const transcript = bucket.speech.join(' ').trim();
|
|
1836
|
+
// Lead with the cross-scene account a person would remember; keep raw
|
|
1837
|
+
// per-clip readings underneath it as source detail.
|
|
1838
|
+
const visualContext = [...new Set([...bucket.story, ...bucket.visual])].join('\n');
|
|
1839
|
+
const guidance = sequence === 0 && guide ? `${guide}\n` : '';
|
|
1840
|
+
return {
|
|
1841
|
+
text: `${guidance}${transcript ? `Speech: ${transcript}\n` : ''}${visualContext}`.trim(),
|
|
1842
|
+
transcript,
|
|
1843
|
+
visualContext,
|
|
1844
|
+
startSecs: bucket.startSecs,
|
|
1845
|
+
endSecs: bucket.endSecs,
|
|
1846
|
+
sequence,
|
|
1847
|
+
};
|
|
1848
|
+
})
|
|
1849
|
+
.filter((segment) => segment.text.length > 0);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** Render the runtime's already-audited account without paying for a second LLM pass. */
|
|
1853
|
+
export function formatVideoKnowledgeSummary(evidence: VideoEvidence): string | null {
|
|
1854
|
+
const summary = evidence.knowledgeSummary;
|
|
1855
|
+
if (!summary?.overview) return null;
|
|
1856
|
+
const time = (milliseconds: number) => {
|
|
1857
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
1858
|
+
const hours = Math.floor(seconds / 3_600);
|
|
1859
|
+
const minutes = Math.floor((seconds % 3_600) / 60);
|
|
1860
|
+
const remainder = seconds % 60;
|
|
1861
|
+
return hours > 0
|
|
1862
|
+
? `${hours}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}`
|
|
1863
|
+
: `${minutes}:${remainder.toString().padStart(2, '0')}`;
|
|
1864
|
+
};
|
|
1865
|
+
// The runtime already writes every value in the recording's language.
|
|
1866
|
+
// Keep this renderer language-neutral instead of surrounding those values
|
|
1867
|
+
// with English headings that make an Arabic (or other-language) index look
|
|
1868
|
+
// translated in the Data view and bias cross-language retrieval.
|
|
1869
|
+
const lines = [summary.overview];
|
|
1870
|
+
if (summary.participants?.length) {
|
|
1871
|
+
lines.push(
|
|
1872
|
+
...summary.participants.map((item) => {
|
|
1873
|
+
const anchor = item.evidence[0];
|
|
1874
|
+
return `- ${anchor ? `[${time(anchor.startMs)}] ` : ''}${item.name} — ${item.role}`;
|
|
1875
|
+
}),
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
const timeline = [
|
|
1879
|
+
...(summary.stateHistory ?? []).map((item) => ({
|
|
1880
|
+
startMs: item.startMs,
|
|
1881
|
+
endMs: item.endMs,
|
|
1882
|
+
text: item.state,
|
|
1883
|
+
})),
|
|
1884
|
+
...(summary.keyEvents ?? []).map((item) => ({
|
|
1885
|
+
startMs: item.startMs,
|
|
1886
|
+
endMs: item.endMs,
|
|
1887
|
+
text: item.event,
|
|
1888
|
+
})),
|
|
1889
|
+
].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
|
|
1890
|
+
if (timeline.length) {
|
|
1891
|
+
lines.push(
|
|
1892
|
+
...timeline.map((item) => `- [${time(item.startMs)}–${time(item.endMs)}] ${item.text}`),
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
if (summary.narrative?.length) {
|
|
1896
|
+
lines.push(
|
|
1897
|
+
...summary.narrative.map(
|
|
1898
|
+
(item) => `- [${time(item.startMs)}–${time(item.endMs)}] ${item.text}`,
|
|
1899
|
+
),
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
if (summary.context?.length) {
|
|
1903
|
+
lines.push(
|
|
1904
|
+
...summary.context.map((item) => {
|
|
1905
|
+
const anchor = item.evidence[0];
|
|
1906
|
+
return `- ${anchor ? `[${time(anchor.startMs)}] ` : ''}${item.fact}`;
|
|
1907
|
+
}),
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
if (summary.sourceItems?.length) {
|
|
1911
|
+
lines.push(
|
|
1912
|
+
...summary.sourceItems.map(
|
|
1913
|
+
(item) =>
|
|
1914
|
+
`- [${time(item.startMs)}–${time(item.endMs)}] ${item.text}${
|
|
1915
|
+
item.answer ? ` — ${item.answer}` : ''
|
|
1916
|
+
}${item.respondent ? ` — ${item.respondent}` : ''}`,
|
|
1917
|
+
),
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
if (summary.sourceActivityStructures?.length) {
|
|
1921
|
+
lines.push(
|
|
1922
|
+
...summary.sourceActivityStructures.map(
|
|
1923
|
+
(structure) =>
|
|
1924
|
+
`- [${time(structure.startMs)}–${time(structure.endMs)}] ${
|
|
1925
|
+
structure.label || '—'
|
|
1926
|
+
} (${structure.dimensions
|
|
1927
|
+
.map((dimension) => `${dimension.label} = ${dimension.values.join(', ')}`)
|
|
1928
|
+
.join(' | ')})`,
|
|
1929
|
+
),
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
if (summary.sourceTaskInstances?.length) {
|
|
1933
|
+
lines.push(
|
|
1934
|
+
...summary.sourceTaskInstances.map(
|
|
1935
|
+
(task) =>
|
|
1936
|
+
`- [${time(task.startMs)}–${time(task.endMs)}] ${task.prompt}${
|
|
1937
|
+
task.answer ? ` — ${task.answer}` : ''
|
|
1938
|
+
}${task.respondent ? ` — ${task.respondent}` : ''}`,
|
|
1939
|
+
),
|
|
1940
|
+
);
|
|
1941
|
+
}
|
|
1942
|
+
if (summary.uncertainties?.length) {
|
|
1943
|
+
lines.push(...summary.uncertainties.map((item) => `- ${item}`));
|
|
1944
|
+
}
|
|
1945
|
+
return lines.join('\n');
|
|
1946
|
+
}
|