chore(C196): merge origin/main (resolve SCRIPTS_SPEC_PINNED com reel-ingest do C195)

This commit is contained in:
Francisco Solla 2026-09-18 22:55:52 -03:00
commit 83092adabf
21 changed files with 17476 additions and 2 deletions

View file

@ -0,0 +1 @@
**C195 — Ingestão do pacote do reel na biblioteca privada (2026-09-18):** a última milha dos Reels era manual — subir arquivo por arquivo no admin e torcer para não duplicar. Entregou-se o comando `pnpm reels:ingest <diretório>` (imagem de manutenção, Local API): (1) **contrato do pacote** no dono `src/lib/reel.ts` — `metadata.json` (título, feature contra `REEL_FEATURES`, `shotListHash` sha256, alt da capa; duração/data validadas quando presentes e não persistidas), obrigatórios `reel.mp4`/`capa.png`/`narracao.srt`, opcionais `reel-audio.mp4`/`narracao.mp3`/`roteiro.md` (→ `transcript`), com nomes/mimetypes/alts determinísticos; (2) **idempotência por `sourceHash`** — campo novo na collection `reel` (nullable, único, readOnly; migration aditiva `20260919_002605_add_reel_source_hash` aplicada local) e re-ingest atualiza a MESMA entrada e os MESMOS `reelMedia` (nunca duplica), preservando o kill switch (nunca ressuscita `unpublished`); (3) **writer** `src/utilities/reels/reelPackageIngest.ts` sem `user` (precedente CLI, `createdBy` nulo), create/update dos artefatos e do reel na mesma transação, primeira ingestão publica, opcional ausente no pacote não apaga o existente, nome de objeto determinístico (`reel-<hash8>-<kind>.<ext>`) com `overwriteExistingFiles` para o re-run sobrescrever o mesmo key; (4) **guard fail-closed** `scripts/lib/reel-ingest.mjs` — `TEQO_ENV=staging|production` obrigatório fora de teste com o **nome exato** do banco como discriminador (`teqo_staging`/`teqo_1313`; o socat mascara o host), `ALLOW_REMOTE_DB` recusado, test só em `teqo*_test`, dry-run por padrão e `--apply` só com `REELS_INGEST_CONFIRM=1`, S3 completo exigido fora de teste; (5) **testes** — unit da matriz de guarda/parser/validador do pacote/nomes (17) e int do fluxo real com fixture (`create` publicado com todos os artefatos e transcript, re-ingest no mesmo reel/media, status preservado, opcional preservado, pacote incompleto sem escrita); (6) **runbook** no `docs/ops/teqo-1313-deploy.md` (staging primeiro, bind mount read-only do pacote, produção depois). Sem UI e sem e2e novo (diff de `scripts/` não acorda e2e), `scripts/lib/reel-ingest.mjs` pinado no `SCRIPTS_SPEC_PINNED`; tensão registrada: o pacote do C196 sozinho (sem `.srt`) é recusado até o C197, por contrato.

View file

@ -776,6 +776,56 @@ read-only é aplicada antes do Payload subir (qualquer write estoura no servidor
explícita, nunca zero). Nunca aponte o extrator para o banco local esperando
dados de produção — o caminho é o proxy do homeserver.
## C195 — ingestão do pacote do reel na biblioteca privada
O comando `pnpm reels:ingest <diretório>` publica um pacote de reel produzido
fora do site (`metadata.json` + `reel.mp4` + `capa.png` + `narracao.srt` e
opcionais `reel-audio.mp4`/`narracao.mp3`/`roteiro.md`) na biblioteca privada do
`/campanha` pela Local API. Roda **na imagem de manutenção do ambiente** (o
pacote chega por rsync; nenhuma credencial de campanha vai para a workstation) e
é **idempotente pelo hash do shot list**: re-ingerir o mesmo pacote atualiza a
mesma entrada e preserva o status (nunca ressuscita um reel despublicado).
O caminho é **staging primeiro**, sempre. O dry-run é o padrão e não escreve:
```bash
# da workstation — copia o pacote para o homeserver:
rsync -av /caminho/do/pacote/ homeserver:/srv/reels/<slug>/
# no homeserver:
ssh homeserver
cd ~/stack
# 1) conferência (dry-run; mostra alvo, operação, artefatos e falha se o pacote estiver incompleto):
docker compose --profile maintenance run --rm \
-v /srv/reels/<slug>:/pkg:ro \
teqo-staging-migrate pnpm reels:ingest /pkg
# 2) escrita no staging (exige TEQO_ENV + confirmação; o par é fail-closed):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e REELS_INGEST_CONFIRM=1 \
-v /srv/reels/<slug>:/pkg:ro \
teqo-staging-migrate pnpm reels:ingest /pkg --apply
# 3) validação no staging (biblioteca/painel) e só então produção, com TEQO_ENV=production
# e o serviço teqo-1313-migrate.
```
Guardas: fora de teste o `TEQO_ENV=staging|production` é obrigatório e o
**nome exato** do banco é o discriminador (`teqo_staging`/`teqo_1313` — o host do
socat é `127.0.0.1` nos dois, então host não distingue); `ALLOW_REMOTE_DB` é
recusado; sem as 4 `S3_*` o comando recusa (o container gravaria em disco
efêmero); `--apply` sem `REELS_INGEST_CONFIRM=1` recusa. Falha no meio não deixa
entrada parcial: os documentos vão numa transação e a publicação é a última
escrita (objeto órfão no bucket é o único resíduo; o nome determinístico faz o
re-run sobrescrevê-lo). O plano de implementação completo vive em
`docs/plans/reels-ingestao-impl.md`; a migration `add_reel_source_hash` é
aditiva e aplicada pelo deploy.
### Rollback
Sem migração para desfazer. O rollback funcional é o kill switch: despublicar o
reel no admin tira da biblioteca e para de servir os arquivos. Para remover de
vez, apagar o `reel` (e as `reelMedia` órfãs) no admin; re-ingerir recria a
entrada (as mesmas chaves de objeto são sobrescritas).
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)

View file

