# Media Pipeline — job, revisão humana, proveniência e marcação

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (load when relevant)
> **Keywords:** media pipeline, media job, geração de mídia, revisão humana, C2PA, SynthID, AI Act, idempotência de mídia, teto de custo, marcação de IA
> **Read by Claude in:** plan e implement (sempre que a feature gerar imagem ou vídeo)

**Verified against:** Microsoft.Extensions.AI 10.9.0 + OpenAI 2.13.0 + Google.GenAI 1.21.0 (ai-pin 2026-09-08); provado por `templates/dotnet/ai-kit/src/Morph.AiKit/Media/` — a máquina de estados (`MediaJob`), a revisão humana (`MediaReview`), a marcação exigida na publicação (`MediaDisclosure` + `MediaJob.Publish`), as duas chaves (`MediaIdempotencyKey` × `MediaHash`), o storage por conteúdo (`MediaStore`) e a acessibilidade `internal` de `IMediaProvider` compilam e são exercitados por teste. Preço, data de desligamento, C2PA/SynthID e o texto legal do AI Act são **verificação documental** contra as páginas de §Como re-verificar este standard. Last-verified: 2026-09-08.

---

**Fronteira, primeiro.** `ai-agents/durable-workflows-hitl.md` cobre HITL e checkpoint **dentro de
um Workflow MAF** — `RequestPort`, `RequestInfoEvent`, `ICheckpointStore`. Este standard é a outra
coisa: um **pipeline de domínio**, cujo mecanismo de job é `BackgroundService`/Channels ou
Hangfire/Quartz com storage persistido. O wiring de job (enqueue, retry, `IDbContextFactory`,
dashboard) já é standard e **não se repete aqui**:
`backend/integrations/hangfire/hangfire-jobs.md`. Use `RequestPort` só quando o passo humano é, de
fato, um nó de um grafo de agente — o que quase nunca é o caso de aprovar uma foto de catálogo.

O que este standard acrescenta ao mecanismo de job é o que o job **não sabe**: quais estados
existem, quem pode aprovar, o que é persistido, o que é marcado e quanto pode custar.

Imagem: `ai-agents/modalities-image-gen.md`. Vídeo: `ai-agents/media-video.md`.

---

## A regra dura

> **Nunca gere mídia dentro de um turno de conversa.** Nem em WhatsApp, nem em chat, nem em webhook
> síncrono. O turno **enfileira e confirma**; a mídia volta depois, revisada.

Três números sustentam a regra, e nenhum deles é opinião:

| Fato | Consequência dentro do turno |
|---|---|
| Imagem: latência documentada de **até 2 minutos** para prompt complexo | O turno estoura antes da resposta |
| Vídeo: **11 s a 6 min**, assíncrono por desenho em todo provedor | Não existe caminho síncrono para tentar |
| Custo **por chamada**, sem teto natural | Um usuário curioso vira uma fatura |
| **Zero revisão possível** dentro do turno | Claim visual errado sai direto para o cliente |

O quarto é o que não tem contorno técnico. Latência se resolve com paciência e custo com orçamento;
"ninguém olhou antes de publicar" não se resolve com nada.

> **De onde vêm os dois números de latência.** "Até 2 minutos" é a frase do guia de geração de imagem
> da OpenAI (`https://developers.openai.com/api/docs/guides/image-generation`); "11 s a 6 min" é a
> faixa da documentação do Veo (`https://ai.google.dev/gemini-api/docs/veo`). Ambas consultadas em
> **2026-09-07**, e ambas na lista de §Como re-verificar este standard. **Nenhum dos dois foi medido
> por esta casa** — gerar mídia para cronometrar custaria dinheiro, e um número medido uma vez numa
> região não vale como SLA.

### Como a regra é presa

**Prosa não prende regra.** O que prende é a forma da API:

- O tipo que **produz bytes** (`IMediaProvider` e as implementações de provedor) é `internal` ao
  assembly do kit, com `InternalsVisibleTo` liberando apenas os testes. Um handler de mensagem no
  projeto consumidor **não consegue nomear** esse tipo — o código não compila.
