---
id: SVC-MEDIA-UI
tipo: ui
onda: 2
status: concluido
unidades: [SVC-MEDIA]
fontes:
  - V2/Services/Media/Views/components/
depende_de: [SVC-MEDIA, SVC-STORAGE, SVC-GALLERY]
consumidores: [MOD-CONEXOES-ASSOCIADOS, MOD-CONEXOES-PARCEIROSINSTITUCIONAIS, MOD-CONEXOES-UNIDADEREGIONAL, MOD-INSTITUCIONAL-HISTORIA, MOD-INSTITUCIONAL-SOBRENOS, MOD-LAYOUTS-HOMEPAGE, MOD-PUBLICACOES-AGENDA, MOD-PUBLICACOES-NOTICIAS, MOD-PUBLICACOES-TRABALHECONOSCO, MOD-SUPPORT-STRUCTURALLANDINGEDITOR, SVC-GALLERY, SVC-SEO, SVC-LANDINGSECTIONSEDITOR]
criticidade: alta
dono: SvcStorageMedia
---

# Contrato de UI — componentes Media

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Renderiza imagem responsiva (`picture`), campo único/múltiplo de mídia com preview e seleção na Galeria (`media-input`, `media-group-input`) e visualização contextual de imagem/PDF/áudio/planilha (`media-viewer`, `audio`). |
| Entrada | `@include('v2-service-media::components.<nome>', [props])`; eventos de Galeria/picker; interação do usuário (drop, clique, crop, player). |
| Saída | HTML (`<picture>`, `<section>`, `<figure>`, `<audio>`, modal), inputs ocultos com IDs/URLs, eventos jQuery/CustomEvent, requisições `POST cms/v2/gallery`. |
| Efeitos colaterais | `@push('scripts')`/`@push('head-styles')`; abertura do `gallery-picker`; upload AJAX da Galeria; novos IDs ocultos em `media-group-input`; nenhuma escrita direta no storage pelo componente. |
| Particularidade central | `media-input` e `media-group-input` **não usam** `MediaInputer` no navegador: delegam o upload à Galeria (`gallery-picker` ou `POST cms/v2/gallery`). O componente que processa é o servidor da Galeria. |
| Como se integra | Namespace `v2-service-media::components.*` registrado por `V2ServiceProvider`; JS global em `public/assets/js/`; jQuery, Bootstrap 5 e Cropper.js. |
| Se removido, quebra | 58 inclusões de `picture`, 36 de `media-input`, 4 de `media-group-input` em módulos/portal; a Galeria perde os previews de seleção; snippets gravados por `ImageRedigestService` deixam de renderizar. |
| Superfície pública | `components.picture`, `components.media-input`, `components.media-group-input`, `components.media-viewer`, `components.audio`; eventos `media-selected.lotep` e `lotep:open-gallery-picker-global-media-picker`. |

## 1. Efeito

### 1.1 O que cada componente renderiza

| Componente | HTML principal | Dados persistidos no formulário | JS |
|---|---|---|---|
| `picture` | `<picture>` com zero ou N `<source type="image/webp">` e `<img>` de fallback | nenhum | nenhum |
| `media-input` | `<section>` com label, 2 hidden inputs, dropzone, preview e ajuda | `name` = ID da mídia; `name + '_alt'` = alt | `media-input.js` |
| `media-group-input` | `<section>` com N hidden inputs, dropzone múltipla, grid de previews e modal Cropper | `name + '_ids[]'` para cada mídia | `media-group-input.js` + Cropper |
| `media-viewer` | `<figure>` com frame por tipo e `<figcaption>` de ações | nenhum | jQuery inline (narração) |
| `audio` | player customizado com `<audio>` oculto, botões, ranges e accordion | nenhum | jQuery inline |

### 1.2 Contrato observável

- `picture` sempre emite um `<img>` com `src` do original; se `variants` for um array não vazio, emite `<source>` WebP (`picture.blade.php:51-72`).
- `media-input` dispara `media-selected.lotep` no elemento raiz quando uma mídia é escolhida (`public/assets/js/media-input.js:84-90`). O payload é `{id, url, alt, mimeType, description}`.
- `media-input` abre a Galeria via `window.dispatchEvent(new CustomEvent('lotep:open-gallery-picker-global-media-picker', {detail:{callback, ownerModule, allowedMimeTypes}}))` (`media-input.js:129-138`).
- `media-group-input` envia `POST` para `data-store-url` (rota `v2.gallery.store`) com `file`, `file_alt`, `description`, `is_shared`, `_token` (`media-group-input.js:54-84`).
- Nenhum componente emite `media-cleared.lotep`; `resetMedia()` apenas limpa os campos (`media-input.js:93-100`).

### 1.3 O que os componentes deliberadamente NÃO fazem

- `media-input` não faz upload AJAX próprio, apesar do comentário `@description Drag & Drop com upload AJAX direto em segundo plano` (`media-input.blade.php:3`); ele abre o picker da Galeria ou entrega o arquivo ao picker (`media-input.js:128-138,168-186`).
- Não validam MIME no servidor; a validação é do endpoint da Galeria (`GalleryController.php:88-93`).
- Não removem a mídia do storage ao limpar o campo; apenas apagam o ID do formulário (`media-input.js:93-100`; `media-group-input.js:225-232`).
- `picture`/`media-viewer`/`audio` não fazem requisição; só montam URLs com `v2_storage_url()`.
- `media-group-input` não envia `owner_module`; o controller assume `'global'` (`GalleryController.php:44-55`), apesar de o Blade expor `data-owner`.

## 2. Abordagem

### 2.1 Estratégia

Três componentes são “PHP puro” (`picture`, `media-viewer`, `audio`) e dois são widgets JS progressivos (`media-input`, `media-group-input`). Os widgets usam o padrão: o Blade imprime DOM com IDs derivados de `$name` e `data-*`; o JS global faz `each` nesses seletores no `document.ready` e mantém estado local por instância. O upload é sempre delegado à Galeria; o componente só transporta IDs.