@ -0,0 +1,134 @@
# Impl: Ingestão do pacote do reel na biblioteca privada do /campanha
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1154
Intenção: docs/plans/reels-ingestao.md
Appetite restante: herdado (~1 dia eng; um comando que publica o pacote uma vez, sem duplicata, com conferência)
## Leitura da intenção
- **Outcome:** `pnpm reels:ingest <diretório>` roda no homeserver, valida o pacote e cria/atualiza UMA entrada na biblioteca privada com vídeo, capa, legenda, narração/áudio e roteiro ligados à mesma entrada; re-ingerir o mesmo reel (mesmo hash de shot list) atualiza a mesma linha; sem confirmação nada é escrito e o alvo errado é recusado.
- **O que NÃO negociar:** escrita só pela Local API no homeserver (nunca SQL cru nem credencial de campanha na workstation); idempotência pelo hash do shot list gravado no `metadata.json`; dry-run por padrão; fail-closed de ambiente; pacote incompleto não gera meia-escrita; sem processamento de mídia; access/privacidade do C193 intocados (`Reel.ts:74-79`, `ReelMedia.ts:26-31`).
- **O que reavaliar:** a hipótese de "pacote completo" (o C196 sozinho só produz `reel.mp4`+`capa.png`+`metadata.json`; `.srt`/`.md`/áudio são do C197); persistir `duração`/`data` do metadata (não persistir sem consumidor); formato exato do hash com o produtor.
## Abordagem recomendada
```mermaid
flowchart LR
P[pacote copiado p/ homeserver] --> V[readReelPackage<br/>scripts/lib/reel-ingest.mjs]
V -->|inválido| D[die: lista de pendências]
V -->|ok| G{assertReelIngestTarget<br/>TEQO_ENV x nome exato do banco}
G -->|não casa| D
G --> E[echo alvo: env, banco, bucket, hash]
E -->|default| DR[dry-run: create/update + ids]
E -->|--apply + REELS_INGEST_CONFIRM=1| T[withPayloadTransaction]
T --> M[reelMedia: mesmo doc por kind<br/>filePath + alt]
T --> R[reel: create published<br/>ou update sem status]
M --> C[commit]
```
**Opções consideradas:** A (contrato em `src/lib/reel.ts` + puros em `scripts/lib/` + writer Local API em `src/utilities/reels/` + CLI fino) | B (tudo inline no script) | C (SQL cru)
**Recomendação:** A — reusa o dono do vocabulário (`src/lib/reel.ts`), os helpers de `scripts/lib/cli.mjs` e a transação (`withPayloadTransaction`, `payloadTransaction.ts:86-96`); o writer fica testável contra `teqo_test` como o upsert de `scripts/lib/staging-test-account.mjs:143-169`.
**Rejeitadas:** B — duplicaria guard/validador e não teria unit dos puros; C — fura o caminho oficial, o access e a transação.
### Decisões de engenharia
1. **Idempotência — campo `sourceHash` no `reel` (caro).**
**Opções:** A) campo `text` `unique: true, index: true`, `admin.readOnly`, nullable, + migration `add_reel_source_hash`; B) collection de mapping hash→reel; C) lookup por título+feature.
**Recomendação:** A — a chave travada é o hash; campo no dono com índice único dá lookup direto sem modelo paralelo; NULL convive com reels manuais (Postgres aceita múltiplos NULL num índice único). Forma do `Speech.sourceKey` (`Speech.ts:96-107` — o adapter gera um único `CREATE UNIQUE INDEX`).
**Rejeitadas:** B — um doc por reel é o twin do modelo (mais migração, mais access, mais escrita para o mesmo fato); C — título colide e não é a chave travada.
2. **Contrato do pacote (caro).**
**Opções:** A) constantes em `src/lib/reel.ts` + validador puro em `scripts/lib/reel-ingest.mjs`; B) schema zod em `src/lib/schemas/`; C) validar inline no CLI.
**Recomendação:** A. `metadata.json`: `title` (≤ `REEL_TITLE_MAX_LENGTH`, `Reel.ts:89`), `feature ∈ REEL_FEATURES` (`src/lib/reel.ts:30`), `shotListHash` (sha256 hex de 64), `coverAlt` não vazio; `durationSeconds`/`createdAt` opcionais, validados e **não persistidos**; chaves extras ignoradas (forward-compat com C197). Artefatos: obrigatórios `reel.mp4`→`video`, `capa.png`→`cover`, `narracao.srt`→`captions`; opcionais `reel-audio.mp4`→`video-audio`, `narracao.mp3`→`narration`, `roteiro.md`→`transcript` (textarea). Reusa `REEL_MEDIA_KINDS`/`reelMediaFieldByKind` (`src/lib/reel.ts:41-52`).
**Rejeitadas:** B — zod é dono de entrada de superfície HTTP, não de pacote de CLI; C — reescreveria o vocabulário em `scripts/`.
**Tensão registrada:** o pacote do C196 sozinho (sem `.srt`) será recusado até o C197 — fail-closed listando os faltantes; **não é defeito do ingest**.
**Revisitação:** C196/C197 pedirem campo novo (voz, duração persistida) → adicionar no dono.
3. **Guard de alvo (caro).**
**Opções:** A) helper puro próprio (molde `assertStagingTestAccountTarget`, `staging-test-account.mjs:77-127`); B) `assertLocalDatabase`; C) confiar no host.
**Recomendação:** A — fora de test o `TEQO_ENV` é obrigatório (`staging|production`) e o **nome exato** do banco é o discriminador (`staging`↔`teqo_staging`, `production`↔`teqo_1313`); protocolo `postgresql:`; host no allowlist local (defesa em profundidade); recusa `ALLOW_REMOTE_DB`; `NODE_ENV=test`/`VITEST` só aceita `TEST_DATABASE_NAME_RE` (`cli.mjs:123`); confirmação sempre exigida para escrever.
**Rejeitadas:** B — no homeserver `assertLocalDatabase` PASSA contra produção (`LOCAL_HOSTS` inclui `postgres`/`127.0.0.1`, `cli.mjs:66-74`); C — o socat reescreve o host para `127.0.0.1`, host não distingue.
4. **Confirmação e modo (caro).**
**Opções:** A) dry-run default; escrita com `--apply` **e** `REELS_INGEST_CONFIRM=1` (molde `migrate-signature-orphan.mjs:48-60`); B) sem flag, só env (molde `recover-media.mjs:79-85`); C) `--confirm` na linha.
**Recomendação:** A — o default nunca grava; o dry-run ecoa alvo (env+banco+bucket), pacote, hash, create×update e id, e a validação termina antes de qualquer escrita.
**Rejeitadas:** B — a invocação sem flag viraria escrita; C — confirmação por linha é fácil de repetir por acidente e foge do molde do repo.
5. **Caminho de escrita (caro).**
**Opções:** A) Local API `overrideAccess: true` sem `user` (precedentes `staging-test-account.mjs`, `seed-posts.mjs:419-424`) + `withPayloadTransaction` com `req:{transactionID}`; B) usuário de sistema; C) SQL cru.
**Recomendação:** A. `createdBy` fica nulo — o hook `stampCampaignCreatedBy` só carimba com `req.user` de campanha (`campaignAuditFields.ts:56-61`); registrado, não é defeito. Primeira ingestão cria `status: 'published'` (o hook carimba `publishedAt`, `Reel.ts:51-59`); re-ingest **omite `status`** → preserva o kill switch (nunca ressuscita `unpublished`). Upload via `filePath`; re-ingest **atualiza o mesmo doc `reelMedia`** por kind (nunca duplica); artefato opcional ausente **não apaga** o existente; mesma regra para `transcript`. Nome do objeto determinístico por hash (`reel-<hash8>-<kind>.<ext>`) — dois reels nunca colidem no mesmo `reel.mp4` e o re-run sobrescreve o mesmo key. Falha no meio → rollback (nada visível); objeto órfão no bucket é custo aceito (chave determinística torna o re-run auto-curativo; cleanup é opcional, não inflar).
**Rejeitadas:** B — credencial/identidade fake e `createdBy` mentiroso; C — fura o access e a transação.
6. **alt dos artefatos (barato).** Capa usa `coverAlt` do metadata; os demais derivam pt-BR determinístico do título (`Vídeo do reel "<título>"`, `Vídeo com áudio do reel "<título>"`, `Narração do reel "<título>"`, `Legendas do reel "<título>"`). Rejeitado: exigir alt de cada arquivo no metadata (infla o contrato; só a capa é superfície de acessibilidade).
7. **CLI (barato).** `pnpm reels:ingest <dir> [--apply]`; parser posicional puro novo em `scripts/lib/reel-ingest.mjs` (não há parser posicional em `cli.mjs`, só `parseEqualsFlags:34-48`); `dieWithLabel('reels:ingest')`; exit 1 em recusa/erro e 0 no sucesso; sem S3 completo (`resolveS3StorageEnv`, `mediaStorage.ts:39-53`) o comando falha fechado quando o alvo é staging/produção (senão o container gravaria em disco efêmero); dev/test caem em disco. Rejeitado: `--confirm` (decisão 4).
8. **UI/testes (barato).** Impeccable **A — sem UI**; sem e2e novo (o manifest só mapeia `src/`; diff de `scripts/` não acorda e2e). Unit dos puros + int do fluxo real no `teqo_test` com pacote de fixture.
9. **Operação (barato).** Sem mudança de compose; runbook em `docs/ops/teqo-1313-deploy.md` (staging primeiro, depois produção, via serviço de manutenção `teqo-staging-migrate`/`teqo-1313-migrate`, `deploy-homeserver.sh:249-254`; precedente manual `docs/ops/teqo-1313-deploy.md:319`) + changelog `docs/changelog/2026-09-18-c195.md`.
### Componentes / mudanças
- **`src/lib/reel.ts`** (editar): `REEL_PACKAGE_METADATA_FILENAME`, `REEL_PACKAGE_TRANSCRIPT_FILENAME`, `REEL_PACKAGE_ARTIFACTS` (kind+filename+required) — dono único do vocabulário do pacote (C196/C197 leem daqui). Não tocar `reelMediaPath` (defer do C193; gatilho é o C194, não o C195).
- **`scripts/lib/reel-ingest.mjs`** (novo, puro): `parseReelIngestArgs`, `assertReelIngestTarget`, `readReelPackage`, `reelArtifactStorageFilename`, `reelArtifactAlt`; reusa `dieWithLabel`/`databaseName`/`databaseHostname`/`isLocalDatabaseUrl`/`isTruthyEnv`/`TEST_DATABASE_NAME_RE` de `cli.mjs`.
- **`src/utilities/reels/reelPackageIngest.ts`** (novo, `import 'server-only'`): `ingestReelPackage(payload, plan, { dryRun })` — lookup por `sourceHash`, create/update de `reelMedia` e `reel` na MESMA transação; importado pelo CLI (tsx) e pelo int spec (precedente `tests/int/campaignStagingTestAccount.int.spec.ts:9-14`).
- **`scripts/reels-ingest.mjs`** (novo): args → confirmação → guard → check S3 → validação do pacote → boot Payload (`seed-loader`, molde `package.json:30-36`) → echo → dry-run/escrita.
- **`src/collections/Reel.ts`** (editar): campo `sourceHash` (`text`, `unique`, `index`, nullable, `admin.readOnly`, `description` pt-BR "Hash do shot list do pacote; identifica o reel na ingestão") — sem `revalidate` (não há leitura cacheada, `reels-reel-privado-impl.md:60`).
- **`package.json`**: script `reels:ingest` com `--import=tsx/esm --import=./scripts/seed-loader.mjs`.
- **`scripts/lib/test-affected-core.mjs`**: inserir `scripts/lib/reel-ingest.mjs` em `SCRIPTS_SPEC_PINNED` (o invariante `ciSkipInvariants.unit.spec.ts:192-216` recomputa a closure e falha se faltar/stale).
- **Migration:** `add_reel_source_hash` (coluna + índice único; tabela minúscula, sem backfill) — **HARD-STOP** abaixo.
- **Access/Consent:** intocados; escrita `overrideAccess: true` sem `user` (CLI); sem `Consent` (mídia interna, sem PII).
- **UI:** Impeccable A — N/A.
- **Testes:** `tests/unit/reelIngest.unit.spec.ts` (puros + guard + parser); `tests/int/reelIngest.int.spec.ts` (fluxo real); fixture `tests/fixtures/reel-package/` (arquivos mínimos com magic bytes reais p/ MP4/PNG, sem PII; cópia em temp nos casos mutantes).
### Dados → forma (se aplicável)
N/A — sem tela. O "dado" é o pacote no disco e o registro criado/atualizado na biblioteca (o C194 consome).
## Fases verificáveis
1. **Puros + contrato (~0,3 dia):** estender `src/lib/reel.ts`; criar `scripts/lib/reel-ingest.mjs` + `tests/unit/reelIngest.unit.spec.ts`; pin em `SCRIPTS_SPEC_PINNED`; `pnpm gate:fast`.
2. **Schema + migration — HARD-STOP (~0,2 dia):** `sourceHash` em `Reel.ts`; `pnpm generate:types`; `pnpm migrate:create add_reel_source_hash`; revisar SQL; **aprovação humana explícita pendente (mesmo em `--auto`)**; `pnpm migrate` só local.
3. **Escrita + CLI + int (~0,4 dia):** `reelPackageIngest.ts`, `scripts/reels-ingest.mjs`, `package.json`, fixture, `tests/int/reelIngest.int.spec.ts` (create→published; re-ingest mesmo id/documentos; status preservado; incompleto sem escrita; dry-run sem escrita); `pnpm test`.
4. **Operação + gates (~0,1 dia):** runbook + changelog; `pnpm gate:fast`; e2e da superfície (nenhum novo — os curados do CI bastam); push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- ffmpeg/transcode/thumb/poster; watcher/daemon; edição de metadados no dry-run; UI (C194) e `reelMediaPath`.
- Cleanup de objetos órfãos no bucket (best-effort opcional; não inflar).
- Skip de PUT por hash de bytes; ingest em lote; `--force`/`--status`/`--dry-run` explícito.
- Versionamento/auditoria por ingestão; `Consent`/PII (não existem).
## Riscos e mitigação
- **Migration em produção:** aditiva e aplicada pelo deploy (`pnpm build` → `payload migrate`); SQL revisado no hard-stop; tabela minúscula, sem backfill.
- **Hash divergente entre C196 e C195 → duplicata:** formato pinado no contrato; o C196 implementa contra este plano (revisitar no C196 se o cálculo mudar).
- **Pacote do C196 sem `.srt` recusado até o C197:** mensagem lista os faltantes; documentado como tensão, não defeito.
- **Colisão de filename no S3:** nome determinístico por hash+kind; re-run sobrescreve o mesmo key.
- **Rollback após PUT:** objeto pode ficar com bytes novos e DB antigo (mesmo key); a rota lê o tamanho do `HeadObject` (`reelMediaResponse.ts:80-86`) — stream consistente; re-run re-sobrescreve.
- **Alvo errado:** guard por nome exato + `TEQO_ENV` + confirmação + echo antes da escrita.
- **S3 ausente:** `resolveS3StorageEnv` fail-closed + check no CLI antes do boot.
- **CI mais caro (high-risk):** `package.json`/`src/collections`/`payload-types.ts` forçam unit/int full + e2e curado; esperado.
## Hard-stops (aprovação humana explícita)
1. **Migração (Fase 2):** `pnpm migrate:create add_reel_source_hash` e qualquer aplicação exigem aprovação humana registrada — `--auto` aprova o plano, **não** o schema. **Aprovada na sessão `--auto` (2026-09-18)** para criar/aplicar só no banco local do worktree (`teqo_wt195`/`teqo_wt195_test`); nunca apontar para banco remoto fora do deploy.
2. **Primeira escrita em produção (Fase 4):** operação humana via runbook, staging validado antes; o comando nunca é automatizado.
## Simplify — defers deste triage
- **Defer (contrato no reader `.mjs`).** O shape `ReelPackage` vive em `src/lib/reel.ts`, mas o leitor o descreve só em JSDoc (tsconfig sem `checkJs`) e o int spec faz a ponte com `as Promise<ReelPackage>`. Gatilho: C196/C197 tocarem o contrato ou um 2º consumidor do reader → mover o reader para `.ts` e apagar o cast.
- **Defer (processo do CLI).** O caminho `--apply` completo de `pnpm reels:ingest` não é spawnado em teste; o gate de confirmação e o parser têm unit e o writer tem int. Gatilho: primeira ingestão real do C196/C197 em staging (a operação é a prova) ou um 2º modo no CLI → smoke do dry-run no int contra `teqo_test`.
- **Descartado:** literais do metadata duplicados em `ReelPackage` (mapped type cerimonioso), comentários de bypass repetidos (exigidos pelo guard de `codebaseConventions` a ≤10 linhas), re-checagem de mídia obrigatória no writer (defesa barata na fronteira da escrita).
## Aceite de engenharia
- [ ] Aceite de produto: comando único publica o pacote na biblioteca; re-ingest atualiza a mesma entrada; dry-run default com alvo e create×update; alvo errado recusado; staging antes de produção; pacote incompleto recusado; nada parcial visível; sem credencial na workstation.
- [ ] Invariantes: Local API `overrideAccess: true` só sem user (CLI, precedente) e registrado; multi-collection em transação com `req`; sem `Consent`/PII; mensagens pt-BR / identificadores em inglês; migração nova sem editar antigas; `pnpm push` como entrega.
- [ ] Testes de domínio: unit dos puros/guard/parser; int do fluxo no `teqo_test` (create→published, re-ingest idempotente, status preservado, dry-run sem escrita, incompleto sem meia-escrita).
## Self-score de decision-quality
**4.5/5.** As decisões caras (campo de identidade, contrato do pacote, guard de alvo, confirmação, writer Local API) têm rejeitadas ancoradas em file:line e reusam donos/helpers existentes; o appetite de ~1 dia cabe. Perde 0,5 porque o formato exato do hash e os nomes do pacote são ratificados pelo C196 (produtor ainda rascunho) — o contrato fica pinado aqui, com gatilho de revisitação.

