orator-conversion 1.0.8 → 1.0.10

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/docs/_cover.md CHANGED
@@ -1,4 +1,4 @@
1
- # Orator File Translation <small>1</small>
1
+ # Orator File Translation
2
2
 
3
3
  > File format conversion endpoints for Orator service servers
4
4
 
@@ -0,0 +1,7 @@
1
+ {
2
+ "Name": "orator-conversion",
3
+ "Version": "1.0.9",
4
+ "Description": "File format conversion endpoints for Orator service servers.",
5
+ "GeneratedAt": "2026-04-10T17:22:48.822Z",
6
+ "GitCommit": "bc72984"
7
+ }
@@ -1,73 +1,327 @@
1
1
  /* ============================================================================
2
- Pict Docuserve - Base Styles
2
+ Pict Docuserve - Base Styles & Theme Variables
3
3
  ============================================================================ */
4
4
 
5
- /* Reset and base */
6
- *, *::before, *::after {
5
+ /* ----------------------------------------------------------------------------
6
+ Theme variables light defaults on :root.
7
+ Dark mode applies when either:
8
+ (a) the user explicitly selected dark via <html data-theme="dark">
9
+ (b) the user hasn't chosen anything AND the system prefers dark
10
+ An explicit <html data-theme="light"> pins the light palette regardless.
11
+ ---------------------------------------------------------------------------- */
12
+
13
+ :root
14
+ {
15
+ /* Surfaces */
16
+ --docuserve-bg: #FDFBF7;
17
+ --docuserve-bg-elevated: #FFFFFF;
18
+ --docuserve-border: #DDD6CA;
19
+ --docuserve-border-soft: #EAE3D8;
20
+
21
+ /* Text */
22
+ --docuserve-text: #2A241E;
23
+ --docuserve-text-strong: #3D3229;
24
+ --docuserve-text-muted: #5E5549;
25
+ --docuserve-text-dim: #8A7F72;
26
+
27
+ /* Accent / links */
28
+ --docuserve-accent: #2E7D74;
29
+ --docuserve-accent-hover: #236660;
30
+
31
+ /* Top bar */
32
+ --docuserve-topbar-bg: #3D3229;
33
+ --docuserve-topbar-text: #E8E0D4;
34
+ --docuserve-topbar-text-muted: #B5AA9A;
35
+ --docuserve-topbar-text-dim: #8A7F72;
36
+ --docuserve-topbar-hover-bg: #524438;
37
+ --docuserve-topbar-version-bg: rgba(255, 255, 255, 0.06);
38
+ --docuserve-topbar-version-border: rgba(255, 255, 255, 0.08);
39
+ --docuserve-topbar-version-text: #B5AA9A;
40
+
41
+ /* Sidebar */
42
+ --docuserve-sidebar-bg: #FAF7F1;
43
+ --docuserve-sidebar-border: #DDD6CA;
44
+ --docuserve-sidebar-border-soft: #E5DED1;
45
+ --docuserve-sidebar-text: #423D37;
46
+ --docuserve-sidebar-group-title: #3D3229;
47
+ --docuserve-sidebar-module-text: #5E5549;
48
+ --docuserve-sidebar-hover-bg: #EAE3D8;
49
+ --docuserve-sidebar-hover-text: #2E7D74;
50
+ --docuserve-sidebar-active-bg: #E5DED1;
51
+ --docuserve-sidebar-active-text: #2E7D74;
52
+ --docuserve-sidebar-search-bg: #FFFFFF;
53
+ --docuserve-sidebar-search-border: #DDD6CA;
54
+
55
+ /* Inline code */
56
+ --docuserve-inline-code-bg: #F0ECE4;
57
+ --docuserve-inline-code-text: #9E3A50;
58
+
59
+ /* Code block panel */
60
+ --docuserve-code-bg: #F6F3EE;
61
+ --docuserve-code-border: #E5DED1;
62
+ --docuserve-code-gutter-bg: #EFEAE0;
63
+ --docuserve-code-gutter-border: #DDD6CA;
64
+ --docuserve-code-gutter-text: #A59986;
65
+ --docuserve-code-text: #2A241E;
66
+
67
+ /* Syntax tokens — low-chroma dark-on-light palette */
68
+ --docuserve-tok-keyword: #A03472;
69
+ --docuserve-tok-string: #1A6640;
70
+ --docuserve-tok-number: #B25A00;
71
+ --docuserve-tok-comment: #8A7F72;
72
+ --docuserve-tok-operator: #2E7D74;
73
+ --docuserve-tok-punctuation: #2A241E;
74
+ --docuserve-tok-function: #2A5DB0;
75
+ --docuserve-tok-property: #9E3A50;
76
+ --docuserve-tok-tag: #9E3A50;
77
+ --docuserve-tok-attr-name: #B25A00;
78
+ --docuserve-tok-attr-value: #1A6640;
79
+
80
+ /* Tables, blockquotes, mermaid */
81
+ --docuserve-table-header-bg: #F5F0E8;
82
+ --docuserve-table-row-alt-bg: #F9F6F0;
83
+ --docuserve-blockquote-bg: #F7F5F0;
84
+ --docuserve-blockquote-border: #2E7D74;
85
+ --docuserve-blockquote-text: #5E5549;
86
+ --docuserve-mermaid-bg: #FFFFFF;
87
+
88
+ /* Scrollbars */
89
+ --docuserve-scrollbar-track: #F5F0E8;
90
+ --docuserve-scrollbar-thumb: #D4CCBE;
91
+ --docuserve-scrollbar-thumb-hover: #B5AA9A;
92
+ }
93
+
94
+ @media (prefers-color-scheme: dark)
95
+ {
96
+ :root:not([data-theme="light"])
97
+ {
98
+ --docuserve-bg: #15120F;
99
+ --docuserve-bg-elevated: #1B1814;
100
+ --docuserve-border: #2F2823;
101
+ --docuserve-border-soft: #26211C;
102
+
103
+ --docuserve-text: #E8E0D4;
104
+ --docuserve-text-strong: #F2ECE0;
105
+ --docuserve-text-muted: #B5AA9A;
106
+ --docuserve-text-dim: #7A6F62;
107
+
108
+ --docuserve-accent: #5DB8A8;
109
+ --docuserve-accent-hover: #7FCCB8;
110
+
111
+ --docuserve-topbar-bg: #1A1612;
112
+ --docuserve-topbar-text: #E8E0D4;
113
+ --docuserve-topbar-text-muted: #B5AA9A;
114
+ --docuserve-topbar-text-dim: #7A6F62;
115
+ --docuserve-topbar-hover-bg: #2A241E;
116
+ --docuserve-topbar-version-bg: rgba(255, 255, 255, 0.05);
117
+ --docuserve-topbar-version-border: rgba(255, 255, 255, 0.09);
118
+ --docuserve-topbar-version-text: #B5AA9A;
119
+
120
+ --docuserve-sidebar-bg: #1B1814;
121
+ --docuserve-sidebar-border: #2F2823;
122
+ --docuserve-sidebar-border-soft: #26211C;
123
+ --docuserve-sidebar-text: #C9C0B3;
124
+ --docuserve-sidebar-group-title: #F2ECE0;
125
+ --docuserve-sidebar-module-text: #B5AA9A;
126
+ --docuserve-sidebar-hover-bg: #2A241E;
127
+ --docuserve-sidebar-hover-text: #7FCCB8;
128
+ --docuserve-sidebar-active-bg: #2F2823;
129
+ --docuserve-sidebar-active-text: #7FCCB8;
130
+ --docuserve-sidebar-search-bg: #26211C;
131
+ --docuserve-sidebar-search-border: #2F2823;
132
+
133
+ --docuserve-inline-code-bg: #2A241E;
134
+ --docuserve-inline-code-text: #E8B07A;
135
+
136
+ --docuserve-code-bg: #1E1A17;
137
+ --docuserve-code-border: #2F2823;
138
+ --docuserve-code-gutter-bg: #17130F;
139
+ --docuserve-code-gutter-border: #2F2823;
140
+ --docuserve-code-gutter-text: #6A6052;
141
+ --docuserve-code-text: #E8E0D4;
142
+
143
+ --docuserve-tok-keyword: #C678DD;
144
+ --docuserve-tok-string: #98C379;
145
+ --docuserve-tok-number: #D19A66;
146
+ --docuserve-tok-comment: #7F848E;
147
+ --docuserve-tok-operator: #56B6C2;
148
+ --docuserve-tok-punctuation: #E8E0D4;
149
+ --docuserve-tok-function: #61AFEF;
150
+ --docuserve-tok-property: #E06C75;
151
+ --docuserve-tok-tag: #E06C75;
152
+ --docuserve-tok-attr-name: #D19A66;
153
+ --docuserve-tok-attr-value: #98C379;
154
+
155
+ --docuserve-table-header-bg: #26211C;
156
+ --docuserve-table-row-alt-bg: #1F1B17;
157
+ --docuserve-blockquote-bg: #1F1B17;
158
+ --docuserve-blockquote-border: #5DB8A8;
159
+ --docuserve-blockquote-text: #C9C0B3;
160
+ --docuserve-mermaid-bg: #E8E0D4;
161
+
162
+ --docuserve-scrollbar-track: #1B1814;
163
+ --docuserve-scrollbar-thumb: #3A322B;
164
+ --docuserve-scrollbar-thumb-hover: #524438;
165
+ }
166
+ }
167
+
168
+ :root[data-theme="dark"]
169
+ {
170
+ --docuserve-bg: #15120F;
171
+ --docuserve-bg-elevated: #1B1814;
172
+ --docuserve-border: #2F2823;
173
+ --docuserve-border-soft: #26211C;
174
+
175
+ --docuserve-text: #E8E0D4;
176
+ --docuserve-text-strong: #F2ECE0;
177
+ --docuserve-text-muted: #B5AA9A;
178
+ --docuserve-text-dim: #7A6F62;
179
+
180
+ --docuserve-accent: #5DB8A8;
181
+ --docuserve-accent-hover: #7FCCB8;
182
+
183
+ --docuserve-topbar-bg: #1A1612;
184
+ --docuserve-topbar-text: #E8E0D4;
185
+ --docuserve-topbar-text-muted: #B5AA9A;
186
+ --docuserve-topbar-text-dim: #7A6F62;
187
+ --docuserve-topbar-hover-bg: #2A241E;
188
+ --docuserve-topbar-version-bg: rgba(255, 255, 255, 0.05);
189
+ --docuserve-topbar-version-border: rgba(255, 255, 255, 0.09);
190
+ --docuserve-topbar-version-text: #B5AA9A;
191
+
192
+ --docuserve-sidebar-bg: #1B1814;
193
+ --docuserve-sidebar-border: #2F2823;
194
+ --docuserve-sidebar-border-soft: #26211C;
195
+ --docuserve-sidebar-text: #C9C0B3;
196
+ --docuserve-sidebar-group-title: #F2ECE0;
197
+ --docuserve-sidebar-module-text: #B5AA9A;
198
+ --docuserve-sidebar-hover-bg: #2A241E;
199
+ --docuserve-sidebar-hover-text: #7FCCB8;
200
+ --docuserve-sidebar-active-bg: #2F2823;
201
+ --docuserve-sidebar-active-text: #7FCCB8;
202
+ --docuserve-sidebar-search-bg: #26211C;
203
+ --docuserve-sidebar-search-border: #2F2823;
204
+
205
+ --docuserve-inline-code-bg: #2A241E;
206
+ --docuserve-inline-code-text: #E8B07A;
207
+
208
+ --docuserve-code-bg: #1E1A17;
209
+ --docuserve-code-border: #2F2823;
210
+ --docuserve-code-gutter-bg: #17130F;
211
+ --docuserve-code-gutter-border: #2F2823;
212
+ --docuserve-code-gutter-text: #6A6052;
213
+ --docuserve-code-text: #E8E0D4;
214
+
215
+ --docuserve-tok-keyword: #C678DD;
216
+ --docuserve-tok-string: #98C379;
217
+ --docuserve-tok-number: #D19A66;
218
+ --docuserve-tok-comment: #7F848E;
219
+ --docuserve-tok-operator: #56B6C2;
220
+ --docuserve-tok-punctuation: #E8E0D4;
221
+ --docuserve-tok-function: #61AFEF;
222
+ --docuserve-tok-property: #E06C75;
223
+ --docuserve-tok-tag: #E06C75;
224
+ --docuserve-tok-attr-name: #D19A66;
225
+ --docuserve-tok-attr-value: #98C379;
226
+
227
+ --docuserve-table-header-bg: #26211C;
228
+ --docuserve-table-row-alt-bg: #1F1B17;
229
+ --docuserve-blockquote-bg: #1F1B17;
230
+ --docuserve-blockquote-border: #5DB8A8;
231
+ --docuserve-blockquote-text: #C9C0B3;
232
+ --docuserve-mermaid-bg: #E8E0D4;
233
+
234
+ --docuserve-scrollbar-track: #1B1814;
235
+ --docuserve-scrollbar-thumb: #3A322B;
236
+ --docuserve-scrollbar-thumb-hover: #524438;
237
+ }
238
+
239
+ /* ----------------------------------------------------------------------------
240
+ Reset and base
241
+ ---------------------------------------------------------------------------- */
242
+
243
+ *, *::before, *::after
244
+ {
7
245
  box-sizing: border-box;
8
246
  }
9
247
 
10
- html, body {
248
+ html, body
249
+ {
11
250
  margin: 0;
12
251
  padding: 0;
13
252
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
14
253
  font-size: 16px;
15
254
  line-height: 1.5;
16
- color: #423D37;
17
- background-color: #fff;
255
+ color: var(--docuserve-text);
256
+ background-color: var(--docuserve-bg);
18
257
  -webkit-font-smoothing: antialiased;
19
258
  -moz-osx-font-smoothing: grayscale;
259
+ transition: background-color 0.15s ease, color 0.15s ease;
20
260
  }
21
261
 
22
262
  /* Typography */
23
- h1, h2, h3, h4, h5, h6 {
263
+ h1, h2, h3, h4, h5, h6
264
+ {
24
265
  margin-top: 0;
25
266
  line-height: 1.3;
267
+ color: var(--docuserve-text-strong);
26
268
  }
27
269
 
28
- a {
29
- color: #2E7D74;
270
+ a
271
+ {
272
+ color: var(--docuserve-accent);
30
273
  text-decoration: none;
31
274
  }
32
275
 
33
- a:hover {
34
- color: #256861;
276
+ a:hover
277
+ {
278
+ color: var(--docuserve-accent-hover);
35
279
  }
36
280
 
37
281
  /* Application container */
38
- #Docuserve-Application-Container {
282
+ #Docuserve-Application-Container
283
+ {
39
284
  min-height: 100vh;
40
285
  }
41
286
 
42
287
  /* Utility: scrollbar styling */
43
- ::-webkit-scrollbar {
288
+ ::-webkit-scrollbar
289
+ {
44
290
  width: 8px;
291
+ height: 8px;
45
292
  }
46
293
 
47
- ::-webkit-scrollbar-track {
48
- background: #F5F0E8;
294
+ ::-webkit-scrollbar-track
295
+ {
296
+ background: var(--docuserve-scrollbar-track);
49
297
  }
50
298
 
51
- ::-webkit-scrollbar-thumb {
52
- background: #D4CCBE;
299
+ ::-webkit-scrollbar-thumb
300
+ {
301
+ background: var(--docuserve-scrollbar-thumb);
53
302
  border-radius: 4px;
54
303
  }
55
304
 
56
- ::-webkit-scrollbar-thumb:hover {
57
- background: #B5AA9A;
305
+ ::-webkit-scrollbar-thumb:hover
306
+ {
307
+ background: var(--docuserve-scrollbar-thumb-hover);
58
308
  }
59
309
 
60
310
  /* Responsive adjustments */
61
- @media (max-width: 768px) {
62
- html {
311
+ @media (max-width: 768px)
312
+ {
313
+ html
314
+ {
63
315
  font-size: 14px;
64
316
  }
65
317
 
66
- #Docuserve-Sidebar-Container {
318
+ #Docuserve-Sidebar-Container
319
+ {
67
320
  display: none;
68
321
  }
69
322
 
70
- .docuserve-body {
323
+ .docuserve-body
324
+ {
71
325
  flex-direction: column;
72
326
  }
73
327
  }
package/docs/index.html CHANGED
@@ -4,9 +4,9 @@
4
4
  <meta charset="utf-8">
5
5
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7
- <meta name="description" content="Documentation powered by pict-docuserve">
7
+ <meta name="description" content="Orator File Translation v1.0.9 Documentation File format conversion endpoints for Orator service servers.">
8
8
 
9
- <title>Documentation</title>
9
+ <title>Orator File Translation v1.0.9 Documentation</title>
10
10
 
11
11
  <!-- Application Stylesheet -->
12
12
  <link href="css/docuserve.css" rel="stylesheet">
@@ -1,5 +1,5 @@
1
1
  {
2
- "Generated": "2026-02-18T03:52:05.406Z",
2
+ "Generated": "2026-04-10T17:22:48.550Z",
3
3
  "GitHubOrg": "stevenvelozo",
4
4
  "DefaultBranch": "master",
5
5
  "Groups": [
@@ -25,6 +25,18 @@
25
25
  "Key": "docs",
26
26
  "Description": "",
27
27
  "Modules": [
28
+ {
29
+ "Name": "css",
30
+ "Repo": "css",
31
+ "Group": "docs",
32
+ "Branch": "master",
33
+ "HasDocs": true,
34
+ "HasCover": false,
35
+ "Sidebar": [],
36
+ "DocFiles": [
37
+ "css/docuserve.css"
38
+ ]
39
+ },
28
40
  {
29
41
  "Name": "endpoints",
30
42
  "Repo": "endpoints",
@@ -1,5 +1,5 @@
1
1
  {
2
- "Generated": "2026-02-18T03:52:05.492Z",
2
+ "Generated": "2026-04-10T17:22:48.793Z",
3
3
  "DocumentCount": 5,
4
4
  "LunrIndex": {
5
5
  "version": "2.3.9",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orator-conversion",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "File format conversion endpoints for Orator service servers.",
5
5
  "main": "source/Orator-File-Translation.js",
6
6
  "scripts": {
@@ -40,16 +40,64 @@
40
40
  "url": "https://github.com/stevenvelozo/orator-conversion/issues"
41
41
  },
42
42
  "homepage": "https://github.com/stevenvelozo/orator-conversion#readme",
43
+ "retoldBeacon": {
44
+ "displayName": "Orator Media Conversion",
45
+ "description": "Image/PDF/video/audio conversion actions exposed as an ultravisor-beacon capability.",
46
+ "category": "media",
47
+ "mode": "capability-provider",
48
+ "providerPath": "./source/Orator-Conversion-BeaconProvider.js",
49
+ "capability": "MediaConversion",
50
+ "healthCheck": {
51
+ "path": "/"
52
+ },
53
+ "defaultPort": 54500,
54
+ "requiresUltravisor": true,
55
+ "configForm": {
56
+ "Fields": [
57
+ {
58
+ "Name": "PdftkPath",
59
+ "Label": "pdftk binary",
60
+ "Type": "text",
61
+ "Default": "pdftk"
62
+ },
63
+ {
64
+ "Name": "PdftoppmPath",
65
+ "Label": "pdftoppm binary",
66
+ "Type": "text",
67
+ "Default": "pdftoppm"
68
+ },
69
+ {
70
+ "Name": "FfmpegPath",
71
+ "Label": "ffmpeg binary",
72
+ "Type": "text",
73
+ "Default": "ffmpeg"
74
+ },
75
+ {
76
+ "Name": "FfprobePath",
77
+ "Label": "ffprobe binary",
78
+ "Type": "text",
79
+ "Default": "ffprobe"
80
+ },
81
+ {
82
+ "Name": "MaxFileSizeBytes",
83
+ "Label": "Max input file size",
84
+ "Type": "number",
85
+ "Default": 104857600
86
+ }
87
+ ]
88
+ }
89
+ },
43
90
  "dependencies": {
44
91
  "fable-serviceproviderbase": "^3.0.19",
45
92
  "sharp": "^0.34.5",
46
- "ultravisor-beacon": "^0.0.8",
93
+ "ultravisor-beacon": "^0.0.9",
47
94
  "ws": "^8.20.0"
48
95
  },
49
96
  "devDependencies": {
50
97
  "fable": "^3.1.67",
51
98
  "orator": "^6.0.4",
52
- "orator-serviceserver-restify": "^2.0.9",
53
- "quackage": "^1.0.65"
99
+ "orator-serviceserver-restify": "^2.0.10",
100
+ "pict-docuserve": "^0.1.5",
101
+ "quackage": "^1.1.0"
54
102
  }
55
103
  }
@@ -767,6 +767,116 @@ class ConversionCore
767
767
  });
768
768
  }
769
769
 
770
+ /**
771
+ * Extract MULTIPLE frames from a video file in a single work item.
772
+ *
773
+ * This is the batch counterpart to videoExtractFrame. It exists so the
774
+ * dispatcher can request all N frames the video explorer wants in one
775
+ * trip — instead of triggering an entire operation graph 20 times — which
776
+ * means one address resolve, one file-transfer / shared-fs check, one
777
+ * probe (optional), and one ffmpeg invocation per frame all served from
778
+ * the same on-disk file.
779
+ *
780
+ * Each frame extraction is a separate ffmpeg call internally because
781
+ * ffmpeg's `-ss` before `-i` is fast (uses container index), and chaining
782
+ * `select=eq(t,X)+eq(t,Y)+...` filters is brittle and produces wrong-sized
783
+ * outputs for variable-bitrate streams. Sequential single-frame extracts
784
+ * give us the same correctness as videoExtractFrame, just amortized.
785
+ *
786
+ * @param {string} pInputPath - Path to the input video file.
787
+ * @param {Array} pFrameSpecs - Frame specs:
788
+ * [
789
+ * { Timestamp: '00:00:05.000', OutputPath: '/abs/path/frame_0000.jpg' },
790
+ * { Timestamp: '00:00:10.000', OutputPath: '/abs/path/frame_0001.jpg' },
791
+ * ...
792
+ * ]
793
+ * @param {object} pOptions - Shared extract options applied to every frame:
794
+ * { Width, Height }
795
+ * @param {Function} fCallback - Called with (pError, pResult) where
796
+ * pResult is { Frames: [{ Index, Timestamp, OutputPath, Size, Success, Error? }, ...] }
797
+ */
798
+ videoExtractFramesBatch(pInputPath, pFrameSpecs, pOptions, fCallback)
799
+ {
800
+ let tmpSelf = this;
801
+
802
+ if (!Array.isArray(pFrameSpecs) || pFrameSpecs.length === 0)
803
+ {
804
+ return fCallback(new Error('videoExtractFramesBatch: Frames array is required and must be non-empty.'));
805
+ }
806
+
807
+ // Quick existence check on the input video — fail fast rather than per-frame
808
+ if (!libFS.existsSync(pInputPath))
809
+ {
810
+ return fCallback(new Error(`videoExtractFramesBatch: input video not found: ${pInputPath}`));
811
+ }
812
+
813
+ let tmpResults = [];
814
+ let tmpIndex = 0;
815
+
816
+ let _extractNext = () =>
817
+ {
818
+ if (tmpIndex >= pFrameSpecs.length)
819
+ {
820
+ return fCallback(null, { Frames: tmpResults });
821
+ }
822
+
823
+ let tmpI = tmpIndex;
824
+ let tmpSpec = pFrameSpecs[tmpI];
825
+ tmpIndex++;
826
+
827
+ if (!tmpSpec || !tmpSpec.OutputPath || !tmpSpec.Timestamp)
828
+ {
829
+ tmpResults.push(
830
+ {
831
+ Index: tmpI,
832
+ Timestamp: tmpSpec ? tmpSpec.Timestamp : null,
833
+ OutputPath: tmpSpec ? tmpSpec.OutputPath : null,
834
+ Size: 0,
835
+ Success: false,
836
+ Error: 'Missing Timestamp or OutputPath'
837
+ });
838
+ return _extractNext();
839
+ }
840
+
841
+ tmpSelf.videoExtractFrame(pInputPath, tmpSpec.OutputPath,
842
+ {
843
+ Timestamp: tmpSpec.Timestamp,
844
+ Width: pOptions ? pOptions.Width : undefined,
845
+ Height: pOptions ? pOptions.Height : undefined
846
+ },
847
+ (pError, pResultPath) =>
848
+ {
849
+ if (pError)
850
+ {
851
+ tmpResults.push(
852
+ {
853
+ Index: tmpI,
854
+ Timestamp: tmpSpec.Timestamp,
855
+ OutputPath: tmpSpec.OutputPath,
856
+ Size: 0,
857
+ Success: false,
858
+ Error: pError.message
859
+ });
860
+ return _extractNext();
861
+ }
862
+
863
+ let tmpSize = 0;
864
+ try { tmpSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
865
+ tmpResults.push(
866
+ {
867
+ Index: tmpI,
868
+ Timestamp: tmpSpec.Timestamp,
869
+ OutputPath: pResultPath,
870
+ Size: tmpSize,
871
+ Success: true
872
+ });
873
+ return _extractNext();
874
+ });
875
+ };
876
+
877
+ _extractNext();
878
+ }
879
+
770
880
  /**
771
881
  * Generate a thumbnail from a video file.
772
882
  *
@@ -192,6 +192,18 @@ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
192
192
  { Name: 'Height', DataType: 'Number', Required: false, Description: 'Scale height (width auto)' }
193
193
  ]
194
194
  },
195
+ 'VideoExtractFrames':
196
+ {
197
+ Description: 'Extract MULTIPLE frames from a video file in a single work item. Each frame spec includes a timestamp and an output filename. All frames are written to the same OutputDir, which must be writable from this beacon (typically via shared filesystem with the dispatcher). Returns a JSON manifest with per-frame results.',
198
+ SettingsSchema:
199
+ [
200
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input video file' },
201
+ { Name: 'OutputDir', DataType: 'String', Required: true, Description: 'Absolute directory where frames should be written. Must be writable from this beacon.' },
202
+ { Name: 'Frames', DataType: 'String', Required: true, Description: 'JSON-encoded array of {Timestamp, Filename} pairs, e.g. [{"Timestamp":"00:00:05","Filename":"frame_0000.jpg"}, ...]' },
203
+ { Name: 'Width', DataType: 'Number', Required: false, Description: 'Scale width applied to every frame' },
204
+ { Name: 'Height', DataType: 'Number', Required: false, Description: 'Scale height applied to every frame' }
205
+ ]
206
+ },
195
207
  'VideoThumbnail':
196
208
  {
197
209
  Description: 'Generate a thumbnail from a video file.',
@@ -325,7 +337,7 @@ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
325
337
  tmpLog.info(`[OratorConversion] Input file OK: ${tmpInputPath} (${libFS.statSync(tmpInputPath).size} bytes)`);
326
338
 
327
339
  // File-path actions skip the buffer read — Sharp and ffmpeg handle files directly
328
- let tmpFilePathActions = { 'ImageResize': true, 'ImageConvert': true, 'MediaProbe': true, 'VideoExtractFrame': true, 'VideoThumbnail': true, 'AudioExtractSegment': true, 'AudioWaveform': true };
340
+ let tmpFilePathActions = { 'ImageResize': true, 'ImageConvert': true, 'MediaProbe': true, 'VideoExtractFrame': true, 'VideoExtractFrames': true, 'VideoThumbnail': true, 'AudioExtractSegment': true, 'AudioWaveform': true };
329
341
  if (tmpFilePathActions[pAction])
330
342
  {
331
343
  return this._executeFilePathAction(pAction, tmpSettings, tmpInputPath, tmpOutputPath, fCallback, fReportProgress);
@@ -605,6 +617,144 @@ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
605
617
  break;
606
618
  }
607
619
 
620
+ case 'VideoExtractFrames':
621
+ {
622
+ // Batch frame extraction. Reads InputFile once, extracts N frames
623
+ // to OutputDir, returns a JSON manifest. Used by the video explorer
624
+ // to avoid 20 separate work-item dispatches per video.
625
+ if (!this._FfmpegAvailable)
626
+ {
627
+ return fCallback(null, {
628
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
629
+ Log: ['OratorConversion: ffmpeg required for VideoExtractFrames but not found.']
630
+ });
631
+ }
632
+
633
+ let tmpOutputDir = pSettings.OutputDir;
634
+ if (!tmpOutputDir)
635
+ {
636
+ return fCallback(null, {
637
+ Outputs: { StdOut: 'No OutputDir specified.', ExitCode: -1, Result: '' },
638
+ Log: ['OratorConversion VideoExtractFrames: OutputDir is required.']
639
+ });
640
+ }
641
+
642
+ let tmpFrameSpecs;
643
+ try
644
+ {
645
+ tmpFrameSpecs = JSON.parse(pSettings.Frames || '[]');
646
+ }
647
+ catch (pParseError)
648
+ {
649
+ return fCallback(null, {
650
+ Outputs: { StdOut: `Invalid Frames JSON: ${pParseError.message}`, ExitCode: -1, Result: '' },
651
+ Log: [`OratorConversion VideoExtractFrames: failed to parse Frames JSON: ${pParseError.message}`]
652
+ });
653
+ }
654
+
655
+ if (!Array.isArray(tmpFrameSpecs) || tmpFrameSpecs.length === 0)
656
+ {
657
+ return fCallback(null, {
658
+ Outputs: { StdOut: 'Frames array is empty.', ExitCode: -1, Result: '' },
659
+ Log: ['OratorConversion VideoExtractFrames: Frames must be a non-empty array.']
660
+ });
661
+ }
662
+
663
+ // Ensure OutputDir exists. With shared-fs the dispatcher already
664
+ // created it; mkdir -p is a no-op in that case but needed when
665
+ // the dispatcher is on a different host.
666
+ try
667
+ {
668
+ libFS.mkdirSync(tmpOutputDir, { recursive: true });
669
+ }
670
+ catch (pMkdirError)
671
+ {
672
+ return fCallback(null, {
673
+ Outputs: { StdOut: `Could not create OutputDir: ${pMkdirError.message}`, ExitCode: -1, Result: '' },
674
+ Log: [`OratorConversion VideoExtractFrames: mkdir failed for ${tmpOutputDir}: ${pMkdirError.message}`]
675
+ });
676
+ }
677
+
678
+ // Resolve absolute output paths from { Timestamp, Filename } pairs
679
+ let tmpResolvedSpecs = [];
680
+ for (let i = 0; i < tmpFrameSpecs.length; i++)
681
+ {
682
+ let tmpEntry = tmpFrameSpecs[i];
683
+ if (!tmpEntry || !tmpEntry.Timestamp || !tmpEntry.Filename)
684
+ {
685
+ tmpResolvedSpecs.push(tmpEntry);
686
+ continue;
687
+ }
688
+ // Reject filenames with path separators or traversal — the
689
+ // caller is meant to give us flat names like frame_0000.jpg
690
+ if (tmpEntry.Filename.indexOf('/') !== -1 ||
691
+ tmpEntry.Filename.indexOf('\\') !== -1 ||
692
+ tmpEntry.Filename.indexOf('..') !== -1)
693
+ {
694
+ return fCallback(null, {
695
+ Outputs: { StdOut: `Invalid filename in Frames: ${tmpEntry.Filename}`, ExitCode: -1, Result: '' },
696
+ Log: [`OratorConversion VideoExtractFrames: refusing filename with path separators: ${tmpEntry.Filename}`]
697
+ });
698
+ }
699
+ tmpResolvedSpecs.push(
700
+ {
701
+ Timestamp: tmpEntry.Timestamp,
702
+ OutputPath: libPath.join(tmpOutputDir, tmpEntry.Filename)
703
+ });
704
+ }
705
+
706
+ if (fReportProgress) fReportProgress({ Percent: 5, Message: `Extracting ${tmpResolvedSpecs.length} frames...` });
707
+
708
+ this._Core.videoExtractFramesBatch(pInputPath, tmpResolvedSpecs,
709
+ {
710
+ Width: pSettings.Width,
711
+ Height: pSettings.Height
712
+ },
713
+ (pError, pBatchResult) =>
714
+ {
715
+ if (pError)
716
+ {
717
+ return fCallback(null, {
718
+ Outputs: { StdOut: `Batch frame extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
719
+ Log: [`OratorConversion VideoExtractFrames error: ${pError.message}`]
720
+ });
721
+ }
722
+
723
+ // Re-attach the original Filename field to each result so
724
+ // the dispatcher can correlate against its own naming.
725
+ let tmpFrames = pBatchResult.Frames || [];
726
+ for (let i = 0; i < tmpFrames.length; i++)
727
+ {
728
+ let tmpOriginal = tmpFrameSpecs[i];
729
+ if (tmpOriginal && tmpOriginal.Filename)
730
+ {
731
+ tmpFrames[i].Filename = tmpOriginal.Filename;
732
+ }
733
+ }
734
+
735
+ let tmpSuccessCount = tmpFrames.filter((f) => f.Success).length;
736
+ let tmpManifest =
737
+ {
738
+ FrameCount: tmpFrames.length,
739
+ SuccessCount: tmpSuccessCount,
740
+ OutputDir: tmpOutputDir,
741
+ Frames: tmpFrames
742
+ };
743
+
744
+ return fCallback(null, {
745
+ Outputs:
746
+ {
747
+ StdOut: `Extracted ${tmpSuccessCount}/${tmpFrames.length} frames from ${pSettings.InputFile} → ${tmpOutputDir}`,
748
+ ExitCode: tmpSuccessCount === tmpFrames.length ? 0 : 1,
749
+ Result: JSON.stringify(tmpManifest),
750
+ ContentType: 'application/json'
751
+ },
752
+ Log: [`OratorConversion VideoExtractFrames: ${tmpSuccessCount}/${tmpFrames.length} frames written to ${tmpOutputDir}`]
753
+ });
754
+ });
755
+ break;
756
+ }
757
+
608
758
  case 'VideoThumbnail':
609
759
  {
610
760
  if (!this._FfmpegAvailable)