neoagent 3.2.1-beta.1 → 3.2.1-beta.11
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/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +654 -90
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +157 -24
- package/flutter_app/lib/main_integrations.dart +607 -8
- package/flutter_app/lib/main_models.dart +7 -2
- package/flutter_app/lib/main_operations.dart +334 -370
- package/flutter_app/lib/main_security.dart +266 -112
- package/flutter_app/lib/main_settings.dart +342 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/backend_client.dart +97 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/landing/index.html +3 -1
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +115 -13
- package/package.json +30 -15
- package/runtime/paths.js +49 -5
- package/server/db/database.js +2 -2
- package/server/guest-agent.cli.package.json +13 -0
- package/server/guest_agent.js +85 -40
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +12 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +81930 -80509
- package/server/routes/admin.js +1 -1
- package/server/routes/android.js +30 -34
- package/server/routes/behavior.js +80 -0
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +107 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +20 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/agents/manager.js +1 -1
- package/server/services/ai/capabilityHealth.js +62 -96
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +45 -6
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +452 -176
- package/server/services/ai/loop/blank_recovery.js +5 -4
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +291 -8
- package/server/services/ai/loop/conversation_loop.js +515 -342
- package/server/services/ai/loop/messaging_delivery.js +176 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +20 -45
- package/server/services/ai/loop/progress_classification.js +2 -0
- package/server/services/ai/loop/tool_dispatch.js +19 -8
- package/server/services/ai/loopPolicy.js +48 -21
- package/server/services/ai/messagingFallback.js +17 -17
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_failure_cache.js +108 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +68 -163
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +166 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +21 -33
- package/server/services/ai/providers/githubCopilot.js +41 -20
- package/server/services/ai/providers/google.js +135 -97
- package/server/services/ai/providers/grok.js +4 -3
- package/server/services/ai/providers/grokOauth.js +19 -27
- package/server/services/ai/providers/nvidia.js +10 -5
- package/server/services/ai/providers/ollama.js +111 -84
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +39 -5
- package/server/services/ai/providers/openaiCodex.js +11 -4
- package/server/services/ai/providers/openrouter.js +29 -7
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/systemPrompt.js +32 -123
- package/server/services/ai/taskAnalysis.js +104 -11
- package/server/services/ai/toolEvidence.js +256 -29
- package/server/services/ai/tools.js +248 -117
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/behavior/config.js +251 -0
- package/server/services/behavior/defaults.js +68 -0
- package/server/services/behavior/delivery.js +176 -0
- package/server/services/behavior/index.js +43 -0
- package/server/services/behavior/model_client.js +35 -0
- package/server/services/behavior/modules/agent_identity.js +37 -0
- package/server/services/behavior/modules/channel_style.js +28 -0
- package/server/services/behavior/modules/index.js +29 -0
- package/server/services/behavior/modules/norms.js +101 -0
- package/server/services/behavior/modules/persona.js +48 -0
- package/server/services/behavior/modules/persona_prompt.js +33 -0
- package/server/services/behavior/modules/social_memory.js +86 -0
- package/server/services/behavior/modules/social_observability.js +94 -0
- package/server/services/behavior/modules/social_signals.js +41 -0
- package/server/services/behavior/modules/theory_of_mind.js +110 -0
- package/server/services/behavior/modules/turn_taking.js +237 -0
- package/server/services/behavior/pipeline.js +285 -0
- package/server/services/behavior/registry.js +78 -0
- package/server/services/behavior/signals.js +107 -0
- package/server/services/behavior/state.js +99 -0
- package/server/services/behavior/system_prompt.js +75 -0
- package/server/services/browser/controller.js +843 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +15 -1
- package/server/services/browser/extension/provider.js +71 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/credentials/bitwarden_cli.js +322 -0
- package/server/services/credentials/broker.js +594 -0
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/bitwarden/constants.js +14 -0
- package/server/services/integrations/bitwarden/provider.js +197 -0
- package/server/services/integrations/bitwarden/snapshot.js +65 -0
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +159 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/registry.js +2 -0
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +110 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +66 -52
- package/server/services/messaging/access_policy.js +10 -6
- package/server/services/messaging/automation.js +240 -34
- package/server/services/messaging/discord.js +11 -1
- package/server/services/messaging/formatting_guides.js +5 -4
- package/server/services/messaging/http_platforms.js +37 -16
- package/server/services/messaging/inbound_queue.js +143 -28
- package/server/services/messaging/inbound_store.js +257 -0
- package/server/services/messaging/manager.js +344 -51
- package/server/services/messaging/telegram.js +10 -1
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/messaging/whatsapp.js +33 -14
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +227 -67
- package/server/services/runtime/docker-vm-manager.js +9 -0
- package/server/services/runtime/guest_bootstrap.js +30 -4
- package/server/services/runtime/guest_image.js +43 -12
- package/server/services/runtime/manager.js +77 -23
- package/server/services/runtime/validation.js +7 -6
- package/server/services/security/tool_categories.js +6 -0
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +13 -8
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/captions.js +2 -2
- package/server/services/social_video/service.js +343 -68
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/message.js +1 -1
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtime.js +2 -2
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- package/server/utils/logger.js +19 -0
- package/server/utils/retry.js +107 -0
|
@@ -8,19 +8,28 @@ const { randomUUID } = require('crypto');
|
|
|
8
8
|
const db = require('../../db/database');
|
|
9
9
|
const { DATA_DIR } = require('../../../runtime/paths');
|
|
10
10
|
const { CLIExecutor } = require('../cli/executor');
|
|
11
|
+
const { executeSafeHttpRequest } = require('../network/safe_request');
|
|
11
12
|
const { getAdapterForPlatform } = require('./adapters');
|
|
12
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
MAX_VTT_BYTES,
|
|
15
|
+
decideTranscriptPath,
|
|
16
|
+
parseCaptionText,
|
|
17
|
+
pickCaptionTrack,
|
|
18
|
+
} = require('./captions');
|
|
13
19
|
const { inferImageContentType, pickDeterministicFrameSecond } = require('./frame');
|
|
14
20
|
const { extractPublicMetadataFromHtml } = require('./metadata');
|
|
15
21
|
const { shapeSocialVideoResult } = require('./result');
|
|
16
22
|
const { normalizeAndDetectPlatform } = require('./url');
|
|
17
23
|
const { isMainAgent } = require('../agents/manager');
|
|
18
24
|
const { resolveSttModel, transcribeVoiceInput } = require('../voice/providers');
|
|
25
|
+
const { createAbortError, isAbortError, throwIfAborted } = require('../../utils/abort');
|
|
19
26
|
|
|
20
27
|
const SOCIAL_VIDEO_TMP_DIR = path.join(DATA_DIR, 'social-video-temp');
|
|
21
28
|
fs.mkdirSync(SOCIAL_VIDEO_TMP_DIR, { recursive: true });
|
|
22
29
|
|
|
23
30
|
const HEALTH_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
31
|
+
const MAX_PAGE_HTML_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
const MAX_THUMBNAIL_BYTES = 10 * 1024 * 1024;
|
|
24
33
|
|
|
25
34
|
// A realistic desktop browser UA. Social platforms frequently return 403/bot
|
|
26
35
|
// challenge pages to the default Node/yt-dlp user agents, so we present a
|
|
@@ -44,17 +53,38 @@ const YT_DLP_NETWORK_FLAGS = [
|
|
|
44
53
|
// bot detection. For these we always try to attach the user's browser cookies.
|
|
45
54
|
const COOKIE_ASSISTED_PLATFORMS = new Set(['instagram', 'youtube', 'tiktok', 'x']);
|
|
46
55
|
|
|
47
|
-
async function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
async function fetchPublicResource(url, options = {}) {
|
|
57
|
+
const result = await executeSafeHttpRequest({
|
|
58
|
+
url,
|
|
59
|
+
method: 'GET',
|
|
60
|
+
timeout_ms: options.timeoutMs || 30000,
|
|
51
61
|
headers: {
|
|
52
62
|
'user-agent': BROWSER_USER_AGENT,
|
|
53
63
|
'accept-language': 'en-US,en;q=0.9',
|
|
54
|
-
accept: '*/*',
|
|
64
|
+
accept: options.accept || '*/*',
|
|
55
65
|
...(options.headers || {}),
|
|
56
66
|
},
|
|
67
|
+
}, {
|
|
68
|
+
signal: options.signal,
|
|
69
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
70
|
+
responseType: options.responseType,
|
|
57
71
|
});
|
|
72
|
+
if (result.truncated) {
|
|
73
|
+
const error = new Error('Social video response exceeded its safety limit.');
|
|
74
|
+
error.code = 'SOCIAL_VIDEO_RESPONSE_TOO_LARGE';
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (result.status < 200 || result.status >= 300) {
|
|
78
|
+
const error = new Error(`Social video request failed (${result.status}).`);
|
|
79
|
+
error.status = result.status;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rethrowCancellation(error, signal) {
|
|
86
|
+
if (signal?.aborted) throw createAbortError(signal);
|
|
87
|
+
if (isAbortError(error)) throw error;
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
function shellEscape(value) {
|
|
@@ -199,6 +229,36 @@ function firstFileMatching(dirPath, startsWith) {
|
|
|
199
229
|
return path.join(dirPath, match);
|
|
200
230
|
}
|
|
201
231
|
|
|
232
|
+
function pickDownloadedCaptionFile(dirPath, preferredLanguages = []) {
|
|
233
|
+
const captionExts = new Set(['.vtt', '.webvtt', '.srt', '.ttml', '.xml', '.json3', '.json']);
|
|
234
|
+
const captionGroups = {};
|
|
235
|
+
for (const name of fs.readdirSync(dirPath)
|
|
236
|
+
.filter((name) => name.startsWith('captions.'))
|
|
237
|
+
.sort()) {
|
|
238
|
+
const fullPath = path.join(dirPath, name);
|
|
239
|
+
const extension = path.extname(name).toLowerCase();
|
|
240
|
+
if (!captionExts.has(extension) || !fileExists(fullPath)) continue;
|
|
241
|
+
const base = path.basename(name, extension);
|
|
242
|
+
const language = base.slice('captions.'.length).toLowerCase();
|
|
243
|
+
captionGroups[language] ||= [];
|
|
244
|
+
captionGroups[language].push({
|
|
245
|
+
url: fullPath,
|
|
246
|
+
ext: extension.replace(/^\./, ''),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const track = pickCaptionTrack(captionGroups, preferredLanguages);
|
|
250
|
+
if (!track) return null;
|
|
251
|
+
return {
|
|
252
|
+
path: track.url,
|
|
253
|
+
language: track.language,
|
|
254
|
+
ext: track.ext,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function cookieArg(cookieFilePath) {
|
|
259
|
+
return cookieFilePath ? ` --cookies ${shellEscape(cookieFilePath)}` : '';
|
|
260
|
+
}
|
|
261
|
+
|
|
202
262
|
function classifyExtractionError(error) {
|
|
203
263
|
const message = String(error?.message || error || '').trim();
|
|
204
264
|
const normalized = message.toLowerCase();
|
|
@@ -245,6 +305,7 @@ class SocialVideoService {
|
|
|
245
305
|
this.artifactStore = options.artifactStore || null;
|
|
246
306
|
this.runtimeManager = options.runtimeManager || null;
|
|
247
307
|
this.cliExecutor = options.cliExecutor || new CLIExecutor();
|
|
308
|
+
this.publicResourceFetcher = options.publicResourceFetcher || fetchPublicResource;
|
|
248
309
|
this.voiceTranscriber = options.voiceTranscriber || transcribeVoiceInput;
|
|
249
310
|
this.voiceSettingsResolver = options.voiceSettingsResolver || ((userId, agentId) => this.#resolveVoiceSttConfig(userId, agentId));
|
|
250
311
|
this.ytDlpBin = String(process.env.YT_DLP_BIN || 'yt-dlp').trim() || 'yt-dlp';
|
|
@@ -256,6 +317,7 @@ class SocialVideoService {
|
|
|
256
317
|
}
|
|
257
318
|
|
|
258
319
|
async getHealthStatus(options = {}) {
|
|
320
|
+
throwIfAborted(options.signal, 'Social video health check aborted.');
|
|
259
321
|
const forceRefresh = options.forceRefresh === true;
|
|
260
322
|
const now = Date.now();
|
|
261
323
|
if (!forceRefresh && this._healthCache.value && (now - this._healthCache.ts) < HEALTH_CACHE_TTL_MS) {
|
|
@@ -263,15 +325,18 @@ class SocialVideoService {
|
|
|
263
325
|
}
|
|
264
326
|
|
|
265
327
|
const [ytDlp, ffmpeg] = await Promise.all([
|
|
266
|
-
this.#probeBinary(this.ytDlpBin, '--version'),
|
|
267
|
-
this.#probeBinary(this.ffmpegBin, '-version'),
|
|
328
|
+
this.#probeBinary(this.ytDlpBin, '--version', options.signal),
|
|
329
|
+
this.#probeBinary(this.ffmpegBin, '-version', options.signal),
|
|
268
330
|
]);
|
|
269
331
|
|
|
270
332
|
const health = {
|
|
271
|
-
ready: ytDlp.available
|
|
272
|
-
dependencies: [
|
|
333
|
+
ready: ytDlp.available,
|
|
334
|
+
dependencies: [
|
|
335
|
+
{ ...ytDlp, required: true },
|
|
336
|
+
{ ...ffmpeg, required: false },
|
|
337
|
+
],
|
|
273
338
|
speechToText: {
|
|
274
|
-
note: '
|
|
339
|
+
note: 'Captions are downloaded directly with yt-dlp without social platform API keys. If captions are unavailable, the configured voice STT provider transcribes the media.',
|
|
275
340
|
},
|
|
276
341
|
checkedAt: new Date().toISOString(),
|
|
277
342
|
};
|
|
@@ -291,9 +356,12 @@ class SocialVideoService {
|
|
|
291
356
|
let jobDir = null;
|
|
292
357
|
|
|
293
358
|
try {
|
|
294
|
-
|
|
359
|
+
throwIfAborted(options.signal, 'Social video extraction aborted.');
|
|
360
|
+
const health = await this.getHealthStatus({ signal: options.signal });
|
|
295
361
|
if (!health.ready) {
|
|
296
|
-
const missing = health.dependencies
|
|
362
|
+
const missing = health.dependencies
|
|
363
|
+
.filter((item) => item.required !== false && !item.available)
|
|
364
|
+
.map((item) => item.name);
|
|
297
365
|
throw new Error(`Missing required dependency: ${missing.join(', ')}`);
|
|
298
366
|
}
|
|
299
367
|
|
|
@@ -303,16 +371,32 @@ class SocialVideoService {
|
|
|
303
371
|
throw new Error(`No adapter registered for platform: ${platform}`);
|
|
304
372
|
}
|
|
305
373
|
|
|
306
|
-
const pageMetadata = await this.#resolvePageMetadata(
|
|
374
|
+
const pageMetadata = await this.#resolvePageMetadata(
|
|
375
|
+
userId,
|
|
376
|
+
normalizedUrl,
|
|
377
|
+
warnings,
|
|
378
|
+
options.signal,
|
|
379
|
+
).catch((error) => {
|
|
380
|
+
rethrowCancellation(error, options.signal);
|
|
381
|
+
warnings.push(`Public page metadata was unavailable: ${error.message}`);
|
|
382
|
+
return {};
|
|
383
|
+
});
|
|
384
|
+
throwIfAborted(options.signal, 'Social video extraction aborted.');
|
|
307
385
|
jobDir = await fsp.mkdtemp(path.join(SOCIAL_VIDEO_TMP_DIR, `${platform}-${Date.now()}-`));
|
|
308
386
|
const cookieFilePath = await this.#resolveCookieFile({
|
|
309
387
|
userId,
|
|
310
388
|
platform,
|
|
311
389
|
jobDir,
|
|
312
390
|
warnings,
|
|
391
|
+
signal: options.signal,
|
|
313
392
|
});
|
|
314
393
|
|
|
315
|
-
const mediaInfo = await this.#readMediaInfo(
|
|
394
|
+
const mediaInfo = await this.#readMediaInfo(
|
|
395
|
+
normalizedUrl,
|
|
396
|
+
jobDir,
|
|
397
|
+
cookieFilePath,
|
|
398
|
+
options.signal,
|
|
399
|
+
);
|
|
316
400
|
const baseTitle = String(pageMetadata.title || mediaInfo.title || '').trim();
|
|
317
401
|
const baseDescription = String(pageMetadata.description || mediaInfo.description || '').trim();
|
|
318
402
|
const resolvedUrl = String(pageMetadata.resolvedUrl || mediaInfo.webpage_url || normalizedUrl).trim();
|
|
@@ -333,12 +417,14 @@ class SocialVideoService {
|
|
|
333
417
|
sourceUrl: normalizedUrl,
|
|
334
418
|
mediaInfo,
|
|
335
419
|
captionTrack,
|
|
420
|
+
preferredLanguages,
|
|
336
421
|
transcriptDecision,
|
|
337
422
|
jobDir,
|
|
338
423
|
cookieFilePath,
|
|
339
424
|
userId,
|
|
340
425
|
agentId,
|
|
341
426
|
warnings,
|
|
427
|
+
signal: options.signal,
|
|
342
428
|
});
|
|
343
429
|
|
|
344
430
|
const frameImage = options.includeFrame === false
|
|
@@ -350,6 +436,7 @@ class SocialVideoService {
|
|
|
350
436
|
jobDir,
|
|
351
437
|
cookieFilePath,
|
|
352
438
|
warnings,
|
|
439
|
+
signal: options.signal,
|
|
353
440
|
});
|
|
354
441
|
|
|
355
442
|
return shapeSocialVideoResult({
|
|
@@ -372,7 +459,8 @@ class SocialVideoService {
|
|
|
372
459
|
errors,
|
|
373
460
|
});
|
|
374
461
|
} catch (error) {
|
|
375
|
-
|
|
462
|
+
rethrowCancellation(error, options.signal);
|
|
463
|
+
const health = await this.getHealthStatus({ signal: options.signal }).catch(() => null);
|
|
376
464
|
errors.push(classifyExtractionError(error));
|
|
377
465
|
return shapeSocialVideoResult({
|
|
378
466
|
sourceUrl: source,
|
|
@@ -403,14 +491,16 @@ class SocialVideoService {
|
|
|
403
491
|
cwd: options.cwd || process.cwd(),
|
|
404
492
|
timeout: options.timeout || 10 * 60 * 1000,
|
|
405
493
|
env: options.env,
|
|
494
|
+
signal: options.signal,
|
|
406
495
|
});
|
|
496
|
+
throwIfAborted(options.signal, 'Social video command aborted.');
|
|
407
497
|
if (result.exitCode !== 0) {
|
|
408
498
|
throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
|
|
409
499
|
}
|
|
410
500
|
return result;
|
|
411
501
|
}
|
|
412
502
|
|
|
413
|
-
async #probeBinary(binary, versionFlag) {
|
|
503
|
+
async #probeBinary(binary, versionFlag, signal = null) {
|
|
414
504
|
const name = String(binary || '').trim();
|
|
415
505
|
const fallback = {
|
|
416
506
|
name,
|
|
@@ -430,7 +520,9 @@ class SocialVideoService {
|
|
|
430
520
|
const command = `${shellEscape(name)} ${versionFlag}`;
|
|
431
521
|
const result = await this.cliExecutor.execute(command, {
|
|
432
522
|
timeout: 8 * 1000,
|
|
523
|
+
signal,
|
|
433
524
|
});
|
|
525
|
+
throwIfAborted(signal, 'Social video dependency probe aborted.');
|
|
434
526
|
if (result.exitCode !== 0) {
|
|
435
527
|
return {
|
|
436
528
|
...fallback,
|
|
@@ -447,6 +539,7 @@ class SocialVideoService {
|
|
|
447
539
|
error: null,
|
|
448
540
|
};
|
|
449
541
|
} catch (error) {
|
|
542
|
+
rethrowCancellation(error, signal);
|
|
450
543
|
return {
|
|
451
544
|
...fallback,
|
|
452
545
|
error: error.message || String(error),
|
|
@@ -454,8 +547,13 @@ class SocialVideoService {
|
|
|
454
547
|
}
|
|
455
548
|
}
|
|
456
549
|
|
|
457
|
-
async #resolvePageMetadata(userId, normalizedUrl, warnings) {
|
|
458
|
-
const browserMetadata = await this.#resolvePageMetadataViaBrowser(
|
|
550
|
+
async #resolvePageMetadata(userId, normalizedUrl, warnings, signal = null) {
|
|
551
|
+
const browserMetadata = await this.#resolvePageMetadataViaBrowser(
|
|
552
|
+
userId,
|
|
553
|
+
normalizedUrl,
|
|
554
|
+
signal,
|
|
555
|
+
).catch((error) => {
|
|
556
|
+
rethrowCancellation(error, signal);
|
|
459
557
|
warnings.push(`Browser metadata resolve failed: ${error.message}`);
|
|
460
558
|
return null;
|
|
461
559
|
});
|
|
@@ -463,21 +561,24 @@ class SocialVideoService {
|
|
|
463
561
|
return browserMetadata;
|
|
464
562
|
}
|
|
465
563
|
|
|
466
|
-
const response = await
|
|
467
|
-
|
|
468
|
-
|
|
564
|
+
const response = await this.publicResourceFetcher(normalizedUrl, {
|
|
565
|
+
signal,
|
|
566
|
+
maxResponseBytes: MAX_PAGE_HTML_BYTES,
|
|
567
|
+
accept: 'text/html,*/*',
|
|
568
|
+
});
|
|
569
|
+
const metadata = extractPublicMetadataFromHtml(response.body, response.finalUrl || normalizedUrl);
|
|
469
570
|
return {
|
|
470
571
|
...metadata,
|
|
471
|
-
resolvedUrl: String(response.
|
|
572
|
+
resolvedUrl: String(response.finalUrl || normalizedUrl),
|
|
472
573
|
};
|
|
473
574
|
}
|
|
474
575
|
|
|
475
|
-
async #resolvePageMetadataViaBrowser(userId, normalizedUrl) {
|
|
576
|
+
async #resolvePageMetadataViaBrowser(userId, normalizedUrl, signal = null) {
|
|
476
577
|
if (!this.runtimeManager || typeof this.runtimeManager.getBrowserProviderForUser !== 'function') {
|
|
477
578
|
throw new Error('Runtime browser provider is unavailable.');
|
|
478
579
|
}
|
|
479
580
|
|
|
480
|
-
const browser = await this.runtimeManager.getBrowserProviderForUser(userId);
|
|
581
|
+
const browser = await this.runtimeManager.getBrowserProviderForUser(userId, { signal });
|
|
481
582
|
if (!browser || typeof browser.navigate !== 'function' || typeof browser.extract !== 'function') {
|
|
482
583
|
throw new Error('Runtime browser provider does not support metadata extraction.');
|
|
483
584
|
}
|
|
@@ -485,16 +586,29 @@ class SocialVideoService {
|
|
|
485
586
|
const nav = await browser.navigate(normalizedUrl, {
|
|
486
587
|
screenshot: false,
|
|
487
588
|
waitUntil: 'domcontentloaded',
|
|
589
|
+
signal,
|
|
488
590
|
});
|
|
489
591
|
if (nav?.error) {
|
|
490
592
|
throw new Error(nav.error);
|
|
491
593
|
}
|
|
492
594
|
|
|
493
595
|
const [canonicalRaw, descriptionRaw, ogDescriptionRaw, titleTagRaw] = await Promise.all([
|
|
494
|
-
browser.extract('link[rel="canonical"]', 'href', false).catch(() =>
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
596
|
+
browser.extract('link[rel="canonical"]', 'href', false, { signal }).catch((error) => {
|
|
597
|
+
rethrowCancellation(error, signal);
|
|
598
|
+
return '';
|
|
599
|
+
}),
|
|
600
|
+
browser.extract('meta[name="description"]', 'content', false, { signal }).catch((error) => {
|
|
601
|
+
rethrowCancellation(error, signal);
|
|
602
|
+
return '';
|
|
603
|
+
}),
|
|
604
|
+
browser.extract('meta[property="og:description"]', 'content', false, { signal }).catch((error) => {
|
|
605
|
+
rethrowCancellation(error, signal);
|
|
606
|
+
return '';
|
|
607
|
+
}),
|
|
608
|
+
browser.extract('meta[property="og:title"]', 'content', false, { signal }).catch((error) => {
|
|
609
|
+
rethrowCancellation(error, signal);
|
|
610
|
+
return '';
|
|
611
|
+
}),
|
|
498
612
|
]);
|
|
499
613
|
const canonical = unwrapBrowserExtractValue(canonicalRaw);
|
|
500
614
|
const description = unwrapBrowserExtractValue(descriptionRaw);
|
|
@@ -509,16 +623,16 @@ class SocialVideoService {
|
|
|
509
623
|
};
|
|
510
624
|
}
|
|
511
625
|
|
|
512
|
-
async #readMediaInfo(normalizedUrl, jobDir, cookieFilePath = null) {
|
|
626
|
+
async #readMediaInfo(normalizedUrl, jobDir, cookieFilePath = null, signal = null) {
|
|
513
627
|
const infoTemplate = path.join(jobDir, 'media.%(ext)s');
|
|
514
628
|
const infoPath = path.join(jobDir, 'media.info.json');
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000 });
|
|
629
|
+
const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()} --skip-download --write-info-json --no-clean-infojson${cookieArg(cookieFilePath)} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
|
|
630
|
+
await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000, signal });
|
|
518
631
|
if (!fileExists(infoPath)) {
|
|
519
632
|
throw new Error('yt-dlp did not produce an info JSON artifact.');
|
|
520
633
|
}
|
|
521
634
|
const raw = String(await fsp.readFile(infoPath, 'utf8')).trim();
|
|
635
|
+
throwIfAborted(signal, 'Social video extraction aborted.');
|
|
522
636
|
let parsed;
|
|
523
637
|
try {
|
|
524
638
|
parsed = JSON.parse(raw);
|
|
@@ -529,8 +643,16 @@ class SocialVideoService {
|
|
|
529
643
|
}
|
|
530
644
|
|
|
531
645
|
async #resolveTranscript(context) {
|
|
532
|
-
|
|
533
|
-
|
|
646
|
+
// Prefer captions over STT and never depend on social platform API keys.
|
|
647
|
+
// Caption URLs from metadata are often signed/short-lived or cookie-gated, and
|
|
648
|
+
// auto-captions may exist even when info JSON omits them, so we always attempt
|
|
649
|
+
// caption acquisition before spending STT quota (unless forceStt is set).
|
|
650
|
+
const forceStt = context.transcriptDecision?.mode === 'stt'
|
|
651
|
+
&& context.transcriptDecision?.reason === 'forced';
|
|
652
|
+
|
|
653
|
+
if (!forceStt) {
|
|
654
|
+
const captionText = await this.#resolveCaptionTranscript(context).catch((error) => {
|
|
655
|
+
rethrowCancellation(error, context.signal);
|
|
534
656
|
context.warnings.push(`Caption transcript failed: ${error.message}`);
|
|
535
657
|
return '';
|
|
536
658
|
});
|
|
@@ -540,10 +662,13 @@ class SocialVideoService {
|
|
|
540
662
|
source: 'captions',
|
|
541
663
|
};
|
|
542
664
|
}
|
|
543
|
-
context.
|
|
665
|
+
if (context.transcriptDecision?.mode === 'captions') {
|
|
666
|
+
context.warnings.push('Caption extraction did not yield usable text. Falling back to speech-to-text.');
|
|
667
|
+
}
|
|
544
668
|
}
|
|
545
669
|
|
|
546
670
|
const transcript = await this.#transcribeViaStt(context).catch((error) => {
|
|
671
|
+
rethrowCancellation(error, context.signal);
|
|
547
672
|
context.warnings.push(`Speech-to-text fallback failed: ${error.message}`);
|
|
548
673
|
return '';
|
|
549
674
|
});
|
|
@@ -553,34 +678,159 @@ class SocialVideoService {
|
|
|
553
678
|
};
|
|
554
679
|
}
|
|
555
680
|
|
|
556
|
-
async #
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
681
|
+
async #resolveCaptionTranscript(context) {
|
|
682
|
+
if (context.captionTrack) {
|
|
683
|
+
const direct = await this.#readTranscriptFromCaption(
|
|
684
|
+
context.captionTrack,
|
|
685
|
+
context.sourceUrl,
|
|
686
|
+
context.signal,
|
|
687
|
+
).catch((error) => {
|
|
688
|
+
rethrowCancellation(error, context.signal);
|
|
689
|
+
context.warnings.push(`Direct caption fetch failed: ${error.message}`);
|
|
690
|
+
return '';
|
|
691
|
+
});
|
|
692
|
+
if (direct) {
|
|
693
|
+
return direct;
|
|
694
|
+
}
|
|
560
695
|
}
|
|
561
|
-
|
|
562
|
-
return
|
|
696
|
+
|
|
697
|
+
return this.#downloadCaptionsViaYtDlp(context);
|
|
563
698
|
}
|
|
564
699
|
|
|
565
|
-
async #
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
700
|
+
async #readTranscriptFromCaption(captionTrack, sourceUrl, signal = null) {
|
|
701
|
+
const response = await this.publicResourceFetcher(captionTrack.url, {
|
|
702
|
+
signal,
|
|
703
|
+
maxResponseBytes: MAX_VTT_BYTES,
|
|
704
|
+
accept: 'text/vtt,text/plain,application/json,application/xml,*/*',
|
|
705
|
+
headers: {
|
|
706
|
+
referer: String(sourceUrl || ''),
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
return parseCaptionText(response.body, captionTrack.ext);
|
|
710
|
+
}
|
|
570
711
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
712
|
+
async #downloadCaptionsViaYtDlp(context) {
|
|
713
|
+
const preferredLanguages = Array.isArray(context.preferredLanguages)
|
|
714
|
+
? context.preferredLanguages
|
|
715
|
+
: [];
|
|
716
|
+
const languageHints = preferredLanguages.length > 0
|
|
717
|
+
? preferredLanguages.join(',')
|
|
718
|
+
: 'en.*,en';
|
|
719
|
+
// Download both manual and auto-generated captions without platform API keys.
|
|
720
|
+
// Prefer formats we can parse directly so caption extraction does not require
|
|
721
|
+
// ffmpeg just to perform a subtitle conversion.
|
|
722
|
+
const outputTemplate = path.join(context.jobDir, 'captions');
|
|
723
|
+
const command = [
|
|
724
|
+
shellEscape(this.ytDlpBin),
|
|
725
|
+
'--quiet',
|
|
726
|
+
'--no-warnings',
|
|
727
|
+
'--no-playlist',
|
|
728
|
+
this.#networkFlags(),
|
|
729
|
+
cookieArg(context.cookieFilePath),
|
|
730
|
+
'--skip-download',
|
|
731
|
+
'--write-subs',
|
|
732
|
+
'--write-auto-subs',
|
|
733
|
+
'--sub-langs', shellEscape(`${languageHints},all,-live_chat`),
|
|
734
|
+
'--sub-format', shellEscape('vtt/srt/ttml/json3/best'),
|
|
735
|
+
'-o', shellEscape(outputTemplate),
|
|
736
|
+
'--',
|
|
737
|
+
shellEscape(context.sourceUrl),
|
|
738
|
+
].filter(Boolean).join(' ');
|
|
739
|
+
|
|
740
|
+
await this.#runCommand(command, {
|
|
741
|
+
cwd: context.jobDir,
|
|
742
|
+
timeout: 4 * 60 * 1000,
|
|
743
|
+
signal: context.signal,
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const captionFile = pickDownloadedCaptionFile(context.jobDir, preferredLanguages);
|
|
747
|
+
if (!captionFile) {
|
|
748
|
+
throw new Error('yt-dlp did not produce a usable caption file.');
|
|
574
749
|
}
|
|
575
750
|
|
|
751
|
+
const raw = await fsp.readFile(captionFile.path, 'utf8');
|
|
752
|
+
throwIfAborted(context.signal, 'Social video extraction aborted.');
|
|
753
|
+
const text = parseCaptionText(raw, captionFile.ext);
|
|
754
|
+
if (!text) {
|
|
755
|
+
throw new Error(`Caption file ${path.basename(captionFile.path)} parsed to empty text.`);
|
|
756
|
+
}
|
|
757
|
+
return text;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async #transcribeViaStt(context) {
|
|
761
|
+
const audioPath = await this.#downloadAudioForStt(context);
|
|
762
|
+
const preparedPath = await this.#prepareAudioForStt(audioPath, context);
|
|
763
|
+
|
|
576
764
|
const sttConfig = await Promise.resolve(
|
|
577
765
|
this.voiceSettingsResolver(context.userId, context.agentId),
|
|
578
766
|
);
|
|
579
|
-
|
|
767
|
+
throwIfAborted(context.signal, 'Social video transcription aborted.');
|
|
768
|
+
return this.voiceTranscriber(preparedPath, {
|
|
580
769
|
provider: sttConfig?.provider || 'openai',
|
|
581
770
|
model: sttConfig?.model || '',
|
|
582
|
-
mimeType: detectMimeFromFile(
|
|
771
|
+
mimeType: detectMimeFromFile(preparedPath),
|
|
772
|
+
signal: context.signal,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async #downloadAudioForStt(context) {
|
|
777
|
+
const template = path.join(context.jobDir, 'audio.%(ext)s');
|
|
778
|
+
const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bestaudio/best" --extract-audio --audio-format mp3 --audio-quality 0 -- ${shellEscape(context.sourceUrl)}`;
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
await this.#runCommand(command, {
|
|
782
|
+
cwd: context.jobDir,
|
|
783
|
+
timeout: 10 * 60 * 1000,
|
|
784
|
+
signal: context.signal,
|
|
785
|
+
});
|
|
786
|
+
} catch (error) {
|
|
787
|
+
rethrowCancellation(error, context.signal);
|
|
788
|
+
// Some extractors reject --extract-audio; fall back to raw bestaudio/best download.
|
|
789
|
+
context.warnings.push(`Audio extract-audio path failed (${error.message}). Retrying raw media download.`);
|
|
790
|
+
const fallbackCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bestaudio/best" -- ${shellEscape(context.sourceUrl)}`;
|
|
791
|
+
await this.#runCommand(fallbackCommand, {
|
|
792
|
+
cwd: context.jobDir,
|
|
793
|
+
timeout: 10 * 60 * 1000,
|
|
794
|
+
signal: context.signal,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const audioPath = firstFileMatching(context.jobDir, 'audio.');
|
|
799
|
+
if (!audioPath || !fileExists(audioPath)) {
|
|
800
|
+
throw new Error('Audio download succeeded but no audio file was created.');
|
|
801
|
+
}
|
|
802
|
+
return audioPath;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async #prepareAudioForStt(audioPath, context) {
|
|
806
|
+
const ext = path.extname(String(audioPath || '')).toLowerCase();
|
|
807
|
+
const mimeType = detectMimeFromFile(audioPath);
|
|
808
|
+
// Prefer formats STT providers accept directly. Normalize anything else
|
|
809
|
+
// (including video containers from bestaudio/best fallbacks) to mono mp3.
|
|
810
|
+
const readyAsIs = (
|
|
811
|
+
mimeType === 'audio/mpeg'
|
|
812
|
+
|| mimeType === 'audio/mp4'
|
|
813
|
+
|| mimeType === 'audio/wav'
|
|
814
|
+
|| mimeType === 'audio/webm'
|
|
815
|
+
|| mimeType === 'audio/ogg'
|
|
816
|
+
|| mimeType === 'audio/opus'
|
|
817
|
+
) && !['.mp4', '.mkv', '.mov', '.avi'].includes(ext);
|
|
818
|
+
|
|
819
|
+
if (readyAsIs) {
|
|
820
|
+
return audioPath;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const normalizedPath = path.join(context.jobDir, 'audio.stt.mp3');
|
|
824
|
+
const command = `${shellEscape(this.ffmpegBin)} -hwaccel none -y -hide_banner -loglevel error -i ${shellEscape(audioPath)} -vn -ac 1 -ar 16000 -b:a 64k ${shellEscape(normalizedPath)}`;
|
|
825
|
+
await this.#runCommand(command, {
|
|
826
|
+
cwd: context.jobDir,
|
|
827
|
+
timeout: 3 * 60 * 1000,
|
|
828
|
+
signal: context.signal,
|
|
583
829
|
});
|
|
830
|
+
if (!fileExists(normalizedPath)) {
|
|
831
|
+
throw new Error('ffmpeg did not produce a normalized audio file for transcription.');
|
|
832
|
+
}
|
|
833
|
+
return normalizedPath;
|
|
584
834
|
}
|
|
585
835
|
|
|
586
836
|
async #resolveVoiceSttConfig(userId, agentId) {
|
|
@@ -599,13 +849,19 @@ class SocialVideoService {
|
|
|
599
849
|
}
|
|
600
850
|
|
|
601
851
|
const browser = await Promise.resolve(
|
|
602
|
-
this.runtimeManager.getBrowserProviderForUser(context.userId
|
|
603
|
-
|
|
852
|
+
this.runtimeManager.getBrowserProviderForUser(context.userId, {
|
|
853
|
+
signal: context.signal,
|
|
854
|
+
}),
|
|
855
|
+
).catch((error) => {
|
|
856
|
+
rethrowCancellation(error, context.signal);
|
|
857
|
+
return null;
|
|
858
|
+
});
|
|
604
859
|
if (!browser || typeof browser.getCookies !== 'function') {
|
|
605
860
|
return null;
|
|
606
861
|
}
|
|
607
862
|
|
|
608
|
-
const payload = await browser.getCookies().catch((error) => {
|
|
863
|
+
const payload = await browser.getCookies({ signal: context.signal }).catch((error) => {
|
|
864
|
+
rethrowCancellation(error, context.signal);
|
|
609
865
|
context.warnings.push(`Browser cookie export failed: ${error.message}`);
|
|
610
866
|
return null;
|
|
611
867
|
});
|
|
@@ -619,11 +875,13 @@ class SocialVideoService {
|
|
|
619
875
|
|
|
620
876
|
const cookieFilePath = path.join(context.jobDir, 'browser.cookies.txt');
|
|
621
877
|
await fsp.writeFile(cookieFilePath, serializeCookiesForNetscapeJar(cookies), 'utf8');
|
|
878
|
+
throwIfAborted(context.signal, 'Social video cookie export aborted.');
|
|
622
879
|
return cookieFilePath;
|
|
623
880
|
}
|
|
624
881
|
|
|
625
882
|
async #resolveFrameImage(context) {
|
|
626
883
|
const downloadedFrame = await this.#extractFrameFromVideo(context).catch((error) => {
|
|
884
|
+
rethrowCancellation(error, context.signal);
|
|
627
885
|
context.warnings.push(`Frame extraction failed: ${error.message}`);
|
|
628
886
|
return null;
|
|
629
887
|
});
|
|
@@ -636,14 +894,21 @@ class SocialVideoService {
|
|
|
636
894
|
context.warnings.push('No thumbnail fallback was available after frame extraction failed.');
|
|
637
895
|
return null;
|
|
638
896
|
}
|
|
639
|
-
return this.#downloadThumbnailArtifact(
|
|
897
|
+
return this.#downloadThumbnailArtifact(
|
|
898
|
+
context.userId,
|
|
899
|
+
thumbnail.url,
|
|
900
|
+
context.signal,
|
|
901
|
+
);
|
|
640
902
|
}
|
|
641
903
|
|
|
642
904
|
async #extractFrameFromVideo(context) {
|
|
643
905
|
const template = path.join(context.jobDir, 'video.%(ext)s');
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
906
|
+
const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg(context.cookieFilePath)} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
|
|
907
|
+
await this.#runCommand(downloadCommand, {
|
|
908
|
+
cwd: context.jobDir,
|
|
909
|
+
timeout: 14 * 60 * 1000,
|
|
910
|
+
signal: context.signal,
|
|
911
|
+
});
|
|
647
912
|
|
|
648
913
|
const videoPath = firstFileMatching(context.jobDir, 'video.');
|
|
649
914
|
if (!videoPath || !fileExists(videoPath)) {
|
|
@@ -653,7 +918,11 @@ class SocialVideoService {
|
|
|
653
918
|
const framePath = path.join(context.jobDir, 'frame.jpg');
|
|
654
919
|
const frameSecond = pickDeterministicFrameSecond(context.mediaInfo.duration);
|
|
655
920
|
const frameCommand = `${shellEscape(this.ffmpegBin)} -hwaccel none -y -hide_banner -loglevel error -ss ${frameSecond} -i ${shellEscape(videoPath)} -frames:v 1 -q:v 2 ${shellEscape(framePath)}`;
|
|
656
|
-
await this.#runCommand(frameCommand, {
|
|
921
|
+
await this.#runCommand(frameCommand, {
|
|
922
|
+
cwd: context.jobDir,
|
|
923
|
+
timeout: 2 * 60 * 1000,
|
|
924
|
+
signal: context.signal,
|
|
925
|
+
});
|
|
657
926
|
|
|
658
927
|
if (!fileExists(framePath)) {
|
|
659
928
|
throw new Error('ffmpeg did not produce a frame image.');
|
|
@@ -661,14 +930,19 @@ class SocialVideoService {
|
|
|
661
930
|
return this.#saveImageArtifact(context.userId, framePath, 'frame');
|
|
662
931
|
}
|
|
663
932
|
|
|
664
|
-
async #downloadThumbnailArtifact(userId, thumbnailUrl) {
|
|
665
|
-
const response = await
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const
|
|
933
|
+
async #downloadThumbnailArtifact(userId, thumbnailUrl, signal = null) {
|
|
934
|
+
const response = await this.publicResourceFetcher(thumbnailUrl, {
|
|
935
|
+
signal,
|
|
936
|
+
maxResponseBytes: MAX_THUMBNAIL_BYTES,
|
|
937
|
+
responseType: 'buffer',
|
|
938
|
+
accept: 'image/*,*/*',
|
|
939
|
+
});
|
|
940
|
+
const buffer = response.body;
|
|
941
|
+
const guessedExtension = path.extname(
|
|
942
|
+
new URL(response.finalUrl || thumbnailUrl).pathname,
|
|
943
|
+
).replace('.', '') || 'jpg';
|
|
944
|
+
const mimeType = String(response.headers['content-type'] || '').trim()
|
|
945
|
+
|| `image/${guessedExtension}`;
|
|
672
946
|
if (!this.artifactStore || userId == null) {
|
|
673
947
|
return {
|
|
674
948
|
url: null,
|
|
@@ -745,6 +1019,7 @@ module.exports = {
|
|
|
745
1019
|
detectMimeFromFile,
|
|
746
1020
|
fileExists,
|
|
747
1021
|
firstFileMatching,
|
|
1022
|
+
pickDownloadedCaptionFile,
|
|
748
1023
|
pickBestThumbnail,
|
|
749
1024
|
classifyExtractionError,
|
|
750
1025
|
resolveVoiceSttConfigFromSettings,
|