View file

@ -33,6 +33,7 @@
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
"media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs",
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs",
"db:check:push-chain": "node scripts/check-push-chain.mjs",

325
scripts/lib/reel-ingest.mjs Normal file
View file

@ -0,0 +1,325 @@
/**
* C195 — pure rules of `pnpm reels:ingest`: CLI parsing, the fail-closed target
* guard and the package reader/validator. No Payload and no `server-only`: the
* CLI (`scripts/reels-ingest.mjs`) and the int spec share it, and the unit pin
* covers the refusal matrix.
*
* The package contract itself (filenames, kinds, required flag, metadata keys,
* stored names/mimetypes/alts) lives in `src/lib/reel.ts`, the owner of the
* reel vocabulary that the production skill and the writer also read.
*/
import { readFile, readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import {
REEL_FEATURES,
REEL_PACKAGE_ARTIFACTS,
REEL_PACKAGE_METADATA_FILENAME,
REEL_PACKAGE_METADATA_KEYS,
REEL_PACKAGE_TRANSCRIPT_FILENAME,
REEL_TITLE_MAX_LENGTH,
reelMediaFieldByKind,
} from '../../src/lib/reel.ts'
import {
ALLOW_REMOTE_DB_FLAG,
TEST_DATABASE_NAME_RE,
databaseHostname,
databaseName,
isLocalDatabaseUrl,
isTruthyEnv,
parseEqualsFlags,
} from './cli.mjs'
/** Explicit write-intent flag; dry-run (the default) never needs it. */
export const REELS_INGEST_CONFIRM_FLAG = 'REELS_INGEST_CONFIRM'
const USAGE = 'uso: `pnpm reels:ingest <diretório> [--apply]`'
/**
* Declared environment → exact database name. The name is the honest
* discriminator on the homeserver: the socat proxy rewrites the host to
* 127.0.0.1 and `NODE_ENV=production` is set in both env files, so
* `assertLocalDatabase` alone cannot tell staging from production.
*/
const REEL_INGEST_DATABASE_BY_ENV = Object.freeze({
staging: 'teqo_staging',
production: 'teqo_1313',
})
/** Identity hash of the shot list written by the production skill (sha256 hex). */
const REEL_INGEST_SHOT_LIST_HASH_RE = /^[a-f0-9]{64}$/
/**
* Fail-closed write gate: `--apply` only proceeds with the explicit intent
* flag. Pure and injectable so the unit pin covers the refusal.
*
* @param {{ apply?: boolean, confirm?: boolean }} [options]
*/
export const assertReelIngestApplyConfirm = ({
apply = false,
confirm = isTruthyEnv(process.env[REELS_INGEST_CONFIRM_FLAG]),
} = {}) => {
if (!apply || confirm) return
throw new Error(
'modo --apply ESCREVE na biblioteca — exige confirmação explícita de intenção.\n' +
` Re-rodar com: ${REELS_INGEST_CONFIRM_FLAG}=1 pnpm reels:ingest <diretório> --apply\n` +
' (sem --apply o comando só confere e mostra o que seria criado ou atualizado).',
)
}
/**
* `pnpm reels:ingest <diretório> [--apply]` argv parser. The directory is the
* one positional; `--apply` is the only flag (bare, no value). Errors carry the
* usage so the CLI can `die` with them.
*
* @param {string[]} [argv]
* @returns {{ directory: string, apply: boolean }}
*/
export const parseReelIngestArgs = (argv = process.argv.slice(2)) => {
const { flags, positional } = parseEqualsFlags(argv)
const unknown = Object.keys(flags).filter((flag) => flag !== 'apply')
if (unknown.length > 0) {
throw new Error(
`argumento desconhecido: ${unknown.map((flag) => `--${flag}`).join(', ')} — ${USAGE}.`,
)
}
if (flags.apply !== undefined && flags.apply !== true) {
throw new Error(`--apply não aceita valor — ${USAGE}.`)
}
if (positional.length === 0) {
throw new Error(`informe o diretório do pacote — ${USAGE}.`)
}
if (positional.length > 1) {
throw new Error(
`argumento posicional extra: ${positional.slice(1).join(', ')} — o pacote é um único diretório; ${USAGE}.`,
)
}
return { directory: positional[0], apply: flags.apply === true }
}
/**
* Fail-closed target guard. Outside a test run the declared `TEQO_ENV`
* (`staging`|`production`) is mandatory and must match the exact database name;
* inside vitest only a `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is
* always refused — the ingest never runs from a workstation against a remote
* database.
*
* @param {{
* databaseUrl?: string | null,
* teqoEnv?: string | null,
* allowRemoteDb?: boolean,
* isTest?: boolean,
* }} [options]
* @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }}
*/
export const assertReelIngestTarget = ({
databaseUrl = process.env.DATABASE_URL,
teqoEnv = process.env.TEQO_ENV,
allowRemoteDb = isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]),
isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST),
} = {}) => {
if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') {
throw new Error(
'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.',
)
}
const targetDatabaseName = databaseName(databaseUrl)
let protocol = null
try {
protocol = new URL(String(databaseUrl ?? '')).protocol
} catch {
protocol = null
}
if (protocol !== 'postgresql:') {
throw new Error(
`protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — o ingest só escreve num alvo PostgreSQL.`,
)
}
if (!isLocalDatabaseUrl(databaseUrl)) {
// Never echo the URL — it carries the DB password.
throw new Error(
`host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — o ingest nunca escreve em alvo remoto.`,
)
}
if (allowRemoteDb) {
throw new Error(
'ALLOW_REMOTE_DB está setado — remova; o ingest não precisa do override remoto.',
)
}
if (isTest) {
if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — o ingest em teste só escreve em "teqo*_test".`,
)
}
return { environment: 'test', databaseName: targetDatabaseName }
}
if (teqoEnv !== 'staging' && teqoEnv !== 'production') {
throw new Error(
`TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`,
)
}
const expected = REEL_INGEST_DATABASE_BY_ENV[teqoEnv]
if (targetDatabaseName !== expected) {
throw new Error(
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` +
'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.',
)
}
return { environment: teqoEnv, databaseName: targetDatabaseName }
}
/**
* All reasons a parsed metadata + file listing is not a valid package. Empty
* array means valid; the reader joins the list into one error.
*
* @param {{ metadata: unknown, fileNames: Set<string> }} input
* @returns {string[]}
*/
export const reelPackageIssues = ({ metadata, fileNames }) => {
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
return [`${REEL_PACKAGE_METADATA_FILENAME} deve conter um objeto JSON.`]
}
const issues = []
const keys = REEL_PACKAGE_METADATA_KEYS
const title = metadata[keys.title]
if (typeof title !== 'string' || title.trim() === '') {
issues.push(`${keys.title} ausente ou vazio.`)
} else if (title.length > REEL_TITLE_MAX_LENGTH) {
issues.push(`${keys.title} excede ${REEL_TITLE_MAX_LENGTH} caracteres.`)
}
const feature = metadata[keys.feature]
if (!REEL_FEATURES.includes(feature)) {
issues.push(
`${keys.feature} "${String(feature)}" fora do vocabulário (${REEL_FEATURES.join(', ')}).`,
)
}
const shotListHash = metadata[keys.shotListHash]
if (typeof shotListHash !== 'string' || !REEL_INGEST_SHOT_LIST_HASH_RE.test(shotListHash)) {
issues.push(`${keys.shotListHash} deve ser um sha256 hex (64 caracteres minúsculos).`)
}
const coverAlt = metadata[keys.coverAlt]
if (typeof coverAlt !== 'string' || coverAlt.trim() === '') {
issues.push(`${keys.coverAlt} ausente ou vazio.`)
}
const durationSeconds = metadata[keys.durationSeconds]
if (
durationSeconds !== undefined &&
durationSeconds !== null &&
(typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0)
) {
issues.push(`${keys.durationSeconds} inválido (segundos positivos).`)
}
const createdAt = metadata[keys.createdAt]
if (
createdAt !== undefined &&
createdAt !== null &&
(typeof createdAt !== 'string' || Number.isNaN(Date.parse(createdAt)))
) {
issues.push(`${keys.createdAt} inválido (data ISO 8601).`)
}
for (const artifact of REEL_PACKAGE_ARTIFACTS) {
if (artifact.required && !fileNames.has(artifact.filename)) {
issues.push(`artefato obrigatório ausente: ${artifact.filename}.`)
}
}
return issues
}
/**
* Reads and validates one package directory. Throws with every pending item so
* the operator fixes the package in one pass; never touches the database.
*
* @param {string} directory
* @returns {Promise<{
* directory: string,
* metadata: Record<string, unknown>,
* artifacts: Array<{
* filename: string,
* kind: string,
* field: string,
* required: boolean,
* present: boolean,
* path: string,
* }>,
* transcriptPath: string | null,
* }>}
*/
export const readReelPackage = async (directory) => {
const directoryStat = await stat(directory).catch(() => null)
if (!directoryStat?.isDirectory()) {
throw new Error(`diretório do pacote inválido: ${directory}`)
}
const entries = await readdir(directory, { withFileTypes: true })
const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
if (!fileNames.has(REEL_PACKAGE_METADATA_FILENAME)) {
throw new Error(`pacote incompleto: ${REEL_PACKAGE_METADATA_FILENAME} não encontrado.`)
}
let metadata
try {
metadata = JSON.parse(await readFile(join(directory, REEL_PACKAGE_METADATA_FILENAME), 'utf8'))
} catch (error) {
throw new Error(
`${REEL_PACKAGE_METADATA_FILENAME} ilegível: ${error instanceof Error ? error.message : String(error)}`,
)
}
const issues = reelPackageIssues({ metadata, fileNames })
if (issues.length > 0) {
throw new Error(`pacote inválido:\n - ${issues.join('\n - ')}`)
}
const artifacts = REEL_PACKAGE_ARTIFACTS.map((artifact) => ({
...artifact,
field: reelMediaFieldByKind[artifact.kind],
present: fileNames.has(artifact.filename),
path: join(directory, artifact.filename),
}))
const emptyArtifacts = []
for (const artifact of artifacts) {
if (!artifact.present) continue
const artifactStat = await stat(artifact.path).catch(() => null)
if (artifactStat === null || !artifactStat.isFile() || artifactStat.size === 0) {
emptyArtifacts.push(artifact.filename)
}
}
if (emptyArtifacts.length > 0) {
throw new Error(
`pacote inválido:\n - artefato vazio ou ilegível: ${emptyArtifacts.join(', ')}.`,
)
}
return {
directory,
metadata,
artifacts,
transcriptPath: fileNames.has(REEL_PACKAGE_TRANSCRIPT_FILENAME)
? join(directory, REEL_PACKAGE_TRANSCRIPT_FILENAME)
: null,
}
}

