@alexkroman1/aai-cli 6.10.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -0,0 +1,251 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The step where ffmpeg turns an arbitrary recording into something the rest of
4
+ * the desk can reason about.
5
+ *
6
+ * ```text
7
+ * materialize the upload → a temp file (windowed, nothing on the heap)
8
+ * probe ffprobe → what it WAS
9
+ * measure loudnorm pass one → five numbers
10
+ * normalize loudnorm pass two → levelled raw PCM + every pause
11
+ * store the PCM → an upload (streamed)
12
+ * ```
13
+ *
14
+ * Five things, ONE step, and that is the decision in this file worth arguing
15
+ * about — so here is the argument.
16
+ *
17
+ * ## Why not five steps
18
+ *
19
+ * Splitting steps buys a cheaper retry: a failure re-runs one stage instead of
20
+ * five. It costs a MATERIALIZATION each, because a temp file cannot cross a step
21
+ * boundary (see `@alexkroman1/aai/step-files`) — so a five-step version reads the whole
22
+ * recording out of the upload store five times, and on a 700 MB file that is the
23
+ * expensive part by an order of magnitude. The decode passes are cheap: ffmpeg
24
+ * resamples two orders of magnitude faster than realtime, so a two-hour recording
25
+ * is seconds of CPU.
26
+ *
27
+ * The retry it would buy is also mostly imaginary. The stages here fail together:
28
+ * a corrupt file fails the probe and would have failed both passes, and a
29
+ * conversion that ran out of time re-runs from the beginning anyway. The one
30
+ * genuine case — pass two failing after pass one succeeded — is a `timeout`,
31
+ * which is exactly the case a retry re-does wholesale.
32
+ *
33
+ * So: one materialization, three invocations, one journal entry. What that entry
34
+ * holds is an upload id and some numbers, which is the rule this template obeys
35
+ * everywhere — a step is replayed by its return value, so bytes must never be in
36
+ * one.
37
+ *
38
+ * ## The two analyses come back by different routes
39
+ *
40
+ * `media.ts`'s module doc carries this in full, and it is the single most
41
+ * surprising thing about the file: loudness arrives on **stderr** (one block,
42
+ * printed last, so a capped tail holds it) and the pauses arrive in a **file**
43
+ * (one event per pause, so their size grows with the recording and a tail would
44
+ * silently drop the earliest ones). Both are read here.
45
+ *
46
+ * ## Everything ffmpeg-shaped is named from inside the step BODY
47
+ *
48
+ * `@alexkroman1/aai/ffmpeg` and `@alexkroman1/aai/step-files` both reach a
49
+ * `node:` builtin, and a name this module holds at MODULE scope keeps its import
50
+ * in the workflow bundle — which is compiled as a `node:vm` Script with no
51
+ * `require`. The import statements are at the top, as the SDK's own examples
52
+ * write them, and every name they bind is referenced only inside
53
+ * {@link ingestRecording}'s body, which the workflow transform removes along
54
+ * with the imports it is the only user of. A module-scope FUNCTION naming one is
55
+ * what breaks a run at replay; this template used to carry a whole
56
+ * `ffmpeg-verdict.ts` because of it, and `throwFfmpegStepError` — which reaches
57
+ * no `node:` builtin at all — is what dissolved the boundary.
58
+ *
59
+ * `analyse` stays here for the other half of that rule: everything IT names is
60
+ * pure, so it may survive into the bundle.
61
+ */
62
+
63
+ import { readFile, stat } from "node:fs/promises";
64
+ import { join } from "node:path";
65
+ import { probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
66
+ import { pcmDurationMs, report, uploadInfo } from "@alexkroman1/aai/step";
67
+ import { throwFatalStepError, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
68
+ import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
69
+ import { formatBytes, formatDuration, plural } from "@alexkroman1/aai/utils";
70
+ import {
71
+ ANALYSIS_FORMAT,
72
+ type Loudness,
73
+ MediaAnalysisError,
74
+ measureLoudnessArgs,
75
+ normalizeArgs,
76
+ parseLoudness,
77
+ parseSilences,
78
+ type Silence,
79
+ speechFraction,
80
+ } from "./media.ts";
81
+
82
+ /**
83
+ * How long any one ffmpeg invocation may run before it is killed.
84
+ *
85
+ * Well past what the work takes, because the reason for a bound at all is a file
86
+ * that makes a decoder pathological rather than one that is merely long. A
87
+ * `timeout` is retryable and an `exit` is not; `throwFfmpegStepError` decides.
88
+ */
89
+ const FFMPEG_TIMEOUT_MS = 20 * 60_000;
90
+
91
+ /** What the ingest step hands the rest of the run. Numbers and an id — never bytes. */
92
+ export type Ingested = {
93
+ /**
94
+ * Upload id of the normalized audio: headerless raw PCM in
95
+ * {@link ANALYSIS_FORMAT}.
96
+ *
97
+ * Every later step addresses this rather than the caller's file, and reads it
98
+ * by byte range. Because it has no header, byte zero is second zero.
99
+ */
100
+ audio: string;
101
+ /** The uploaded file's own name, so a reader knows which run they are looking at. */
102
+ source: string;
103
+ /** What ffprobe made of the original — `aac`, `mp3`, `pcm_s16le`. */
104
+ codec: string;
105
+ /** Length of the audio, measured from the PCM byte count rather than from a header. */
106
+ durationMs: number;
107
+ /** Size of the normalized PCM. */
108
+ bytes: number;
109
+ /** What the recording measured before it was levelled. */
110
+ loudness: Loudness;
111
+ /** Every pause long enough to cut in. The fan-out's cut points come from these. */
112
+ silences: Silence[];
113
+ };
114
+
115
+ /**
116
+ * Convert, level, and map the pauses in the recording.
117
+ *
118
+ * A step for the ordinary two reasons — it does I/O, and its RESULT is what
119
+ * everything later addresses — plus one that is specific to what it produces: the
120
+ * normalization writes a file, so journaling the id means a resumed run reads the
121
+ * file that already exists instead of paying to make a second one.
122
+ */
123
+ export async function ingestRecording(uploadId: string): Promise<Ingested> {
124
+ "use step";
125
+
126
+ const stored = await uploadInfo(uploadId);
127
+ await report(`Reading ${stored.name || uploadId} (${formatBytes(stored.size)}).`);
128
+
129
+ return await withTempDir(
130
+ async (dir) => {
131
+ const source = join(dir, "source");
132
+ const normalized = join(dir, "audio.pcm");
133
+ const silenceLog = join(dir, "silence.txt");
134
+
135
+ await readUploadToFile(uploadId, source, { size: stored.size });
136
+
137
+ // What it WAS, for the progress log and the page. Worth one ffprobe: "41
138
+ // minutes of aac" explains the shape of the run, where "the recording" leaves
139
+ // a reader guessing what the desk decided. On a temp FILE rather than a pipe,
140
+ // so a trailing index is readable.
141
+ const probed = await probeMedia(source, { timeoutMs: FFMPEG_TIMEOUT_MS }).catch(
142
+ throwFfmpegStepError,
143
+ );
144
+ const codec = probed.audio?.codec ?? "unknown";
145
+ await report(
146
+ `Levelling ${describeSource(codec, probed.durationSec)} to ${ANALYSIS_FORMAT.sampleRate / 1000} kHz mono.`,
147
+ );
148
+
149
+ // Pass one: measure. `-f null -` decodes every frame and writes no audio, so
150
+ // this costs a decode and produces five numbers.
151
+ const measured = await runFfmpeg(measureLoudnessArgs(source), {
152
+ timeoutMs: FFMPEG_TIMEOUT_MS,
153
+ }).catch(throwFfmpegStepError);
154
+ const loudness = analyse(() => parseLoudness(measured.stderr));
155
+
156
+ // Pass two: apply the measurement, find the pauses, write the audio.
157
+ await runFfmpeg(normalizeArgs(source, loudness, normalized, silenceLog), {
158
+ timeoutMs: FFMPEG_TIMEOUT_MS,
159
+ }).catch(throwFfmpegStepError);
160
+
161
+ // The duration comes from the BYTE COUNT, not from the original's header or
162
+ // from ffprobe. It is the only measurement that agrees with the byte offsets
163
+ // the fan-out will use — a container's declared duration can disagree with
164
+ // what was actually decoded (an AAC file's encoder padding puts this one ~16ms
165
+ // over), and a segment planned against the wrong one runs off the end.
166
+ const bytes = (await stat(normalized)).size;
167
+ const durationMs = pcmDurationMs(bytes, ANALYSIS_FORMAT);
168
+
169
+ // Verified on ffmpeg 6.1: `ametadata` creates the file at filter-init, so a
170
+ // recording with no pause in it leaves an EMPTY log rather than no log. A
171
+ // missing file here is therefore a real failure and not a case to tolerate.
172
+ const log = await readFile(silenceLog, "utf-8");
173
+ const silences = analyse(() => parseSilences(log, durationMs / 1000));
174
+
175
+ const written = await writeUploadFromFile(normalized, {
176
+ // Named after the original, so a download reads as the recording it came
177
+ // from. `.pcm` because that is what it is — raw samples with no header, and
178
+ // a `.wav` name on a headerless file is one no player will open.
179
+ name: `${baseName(stored.name || uploadId)}.pcm`,
180
+ // Not `audio/wav`: the type is served back on the byte route, and claiming a
181
+ // container this file does not have would be a lie a browser acts on. Not
182
+ // `audio/L16` either, which looks right and is not — that type is defined as
183
+ // BIG-endian 16-bit PCM, where this is `s16le`. Nothing plays this file; the
184
+ // fan-out reads byte ranges out of it and puts a real header back on each one
185
+ // with `encodeWav`.
186
+ type: "application/octet-stream",
187
+ });
188
+
189
+ await report(
190
+ `Levelled ${formatDuration(durationMs)} from ${loudness.inputLufs} LUFS, ` +
191
+ `${Math.round(speechFraction(silences, durationMs / 1000) * 100)}% speech across ` +
192
+ `${silences.length} ${plural(silences.length, "pause")}.`,
193
+ );
194
+
195
+ return {
196
+ audio: written.id,
197
+ source: stored.name || uploadId,
198
+ codec,
199
+ durationMs,
200
+ bytes,
201
+ loudness,
202
+ silences,
203
+ };
204
+ },
205
+ { prefix: "aai-call-audit-" },
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Retries beyond the default 3.
211
+ *
212
+ * Not because a conversion is flaky — a corrupt file fails identically forever,
213
+ * and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
214
+ * two I/O halves that are worth another attempt: this step reads a whole
215
+ * recording out of the store and writes a whole one back, and either can lose a
216
+ * connection on a file this size.
217
+ */
218
+ ingestRecording.maxRetries = 5;
219
+
220
+ /**
221
+ * Run a `media.ts` reader, turning "I cannot read this analysis" into a terminal
222
+ * failure.
223
+ *
224
+ * Fatal rather than retryable, and the distinction is real: a
225
+ * {@link MediaAnalysisError} means ffmpeg SUCCEEDED and printed something this
226
+ * desk does not understand — a version whose `loudnorm` renamed a key, an argv
227
+ * that lost `-loglevel info`. Every retry runs the same binary with the same argv
228
+ * and prints the same thing, so the retries only delay a person reading the
229
+ * message.
230
+ */
231
+ export function analyse<T>(read: () => T): T {
232
+ try {
233
+ return read();
234
+ } catch (err: unknown) {
235
+ if (err instanceof MediaAnalysisError) return throwFatalStepError(err);
236
+ throw err;
237
+ }
238
+ }
239
+
240
+ /** `41:20 of aac`, or as much of that as ffprobe would say. */
241
+ function describeSource(codec: string, durationSec: number | undefined): string {
242
+ const length =
243
+ durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
244
+ return length === undefined ? codec : `${length} of ${codec}`;
245
+ }
246
+
247
+ /** A filename without its extension, so a new one can be put on. */
248
+ function baseName(name: string): string {
249
+ const dot = name.lastIndexOf(".");
250
+ return dot > 0 ? name.slice(0, dot) : name;
251
+ }