Uma gravação de reunião pode alimentar dois arquivos úteis: ata.md, com resumo, decisões, tarefas e pendências, e uma imagem de capa. Neste exemplo, o objetivo é também baixar essa imagem e gravá-la como capa.jpg.

Vamos construir esse fluxo em Node.js 18 ou mais novo, usando três chamadas HTTP sequenciais e nenhum pacote de terceiro. Um modelo de áudio transcreve, um modelo de linguagem organiza o conteúdo e um modelo de geração de imagens cria a capa.

Inspecione a saída de cada etapa antes de alimentá-la na próxima. Essa prática torna a depuração mais direta, porque você consegue verificar a transcrição, o JSON estruturado e a URL da imagem separadamente.

Primeiro, faça a transcrição existir

Crie um arquivo chamado meeting-pipeline.mjs. A chave ficará em process.env.HINOW_API_KEY, nunca no código. O áudio entra como multipart/form-data, com language=pt, porque a rota aceita o idioma nesse formato.

import { readFile, writeFile } from "node:fs/promises";

const API_BASE = "https://api.hinow.ai/v1";
const API_KEY = process.env.HINOW_API_KEY;
const audioPath = process.argv[2];

if (!API_KEY) throw new Error("HINOW_API_KEY is not set");
if (!audioPath) throw new Error("Usage: node meeting-pipeline.mjs meeting.mp3");

async function transcribeAudio() {
  const audioBytes = await readFile(audioPath);
  const form = new FormData();
  form.append("model", "hinow/hivox");
  form.append("language", "pt");
  form.append("file", new Blob([audioBytes]), audioPath);

  const response = await fetch(`${API_BASE}/audio/transcriptions`, {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}` },
    body: form
  });

  if (!response.ok) throw new Error(await response.text());
  return response.json();
}

O retorno contém duration e text. Salvar a transcrição antes de continuar é uma decisão de depuração, não um luxo. Se a etapa seguinte falhar, você não precisa enviar o áudio de novo.

Depois, transforme fala em estrutura

Texto macro de um fluido translúcido cor de vinho misturando-se com blocos geométricos rígidos e gelados
A sintaxe que organiza o caos do áudio

Agora envie o texto para chat/completions. O formato JSON orienta a saída para uma estrutura previsível, mas ainda exige validação. Sem essa conferência, uma ata pode trazer campos ausentes ou valores incompatíveis com o que o código espera.

async function structureMinutes(transcript) {
  const prompt = `
Você é um editor de atas de reunião. Organize a transcrição abaixo em português do Brasil.
Retorne apenas um objeto JSON com as chaves:
resumo, decisoes, tarefas e pendencias.
Tarefas devem ser objetos com descricao, responsavel e prazo.
Registre responsável e prazo apenas quando forem ditos na reunião. Nunca invente informação.
Se algo não estiver definido, use string vazia.

TRANSCRIÇÃO:
${transcript}
`;

  const response = await fetch(`${API_BASE}/chat/completions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "hinow/hinova",
      response_format: { type: "json_object" },
      messages: [{ role: "user", content: prompt }]
    })
  });

  if (!response.ok) throw new Error(await response.text());
  const data = await response.json();
  return JSON.parse(data.choices[0].message.content);
}

A instrução sobre responsáveis e prazos é a parte mais importante do prompt. Um campo vazio é honesto. Um nome inventado cria uma obrigação falsa e pode contaminar o trabalho da equipe.

Para documentos muito longos ou transcrições difíceis, use um modelo de linguagem de maior contexto como alternativa. Outra opção é dividir o áudio ou o texto em blocos, estruturar cada bloco e fazer uma etapa final de consolidação.

Por fim, gere uma capa sem pedir texto

Objeto abstrato 3D isolado com formato de malha conectada em material brilhante vermelho e detalhes ciano
A etapa visual final do pipeline

Neste fluxo, tratamos a capa como etapa independente. Ela não precisa conhecer a transcrição inteira, apenas o tema da reunião. O prompt deve descrever uma cena visual e proibir texto na imagem, porque letras geradas dentro da arte frequentemente saem com grafia errada.

async function generateCover(topic) {
  const response = await fetch(`${API_BASE}/images`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "hinow/himegia",
      prompt: `Capa abstrata e profissional para uma reunião sobre ${topic}. Formas conectadas, sensação de colaboração e planejamento, composição limpa, sem palavras, sem letras, sem logotipos.`,
      parameters: { aspect_ratio: "16:9", output_format: "jpeg" }
    })
  });

  if (!response.ok) throw new Error(await response.text());
  const data = await response.json();
  return data.data.urls[0];
}