View file

@ -83,6 +83,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',
'scripts/lib/readOnlyExtract.mjs',
'scripts/lib/reel-ingest.mjs',
'scripts/lib/reelFfmpeg.mjs',
'scripts/lib/reelPackage.mjs',
'scripts/lib/reelShotList.mjs',

135
scripts/reels-ingest.mjs Normal file
View file

@ -0,0 +1,135 @@
/**
* `pnpm reels:ingest <diretório-do-pacote> [--apply]` — C195 ingest of a reel
* package into the private library of `/campanha`.
*
* Runs on the homeserver maintenance image, where the production credentials
* already live and the package arrives by rsync: the command writes through the
* Payload Local API (never raw SQL, never a campaign credential on the
* workstation). Dry-run is the default and validates everything — target,
* package, S3 — without writing; `--apply` additionally requires the explicit
* intent flag REELS_INGEST_CONFIRM=1 and refuses a database that does not match
* the declared TEQO_ENV (fail-closed pair).
*
* Runbook (staging first, then production): docs/ops/teqo-1313-deploy.md.
*/
import { getPayload } from 'payload'
import { dieWithLabel, loadCliEnv } from './lib/cli.mjs'
import {
REELS_INGEST_CONFIRM_FLAG,
assertReelIngestApplyConfirm,
assertReelIngestTarget,
parseReelIngestArgs,
readReelPackage,
} from './lib/reel-ingest.mjs'
import { REEL_PACKAGE_TRANSCRIPT_FILENAME } from '../src/lib/reel.ts'
import { resolveS3StorageEnv } from '../src/utilities/mediaStorage.ts'
import {
findReelBySourceHash,
ingestReelPackage,
} from '../src/utilities/reels/reelPackageIngest.ts'
loadCliEnv()
const die = dieWithLabel('reels:ingest')
let args
try {
args = parseReelIngestArgs()
} catch (error) {
die(error.message)
}
try {
assertReelIngestApplyConfirm({ apply: args.apply })
} catch (error) {
die(error.message)
}
let target
try {
target = assertReelIngestTarget()
} catch (error) {
die(error.message)
}
let storage
try {
storage = resolveS3StorageEnv(process.env)
} catch (error) {
die(error.message)
}
if (target.environment !== 'test' && !storage.enabled) {
die(
`sem S3_* completo no alvo ${target.environment} — a ingestão é uma operação do bucket privado de produção.\n` +
' O container de manutenção gravaria num disco efêmero; configure as 4 S3_* no env file do ambiente.',
)
}
let reelPackage
try {
reelPackage = await readReelPackage(args.directory)
} catch (error) {
die(error.message)
}
const mode = args.apply ? 'apply' : 'dry-run'
console.log(
[
'\n[reels:ingest] Alvo da execução:',
` TEQO_ENV : ${target.environment}`,
` DB : ${target.databaseName}`,
` Bucket : ${storage.enabled ? storage.bucket : '(storage local de teste)'}`,
` Modo : ${mode}`,
].join('\n'),
)
const payload = await getPayload({ config: (await import('../src/payload.config.ts')).default })
const existing = await findReelBySourceHash(payload, reelPackage.metadata.shotListHash)
console.log(
[
`[reels:ingest] Pacote : ${reelPackage.directory}`,
` Título : ${reelPackage.metadata.title}`,
` Feature : ${reelPackage.metadata.feature}`,
` Hash : ${reelPackage.metadata.shotListHash}`,
` Operação : ${
existing === null
? 'cria uma nova entrada (publicada)'
: `atualiza o reel #${existing.id} (status atual: ${existing.status})`
}`,
].join('\n'),
)
for (const artifact of reelPackage.artifacts) {
// A missing required artifact never reaches the echo: readReelPackage refuses
// the package first.
const action = artifact.present ? `→ ${artifact.kind}` : 'ausente (opcional)'
console.log(`[reels:ingest] ${artifact.filename.padEnd(16)} ${action}`)
}
console.log(
`[reels:ingest] ${REEL_PACKAGE_TRANSCRIPT_FILENAME.padEnd(16)} ${
reelPackage.transcriptPath === null ? 'ausente (opcional)' : '→ transcript'
}`,
)
if (!args.apply) {
console.log(
`\n[reels:ingest] dry-run OK — nenhuma escrita. Para aplicar: ${REELS_INGEST_CONFIRM_FLAG}=1 pnpm reels:ingest ${args.directory} --apply`,
)
process.exit(0)
}
let result
try {
result = await ingestReelPackage(payload, reelPackage)
} catch (error) {
die(
`a escrita falhou (nada parcial ficou visível — a transação foi revertida): ${error?.message ?? String(error)}`,
)
}
console.log(
`\n[reels:ingest] ${result.operation === 'created' ? 'criado' : 'atualizado'} o reel #${result.reelId} — a biblioteca já reflete o pacote.`,
)
process.exit(0)