```mermaid
sequenceDiagram
    participant U as Usuário
    participant MI as media-input
    participant GP as gallery-picker (SVC-GALLERY)
    participant GS as GalleryService
    participant Media as MediaInputer
    participant DB as v2_gallery_media
    U->>MI: drop/seleciona arquivo
    MI->>GP: lotep:open-gallery-picker-global-media-picker (droppedFile?)
    GP->>GS: POST cms/v2/gallery
    GS->>Media: processUpload(...)
    Media-->>GS: path + variants
    GS->>DB: insert
    GS-->>GP: JSON do registro
    GP->>MI: callback(id,url,alt,mimeType,description)
    MI->>MI: hidden name=id, name_alt=alt
    MI-->>U: preview + media-selected.lotep
```

### 2.2 Mecânica passo a passo

1. `media-input.blade.php` normaliza defaults (`currentId`, `currentUrl`, `currentAlt`, `ownerModule`, `accept`, `required`) e detecta o tipo inicial por substring da URL (`:13-26`).
2. Imprime dois hidden inputs: `name` (ID) e `name_alt`; o primeiro recebe `required` quando `$required && empty($currentId)` (`:46-47`).
3. A dropzone é um `<div role="button" tabindex="0">` com um `<input type="file">` transparente cobrindo a área (`:50-58`).
4. `media-input.js` faz `each` em `.js-media-input`, lê `data-name`/`data-owner`/`data-accept`/`data-no-picker` e resolve os elementos por ID (`media-input.js:1-23`).
5. Clique na dropzone: se `noPicker`, retorna; senão previne o clique e dispara o CustomEvent do picker (`:120-139`).
6. Arquivo escolhido no input: valida extensão/MIME contra `accept` (`:141-169`); se válido, dispara o picker com `droppedFile` (`:171-186`).
7. O callback do picker chama `selectMedia(id,url,alt,mimeType,description)`, que grava os hidden inputs, alterna preview e dispara `media-selected.lotep` (`:26-91`).
8. `resetMedia()` limpa hidden inputs e volta ao prompt (`:93-100`); não dispara evento.
9. `media-group-input` imprime `data-store-url` com `route('v2.gallery.store')`, `data-input-name = name.'_ids'`, N hidden inputs existentes e modal Cropper (`media-group-input.blade.php:17-44,83-125`).
10. `media-group-input.js` mantém `uploadQueue`, `processing`, `cropperInstance`, `currentFile` e `cropModal` por instância (`media-group-input.js:12-27`).
11. Imagens abrem o modal Cropper; não-imagens vão direto para `performUpload(file,'','',true)` (`:130-142`).
12. `performUpload` envia FormData para a Galeria, adiciona preview com o `id` e a URL retornados e processa a fila (`:54-85`).
13. Confirmação do crop usa `getCroppedCanvas().toBlob(...)` e envia o blob como novo `File` (`:145-165`).
14. Remoção no grid apaga o hidden input correspondente e o card, sem chamar o servidor (`:225-232`).

### 2.3 Trechos de código pivô

```blade
{{-- V2/Services/Media/Views/components/picture.blade.php:51-69 — sources WebP + fallback. --}}
<picture>
    @if(count($variants) > 0)
        @foreach($variants as $width => $variantPath)
            <source 
                @if($width > $minWidth) media="(min-width: {{ $width }}px)" @endif
                srcset="{{ v2_storage_url($variantPath) }}" 
                type="image/webp"
                @if($isFirstSource && !$lazy) fetchpriority="high" @endif>
            @php $isFirstSource = false; @endphp
        @endforeach
    @endif

    {{-- Fallback para navegadores sem suporte a <picture> ou WebP --}}
    <img 
        src="{{ v2_storage_url($originalPath) }}" 
        alt="{{ $altValue }}" 
        class="{{ $class }}" 
        style="{{ $style }}"
        @if($imgLazy) loading="lazy" @endif
```

O componente ordena as variantes de forma decrescente (`krsort`, `:32`) e emite um `<source>` por largura; a menor largura não recebe `media` e funciona como base. Não há `sizes` nem descritor de largura no `srcset`: a seleção é feita **só** por `media="(min-width: Npx)"`. `style` é impresso sem escape; `alt`/`class` usam escape de Blade.

```blade
{{-- V2/Services/Media/Views/components/media-input.blade.php:29-47 — raiz, hidden inputs e dropzone. --}}
<section class="v2-media-input-wrapper mb-3 js-media-input" 
         id="wrapper-{{ $name }}" 
         aria-labelledby="label-container-{{ $name }}"
         data-name="{{ $name }}"
         data-owner="{{ $ownerModule }}"
         data-accept="{{ $accept }}"
         data-no-picker="{{ isset($noPicker) && $noPicker ? '1' : '0' }}">
    <div class="mb-0">
        <label id="label-container-{{ $name }}" class="form-label fw-bold mb-2">
            {{ $label }}
            @if($required) 
                <span class="text-danger" title="Campo Obrigatório" aria-hidden="true" style="margin-left: 5px;">*</span>
                <span class="visually-hidden">Obrigatório</span>
            @endif
        </label>
        
        {{-- Input Oculto contendo o ID físico da mídia da Galeria --}}
        <input type="hidden" name="{{ $name }}" id="media-id-{{ $name }}" value="{{ $currentId }}" @if($required && empty($currentId)) required @endif>
        <input type="hidden" name="{{ $name }}_alt" id="media-alt-{{ $name }}" value="{{ $currentAlt }}">
```

O ID do formulário é o **ID da mídia na Galeria**, não o `file_path`. O hidden `required` é impresso quando não há mídia atual, mas `input type=hidden` é barred from constraint validation no HTML: o atributo é ignorado pelo navegador e a obrigatoriedade precisa ser validada no servidor. `$currentUrl` é usado como `src` do preview sem passar por `v2_storage_url()`; o chamador deve passar a URL pronta.

