neoagent 2.3.1-beta.87 → 2.3.1-beta.89
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/flutter_app/lib/features/notifications/notification_interceptor.dart +11 -45
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_app_shell.dart +23 -14
- package/flutter_app/lib/main_chat.dart +128 -22
- package/flutter_app/lib/main_controller.dart +18 -7
- package/flutter_app/lib/main_settings.dart +16 -14
- package/flutter_app/lib/main_shared.dart +21 -16
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
- package/flutter_app/pubspec.lock +24 -0
- package/flutter_app/pubspec.yaml +1 -0
- package/flutter_app/web/index.html +0 -1
- package/package.json +1 -1
- package/server/db/database.js +42 -4
- package/server/guest_agent.js +8 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +70 -44
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/index.html +0 -1
- package/server/public/main.dart.js +54424 -54174
- package/server/routes/browser.js +1 -1
- package/server/routes/social_video.js +4 -0
- package/server/services/ai/tools.js +1 -0
- package/server/services/android/android_bootstrap_worker.js +1 -0
- package/server/services/android/controller.js +456 -117
- package/server/services/browser/controller.js +24 -8
- package/server/services/runtime/backends/local-vm.js +37 -5
- package/server/services/social_video/service.js +110 -36
|
@@ -490,7 +490,9 @@ class BrowserController {
|
|
|
490
490
|
url: currentUrl,
|
|
491
491
|
status: response?.status() || 0,
|
|
492
492
|
bodyText,
|
|
493
|
-
screenshotPath: screenshot?.screenshotPath || null
|
|
493
|
+
screenshotPath: screenshot?.screenshotPath || null,
|
|
494
|
+
artifactId: screenshot?.artifactId || null,
|
|
495
|
+
fullPath: screenshot?.fullPath || null
|
|
494
496
|
};
|
|
495
497
|
} catch (err) {
|
|
496
498
|
let screenshot = null;
|
|
@@ -498,7 +500,9 @@ class BrowserController {
|
|
|
498
500
|
return {
|
|
499
501
|
error: err.message,
|
|
500
502
|
url,
|
|
501
|
-
screenshotPath: screenshot?.screenshotPath || null
|
|
503
|
+
screenshotPath: screenshot?.screenshotPath || null,
|
|
504
|
+
artifactId: screenshot?.artifactId || null,
|
|
505
|
+
fullPath: screenshot?.fullPath || null
|
|
502
506
|
};
|
|
503
507
|
}
|
|
504
508
|
}
|
|
@@ -540,7 +544,9 @@ class BrowserController {
|
|
|
540
544
|
success: true,
|
|
541
545
|
url: page.url(),
|
|
542
546
|
title: await page.title(),
|
|
543
|
-
screenshotPath: screenshotResult?.screenshotPath || null
|
|
547
|
+
screenshotPath: screenshotResult?.screenshotPath || null,
|
|
548
|
+
artifactId: screenshotResult?.artifactId || null,
|
|
549
|
+
fullPath: screenshotResult?.fullPath || null
|
|
544
550
|
};
|
|
545
551
|
} catch (err) {
|
|
546
552
|
return { error: err.message };
|
|
@@ -569,7 +575,9 @@ class BrowserController {
|
|
|
569
575
|
y: py,
|
|
570
576
|
url: page.url(),
|
|
571
577
|
title: await page.title(),
|
|
572
|
-
screenshotPath: screenshotResult?.screenshotPath || null
|
|
578
|
+
screenshotPath: screenshotResult?.screenshotPath || null,
|
|
579
|
+
artifactId: screenshotResult?.artifactId || null,
|
|
580
|
+
fullPath: screenshotResult?.fullPath || null
|
|
573
581
|
};
|
|
574
582
|
} catch (err) {
|
|
575
583
|
return { error: err.message };
|
|
@@ -593,7 +601,9 @@ class BrowserController {
|
|
|
593
601
|
success: true,
|
|
594
602
|
url: page.url(),
|
|
595
603
|
title: await page.title(),
|
|
596
|
-
screenshotPath: screenshotResult?.screenshotPath || null
|
|
604
|
+
screenshotPath: screenshotResult?.screenshotPath || null,
|
|
605
|
+
artifactId: screenshotResult?.artifactId || null,
|
|
606
|
+
fullPath: screenshotResult?.fullPath || null
|
|
597
607
|
};
|
|
598
608
|
} catch (err) {
|
|
599
609
|
return { error: err.message };
|
|
@@ -624,7 +634,9 @@ class BrowserController {
|
|
|
624
634
|
return {
|
|
625
635
|
success: true,
|
|
626
636
|
typed: text,
|
|
627
|
-
screenshotPath: screenshotResult?.screenshotPath || null
|
|
637
|
+
screenshotPath: screenshotResult?.screenshotPath || null,
|
|
638
|
+
artifactId: screenshotResult?.artifactId || null,
|
|
639
|
+
fullPath: screenshotResult?.fullPath || null
|
|
628
640
|
};
|
|
629
641
|
} catch (err) {
|
|
630
642
|
return { error: err.message };
|
|
@@ -650,7 +662,9 @@ class BrowserController {
|
|
|
650
662
|
return {
|
|
651
663
|
success: true,
|
|
652
664
|
typed: String(text || ''),
|
|
653
|
-
screenshotPath: screenshotResult?.screenshotPath || null
|
|
665
|
+
screenshotPath: screenshotResult?.screenshotPath || null,
|
|
666
|
+
artifactId: screenshotResult?.artifactId || null,
|
|
667
|
+
fullPath: screenshotResult?.fullPath || null
|
|
654
668
|
};
|
|
655
669
|
} catch (err) {
|
|
656
670
|
return { error: err.message };
|
|
@@ -674,7 +688,9 @@ class BrowserController {
|
|
|
674
688
|
return {
|
|
675
689
|
success: true,
|
|
676
690
|
key: normalized,
|
|
677
|
-
screenshotPath: screenshotResult?.screenshotPath || null
|
|
691
|
+
screenshotPath: screenshotResult?.screenshotPath || null,
|
|
692
|
+
artifactId: screenshotResult?.artifactId || null,
|
|
693
|
+
fullPath: screenshotResult?.fullPath || null
|
|
678
694
|
};
|
|
679
695
|
} catch (err) {
|
|
680
696
|
return { error: err.message };
|
|
@@ -165,13 +165,45 @@ class VmBrowserProvider {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
async #materialize(result) {
|
|
168
|
-
if (!result || !
|
|
168
|
+
if (!result || !this.artifactStore || this.userId == null) {
|
|
169
169
|
return result;
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
|
|
172
|
+
const readablePathCandidates = [];
|
|
173
|
+
if (result.fullPath) {
|
|
174
|
+
readablePathCandidates.push(String(result.fullPath));
|
|
175
|
+
}
|
|
176
|
+
if (typeof result.screenshotPath === 'string' && result.screenshotPath.startsWith('/screenshots/')) {
|
|
177
|
+
readablePathCandidates.push(result.screenshotPath);
|
|
178
|
+
}
|
|
179
|
+
if (readablePathCandidates.length === 0) {
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let file = null;
|
|
184
|
+
for (const candidate of readablePathCandidates) {
|
|
185
|
+
try {
|
|
186
|
+
file = await this.client.request('POST', '/files/read', {
|
|
187
|
+
path: candidate,
|
|
188
|
+
encoding: 'base64',
|
|
189
|
+
});
|
|
190
|
+
if (file?.content) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
if (!file?.content) {
|
|
196
|
+
if (typeof result.screenshotPath === 'string' && result.screenshotPath.startsWith('/screenshots/')) {
|
|
197
|
+
return {
|
|
198
|
+
...result,
|
|
199
|
+
screenshotPath: null,
|
|
200
|
+
artifactId: result.artifactId || null,
|
|
201
|
+
fullPath: result.fullPath || null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
175
207
|
const allocation = this.artifactStore.allocateFile(this.userId, {
|
|
176
208
|
kind: 'browser-screenshot',
|
|
177
209
|
backend: 'vm',
|
|
@@ -5,15 +5,17 @@ const fsp = require('fs/promises');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { randomUUID } = require('crypto');
|
|
7
7
|
|
|
8
|
+
const db = require('../../db/database');
|
|
8
9
|
const { DATA_DIR } = require('../../../runtime/paths');
|
|
9
10
|
const { CLIExecutor } = require('../cli/executor');
|
|
10
|
-
const { isDeepgramConfigured, transcribeChunkWithDeepgram } = require('../recordings/deepgram');
|
|
11
11
|
const { getAdapterForPlatform } = require('./adapters');
|
|
12
12
|
const { decideTranscriptPath, parseCaptionText, pickCaptionTrack } = require('./captions');
|
|
13
13
|
const { inferImageContentType, pickDeterministicFrameSecond } = require('./frame');
|
|
14
14
|
const { extractPublicMetadataFromHtml } = require('./metadata');
|
|
15
15
|
const { shapeSocialVideoResult } = require('./result');
|
|
16
16
|
const { normalizeAndDetectPlatform } = require('./url');
|
|
17
|
+
const { isMainAgent } = require('../agents/manager');
|
|
18
|
+
const { resolveSttModel, transcribeVoiceInput } = require('../voice/providers');
|
|
17
19
|
|
|
18
20
|
const SOCIAL_VIDEO_TMP_DIR = path.join(DATA_DIR, 'social-video-temp');
|
|
19
21
|
fs.mkdirSync(SOCIAL_VIDEO_TMP_DIR, { recursive: true });
|
|
@@ -70,6 +72,60 @@ function unwrapBrowserExtractValue(payload) {
|
|
|
70
72
|
return '';
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function parseStoredSettingValue(value) {
|
|
76
|
+
if (typeof value !== 'string') {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(value);
|
|
81
|
+
} catch {
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readStoredSetting(userId, agentId, key) {
|
|
87
|
+
if (!userId) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (agentId) {
|
|
92
|
+
const agentRow = db.prepare(
|
|
93
|
+
'SELECT value FROM agent_settings WHERE user_id = ? AND agent_id = ? AND key = ?',
|
|
94
|
+
).get(userId, agentId, key);
|
|
95
|
+
if (agentRow) {
|
|
96
|
+
return parseStoredSettingValue(agentRow.value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!agentId || isMainAgent(userId, agentId)) {
|
|
101
|
+
const userRow = db.prepare(
|
|
102
|
+
'SELECT value FROM user_settings WHERE user_id = ? AND key = ?',
|
|
103
|
+
).get(userId, key);
|
|
104
|
+
if (userRow) {
|
|
105
|
+
return parseStoredSettingValue(userRow.value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveVoiceSttConfigFromSettings(settings = {}) {
|
|
113
|
+
const provider = String(
|
|
114
|
+
settings.voice_stt_provider
|
|
115
|
+
|| settings.default_recording_transcription_provider
|
|
116
|
+
|| '',
|
|
117
|
+
).trim().toLowerCase() || 'openai';
|
|
118
|
+
const model = String(
|
|
119
|
+
settings.voice_stt_model
|
|
120
|
+
|| settings.default_recording_transcription_model
|
|
121
|
+
|| '',
|
|
122
|
+
).trim();
|
|
123
|
+
return {
|
|
124
|
+
provider,
|
|
125
|
+
model: resolveSttModel(provider, model),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
73
129
|
function fileExists(filePath) {
|
|
74
130
|
try {
|
|
75
131
|
return fs.statSync(filePath).isFile();
|
|
@@ -124,6 +180,8 @@ class SocialVideoService {
|
|
|
124
180
|
this.artifactStore = options.artifactStore || null;
|
|
125
181
|
this.runtimeManager = options.runtimeManager || null;
|
|
126
182
|
this.cliExecutor = options.cliExecutor || new CLIExecutor();
|
|
183
|
+
this.voiceTranscriber = options.voiceTranscriber || transcribeVoiceInput;
|
|
184
|
+
this.voiceSettingsResolver = options.voiceSettingsResolver || ((userId, agentId) => this.#resolveVoiceSttConfig(userId, agentId));
|
|
127
185
|
this.ytDlpBin = String(process.env.YT_DLP_BIN || 'yt-dlp').trim() || 'yt-dlp';
|
|
128
186
|
this.ffmpegBin = String(process.env.FFMPEG_BIN || 'ffmpeg').trim() || 'ffmpeg';
|
|
129
187
|
this._healthCache = {
|
|
@@ -148,10 +206,7 @@ class SocialVideoService {
|
|
|
148
206
|
ready: ytDlp.available && ffmpeg.available,
|
|
149
207
|
dependencies: [ytDlp, ffmpeg],
|
|
150
208
|
speechToText: {
|
|
151
|
-
|
|
152
|
-
note: isDeepgramConfigured()
|
|
153
|
-
? 'Deepgram is configured for speech-to-text fallback.'
|
|
154
|
-
: 'DEEPGRAM_API_KEY is not configured. Extraction still works when platform captions are available.',
|
|
209
|
+
note: 'Transcript fallback uses the configured voice STT provider from Flutter settings.',
|
|
155
210
|
},
|
|
156
211
|
checkedAt: new Date().toISOString(),
|
|
157
212
|
};
|
|
@@ -167,6 +222,7 @@ class SocialVideoService {
|
|
|
167
222
|
const warnings = [];
|
|
168
223
|
const errors = [];
|
|
169
224
|
const source = String(sourceUrl || '').trim();
|
|
225
|
+
const agentId = options.agentId || null;
|
|
170
226
|
let jobDir = null;
|
|
171
227
|
|
|
172
228
|
try {
|
|
@@ -208,6 +264,8 @@ class SocialVideoService {
|
|
|
208
264
|
captionTrack,
|
|
209
265
|
transcriptDecision,
|
|
210
266
|
jobDir,
|
|
267
|
+
userId,
|
|
268
|
+
agentId,
|
|
211
269
|
warnings,
|
|
212
270
|
});
|
|
213
271
|
|
|
@@ -375,14 +433,14 @@ class SocialVideoService {
|
|
|
375
433
|
}
|
|
376
434
|
|
|
377
435
|
async #readMediaInfo(normalizedUrl, jobDir) {
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
if (!
|
|
383
|
-
throw new Error('yt-dlp
|
|
436
|
+
const infoTemplate = path.join(jobDir, 'media.%(ext)s');
|
|
437
|
+
const infoPath = path.join(jobDir, 'media.info.json');
|
|
438
|
+
const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist --skip-download --write-info-json --no-clean-infojson -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
|
|
439
|
+
await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000 });
|
|
440
|
+
if (!fileExists(infoPath)) {
|
|
441
|
+
throw new Error('yt-dlp did not produce an info JSON artifact.');
|
|
384
442
|
}
|
|
385
|
-
await fsp.
|
|
443
|
+
const raw = String(await fsp.readFile(infoPath, 'utf8')).trim();
|
|
386
444
|
let parsed;
|
|
387
445
|
try {
|
|
388
446
|
parsed = JSON.parse(raw);
|
|
@@ -407,15 +465,10 @@ class SocialVideoService {
|
|
|
407
465
|
context.warnings.push('Caption track was present but transcript text was empty. Falling back to speech-to-text.');
|
|
408
466
|
}
|
|
409
467
|
|
|
410
|
-
|
|
411
|
-
context.warnings.push(
|
|
412
|
-
return
|
|
413
|
-
|
|
414
|
-
source: 'unavailable',
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const transcript = await this.#transcribeViaStt(context.sourceUrl, context.jobDir);
|
|
468
|
+
const transcript = await this.#transcribeViaStt(context).catch((error) => {
|
|
469
|
+
context.warnings.push(`Speech-to-text fallback failed: ${error.message}`);
|
|
470
|
+
return '';
|
|
471
|
+
});
|
|
419
472
|
return {
|
|
420
473
|
text: transcript,
|
|
421
474
|
source: transcript ? 'stt' : 'unavailable',
|
|
@@ -431,23 +484,41 @@ class SocialVideoService {
|
|
|
431
484
|
return parseCaptionText(raw, captionTrack.ext);
|
|
432
485
|
}
|
|
433
486
|
|
|
434
|
-
async #transcribeViaStt(
|
|
435
|
-
const template = path.join(jobDir, 'audio.%(ext)s');
|
|
436
|
-
const command = `${shellEscape(this.ytDlpBin)} --no-playlist -
|
|
437
|
-
await this.#runCommand(command, { cwd: jobDir, timeout: 10 * 60 * 1000 });
|
|
487
|
+
async #transcribeViaStt(context) {
|
|
488
|
+
const template = path.join(context.jobDir, 'audio.%(ext)s');
|
|
489
|
+
const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist -o ${shellEscape(template)} -f bestaudio -- ${shellEscape(context.sourceUrl)}`;
|
|
490
|
+
await this.#runCommand(command, { cwd: context.jobDir, timeout: 10 * 60 * 1000 });
|
|
438
491
|
|
|
439
|
-
const audioPath = firstFileMatching(jobDir, 'audio.');
|
|
492
|
+
const audioPath = firstFileMatching(context.jobDir, 'audio.');
|
|
440
493
|
if (!audioPath || !fileExists(audioPath)) {
|
|
441
494
|
throw new Error('Audio download succeeded but no audio file was created.');
|
|
442
495
|
}
|
|
443
496
|
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
497
|
+
const sttConfig = await Promise.resolve(
|
|
498
|
+
this.voiceSettingsResolver(context.userId, context.agentId),
|
|
499
|
+
);
|
|
500
|
+
return this.voiceTranscriber(audioPath, {
|
|
501
|
+
provider: sttConfig?.provider || 'openai',
|
|
502
|
+
model: sttConfig?.model || '',
|
|
447
503
|
mimeType: detectMimeFromFile(audioPath),
|
|
448
504
|
});
|
|
449
|
-
|
|
450
|
-
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async #resolveVoiceSttConfig(userId, agentId) {
|
|
508
|
+
return resolveVoiceSttConfigFromSettings({
|
|
509
|
+
voice_stt_provider: readStoredSetting(userId, agentId, 'voice_stt_provider'),
|
|
510
|
+
voice_stt_model: readStoredSetting(userId, agentId, 'voice_stt_model'),
|
|
511
|
+
default_recording_transcription_provider: readStoredSetting(
|
|
512
|
+
userId,
|
|
513
|
+
agentId,
|
|
514
|
+
'default_recording_transcription_provider',
|
|
515
|
+
),
|
|
516
|
+
default_recording_transcription_model: readStoredSetting(
|
|
517
|
+
userId,
|
|
518
|
+
agentId,
|
|
519
|
+
'default_recording_transcription_model',
|
|
520
|
+
),
|
|
521
|
+
});
|
|
451
522
|
}
|
|
452
523
|
|
|
453
524
|
async #resolveFrameImage(context) {
|
|
@@ -469,7 +540,7 @@ class SocialVideoService {
|
|
|
469
540
|
|
|
470
541
|
async #extractFrameFromVideo(context) {
|
|
471
542
|
const template = path.join(context.jobDir, 'video.%(ext)s');
|
|
472
|
-
const downloadCommand = `${shellEscape(this.ytDlpBin)} --no-playlist -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}
|
|
543
|
+
const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
|
|
473
544
|
await this.#runCommand(downloadCommand, { cwd: context.jobDir, timeout: 14 * 60 * 1000 });
|
|
474
545
|
|
|
475
546
|
const videoPath = firstFileMatching(context.jobDir, 'video.');
|
|
@@ -479,7 +550,7 @@ class SocialVideoService {
|
|
|
479
550
|
|
|
480
551
|
const framePath = path.join(context.jobDir, 'frame.jpg');
|
|
481
552
|
const frameSecond = pickDeterministicFrameSecond(context.mediaInfo.duration);
|
|
482
|
-
const frameCommand = `${shellEscape(this.ffmpegBin)} -y -hide_banner -loglevel error -ss ${frameSecond} -i ${shellEscape(videoPath)} -frames:v 1 -q:v 2 ${shellEscape(framePath)}`;
|
|
553
|
+
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)}`;
|
|
483
554
|
await this.#runCommand(frameCommand, { cwd: context.jobDir, timeout: 2 * 60 * 1000 });
|
|
484
555
|
|
|
485
556
|
if (!fileExists(framePath)) {
|
|
@@ -505,7 +576,7 @@ class SocialVideoService {
|
|
|
505
576
|
source: 'thumbnail',
|
|
506
577
|
};
|
|
507
578
|
}
|
|
508
|
-
const allocation = this.artifactStore.allocateFile(userId, {
|
|
579
|
+
const allocation = await Promise.resolve(this.artifactStore.allocateFile(userId, {
|
|
509
580
|
kind: 'social-video-frame',
|
|
510
581
|
extension: guessedExtension,
|
|
511
582
|
contentType: mimeType,
|
|
@@ -513,9 +584,11 @@ class SocialVideoService {
|
|
|
513
584
|
metadata: {
|
|
514
585
|
source: 'social-video-thumbnail',
|
|
515
586
|
},
|
|
516
|
-
});
|
|
587
|
+
}));
|
|
517
588
|
await fsp.writeFile(allocation.storagePath, buffer);
|
|
518
|
-
const finalized =
|
|
589
|
+
const finalized = await Promise.resolve(
|
|
590
|
+
this.artifactStore.finalizeFile(allocation.artifactId, allocation.storagePath),
|
|
591
|
+
);
|
|
519
592
|
return {
|
|
520
593
|
url: finalized.url,
|
|
521
594
|
artifactId: finalized.artifactId,
|
|
@@ -572,5 +645,6 @@ module.exports = {
|
|
|
572
645
|
firstFileMatching,
|
|
573
646
|
pickBestThumbnail,
|
|
574
647
|
classifyExtractionError,
|
|
648
|
+
resolveVoiceSttConfigFromSettings,
|
|
575
649
|
shellEscape,
|
|
576
650
|
};
|