View file

@ -98,6 +98,21 @@ export const Reel: CollectionConfig = {
description: 'Funcionalidade do site que o tutorial ensina.',
},
},
{
// C195 identity: re-ingesting the same package (same shot list hash)
// updates this reel instead of creating a duplicate. Nullable so reels
// registered by hand in the admin have no hash; unique so the database
// itself refuses two reels with the same package.
name: 'sourceHash',
type: 'text',
label: 'Hash do shot list',
unique: true,
index: true,
admin: {
readOnly: true,
description: 'Hash do shot list do pacote; identifica o reel na ingestão (C195).',
},
},
{
name: 'status',
type: 'select',

View file

@ -2,7 +2,9 @@
* C193 — pure rules of a reel: the status vocabulary (kill switch), the
* feature catalog, the artifact `kind` → upload-field map and the private
* media path. No I/O and no `server-only`: the collection config, the
* authenticated media route and the C194 library share this module.
* authenticated media route and the C194 library share this module. Since C195
* it also owns the ingestion package contract (`REEL_PACKAGE_*`) that the
* production skill (C196/C197) writes and `pnpm reels:ingest` validates.
*/
import { campaignReelDetailHref } from '@/lib/campaignPaths'
@ -74,6 +76,112 @@ export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'
/** Manifest file of the ingestion package (C195). */
export const REEL_PACKAGE_METADATA_FILENAME = 'metadata.json'
/** Human-readable script of the package; becomes the reel `transcript`. */
export const REEL_PACKAGE_TRANSCRIPT_FILENAME = 'roteiro.md'
/**
* Keys of `metadata.json`. `shotListHash` is the identity of the reel (the
* ingest updates the same entry for the same hash); `coverAlt` is the required
* alt text of the cover. `durationSeconds`/`createdAt` are validated when
* present but deliberately not persisted (no consumer yet).
*/
export const REEL_PACKAGE_METADATA_KEYS = {
title: 'title',
feature: 'feature',
shotListHash: 'shotListHash',
coverAlt: 'coverAlt',
durationSeconds: 'durationSeconds',
createdAt: 'createdAt',
} as const
export type ReelPackageArtifact = {
/** File name inside the package directory. */
filename: string
kind: ReelMediaKind
/** Required artifacts must be present; optional ones are ingested when there. */
required: boolean
}
/** The package files, in the order the ingest echoes them. */
export const REEL_PACKAGE_ARTIFACTS: readonly ReelPackageArtifact[] = [
{ filename: 'reel.mp4', kind: 'video', required: true },
{ filename: 'capa.png', kind: 'cover', required: true },
{ filename: 'narracao.srt', kind: 'captions', required: true },
{ filename: 'reel-audio.mp4', kind: 'video-audio', required: false },
{ filename: 'narracao.mp3', kind: 'narration', required: false },
]
/** Content type per artifact kind — the package filenames are fixed, so is this. */
const REEL_PACKAGE_MIMETYPES: Record<ReelMediaKind, string> = {
video: 'video/mp4',
'video-audio': 'video/mp4',
narration: 'audio/mpeg',
captions: 'application/x-subrip',
cover: 'image/png',
}
export const reelArtifactMimetype = (kind: ReelMediaKind): string => REEL_PACKAGE_MIMETYPES[kind]
/**
* Deterministic stored file name: re-ingesting the same reel (same shot list
* hash) overwrites the same object key, and the full hash makes two reels
* collide only if their shot lists do.
*/
export const reelArtifactStorageFilename = (
shotListHash: string,
artifact: Pick<ReelPackageArtifact, 'filename' | 'kind'>,
): string => {
const extension = artifact.filename.match(/\.[^.]+$/)?.[0] ?? ''
return `reel-${shotListHash}-${artifact.kind}${extension}`
}
/** One artifact entry of a read package: contract + where it lives on disk. */
export type ReelPackageArtifactEntry = ReelPackageArtifact & {
field: ReelMediaField
present: boolean
path: string
}
/**
* The shape `readReelPackage` returns and `ingestReelPackage` consumes. Lives
* next to the package contract because it is the same contract: the producer
* writes these files, the CLI validates them and the writer persists them.
*/
export type ReelPackage = {
directory: string
metadata: {
title: string
feature: ReelFeature
shotListHash: string
coverAlt: string
}
artifacts: ReelPackageArtifactEntry[]
transcriptPath: string | null
}
/** Alt text: the manifest alt for the cover, derived from the title otherwise. */
export const reelArtifactAlt = ({
title,
kind,
coverAlt,
}: {
title: string
kind: ReelMediaKind
coverAlt: string
}): string => {
if (kind === 'cover') return coverAlt
const labels: Record<Exclude<ReelMediaKind, 'cover'>, string> = {
video: 'Vídeo',
'video-audio': 'Vídeo com áudio',
narration: 'Narração',
captions: 'Legendas',
}
return `${labels[kind]} do reel "${title}"`
}
const isReelFeature = (value: unknown): value is ReelFeature =>
REEL_FEATURES.includes(value as ReelFeature)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "reel" ADD COLUMN "source_hash" varchar;
CREATE UNIQUE INDEX "reel_source_hash_idx" ON "reel" USING btree ("source_hash");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "reel_source_hash_idx";
ALTER TABLE "reel" DROP COLUMN "source_hash";`)
}

View file

@ -73,6 +73,7 @@ import * as migration_20260913_150000_backfill_speech_search_text_trgm_index fro
import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add_speech_cut';
import * as migration_20260915_142058_activity_google_import_link from './20260915_142058_activity_google_import_link';
import * as migration_20260918_201009_add_reel from './20260918_201009_add_reel';
import * as migration_20260919_002605_add_reel_source_hash from './20260919_002605_add_reel_source_hash';
export const migrations = [
{
@ -448,6 +449,11 @@ export const migrations = [
{
up: migration_20260918_201009_add_reel.up,
down: migration_20260918_201009_add_reel.down,
name: '20260918_201009_add_reel'
name: '20260918_201009_add_reel',
},
{
up: migration_20260919_002605_add_reel_source_hash.up,
down: migration_20260919_002605_add_reel_source_hash.down,
name: '20260919_002605_add_reel_source_hash'
},
];

View file

@ -1078,6 +1078,10 @@ export interface Reel {
* Funcionalidade do site que o tutorial ensina.
*/
feature: 'cards';
/**
* Hash do shot list do pacote; identifica o reel na ingestão (C195).
*/
sourceHash?: string | null;
/**
* Despublicar tira o reel da biblioteca e para de servir os arquivos (kill switch).
*/
@ -2280,6 +2284,7 @@ export interface SpeechCutSelect<T extends boolean = true> {
export interface ReelSelect<T extends boolean = true> {
title?: T;
feature?: T;
sourceHash?: T;
status?: T;
video?: T;
videoWithAudio?: T;

View file

@ -0,0 +1,186 @@
import 'server-only'
import { readFile } from 'node:fs/promises'
import type { Payload } from 'payload'
import {
reelArtifactAlt,
reelArtifactMimetype,
reelArtifactStorageFilename,
type ReelMediaField,
type ReelPackage,
type ReelPackageArtifactEntry,
} from '@/lib/reel'
import type { Reel, ReelMedia } from '@/payload-types'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
/**
* C195 — writes one validated reel package into the private library through the
* Local API. Runs on the homeserver maintenance image (`pnpm reels:ingest`),
* so it deliberately has no `user`: the CLI is an admin-level operation (same
* precedent as `seed-minimal`/staging test account), `createdBy` stays null and
* every database write shares one transaction — a failure never leaves a
* partially visible reel. Upload bytes live outside the transaction;
* deterministic object names make a re-run overwrite the same keys (an orphan
* object after a rollback is accepted and self-heals on the next run).
*
* Idempotency: the `sourceHash` (shot list hash of the manifest) identifies the
* reel. A package with a known hash updates that entry in place — the same
* `reelMedia` documents, never duplicates — and preserves the current `status`,
* so re-ingesting never resurrects an unpublished reel. A new hash publishes a
* new entry.
*/
export type ReelIngestResult = {
operation: 'created' | 'updated'
reelId: number
}
type ReelWriteData = {
title: string
feature: Reel['feature']
sourceHash: string
video: number
cover: number
transcript?: string
} & Partial<Record<'videoWithAudio' | 'narrationAudio' | 'captions', number>>
const mediaIdOf = (value: Reel[ReelMediaField]): number | null => {
if (value === null || value === undefined) return null
return typeof value === 'number' ? value : value.id
}
/**
* The reel registered by this package, or null when the hash is new. Reads
* without a campaign user — the documented CLI admin bypass (same precedent as
* seed-minimal/staging test account).
*/
export const findReelBySourceHash = async (
payload: Payload,
sourceHash: string,
): Promise<Reel | null> => {
// Documented admin bypass: the CLI has no campaign user.
const found = await payload.find({
collection: 'reel',
where: { sourceHash: { equals: sourceHash } },
limit: 1,
depth: 0,
overrideAccess: true,
})
return found.docs[0] ?? null
}
const artifactUpload = async (artifact: ReelPackageArtifactEntry, shotListHash: string) => {
const data = await readFile(artifact.path)
return {
data,
mimetype: reelArtifactMimetype(artifact.kind),
name: reelArtifactStorageFilename(shotListHash, artifact),
size: data.length,
}
}
/**
* Creates or updates the reel of the package. Required artifacts are validated
* by `readReelPackage` before this runs; optional artifacts absent from the
* package leave the previous value untouched (a re-render without the draft
* audio never silently erases it). The transcript follows the same rule.
*/
export const ingestReelPackage = async (
payload: Payload,
reelPackage: ReelPackage,
): Promise<ReelIngestResult> => {
const { metadata, artifacts, transcriptPath } = reelPackage
const transcript = transcriptPath === null ? null : await readFile(transcriptPath, 'utf8')
const existing = await findReelBySourceHash(payload, metadata.shotListHash)
return withPayloadTransaction(payload, async ({ req }) => {
const mediaIds: Partial<Record<ReelMediaField, number>> = {}
for (const artifact of artifacts) {
if (!artifact.present) continue
const alt = reelArtifactAlt({
title: metadata.title,
kind: artifact.kind,
coverAlt: metadata.coverAlt,
})
const existingMediaId = existing === null ? null : mediaIdOf(existing[artifact.field])
// No campaign user in the CLI: the documented admin bypass for reelMedia.
let media: ReelMedia
if (existingMediaId === null) {
media = await payload.create({
collection: 'reelMedia',
data: { alt },
file: await artifactUpload(artifact, metadata.shotListHash),
// Documented admin bypass. Deterministic name: overwrite the
// previous object instead of letting Payload suffix the filename on
// every re-ingest.
overwriteExistingFiles: true,
depth: 0,
overrideAccess: true,
req,
})
} else {
media = await payload.update({
collection: 'reelMedia',
id: existingMediaId,
data: { alt },
file: await artifactUpload(artifact, metadata.shotListHash),
overwriteExistingFiles: true,
depth: 0,
// Same documented bypass on the update path.
overrideAccess: true,
req,
})
}
mediaIds[artifact.field] = media.id
}
// The reader already required video + cover; the ids come from the existing
// reel or the freshly created media, so the create data is complete.
const requiredMediaId = (field: 'video' | 'cover'): number => {
const id = mediaIds[field] ?? (existing === null ? null : mediaIdOf(existing[field]))
if (id === null || id === undefined) {
throw new Error(`artefato obrigatório sem mídia: ${field}.`)
}
return id
}
const data: ReelWriteData = {
title: metadata.title,
feature: metadata.feature,
sourceHash: metadata.shotListHash,
video: requiredMediaId('video'),
cover: requiredMediaId('cover'),
...(transcript === null ? {} : { transcript }),
...(mediaIds.videoWithAudio === undefined ? {} : { videoWithAudio: mediaIds.videoWithAudio }),
...(mediaIds.narrationAudio === undefined ? {} : { narrationAudio: mediaIds.narrationAudio }),
...(mediaIds.captions === undefined ? {} : { captions: mediaIds.captions }),
}
if (existing !== null) {
// No campaign user in the CLI: the documented admin bypass for the reel.
const updated = await payload.update({
collection: 'reel',
id: existing.id,
data,
depth: 0,
overrideAccess: true,
req,
})
return { operation: 'updated', reelId: updated.id }
}
// No campaign user in the CLI: the documented admin bypass for the reel.
const created = await payload.create({
collection: 'reel',
data: { ...data, status: 'published' },
depth: 0,
overrideAccess: true,
req,
})
return { operation: 'created', reelId: created.id }
})
}

BIN
tests/fixtures/reel-package/capa.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View file

@ -0,0 +1,8 @@
{
"title": "Tutorial dos cards de apoio",
"feature": "cards",
"shotListHash": "8f900e80b7e4628ea39050031436b25ea337e15924243e21676ea7b25bfeefc3",
"coverAlt": "Tela inicial dos cards de apoio do site",
"durationSeconds": 38,
"createdAt": "2026-09-18T18:00:00.000Z"
}

View file

@ -0,0 +1,3 @@
1
00:00:00,000 --> 00:00:02,000
Bem-vindo aos cards

BIN
tests/fixtures/reel-package/reel.mp4 vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,3 @@
# Roteiro do tutorial dos cards
Aponte para os cards de apoio na home.

View file

@ -0,0 +1,190 @@
// @vitest-environment node
import { copyFile, cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
import type { ReelPackage } from '@/lib/reel'
import type { Reel } from '@/payload-types'
import config from '@/payload.config'
import { findReelBySourceHash, ingestReelPackage } from '@/utilities/reels/reelPackageIngest'
import { readReelPackage } from '../../scripts/lib/reel-ingest.mjs'
const FIXTURE_DIR = join(process.cwd(), 'tests/fixtures/reel-package')
const FIXTURE_HASH = '8f900e80b7e4628ea39050031436b25ea337e15924243e21676ea7b25bfeefc3'
const MEDIA_FIELDS = ['video', 'videoWithAudio', 'narrationAudio', 'captions', 'cover'] as const
let payload: Payload
const createdReelIds = new Set<number>()
const createdMediaIds = new Set<number>()
const tempDirs: string[] = []
const trackReel = (reel: Reel): Reel => {
createdReelIds.add(reel.id)
for (const field of MEDIA_FIELDS) {
const value = reel[field]
if (typeof value === 'number') createdMediaIds.add(value)
else if (typeof value === 'object' && value !== null) createdMediaIds.add(value.id)
}
return reel
}
const tempFixture = async (mutate?: (directory: string) => Promise<void>) => {
const directory = await mkdtemp(join(tmpdir(), 'reel-package-int-'))
tempDirs.push(directory)
await cp(FIXTURE_DIR, directory, { recursive: true })
if (mutate) await mutate(directory)
return directory
}
/** The CLI passes the reader's validated output straight to the writer. */
const readPackage = (directory: string) => readReelPackage(directory) as Promise<ReelPackage>
describe('reel package ingest (C195)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const id of createdReelIds) {
await payload.delete({ collection: 'reel', id, overrideAccess: true }).catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'reelMedia', id, overrideAccess: true })
.catch(() => undefined)
}
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
it('creates one published reel with every artifact linked to the same entry', async () => {
const reelPackage = await readPackage(FIXTURE_DIR)
const result = await ingestReelPackage(payload, reelPackage)
expect(result.operation).toBe('created')
const reel = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
expect(reel.id).toBe(result.reelId)
expect(reel.status).toBe('published')
expect(reel.publishedAt).toBeTruthy()
expect(reel.title).toBe('Tutorial dos cards de apoio')
expect(reel.feature).toBe('cards')
expect(reel.sourceHash).toBe(FIXTURE_HASH)
expect(reel.transcript).toContain('Roteiro do tutorial dos cards')
for (const field of ['video', 'cover', 'captions'] as const) {
expect(typeof reel[field], field).toBe('number')
}
for (const field of ['videoWithAudio', 'narrationAudio'] as const) {
expect(reel[field], field).toBeNull()
}
const video = await payload.findByID({
collection: 'reelMedia',
id: reel.video as number,
depth: 0,
overrideAccess: true,
})
expect(video.filename).toBe(`reel-${FIXTURE_HASH}-video.mp4`)
expect(video.mimeType).toBe('video/mp4')
expect(video.alt).toBe('Vídeo do reel "Tutorial dos cards de apoio"')
const cover = await payload.findByID({
collection: 'reelMedia',
id: reel.cover as number,
depth: 0,
overrideAccess: true,
})
expect(cover.alt).toBe('Tela inicial dos cards de apoio do site')
const captions = await payload.findByID({
collection: 'reelMedia',
id: reel.captions as number,
depth: 0,
overrideAccess: true,
})
expect(captions.mimeType).toBe('application/x-subrip')
})
it('re-ingests the same hash into the same reel and media, never a duplicate', async () => {
const first = await readPackage(FIXTURE_DIR)
const firstResult = await ingestReelPackage(payload, first)
const before = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
const second = await readPackage(FIXTURE_DIR)
const secondResult = await ingestReelPackage(payload, second)
const after = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
expect(secondResult.operation).toBe('updated')
expect(secondResult.reelId).toBe(firstResult.reelId)
expect(after.id).toBe(before.id)
expect(after.video).toBe(before.video)
expect(after.cover).toBe(before.cover)
expect(after.captions).toBe(before.captions)
const matches = await payload.find({
collection: 'reel',
where: { sourceHash: { equals: FIXTURE_HASH } },
depth: 0,
overrideAccess: true,
})
expect(matches.totalDocs).toBe(1)
})
it('never resurrects an unpublished reel on re-ingest', async () => {
const first = await readPackage(FIXTURE_DIR)
const { reelId } = await ingestReelPackage(payload, first)
await payload.update({
collection: 'reel',
id: reelId,
data: { status: 'unpublished' },
overrideAccess: true,
})
const second = await readPackage(FIXTURE_DIR)
await ingestReelPackage(payload, second)
const reel = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
expect(reel.status).toBe('unpublished')
expect(reel.title).toBe('Tutorial dos cards de apoio')
})
it('keeps an optional artifact that a later package omitted', async () => {
const withAudio = await tempFixture(async (directory) => {
await copyFile(join(FIXTURE_DIR, 'reel.mp4'), join(directory, 'reel-audio.mp4'))
})
await ingestReelPackage(payload, await readPackage(withAudio))
const before = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
expect(typeof before.videoWithAudio).toBe('number')
await ingestReelPackage(payload, await readPackage(FIXTURE_DIR))
const after = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
expect(after.videoWithAudio).toBe(before.videoWithAudio)
})
it('refuses an incomplete package before any database write', async () => {
const otherHash = 'b'.repeat(64)
const directory = await tempFixture(async (dir) => {
await rm(join(dir, 'narracao.srt'))
const metadata = JSON.parse(await readFile(join(dir, 'metadata.json'), 'utf8'))
await writeFile(
join(dir, 'metadata.json'),
JSON.stringify({ ...metadata, shotListHash: otherHash }),
)
})
await expect(readReelPackage(directory)).rejects.toThrow(/narracao\.srt/)
expect(await findReelBySourceHash(payload, otherHash)).toBeNull()
})
})

View file

@ -0,0 +1,306 @@
// @vitest-environment node
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { reelArtifactAlt, reelArtifactMimetype, reelArtifactStorageFilename } from '@/lib/reel'
import {
assertReelIngestApplyConfirm,
assertReelIngestTarget,
parseReelIngestArgs,
readReelPackage,
reelPackageIssues,
} from '../../scripts/lib/reel-ingest.mjs'
const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
const MP4_BYTES = Buffer.from('fixture-mp4-bytes')
const SRT_BYTES = Buffer.from('1\n00:00:00,000 --> 00:00:01,000\nOlá\n')
const validMetadata = (overrides: Record<string, unknown> = {}) => ({
title: 'Tutorial dos cards',
feature: 'cards',
shotListHash: 'a'.repeat(64),
coverAlt: 'Tela dos cards de apoio da home',
durationSeconds: 42,
createdAt: '2026-09-18T12:00:00.000Z',
...overrides,
})
const tempDirs: string[] = []
const makePackage = async ({
metadata = validMetadata(),
files = { 'reel.mp4': MP4_BYTES, 'capa.png': PNG_BYTES, 'narracao.srt': SRT_BYTES },
omitMetadata = false,
}: {
metadata?: unknown
files?: Record<string, Buffer>
omitMetadata?: boolean
} = {}) => {
const directory = await mkdtemp(join(tmpdir(), 'reel-package-'))
tempDirs.push(directory)
for (const [name, body] of Object.entries(files)) {
await writeFile(join(directory, name), body)
}
if (!omitMetadata) {
await writeFile(
join(directory, 'metadata.json'),
typeof metadata === 'string' ? metadata : JSON.stringify(metadata),
)
}
return directory
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('parseReelIngestArgs (C195)', () => {
it('reads the package directory and defaults to dry-run', () => {
expect(parseReelIngestArgs(['/srv/reels/cards'])).toEqual({
directory: '/srv/reels/cards',
apply: false,
})
})
it('accepts the bare --apply write flag', () => {
expect(parseReelIngestArgs(['/srv/reels/cards', '--apply'])).toEqual({
directory: '/srv/reels/cards',
apply: true,
})
})
it('refuses a missing directory, extra positionals, unknown flags and valued --apply', () => {
expect(() => parseReelIngestArgs([])).toThrow(/diretório do pacote/)
expect(() => parseReelIngestArgs(['a', 'b'])).toThrow(/posicional extra/)
expect(() => parseReelIngestArgs(['/pkg', '--force'])).toThrow(/desconhecido/)
expect(() => parseReelIngestArgs(['/pkg', '--apply=1'])).toThrow(/não aceita valor/)
for (const argv of [[], ['a', 'b'], ['/pkg', '--force'], ['/pkg', '--apply=1']]) {
expect(() => parseReelIngestArgs(argv)).toThrow(/pnpm reels:ingest/)
}
})
})
describe('assertReelIngestApplyConfirm (C195 write gate)', () => {
it('requires the intent flag only on --apply', () => {
expect(() => assertReelIngestApplyConfirm({ apply: false, confirm: false })).not.toThrow()
expect(() => assertReelIngestApplyConfirm({ apply: true, confirm: true })).not.toThrow()
expect(() => assertReelIngestApplyConfirm({ apply: true, confirm: false })).toThrow(
/REELS_INGEST_CONFIRM=1/,
)
expect(() => assertReelIngestApplyConfirm()).not.toThrow()
})
})
describe('assertReelIngestTarget (C195 fail-closed)', () => {
it('accepts the declared environment paired with its exact database', () => {
expect(
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
teqoEnv: 'production',
isTest: false,
}),
).toEqual({ environment: 'production', databaseName: 'teqo_1313' })
expect(
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@postgres:5432/teqo_staging',
teqoEnv: 'staging',
isTest: false,
}),
).toEqual({ environment: 'staging', databaseName: 'teqo_staging' })
})
it('refuses a database that does not match the declared environment', () => {
expect(() =>
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_staging',
teqoEnv: 'production',
isTest: false,
}),
).toThrow(/≠ "teqo_1313"/)
})
it('refuses a missing or unknown TEQO_ENV', () => {
expect(() =>
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
teqoEnv: undefined,
isTest: false,
}),
).toThrow(/TEQO_ENV/)
expect(() =>
assertReelIngestTarget({ databaseUrl: '', teqoEnv: 'production', isTest: false }),
).toThrow(/DATABASE_URL ausente/)
expect(() =>
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo',
teqoEnv: 'dev',
isTest: false,
}),
).toThrow(/TEQO_ENV/)
})
it('refuses the remote override, a remote host and a non-Postgres protocol', () => {
expect(() =>
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
teqoEnv: 'production',
allowRemoteDb: true,
isTest: false,
}),
).toThrow(/ALLOW_REMOTE_DB/)
expect(() =>
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@db.example.com:5432/teqo_1313',
teqoEnv: 'production',
isTest: false,
}),
).toThrow(/allowlist local/)
expect(() =>
assertReelIngestTarget({
databaseUrl: 'mysql://teqo:teqo@127.0.0.1:3306/teqo_1313',
teqoEnv: 'production',
isTest: false,
}),
).toThrow(/postgresql/)
})
it('accepts only teqo*_test databases inside a test run', () => {
expect(
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt195_test',
isTest: true,
}),
).toEqual({ environment: 'test', databaseName: 'teqo_wt195_test' })
expect(() =>
assertReelIngestTarget({
databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_1313',
isTest: true,
}),
).toThrow(/não é de teste/)
})
})
describe('readReelPackage (C195 package contract)', () => {
it('reads a complete package and flags the present artifacts', async () => {
const directory = await makePackage()
const reelPackage = await readReelPackage(directory)
expect(reelPackage.metadata.title).toBe('Tutorial dos cards')
expect(reelPackage.transcriptPath).toBeNull()
const byKind = Object.fromEntries(reelPackage.artifacts.map((a) => [a.kind, a]))
expect(byKind.video).toMatchObject({ field: 'video', required: true, present: true })
expect(byKind.cover).toMatchObject({ field: 'cover', required: true, present: true })
expect(byKind.captions).toMatchObject({ field: 'captions', required: true, present: true })
expect(byKind.narration).toMatchObject({
field: 'narrationAudio',
required: false,
present: false,
})
expect(byKind['video-audio']).toMatchObject({ field: 'videoWithAudio', present: false })
})
it('reads the optional transcript when the package carries it', async () => {
const directory = await makePackage({
files: {
'reel.mp4': MP4_BYTES,
'capa.png': PNG_BYTES,
'narracao.srt': SRT_BYTES,
'roteiro.md': Buffer.from('# Roteiro'),
},
})
const reelPackage = await readReelPackage(directory)
expect(reelPackage.transcriptPath).toBe(join(directory, 'roteiro.md'))
})
it('refuses an incomplete or invalid package listing every pending item', async () => {
const directory = await makePackage({
metadata: validMetadata({ feature: 'inventada', shotListHash: 'abc', coverAlt: '' }),
files: { 'reel.mp4': MP4_BYTES, 'capa.png': PNG_BYTES },
})
await expect(readReelPackage(directory)).rejects.toThrow(
/feature[\s\S]*shotListHash[\s\S]*coverAlt[\s\S]*narracao\.srt/,
)
})
it('refuses a missing metadata.json, an unreadable one and an unknown directory', async () => {
const noMetadata = await makePackage({ omitMetadata: true })
await expect(readReelPackage(noMetadata)).rejects.toThrow(/metadata\.json/)
const brokenMetadata = await makePackage({ metadata: '{ not json' })
await expect(readReelPackage(brokenMetadata)).rejects.toThrow(/ilegível/)
await expect(readReelPackage('/nonexistent/reel-package')).rejects.toThrow(
/diretório do pacote inválido/,
)
})
it('validates the optional duration and date when present', async () => {
const directory = await makePackage({
metadata: validMetadata({ durationSeconds: -1, createdAt: 'ontem' }),
})
await expect(readReelPackage(directory)).rejects.toThrow(/durationSeconds[\s\S]*createdAt/)
})
it('refuses an empty artifact before the dry-run says OK', async () => {
const directory = await makePackage({
files: { 'reel.mp4': Buffer.alloc(0), 'capa.png': PNG_BYTES, 'narracao.srt': SRT_BYTES },
})
await expect(readReelPackage(directory)).rejects.toThrow(
/artefato vazio ou ilegível: reel\.mp4/,
)
})
it('collects issues without a filesystem through reelPackageIssues', () => {
const issues = reelPackageIssues({
metadata: validMetadata({ title: 'x'.repeat(201) }),
fileNames: new Set(['metadata.json', 'reel.mp4', 'capa.png', 'narracao.srt']),
})
expect(issues).toHaveLength(1)
expect(issues[0]).toMatch(/title/)
expect(reelPackageIssues({ metadata: 42, fileNames: new Set() })).toHaveLength(1)
})
})
describe('artifact naming, mimetype and alt (C195)', () => {
it('names stored files deterministically by hash prefix, kind and extension', () => {
const hash = '0123456789abcdef'.repeat(4)
const video = { filename: 'reel.mp4', kind: 'video' } as const
const captions = { filename: 'narracao.srt', kind: 'captions' } as const
expect(reelArtifactStorageFilename(hash, video)).toBe(`reel-${hash}-video.mp4`)
expect(reelArtifactStorageFilename(hash, captions)).toBe(`reel-${hash}-captions.srt`)
expect(reelArtifactStorageFilename(hash, video)).toBe(reelArtifactStorageFilename(hash, video))
})
it('maps the fixed content types of the contract', () => {
expect(reelArtifactMimetype('video')).toBe('video/mp4')
expect(reelArtifactMimetype('video-audio')).toBe('video/mp4')
expect(reelArtifactMimetype('narration')).toBe('audio/mpeg')
expect(reelArtifactMimetype('captions')).toBe('application/x-subrip')
expect(reelArtifactMimetype('cover')).toBe('image/png')
})
it('uses the manifest alt for the cover and derives the others from the title', () => {
const input = { title: 'Tutorial dos cards', coverAlt: 'Tela dos cards' }
expect(reelArtifactAlt({ ...input, kind: 'cover' })).toBe('Tela dos cards')
expect(reelArtifactAlt({ ...input, kind: 'video' })).toBe('Vídeo do reel "Tutorial dos cards"')
expect(reelArtifactAlt({ ...input, kind: 'narration' })).toBe(
'Narração do reel "Tutorial dos cards"',
)
})
})