```blade
{{-- V2/Services/Media/Views/components/media-group-input.blade.php:28-44 — contrato de dados do widget. --}}
<section class="v2-media-group-input-wrapper mb-3 position-relative js-media-group-input"
         id="group-wrapper-{{ $name }}"
         data-name="{{ $name }}"
         data-owner="{{ $ownerModule }}"
         data-accept="{{ $accept }}"
         data-max-files="{{ $maxFiles }}"
         data-input-name="{{ $inputName }}"
         data-existing-count="{{ count($existingIds) }}"
         data-store-url="{{ route('v2.gallery.store') }}">
    <label class="form-label fw-bold mb-2">{{ $label }}</label>

    {{-- Hidden inputs para UUIDs --}}
    <div id="group-ids-container-{{ $name }}">
        @foreach($existingIds as $index => $id)
            <input type="hidden" name="{{ $inputName }}[]" value="{{ $id }}" class="group-media-id-{{ $name }}">
        @endforeach
    </div>
```

`inputName` é sempre `name . '_ids'` (`:18`), portanto o POST do formulário recebe `<name>_ids[]`. A rota `v2.gallery.store` é resolvida no render; se a Galeria não estiver registrada, o Blade lança erro de rota. Os comentários dizem “UUIDs”, mas `v2_gallery_media.id` é `increments` (`2026_06_28_000000_create_v2_gallery_media_table.php:24`).

```js
// public/assets/js/media-group-input.js:54-72 — upload imediato para a Galeria.
function performUpload(file, alt, desc, shared) {
    var formData = new FormData();
    formData.append('file', file);
    formData.append('file_alt', alt || '');
    formData.append('description', desc || '');
    formData.append('is_shared', shared ? 'true' : 'false');
    formData.append('_token', $('meta[name="csrf-token"]').attr('content'));

    $.ajax({
        url: storeUrl,
        type: 'POST',
        data: formData,
        processData: false,
        contentType: false,
        success: function(resp) {
            if (resp && (resp.id || (resp.media && resp.media.id))) {
                var id = resp.id || resp.media.id;
                var url = resp.url || resp.media_url || (resp.file_path ? '/storage/' + resp.file_path : '');
                addPreviewItem(id, url);
```

O endpoint real devolve o registro cru (`id`, `file_path`, `alt_text`, ...), sem `url`/`media` (`GalleryController.php:102-104`), então o preview usa `'/storage/' + file_path`. Em S3/CDN isso aponta para o caminho errado; o correto seria `v2_storage_url` no servidor. O upload é **imediato**, antes do submit do formulário; cancelar o formulário deixa a mídia na Galeria.

```js
// public/assets/js/media-input.js:79-90 — estado e evento de seleção.
$prompt.addClass('d-none');
$progress.addClass('d-none');
$error.addClass('d-none');
$selected.removeClass('d-none');

$wrapper.trigger('media-selected.lotep', [{
    id: id,
    url: url,
    alt: alt,
    mimeType: mimeType,
    description: description
}]);
```

O evento é disparado no **wrapper** (`#wrapper-<name>`), não no `document`; consumidores precisam escutar no elemento ou delegar a partir de um ancestral. O payload não inclui `file_path` separado: `url` pode ser `/storage/<path>` ou a URL devolvida pela Galeria. Não há evento de limpeza correspondente.

```blade
{{-- V2/Services/Media/Views/components/media-viewer.blade.php:19-38 — detecção de tipo e ações. --}}
if (!isset($type) || empty($type)) {
    $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
    $audioExts = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'webm', 'wma'];
    $spreadsheetExts = ['xlsx', 'xls', 'csv', 'ods'];
    $pdfExts = ['pdf'];

    if (in_array($ext, $pdfExts)) {
        $type = 'pdf';
    } elseif (in_array($ext, $audioExts)) {
        $type = 'audio';
    } elseif (in_array($ext, $spreadsheetExts)) {
        $type = 'spreadsheet';
    } else {
        $type = 'image';
    }
}

$fileUrl = v2_storage_url($path);
$filename = basename($path);
```

A detecção é só por extensão; `type` explícito vence. `$variants` só é aceito se já for array (`:16`), então JSON string cai em `[]` e a imagem perde os WebP. `$disk` é lido mas não usado: `$fileUrl` sempre passa por `v2_storage_url()` (`:13,36`). `$noActions` não está documentado no cabeçalho, mas suprime o `<figcaption>`.

```js
// V2/Services/Media/Views/components/media-viewer.blade.php:293-304 — accordion da narração.
$(document).ready(function() {
    $('.v2-media-viewer .js-audio-narration-toggle').on('click', function(e) {
        e.preventDefault();
        var $btn = $(this);
        var $textContainer = $btn.closest('.v2-media-viewer').find($btn.attr('aria-controls'));
        if ($textContainer.length) {
            var visible = $textContainer.is(':visible');
            $textContainer.slideToggle(200);
            $btn.attr('aria-expanded', !visible);
            $btn.find('span').text(visible ? 'Exibir descrição da narração' : 'Ocultar descrição da narração');
        }
    });
});
```

Bug confirmado: o botão tem `aria-controls="narration-text-<md5>"` **sem `#`** (`media-viewer.blade.php:127-133`), e o JS usa `.find($btn.attr('aria-controls'))`, que interpreta o valor como seletor de tag. O resultado é `length === 0` e o texto nunca abre. O accordion equivalente do componente `audio` usa uma classe (`.js-text-container`) e funciona.

```js
// V2/Services/Media/Views/components/audio.blade.php:141-153 — init por instância.
const container = $('#{{ $id }}');
const audio = $('#{{ $id }}-element')[0];
const playBtn = container.find('.js-play-pause');
const progress = container.find('.js-progress-bar');
const currentTimeEl = container.find('.js-current-time');
const durationEl = container.find('.js-duration');
const skipBwd = container.find('.js-skip-backward');
const skipFwd = container.find('.js-skip-forward');
const muteBtn = container.find('.js-mute');
const volumeBar = container.find('.js-volume-bar');
const speedOpts = container.find('.js-speed-opt');
const currentSpeedEl = container.find('.js-current-speed');
const wave = container.find('.js-wave span');
```

