Files
zooqueen ae80da5998 refactor(media-use): resolve every media type through api.hanzo.ai
Voice is POST /v1/audio/speech and word timings come from
POST /v1/audio/transcriptions over the rendered audio. Music, sound
effects, images, and video generate through /v1/audio/music,
/v1/audio/foley, /v1/images/generations, and /v1/videos/generations. The
shared catalog is a listing under media/<type>/ in our own object store,
browsed through /v1/s3 — an empty prefix reports itself as empty rather
than reading as a bad query.

One client (scripts/lib/api.mjs) holds every route and the one credential:
a Hanzo IAM token in $HANZO_API_KEY, read from Hanzo KMS. No credential
file, no per-repo .env, no per-provider CLI to install or keep current.
$HANZO_BASE_URL points the same code at a local Hanzo Engine.

Word timings are passed through, never interpolated: a caption cut on
invented boundaries reads as correct and drifts against the voice. When
the service returns text without them, the word array is empty and the
caller is told.

Deletes the provider cascade this replaces, its model ladders, and the
account linking in telemetry — the install id stands alone.
2026-08-13 17:18:37 -07:00

79 lines
2.7 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, renameSync, writeFileSync } from "node:fs";
import { basename, extname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { MODEL, transcript } from "./lib/api.mjs";
import { track } from "./lib/telemetry.mjs";
// Transcription: POST /v1/audio/transcriptions through api.hanzo.ai. Emits
// { text, words: [{ text, start, end }] } for transcript-cut, captions, and the
// audio engine — the one transcript shape the rest of media-use reads.
//
// `words` is empty when the service returns no per-word timings. That is
// reported, never interpolated: captions cut on invented boundaries look right
// and drift against the audio.
const { values: args } = parseArgs({
options: {
input: { type: "string", short: "i" },
out: { type: "string", short: "o" },
model: { type: "string", default: MODEL.words },
lang: { type: "string", default: "en" },
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
strict: true,
});
if (args.help) {
console.log(`media-use transcribe: audio -> text + word timings
Usage:
node transcribe.mjs --input audio.wav [--out audio.transcribe.json] [--model ${MODEL.words}] [--lang en]
Runs against api.hanzo.ai/v1/audio/transcriptions. Needs $HANZO_API_KEY.`);
process.exit(0);
}
if (!args.input) {
console.error("error: --input is required");
process.exit(2);
}
const inputPath = resolve(args.input);
if (!existsSync(inputPath)) {
console.error(`error: input not found: ${inputPath}`);
process.exit(2);
}
const outPath = resolve(
args.out || `${inputPath.slice(0, -extname(inputPath).length)}.transcribe.json`,
);
// Write via a sibling temp + atomic rename so a SIGKILL mid-write can't leave a
// truncated transcript at outPath (downstream reads it as valid JSON).
function atomicWrite(target, data) {
const tmp = `${target}.tmp-${process.pid}`;
writeFileSync(tmp, data);
renameSync(tmp, target);
}
try {
const result = await transcript({ file: inputPath, lang: args.lang, model: args.model });
atomicWrite(outPath, JSON.stringify(result, null, 2));
if (args.json) {
console.log(JSON.stringify({ ok: true, out: outPath, words: result.words.length }));
} else {
console.log(
`transcribed ${basename(inputPath)} -> ${outPath} (${result.words.length} words, ${args.model})`,
);
if (!result.words.length) {
console.error("note: the service returned text without word timings");
}
}
await track("media_use_transcribe", { model: args.model, words: result.words.length });
} catch (err) {
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
else console.error(`error: transcription failed: ${err.message}`);
process.exit(1);
}