- A única superfície pública é `IMediaGenerator.EnqueueAsync`, que devolve um **id de job** e nunca
  bytes. `GetAsync` devolve o job, com o estado dele.
- O worker que chama o provedor também é `internal`. Ele é montado por uma **raiz de composição**
  (`MorphMedia.Create(options)`, no exemplar), que devolve as duas interfaces públicas —
  `IMediaGenerator` e `IMediaJobRunner` — e nenhuma forma de alcançar o provider. O projeto registra
  o resultado no seu contêiner numa linha; a montagem **não** é uma extensão de `IServiceCollection`,
  porque isso obrigaria o kit a referenciar o pacote de DI, e o exemplar deste repositório escreve o
  porquê no próprio arquivo. A propriedade que importa é a mesma nos dois desenhos: **não existe
  caminho público até o tipo que produz bytes.**

É a diferença entre "o standard diz para não fazer" e "o compilador recusa". O exemplar está em
`templates/dotnet/ai-kit/src/Morph.AiKit/Media/`, e a acessibilidade é asserida por reflexão num
teste — porque uma regra que ninguém viu falhar pode ser a regra errada.

### O que o tipo prende, e o que ele não prende

Ser exato aqui vale mais que ser tranquilizador, porque é o exato que sobrevive a uma revisão.

**Prende:** um handler não consegue **obter bytes** (a superfície pública devolve id de job) nem
**nomear** o tipo que os produz (`internal` — o código não compila). Nenhuma quantidade de pressa
contorna isso, e a acessibilidade é asserida por reflexão num teste.

**Não prende:** `IMediaJobRunner.RunAsync` é **público** — precisa ser, porque é o
`BackgroundService`/Hangfire do projeto que o dispara. Um handler pode escrever
`await runner.RunAsync(id)` e, com isso, **aguardar a chamada paga dentro do turno**. Ele não vê os
bytes, mas paga a latência: os mesmos até 2 min em imagem, 11 s a 6 min em vídeo.

Ou seja: o tipo fecha **"gere estes bytes agora"** e deixa aberto **"rode este job agora"**. Fechar
o segundo também exigiria esconder o gatilho de quem precisa dele, e o remédio seria pior. A leitura
no seu projeto é literal: **um `await` num runner dentro de um handler de mensagem é o defeito**,
mesmo compilando — e é isso que a revisão de código procura, porque o compilador não vai procurar
por ela.

---

## A máquina de estados

```
Pending ──▶ Generating ──▶ Generated ──┬──▶ Approved ──▶ Published
   │            │                      ├──▶ Rejected
   └────────────┴──▶ Failed            └──▶ RejectedByPolicy
```

| Estado | Significa | Retryable? |
|---|---|---|
| `Pending` | Pedido aceito, nada gasto ainda | — |
| `Generating` | Chamada paga em voo (ou tarefa remota em polling) | — |
| `Generated` | Bytes existem no **seu** storage | — |
| `Approved` | Humano aprovou. **Única** porta para `Published` | — |
| `Rejected` | Humano recusou | não |
| `RejectedByPolicy` | **Bloqueio de moderação do provedor** | **não** |
| `Published` | Publicado, com a marcação **registrada junto** — ver a terceira porta abaixo | — |
| `Failed` | Erro técnico (429, 5xx, timeout, download quebrado) | **sim** |

**`RejectedByPolicy` não é `Failed`, e a distinção é dinheiro.** Um bloqueio de moderação é uma
resposta definitiva: retentá-lo gasta para receber o mesmo "não" — e nos provedores que cobram o
bloqueio, gasta duas vezes. Modelar bloqueio como erro técnico é como um retry loop vira uma fatura.

**`Approve(reviewer)` é a única transição para `Approved`**, e `Publish()` devolve falha a partir de
qualquer estado que não seja `Approved`. É assim que "há revisão humana" deixa de ser uma frase no
README e vira um caminho que o código não tem.

**A publicação tem três portas, e não uma.** No exemplar, a assinatura é
`Publish(MediaDisclosure? disclosure, DateTimeOffset publishedAt)`, e ela recusa:

1. **estado** diferente de `Approved` — inclusive `Generated`, que é o atalho que alguém sempre quer;
2. **finalidade não publicável** — `Draft` se declarava "nunca publicável" num comentário que nada
   cobrava;
3. **marcação ausente** quando a finalidade a exige (`RequiresDisclosure()`: catálogo e marketing).

A ordem importa e é testada: um job não aprovado reclama da revisão humana, **não** da marcação —
mandar consertar o rótulo de algo que ninguém revisou manda consertar a coisa errada.

---

## As duas chaves de idempotência

Duas chaves distintas, e confundi-las é o defeito caro:

| Chave | Fórmula | O que dedupe |
|---|---|---|
| **De pedido** | `sha256(provider + model + prompt normalizado + sha256 de cada referência + opções)` | A **chamada paga** |
| **De conteúdo** | `sha256(bytes)` | O **storage** |

A chave **de pedido** é consultada **antes** de chamar o provedor: se já existe job com a mesma
chave e resultado, reuse o artefato em vez de pagar de novo. É o que faz um retry — de fila, de
worker reiniciado, de webhook duplicado — custar zero.

A chave **de conteúdo** é o endereço do arquivo: `PutAsync` do mesmo conteúdo devolve o caminho já
existente sem regravar. Dois pedidos diferentes podem produzir bytes idênticos; um pedido só sempre
produz o mesmo pedido.

Trocar a foto de referência muda a chave de pedido mesmo com prompt idêntico — por isso o sha256 de
**cada** referência entra na fórmula, e não a contagem delas.

---

## Storage

- **Bytes no blob próprio**, sempre. URL de provedor expira: o Veo apaga o arquivo em 2 dias, e todo
  agregador devolve URL temporária.
- O banco guarda **caminho relativo + sha256**, nunca URL absoluta de provedor e nunca o caminho
  absoluto do disco de hoje.
- O download acontece **no mesmo passo** que detectou a conclusão, antes de marcar `Generated`. Um
  job em `Generated` sem bytes no seu storage está mentindo sobre o próprio estado.

---

## Proveniência persistida

Um registro por job, gravado no momento da geração:

| Campo | Por quê |
|---|---|
| `provider`, `modelId` | Sem eles, nenhuma auditoria de "que modelo gerou isto" é possível |
| `promptFinal` | O texto que foi enviado, não o template |
| `revisedPrompt` | O que o provedor reescreveu — frequentemente diferente do enviado |
| `refsHash` | sha256 das referências, o mesmo que entra na chave de pedido |
| `options` | Tamanho, qualidade, formato, background |
| `responseId` | O id do lado do provedor, para abrir chamado |
| `estimatedCostUsd` | Gravado **antes** da chamada — ver §Teto de custo |
| `createdAt` | — |
| `c2paPreserved` | Se os metadados sobreviveram ao seu pipeline |

**Metadados C2PA são preservados: nada de re-encode destrutivo.** Um resize ou uma recompressão
"para economizar CDN" apaga a credencial de conteúdo que o provedor embutiu, e você fica sem a única
prova técnica de origem que tinha.

---

## Marcação, C2PA e SynthID

As imagens das duas famílias já vêm com credencial embutida — **C2PA Content Credentials + SynthID**
na OpenAI, **SynthID** em toda imagem Gemini — e a OpenAI expõe um endpoint
`content_provenance_check` que verifica ambos.

**E isso não basta**, por três razões escritas na própria documentação:

1. **C2PA cai com screenshot e com re-encode.** Qualquer trânsito por uma ferramenta que reprocessa
   o arquivo apaga a credencial. SynthID sobrevive melhor, mas não é infalível.
2. **`not_detected` não significa "feito por humano".** A ausência de marca é ausência de
   informação, nunca prova de origem.
3. **A credencial é invisível para quem olha a imagem.** Um cliente no WhatsApp não roda verificador
   de proveniência.

Por isso **a marcação visível é obrigação separada**, aplicada no ponto de publicação e não no de
geração.

### Como esta obrigação é presa — e até onde ela chega

Uma obrigação legal escrita só em prosa é a mesma coisa que nenhuma obrigação: alguém esquece, o
teste fica verde, e o silêncio vira aprovação. No exemplar ela é **um parâmetro obrigatório da
transição**, não um lembrete:

```csharp
public sealed record MediaDisclosure(string Text, string Surface, DateTimeOffset AppliedAt);

// Texto E superfície são exigidos na construção: "marcado" sem dizer ONDE não responde
// se alguém consegue ver, e texto em branco é o campo preenchido "para passar".
public static bool RequiresDisclosure(this MediaPurpose purpose)
    => purpose is MediaPurpose.CatalogPhoto or MediaPurpose.Marketing;
```

`MediaJob.Publish(null, at)` sobre um job de catálogo **aprovado** devolve falha nomeando a
marcação. A mutação foi rodada: removida a terceira porta, os dois casos de
`MediaDisclosureTests.PublishFailsWithoutADisclosureWhenThePurposeDemandsOne` morrem
(`CatalogPhoto` e `Marketing`), a suíte cai de 220/0 para 220/2, e a mensagem de falha diz por quê.

**O limite, dito em voz alta.** Isto prova que a **decisão foi tomada e gravada**, com texto e
superfície nomeados — não que o rótulo chegou aos olhos de alguém. Nenhum tipo em C# alcança um
pixel: a renderização é do aplicativo consumidor. O que o mecanismo elimina é o caminho silencioso
"esquecemos de marcar", que passa a ser uma recusa na publicação.

### EU AI Act art. 50

O Regulamento (UE) 2024/1689 exige, no art. 50, transparência sobre conteúdo sintético: imagem
gerada que **possa parecer autêntica** sobre produto, pessoa ou lugar exige aviso claro no primeiro
contato.

**Ressalva de fonte, declarada em vez de escondida.** O art. 113 do regulamento diz *"It shall apply
from 2 August 2026"*, e o Capítulo IV — onde vive o art. 50 — **não tem data própria** naquele
artigo; daí a leitura de que ele segue a data geral de **2026-08-02**. Essa derivação é **leitura do
artigo, não citação de um considerando explícito**, e foi feita sobre
`https://artificialintelligenceact.eu/article/113/`, uma fonte secundária que reproduz o texto.
**Antes de o seu projeto assumir consequência jurídica** — prazo, multa, obrigação de rótulo —
confira o mesmo artigo no EUR-Lex e registre a leitura com a data. Este standard afirma o
mecanismo (marcar é obrigação de desenho); ele **não** é parecer jurídico, e escrever um número de
multa aqui como se fosse fato verificado seria exatamente o defeito que ele combate.

Regra de desenho, independente do prazo: **produto real sobre cena gerada é declarado como tal**, e
nenhuma geração cria claim visual de material, medida ou cor que o produto não tem. Isso é
codificado — uma lista de atributos bloqueados no validador do prompt — e não confiado ao texto do
prompt.

---

## Revisão humana

- `Approved` **exige humano** antes de `Published` para qualquer mídia de catálogo ou peça pública.
- O revisor é identificado e carimbado com data no `MediaReview` — "aprovado" sem quem e sem quando
  não é revisão, é campo booleano.
- **Juiz LLM é pré-filtro, nunca substituto.** Ele derruba as candidatas obviamente ruins e economiza
  o tempo do humano; ele não assume a responsabilidade de um claim visual.
- O gate humano natural é a **escolha entre N candidatas** geradas em qualidade baixa. A escada de
  custo e a revisão são o mesmo passo, não dois.

---

## Teto de custo

- **Orçamento por tenant/dia em unidades**, não em reais: imagens × qualidade para imagem, segundos
  de saída para vídeo. Unidade é o que o provedor cobra; moeda é derivada.
- **`estimatedCostUsd` é gravado antes da chamada**, com a tarifa da tabela de preço do standard da
  modalidade (`modalities-image-gen.md` §Custo por chamada, `media-video.md` §Custo por segundo).
  Estimar depois é contabilidade; estimar antes é controle.
- Estouro de teto é recusa **no enqueue**, com erro nomeado — nunca uma chamada que já saiu e um
  alerta que chega depois.

---

## Telemetria

Quatro séries, por modelo, e cada uma responde a uma pergunta que alguém vai fazer:

| Série | Pergunta |
|---|---|
| Latência | "Por que o cliente ainda não recebeu?" |
| Custo | "Quanto gastamos com isto no mês?" |
| Taxa de rejeição por política | "Nosso prompt está pedindo o que o provedor não faz?" |
| Taxa de aprovação humana | "O modelo que escolhemos serve para este trabalho?" |

A última é a que ninguém instrumenta e a que mais decide: um modelo com 30 % de aprovação humana é
mais caro que um modelo com o dobro da tarifa e 90 %.

---

## Testes

- **Nenhum teste chama API paga.** Provider fake implementando a mesma abstração interna, store em
  memória. Isso não é preferência: é a única forma de a suíte rodar em CI sem chave e sem fatura.
- O que se testa é o **mecanismo**: que `Publish` falha fora de `Approved`; que `RejectedByPolicy`
  não oferece retry; que a chave de pedido é estável para o mesmo pedido e muda quando qualquer campo
  participante muda; que gravar o mesmo conteúdo duas vezes grava uma.
- **Teste de acessibilidade por reflexão** para a regra dura: o tipo que produz bytes não é público.
  Torná-lo público tem de derrubar esse teste — e vale rodar a mutação uma vez para ver.

---

## Checklist (verifiable by morph-eval)

- [ ] Nenhum handler síncrono consegue nomear o tipo que produz bytes
- [ ] Nenhum `await runner.RunAsync(...)` dentro de handler de turno — **o tipo não fecha esta
      porta** (o runner é público de propósito); quem a fecha é a revisão de código
- [ ] A superfície pública devolve id de job, nunca bytes
- [ ] Os oito estados existem, e `Publish` falha fora de `Approved`
- [ ] `Publish` recusa sem marcação quando a finalidade a exige — e o teste dessa recusa existe
- [ ] Bloqueio de moderação é `rejected_by_policy`, sem retry
- [ ] Chave de **pedido** consultada antes da chamada paga; chave de **conteúdo** endereça o storage
- [ ] Bytes no blob próprio; banco com caminho relativo + sha256
- [ ] Proveniência com os nove campos, C2PA preservado sem re-encode
- [ ] Marcação visível aplicada no ponto de publicação, com texto **e** superfície nomeados
- [ ] `Approve` carimba revisor e data
- [ ] `estimatedCostUsd` gravado antes da chamada; teto por tenant/dia recusa no enqueue
- [ ] Telemetria com as quatro séries, incluindo taxa de aprovação humana
- [ ] Nenhum teste toca API paga

---

## Como re-verificar este standard

| Página | O que conferir |
|---|---|
| `https://developers.openai.com/api/docs/guides/content-provenance` | Se `content_provenance_check` mudou, e a afirmação de que C2PA cai com re-encode |
| `https://ai.google.dev/gemini-api/docs/image-generation` | Se SynthID continua em toda imagem Gemini |
| `https://eur-lex.europa.eu/eli/reg/2024/1689/oj` | O art. 50 e o art. 113 no texto oficial — é a fonte primária que a §EU AI Act ainda não tem |
| `https://ai.google.dev/gemini-api/docs/veo` | A retenção de 2 dias citada em §Storage |
| `modalities-image-gen.md` e `media-video.md` | As tabelas de preço que este standard referencia sem copiar |

Ao terminar, atualize `Last-verified` **e** o `ai-pin` citado no header.

---

## References

- `ai-agents/modalities-image-gen.md` — a chamada de imagem, os aliases e o custo
- `ai-agents/media-video.md` — a chamada de vídeo, o polling e o custo por segundo
- `backend/integrations/hangfire/hangfire-jobs.md` — o mecanismo de job: enqueue, retry,
  `IDbContextFactory`, dashboard. Este standard **cita** e não reescreve
- `ai-agents/durable-workflows-hitl.md` — HITL dentro de um Workflow MAF; a outra coisa
- `ai-agents/production.md` — custo por chamada, teste sem rede, observabilidade
- `ai-agents/batching.md` — o desconto de lote que o catálogo usa

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/media-pipeline.md v1.0*