O `$id` é `'v2-audio-player-' . uniqid()` (`audio.blade.php:16`), gerado a cada render; os seletores são globais no documento, mas escopados por `container`. O script inline é empilhado uma vez **por componente** (`@push('scripts')`), sem dedupe, e não há teardown: múltiplas instâncias registram handlers independentes, o que é correto, mas o CSS/JS é duplicado no HTML.

### 2.4 Custos e ciclo de vida

- `picture`, `media-viewer` e `audio` são renders puros; custo é o número de variantes e `v2_storage_url` (consulta config, sem I/O).
- `media-input.js` inicializa O(n) wrappers e mantém closures; sem observers, sem teardown. Se o HTML for inserido dinamicamente após `document.ready`, não há init.
- `media-group-input.js` mantém fila **sequencial** por instância; um upload por vez. O modal Cropper é movido para `document.body` na primeira abertura (`media-group-input.js:129-137`).
- `media-viewer` e `audio` empilham CSS/JS inline a cada render; uma página com N players tem N blocos de `<style>`/`<script>`.
- `media-input` carrega `media-input.js` no máximo uma vez por request via `$GLOBALS['media_input_scripts_rendered']` (`media-input.blade.php:108-114`); `media-group-input` faz o mesmo para Cropper CSS/JS (`media-group-input.blade.php:23-26,132-136`).

## 3. Particularidades

### 3.1 O que é exclusivo destes componentes

- `picture` é o único renderizador sancionado de variantes WebP e o destino do snippet gravado por `ImageRedigestService`; qualquer mudança de props quebra os snippets já persistidos.
- `media-input` é o único campo de mídia única que carrega **ID da Galeria + alt** em dois hidden inputs; é consumido por 36 templates.
- `media-group-input` é o único widget que faz upload múltiplo com crop e alt por arquivo, e o único que posta direto em `v2.gallery.store`.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `CT-MEDIA-01` | `media-input` grava `name=<id>` e `name_alt=<alt>`; o ID é o da Galeria. | O backend precisa resolver o ID para `v2_gallery_media`. | `media-input.blade.php:45-47` |
| `CT-MEDIA-02` | `media-group-input` grava `name_ids[]`. | O request recebe um array; nome diferente quebra o vínculo. | `media-group-input.blade.php:18,40-43` |
| `CT-MEDIA-03` | `picture` só usa `variants` se for array; chaves são largura ou `'original'`. | JSON string é ignorada. | `picture.blade.php:28-35`; `ImageOptimizer.php:90-116` |
| `CT-MEDIA-04` | Com variantes e `lazy=false`, o `<img>` continua `loading="lazy"` e o primeiro source recebe `fetchpriority="high"`. | Evita double-download do original. | `picture.blade.php:37-48,58,69` |
| `CT-MEDIA-05` | `media-input` não emite `media-cleared.lotep`. | Consumidores que limpam SEO ao remover não são notificados. | `media-input.js:93-100` |
| `CT-MEDIA-06` | `noPicker=1` desabilita a abertura da Galeria, mas o `<input type=file>` continua clicável e o `change` retorna sem ação. | Campo fica praticamente somente leitura, sem mensagem. | `media-input.blade.php:35,58`; `media-input.js:124-126,174-176` |
| `CT-MEDIA-07` | O evento de seleção é emitido no wrapper, não no `document`. | Quem escuta no `document` não recebe; quem usa classe errada também não. | `media-input.js:84`; `Noticias/Views/cms/includes/scripts.blade.php:109-110` |
| `CT-MEDIA-08` | `media-group-input` usa `/storage/` fixo para preview quando a resposta não traz URL. | Em S3/CDN o preview quebra. | `media-group-input.js:71`; `GalleryController.php:102-104` |
| `CT-MEDIA-09` | O upload do grupo é imediato; remover o card não apaga a mídia nem o registro. | Cancelar o formulário deixa lixo na Galeria. | `media-group-input.js:54-85,225-232` |
| `CT-MEDIA-10` | `media-viewer`/`audio` recebem `$disk` e o ignoram. | A URL sempre vem do `StorageService` global. | `media-viewer.blade.php:13,36`; `audio.blade.php:12-13` |
| `CT-MEDIA-11` | `media-viewer` detecta tipo por extensão e trata desconhecido como imagem. | PDF/áudio sem extensão viram `<img>` quebrada. | `media-viewer.blade.php:18-34` |
| `CT-MEDIA-12` | `audio` gera ID com `uniqid()` a cada render. | Não é possível mirar o player por ID estável. | `audio.blade.php:16` |
| `CT-MEDIA-13` | `picture` imprime `style` sem escape. | Conteúdo controlado pelo usuário no `style` vira vetor de HTML. | `picture.blade.php:68` |
| `CT-MEDIA-14` | `media-group-input` não envia `owner_module` nem `allowed_mime_types`; só `file`, `file_alt`, `description`, `is_shared`, `_token`. | O controller assume `owner_module='global'`, `allow_shared=true` e lista de MIME vazia. | `media-group-input.js:54-67`; `GalleryController.php:44-55` |

### 3.3 Casos-limite