A duração da geração pode variar. Configure um timeout compatível com a latência observada e trate falhas de forma explícita. A capa é um artefato complementar: se ela falhar, a ata ainda deve ser criada.

Junte as três saídas em ata.md

A função abaixo transforma o JSON em Markdown. O código não tenta embelezar a resposta do modelo. Ele apenas renderiza campos conhecidos, o que reduz a chance de uma mudança de formato quebrar o arquivo.

function renderMinutes(minutes) {
  const decisions = minutes.decisoes
    .map((item) => `- ${item}`)
    .join("\n");

  const tasks = minutes.tarefas
    .map((task) => `| ${task.descricao} | ${task.responsavel} | ${task.prazo} |`)
    .join("\n");

  const pending = minutes.pendencias
    .map((item) => `- ${item}`)
    .join("\n");

  return `# Ata da reunião

## Resumo
${minutes.resumo}

## Decisões
${decisions || "Nenhuma decisão registrada."}

## Tarefas
| Descrição | Responsável | Prazo |
|---|---|---|
${tasks || "| Nenhuma tarefa registrada. |  |  |"}

## Pendências
${pending || "Nenhuma pendência registrada."}
`;
}

A função seguinte baixa a imagem da URL retornada e grava o arquivo local. Assim, o resultado final realmente inclui capa.jpg.

async function saveCoverImage(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(await response.text());
  const imageBytes = new Uint8Array(await response.arrayBuffer());
  await writeFile("capa.jpg", imageBytes);
}

Agora falta orquestrar o fluxo e salvar a transcrição, a ata e a capa.

const transcription = await transcribeAudio();
await writeFile("transcription.txt", transcription.text, "utf8");
console.log(`Transcription complete: ${transcription.duration} seconds`);

const minutes = await structureMinutes(transcription.text);
if (!minutes.resumo || !Array.isArray(minutes.decisoes) || !Array.isArray(minutes.tarefas) || !Array.isArray(minutes.pendencias)) {
  throw new Error("Invalid minutes structure");
}

await writeFile("ata.md", renderMinutes(minutes), "utf8");
console.log("Minutes written to ata.md");

const coverUrl = await generateCover(minutes.resumo);
await saveCoverImage(coverUrl);
console.log("Cover written to capa.jpg");

Suponha, apenas como hipótese ilustrativa, que a transcrição de uma hora leve alguns minutos e a estruturação leve mais alguns segundos. O resultado chegaria enquanto a reunião ainda está fresca, em vez do tempo que uma pessoa gastaria redigindo a ata à mão. Isso é uma conta de ordem de grandeza, não uma garantia de latência: áudio, tamanho do texto e fila do serviço mudam o tempo real.

Valide antes de distribuir

Abra transcription.txt e confira um trecho do áudio. Depois compare todas as tarefas extraídas com a gravação em uma amostra. A regra de ouro é direta: decisão registrada errada é pior que decisão não registrada. Por isso, a ata automática deve circular como rascunho para confirmação, não como registro final.

Neste fluxo mínimo, o modelo não identifica quem falou cada trecho. Se essa atribuição for necessária, seria necessário acrescentar uma etapa de identificação de falantes ou revisão humana, fora do escopo deste fluxo.

Trechos sem fala podem gerar texto espúrio na transcrição. Esse é um risco observado no processamento de áudio, não uma ocorrência garantida. Em gravações com silêncios longos, corte o áudio antes com ffmpeg, que precisa estar instalado no sistema. Também vale testar microfone, ruído ambiente e distância dos participantes antes de automatizar reuniões importantes.

O pipeline começa a ficar produto

Como expansões, você pode enviar o arquivo por e-mail, versioná-lo em um repositório ou criar busca semântica no histórico de atas com um modelo de embeddings. Nesse último caso, transforme cada ata em vetor, armazene os resultados e escolha uma métrica de similaridade adequada à implementação.

A separação entre transcrição, estruturação e capa permite trocar uma etapa sem reescrever as outras. Uma vantagem desta base de API comum é manter a orquestração pequena, enquanto as saídas intermediárias ajudam a localizar em qual etapa ocorreu uma falha.