- **`variants` vazio:** `picture` emite só `<img>`; `media-viewer` usa o fallback visual do PDF ou o frame de imagem.
- **`variants` como string JSON:** ignorado (`picture.blade.php:28`; `media-viewer.blade.php:16`).
- **`originalPath` vazio:** `v2_storage_url('')` devolve `''`; o `<img src="">` recarrega a própria página em alguns navegadores.
- **Arquivo inexistente:** nenhum componente verifica; a URL é montada mesmo assim.
- **Nome do campo repetido:** os IDs DOM (`wrapper-<name>`, `drop-zone-<name>`) colidem; dois componentes com o mesmo `name` na mesma página compartilham hidden inputs.
- **Múltiplos componentes `audio`:** scripts inline repetidos; cada closure usa seu `$id`.
- **`media-input` sem JS:** o input file fica visível/transparente, mas o `change` não é tratado; nenhum ID é preenchido e nenhum preview aparece.
- **`media-group-input` sem JS:** o input file nativo permite escolher arquivos, mas nada é enviado nem adicionado ao grid.
- **Cropper indisponível:** `new Cropper` lança e o modal fica sem imagem; o upload não prossegue (`media-group-input.js:110-118`).
- **Upload do grupo com resposta inesperada:** mostra `"Upload concluído, mas resposta inesperada."` e não adiciona preview (`:73-75`).
- **Erro HTTP do grupo:** tenta `JSON.parse(xhr.responseText)` e mostra `message` ou `"Erro no upload"` (`:78-82`).
- **Limite `maxFiles`:** verificado antes de processar a fila; arquivos já enfileirados além do limite são descartados (`:102-109`). O input `multiple` não aplica limite nativo.
- **`required` em `media-input`:** o hidden recebe `required`, mas `type=hidden` não participa da validação nativa; a obrigatoriedade não é garantida no cliente.

### 3.4 Armadilhas de reimplementação

- **Escutar `media-selected.lotep` em `.media-input-wrapper`.** A classe real é `v2-media-input-wrapper`; o script de Notícias usa `.media-input-wrapper` e não recebe o evento (`Noticias/Views/cms/includes/scripts.blade.php:108-110`).
- **Esperar upload AJAX do `media-input`.** O componente só abre o picker; o upload é do `gallery-picker`.
- **Ler `file_path` do hidden.** O valor é o ID; resolva na Galeria.
- **Usar o `id` do grupo como UUID.** É o `increments` da tabela.
- **Passar `variants` como JSON string.** Decodifique antes.
- **Contar com `media-cleared.lotep`.** Não existe; adicione no `resetMedia()` se o consumidor precisar.
- **Reutilizar o snippet `picture` com `alt` contendo aspas.** O snippet usa `addslashes`; o `alt` em HTML é escapado pelo Blade, mas o literal PHP gerado pode ficar incorreto em casos extremos.
- **Chamar `media-viewer` com `noActions` esperando documentação.** O parâmetro funciona, mas não está no `@param`.

### 3.5 Dívidas e divergências

- **Docblock do `media-input` promete upload AJAX direto** (`media-input.blade.php:3`), mas o JS delega ao picker.
- **Comentário do grupo fala “UUIDs”** (`media-group-input.blade.php:39`), mas a PK é inteira.
- **Script de Notícias usa classe inexistente** para o evento (`scripts.blade.php:108-110`).
- **`media-viewer` tem accordion quebrado** por `aria-controls` sem `#` (`media-viewer.blade.php:127-133` vs `:298`).
- **`media-viewer` e `audio` não têm consumidores no repositório** (0 inclusões), apesar de o README listar `media-viewer` como componente público (`README.md:27-33`).
- **README lista só `picture`, `media-viewer` e `media-input`**; omite `media-group-input` e `audio`.
- **`media-group-input.js` hardcoda `/storage/`**, contrariando a regra de usar `v2_storage_url()` (`README.md:35-45`).
- **JS não pertence ao manifest da unidade**: `public/assets/js/media-input.js` e `media-group-input.js` não estão em `V2/`; a unidade cobre apenas os Blades que os referenciam.

## 4. Integrabilidade

### 4.1 API pública — props

#### `components.picture`

| Prop | Tipo | Default | Obrigatória | Efeito |
|---|---|---|---|---|
| `originalPath` | string | — | sim | `src` do `<img>`; se vazio, `src=""` |
| `variants` | array | `[]` | não | mapa `largura` ou `'original'` => caminho; gera `<source>` |
| `disk` | string | `'public'` | não | **não usado** na URL |
| `alt` | string\|null | `''` | recomendado | `alt` do `<img>`; `null` vira `''` |
| `class` | string | `'img-responsive rounded'` | não | classe do `<img>` |
| `style` | string | `''` | não | estilo inline do `<img>` (sem escape) |
| `lazy` | bool | `true` | não | controla `loading`/`fetchpriority` conforme §3.2 |

DOM: `<picture>` → `<source media? srcset type="image/webp" fetchpriority?>*` → `<img src alt class style loading? role="img">` (`picture.blade.php:51-72`). Sem IDs.

#### `components.media-input`

| Prop | Tipo | Default | Obrigatória | Efeito |
|---|---|---|---|---|
| `name` | string | — | sim | compõe todos os IDs e nomes de campo |
| `label` | string | — | sim | texto do label |
| `accept` | string | `'image/*,application/pdf'` | não | `accept` do file input e validação JS |
| `required` | bool | `false` | não | asterisco, texto “Obrigatório” e `required` no hidden |
| `help` | string | `'Formatos aprovados: '.$accept` | não | texto de ajuda |
| `currentId` | string/int | `''` | não | valor do hidden `name` |
| `currentUrl` | string | `''` | não | prévia inicial (imagem/pdf/áudio/planilha) |
| `currentAlt` | string | `''` | não | valor do hidden `name_alt` e label da prévia |
| `ownerModule` | string | `'global'` | não | `data-owner`, repassado ao picker |
| `noPicker` | bool | ausente | não | `data-no-picker=1`; desabilita abrir a Galeria |

DOM/IDs: `#wrapper-<name>`, `#label-container-<name>`, `#media-id-<name>`, `#media-alt-<name>`, `#drop-zone-<name>`, `#media-input-<name>`, `#help-<name>`; classes `js-zone-content-<name>`, `js-zone-progress-<name>`, `js-progress-bar-<name>`, `js-progress-text-<name>`, `js-zone-error-<name>`, `js-error-text-<name>`, `js-selected-content-<name>`, `js-preview-img-<name>`, `js-preview-label-<name>`, `preview-pdf-<name>`, `preview-audio-<name>`, `preview-spreadsheet-<name>` (`media-input.blade.php:29-104`).

#### `components.media-group-input`

| Prop | Tipo | Default | Obrigatória | Efeito |
|---|---|---|---|---|
| `name` | string | `'media_group'` | não | base de IDs e `inputName = name.'_ids'` |
| `label` | string | `'Mídias'` | não | label |
| `accept` | string | `'image/*'` | não | `accept` do input múltiplo |
| `maxFiles` | int | `0` | não | 0 = ilimitado; checado antes de cada upload |
| `ownerModule` | string | `'global'` | não | `data-owner` (não enviado no POST) |
| `existingIds` | array | `[]` | não | hidden inputs iniciais |
| `existingUrls` | array | `[]` | não | previews iniciais (mesma ordem de `existingIds`) |

DOM/IDs: `#group-wrapper-<name>`, `#group-ids-container-<name>`, `#group-dropzone-<name>`, `#group-file-input-<name>`, `#group-prompt-<name>`, `#group-preview-grid-<name>`, `#group-crop-modal-<name>`, `#group-crop-image-<name>`, `#group-crop-alt-<name>`, `#group-crop-desc-<name>`, `#group-crop-shared-<name>`, `#group-crop-rotate-<name>`, `#group-crop-confirm-<name>` (`media-group-input.blade.php:28-125`).

#### `components.media-viewer`

| Prop | Tipo | Default | Obrigatória | Efeito |
|---|---|---|---|---|
| `path` | string | — | sim | caminho original; `v2_storage_url($path)` |
| `variants` | array | `[]` | não | usado no frame de imagem/PDF com capa |
| `type` | string | auto | não | `image/pdf/audio/spreadsheet`; auto por extensão |
| `disk` | string | `'public'` | não | **não usado** |
| `alt` | string | `''` | não | legenda/descrição e fallback de nome |
| `narrationText` | string | `''` | não | accordion no tipo áudio |
| `noActions` | bool | ausente | não | suprime `<figcaption>` |

DOM: `<figure class="v2-media-viewer" role="group" aria-label="...">` + frame por tipo + `<figcaption class="v2-media-caption">` com links “Visualizar PDF”, “Ver Original”, “Abrir Player”, “Abrir Planilha” e “Fazer Download” (`media-viewer.blade.php:41-269`).

#### `components.audio`

| Prop | Tipo | Default | Obrigatória | Efeito |
|---|---|---|---|---|
| `path` | string | — | sim | `src` do `<audio>` via `v2_storage_url` |
| `disk` | string | `'public'` | não | **não usado** |
| `title` | string | `'Narração do Conteúdo'` | não | título visível |
| `narrator` | string | ausente | não | linha “Voz: …” |
| `narrationText` | string | ausente | não | accordion “Exibir texto da narração” |
| `allowDownload` | bool | `true` | não | mostra link de download |

DOM: `#v2-audio-player-<uniqid>` e `#...-element` (`<audio hidden>`), `#...-progress`, `#...-volume`, `#...-text-container`; classes `js-play-pause`, `js-progress-bar`, `js-current-time`, `js-duration`, `js-skip-backward`, `js-skip-forward`, `js-mute`, `js-volume-bar`, `js-speed-opt`, `js-current-speed`, `js-wave`, `js-toggle-text`, `js-text-container` (`audio.blade.php:19-133`).

### 4.2 Eventos e contratos JS

| Evento | Emitido por | Payload | Quem escuta |
|---|---|---|---|
| `media-selected.lotep` | `media-input.js` no wrapper | `[{id,url,alt,mimeType,description}]` | `Associados/_form.blade.php:368`, `SEO/seo-input.blade.php:295`, `Noticias/scripts.blade.php:110` (classe errada), integrações externas |
| `lotep:open-gallery-picker-global-media-picker` | `media-input.js` em `window` | `{callback, ownerModule, allowedMimeTypes, droppedFile?}` | `gallery-picker` da Galeria, incluído no layout CMS (`V2/layouts/cms/layouts/app.blade.php:139`) |
| `lotep:open-global-media-picker` | `LandingSectionsEditor` (fora da unidade) | ver `SVC-LANDINGSECTIONSEDITOR` | `image-picker-modal` |
| `media-cleared.lotep` | **ninguém** | — | `Noticias/scripts.blade.php:117` (nunca dispara) |

### 4.3 Receitas de uso

```blade
{{-- 1. Imagem responsiva a partir de um registro de galeria. --}}
@php $variants = is_string($item->variants) ? (json_decode($item->variants, true) ?: []) : ($item->variants ?? []); @endphp
@include('v2-service-media::components.picture', [
    'originalPath' => $item->file_path,
    'variants' => $variants,
    'alt' => $item->alt_text ?? '',
    'class' => 'img-fluid rounded',
    'lazy' => true,
])
```

```blade
{{-- 2. Campo único de mídia com prévia e vínculo por ID. --}}
@include('v2-service-media::components.media-input', [
    'name' => 'cover_image_id',
    'label' => 'Imagem de capa',
    'accept' => 'image/*',
    'required' => true,
    'ownerModule' => 'noticias',
    'currentId' => $item->cover_image_id ?? '',
    'currentUrl' => !empty($item->cover) ? v2_storage_url($item->cover) : '',
    'currentAlt' => $item->cover_alt ?? '',
])
```

```blade
{{-- 3. Campo múltiplo com IDs existentes. --}}
@include('v2-service-media::components.media-group-input', [
    'name' => 'gallery',
    'label' => 'Galeria de imagens',
    'accept' => 'image/*',
    'maxFiles' => 8,
    'ownerModule' => 'noticias',
    'existingIds' => $existingIds,
    'existingUrls' => $existingUrls,
])
```

```js
// 4. Reagir à seleção de mídia (o evento sobe do wrapper; use o seletor real).
document.querySelectorAll('.js-media-input').forEach(function (wrapper) {
    wrapper.addEventListener('media-selected.lotep', function (e) {
        console.log(e.detail[0].id, e.detail[0].url);
    });
});
```

### 4.4 Anti-uso

- **Escutar `.media-input-wrapper`.** A classe não existe no HTML; use `.js-media-input` ou `#wrapper-<name>` (`media-input.blade.php:29`; `Noticias/scripts.blade.php:108`).
- **Passar `variants` como JSON string para `picture`/`media-viewer`.** Vira `[]` e a página perde o WebP (`picture.blade.php:28`; `media-viewer.blade.php:16`).
- **Esperar que “Remover” do grupo apague a mídia.** Só remove o hidden input e o card (`media-group-input.js:225-232`).
- **Usar `noPicker` esperando um input file operacional.** O `change` retorna sem tratar o arquivo (`media-input.js:174-176`).
- **Confiar em `disk` nos componentes.** `picture`, `media-viewer` e `audio` ignoram o prop; a URL é sempre do `StorageService`.

### 4.5 Inicialização e dependências

1. O namespace de views é registrado por `V2ServiceProvider` (`V2/Core/Providers/V2ServiceProvider.php:184-188`).
2. `media-input`/`media-group-input` fazem `@push('scripts')`; o layout precisa renderizar `@stack('scripts')` **depois** do conteúdo. `media-viewer` usa `@push('head-styles')` e `@push('scripts')`.
3. Dependências JS: jQuery (`$(document).ready`), Bootstrap 5 (`bootstrap.Modal`, `data-bs-*`, classes `btn-close`, `form-range`), Bootstrap Icons (`bi-*`) e Cropper.js para o grupo.
4. `media-input.js` e `media-group-input.js` carregam uma vez por request via flags globais; cada script inicializa **todas** as instâncias presentes no `document.ready`.
5. Não há reinit para conteúdo AJAX; para HTML inserido depois, é preciso chamar o init manualmente (não existe API pública para isso).
6. O `gallery-picker` precisa existir no layout (`V2/layouts/cms/layouts/app.blade.php:139`) para o `media-input` funcionar.

## 5. Catálogo de arquivos

| Arquivo | Papel | Linhas | Fonte |
|---|---|---|---|
| `V2/Services/Media/Views/components/picture.blade.php` | `<picture>` com WebP e fallback | 72 | `picture.blade.php:1-72` |
| `V2/Services/Media/Views/components/media-input.blade.php` | campo único com dropzone/preview | 114 | `media-input.blade.php:1-114` |
| `V2/Services/Media/Views/components/media-group-input.blade.php` | campo múltiplo com Cropper | 137 | `media-group-input.blade.php:1-137` |
| `V2/Services/Media/Views/components/media-viewer.blade.php` | visualização por tipo + ações | 308 | `media-viewer.blade.php:1-308` |
| `V2/Services/Media/Views/components/audio.blade.php` | player customizado | 306 | `audio.blade.php:1-306` |
| `public/assets/js/media-input.js` (referenciado) | init/seleção do campo único | 245 | `public/assets/js/media-input.js:1-245` |
| `public/assets/js/media-group-input.js` (referenciado) | fila/crop/upload do campo múltiplo | 234 | `public/assets/js/media-group-input.js:1-234` |

## 6. Acessibilidade

| Componente | O que existe | Lacuna |
|---|---|---|
| `picture` | `alt` sempre presente (`''` se não informado); `role="img"`; `loading="lazy"` | `role="img"` em `<img>` é redundante; não há `width`/`height` (CLS); `style` sem escape |
| `media-input` | label com `id` e `aria-labelledby`; asterisco com texto “Obrigatório”; dropzone `role=button tabindex=0`; ícones `aria-hidden`; input file nativo cobre a área | dropzone não trata `Enter`/`Espaço` (sem `keydown`); label não tem `for`; hidden `required` é ignorado pela validação nativa; erro não é `aria-live` |
| `media-group-input` | label; dropzone `role=button tabindex=0`; modal Bootstrap com foco gerenciado; botões com `title` | modal sem `aria-labelledby` visível; dropzone sem teclado; grid de previews com `alt="Preview"` genérico; remover tem só `title` |
| `media-viewer` | `<figure>`/`<figcaption>`; `role="group"` + `aria-label`; links com `title` e `rel="noopener noreferrer"`; botão de narração com `aria-expanded`/`aria-controls` | `aria-controls` sem `#` quebra o toggle; alt padrão “Imagem: arquivo” é pouco descritivo; ações só por link |
| `audio` | botões com `aria-label`; ranges com labels `visually-hidden`; `aria-valuenow` atualizado; accordion com `aria-expanded`; texto alternativo nativo no `<audio>` | onda animada sem `prefers-reduced-motion`; velocidade via dropdown sem `aria-checked`; `uniqid` impede âncoras estáveis |

Boas práticas já aplicadas: `alt=""` para decorativas é suportado (`picture.blade.php:24`); ícones são `aria-hidden="true"`; textos de ação são explícitos (“Abrir documento PDF em nova aba”). Contraste não é verificável no código.

## 7. Assets e build

- JS servido diretamente por `asset('assets/js/media-input.js')` e `asset('assets/js/media-group-input.js')` (`media-input.blade.php:112`; `media-group-input.blade.php:135`), sem bundler.
- Cropper CSS/JS em `assets/vendor/cropperjs/cropper.min.{css,js}` (`media-group-input.blade.php:25,134`), carregados uma vez por request.
- Bootstrap 5 e Bootstrap Icons são pré-requisitos do layout CMS (classes `bi-*`, `data-bs-*`, `bootstrap.Modal`).
- `media-viewer` injeta `<style>` e `<script>` inline por instância; `audio` injeta `<script>` inline por instância.
- Flags de dedupe: `$GLOBALS['media_input_scripts_rendered']`, `$GLOBALS['media_group_cropper_css_rendered']`, `$GLOBALS['media_group_input_scripts_rendered']`.
- Não há build próprio da unidade; os arquivos públicos não estão em `V2/`.

## 8. Lacunas

- `LG-MEDIA-UI-01` — Os JS `public/assets/js/media-input.js` e `media-group-input.js` não fazem parte do manifest; a unidade só os referencia. Confirmar dono/versão no inventário de assets.
- `LG-MEDIA-UI-02` — `media-viewer` e `audio` não têm consumidores no repositório; confirmar se são contrato futuro ou código morto.
- `LG-MEDIA-UI-03` — O accordion de narração do `media-viewer` está quebrado; confirmar se há correção em produção (o código do repositório é a referência).
- `LG-MEDIA-UI-04` — O `gallery-picker` (SVC-GALLERY) define o contrato real do callback e do upload; detalhes em `SVC-GALLERY`.
- `LG-MEDIA-UI-05` — Não há teste automatizado dos componentes/JS no manifest; confirmar cobertura no CI.
- `LG-MEDIA-UI-06` — O comportamento de `noPicker` não está documentado no docblock; confirmar intenção (campo read-only?).

## 9. Checklist de paridade

- [ ] `PC-MEDIA-UI-01` — `picture` com `variants=[360=>'a',720=>'b']` emite 2 `<source type="image/webp">` em ordem decrescente, o de 360 sem `media`, e `<img>` com `src` do original (`picture.blade.php:51-69`).
- [ ] `PC-MEDIA-UI-02` — `picture` com `lazy=false` e variantes emite `loading="lazy"` no `<img>` e `fetchpriority="high"` no primeiro `<source>` (`picture.blade.php:44-48,58,69`).
- [ ] `PC-MEDIA-UI-03` — `media-input` renderiza `#media-id-<name>` com `currentId` e `#media-alt-<name>` com `currentAlt` (`media-input.blade.php:46-47`).
- [ ] `PC-MEDIA-UI-04` — Selecionar mídia dispara `media-selected.lotep` com `{id,url,alt,mimeType,description}` no wrapper (`media-input.js:84-90`).
- [ ] `PC-MEDIA-UI-05` — Clicar na dropzone dispara `lotep:open-gallery-picker-global-media-picker` com `callback`, `ownerModule` e `allowedMimeTypes` (`media-input.js:129-138`).
- [ ] `PC-MEDIA-UI-06` — `media-group-input` renderiza `data-store-url` igual a `route('v2.gallery.store')` e hidden inputs `name_ids[]` (`media-group-input.blade.php:34-43`).
- [ ] `PC-MEDIA-UI-07` — Upload do grupo envia `file`, `file_alt`, `description`, `is_shared`, `_token` via POST multipart (`media-group-input.js:55-67`).
- [ ] `PC-MEDIA-UI-08` — Resposta do grupo com `file_path` produz preview `'/storage/' + file_path` (`:69-72`).
- [ ] `PC-MEDIA-UI-09` — Remover item do grupo apaga o hidden input e o card, sem request (`:225-232`).
- [ ] `PC-MEDIA-UI-10` — `media-viewer` detecta `pdf|audio|spreadsheet|image` pela extensão quando `type` é omitido (`media-viewer.blade.php:18-34`).
- [ ] `PC-MEDIA-UI-11` — `audio` inicializa player, progresso, volume, skip ±10 s, mute e velocidades 0.5/1.0/1.25/1.5/2.0 (`audio.blade.php:98-108,141-265`).
- [ ] `PC-MEDIA-UI-12` — O toggle de narração do `audio` abre/fecha `#<id>-text-container` e alterna `aria-expanded` (`audio.blade.php:117-127,267-274`).
- [ ] `PC-MEDIA-UI-13` — `media-viewer` não abre a narração no código atual (bug de `aria-controls` sem `#`) (`media-viewer.blade.php:127-133,298`).

### 9.1 Checklist de extração (A–M)

| Item | Resposta |
|---|---|
| A | Efeito por componente na §1.1–1.2; o que não faz na §1.3. |
| B | Estratégia/fluxo na §2.1–2.2; trechos na §2.3; ciclo de vida na §2.4. |
| C | Invariantes `CT-*` na §3.2; casos-limite na §3.3; dívidas na §3.5. |
| D | Props na §4.1; eventos/JS na §4.2; receitas/anti-uso na §4.3–4.4; init/deps na §4.5. |
| E | Não se aplica: componentes não registram rotas (usam `v2.gallery.store`). |
| F | Não se aplica: componentes não acessam banco. |
| G | Campos/preview/estado do CMS cobertos na §4.1 e §2.2; mensagens JS na §3.3. |
| H | `picture`/`media-viewer` entregam variantes, `alt`, lazy e fallback (§1, §6). |
| I | Contrato completo nas §4.1–4.5. |
| J | Não se aplica: sem config/permissões próprias. |
| K | Erros JS na §3.3; sem logs de servidor. |
| L | Não se aplica: sem seeds. |
| M | PCs na §9; lacunas na §8; evidências na §10. |

## 10. Evidências consultadas

- `V2/Services/Media/Views/components/picture.blade.php:1-72`.
- `V2/Services/Media/Views/components/media-input.blade.php:1-114`.
- `V2/Services/Media/Views/components/media-group-input.blade.php:1-137`.
- `V2/Services/Media/Views/components/media-viewer.blade.php:1-308`.
- `V2/Services/Media/Views/components/audio.blade.php:1-306`.
- `public/assets/js/media-input.js:1-245`.
- `public/assets/js/media-group-input.js:1-234`.
- `V2/Services/Gallery/Controllers/GalleryController.php:86-109`; `V2/Services/Gallery/Routes/cms.php:5-13`.
- `V2/layouts/cms/layouts/app.blade.php:139`.
- `V2/Modules/Publicacoes/Noticias/Views/cms/includes/scripts.blade.php:108-118`.
- `V2/Modules/Conexoes/Associados/Views/cms/instituicoes/_form.blade.php:224-245,368`.
- `V2/Core/Providers/V2ServiceProvider.php:184-188`.
- `V2/Services/Media/README.md:27-45`; `V2/Services/Media/CMS_INTEGRATION.md:1-121`.
