feat: adicionadas métricas opcionais de fala ao Scanner com anális

- adicionadas métricas opcionais de fala ao Scanner com análise acústica na engine
- corrigido o progresso da transcrição do Scanner durante o processamento do Whisper
- refatorado o runner Swift do Apple Vision para executar requests em lote com retry CPU-only, corrigindo falhas de CVPixelBuffer/ANE/OCR
- documentado o fluxo de edição de vídeo por voz integrado ao Scanner, banco e engine
- formalizado o protocolo de edição por chat com perfil editorial e consultas progressivas

Resumo:
- 10 arquivos alterados
- 5 novos
- 5 modificados
- 0 removidos

 5 files changed, 367 insertions(+), 95 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/engine/executar_scanner.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/integracoes/whisper/provider_de_transcricao_local.py
  - code/engine/testes/test_provider_de_transcricao_local.py
  - .jhonny/analises.db-shm
  - .jhonny/analises.db-wal
  - apple_vision_runner
  - code/relatorios/teste-enquadramento-20s.json
  - docs/
This commit is contained in:
João Henrique
2026-09-09 18:50:00 -04:00
parent b03b175973
commit c4c942044f
10 changed files with 15086 additions and 96 deletions
Binary file not shown.
Binary file not shown.
View File
BIN
View File
Binary file not shown.
+5 -2
View File
@@ -142,9 +142,11 @@ def executar(entrada: Path, saida: Path) -> Path:
from engine.integracoes.whisper import ProviderDeTranscricaoLocal from engine.integracoes.whisper import ProviderDeTranscricaoLocal
from engine.integracoes.audio import AnalisadorDeMetricasDeVoz from engine.integracoes.audio import AnalisadorDeMetricasDeVoz
from engine.dominio import Timeline from engine.dominio import Timeline
emitir("Transcrevendo áudio", 5) emitir("Carregando modelo Whisper", 5)
provider = ProviderDeTranscricaoLocal(transcricao_configurada["caminho_modelo"], provider = ProviderDeTranscricaoLocal(transcricao_configurada["caminho_modelo"],
idioma=transcricao_configurada.get("idioma", "pt")) idioma=transcricao_configurada.get("idioma", "pt"),
ao_progresso=lambda percentual: emitir(
"Transcrevendo áudio", 5 + percentual * 0.8))
analisador_de_metricas_de_voz = ( analisador_de_metricas_de_voz = (
AnalisadorDeMetricasDeVoz() if configuracao.metricas_de_fala else None AnalisadorDeMetricasDeVoz() if configuracao.metricas_de_fala else None
) )
@@ -159,6 +161,7 @@ def executar(entrada: Path, saida: Path) -> Path:
diarizador = ProviderDeDiarizacaoHuggingFace(modelo_diarizacao) diarizador = ProviderDeDiarizacaoHuggingFace(modelo_diarizacao)
transcricao_configurada = {**transcricao_configurada, transcricao_configurada = {**transcricao_configurada,
"modelo_diarizacao": modelo_diarizacao} "modelo_diarizacao": modelo_diarizacao}
emitir("Transcrevendo áudio", 5)
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio", transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
diarizador=diarizador, diarizador=diarizador,
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar( analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar(
@@ -53,17 +53,54 @@ func ponto(_ point: VNRecognizedPoint) -> JSONValue {
"confianca": .numero(Double(point.confidence))]) "confianca": .numero(Double(point.confidence))])
} }
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] { func carregarImagem(_ url: URL) throws -> CGImage {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let imagem = CGImageSourceCreateImageAtIndex(source, 0, nil) else { let imagem = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
throw NSError(domain: "JhonnyAppleVision", code: 1, throw NSError(domain: "JhonnyAppleVision", code: 1,
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"]) userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
} }
return imagem
}
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
let imagem = try carregarImagem(url)
let handler = VNImageRequestHandler(cgImage: imagem, options: [:]) let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
try handler.perform([request]) try handler.perform([request])
return request.results ?? [] return request.results ?? []
} }
/// Executa múltiplos requests em lote via um único handler, reutilizando a imagem e o CVPixelBuffer interno.
/// Retorna (resultados, erros_por_indice) para que o chamador possa decidir quais requests retryar.
func executarLote(_ requests: [VNRequest], em url: URL) -> ([[VNObservation]], [Int: Error]) {
guard let imagem = try? carregarImagem(url) else {
let erro = NSError(domain: "JhonnyAppleVision", code: 1,
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
let todosErros = Dictionary(uniqueKeysWithValues: requests.indices.map { ($0, erro) })
return (Array(repeating: [], count: requests.count), todosErros)
}
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
do {
try handler.perform(requests)
} catch {
let todosErros = Dictionary(uniqueKeysWithValues: requests.indices.map { ($0, error) })
return (Array(repeating: [], count: requests.count), todosErros)
}
var resultados: [[VNObservation]] = []
var erros: [Int: Error] = [:]
for (indice, request) in requests.enumerated() {
if let resultadosReais = request.results, !resultadosReais.isEmpty {
resultados.append(resultadosReais)
} else {
resultados.append([])
erros[indice] = NSError(domain: "JhonnyAppleVision", code: 3,
userInfo: [NSLocalizedDescriptionKey: "\(type(of: request)) não produziu resultados"])
}
}
return (resultados, erros)
}
func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double { func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
@@ -79,25 +116,124 @@ func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
return Double(ocupados) / Double(max(1, altura * largura)) return Double(ocupados) / Double(max(1, altura * largura))
} }
func analisarVision(_ entrada: Entrada) -> Saida { /// Analisa brilho, contraste e exposição de um CGImage via histograma de luminosidade.
guard let frame = entrada.frame else { /// Não usa Vision — é puro CoreImage, custo ~10ms.
return Saida(evidencias: [], avisos: ["frame: caminho obrigatório para análise individual"]) func analisarBrilho(_ imagem: CGImage) -> [String: JSONValue] {
} let largura = imagem.width
let url = URL(fileURLWithPath: frame) let altura = imagem.height
var evidencias: [Evidencia] = [] let totalPixels = Double(largura * altura)
var avisos: [String] = []
func tentar(_ recurso: String, _ bloco: () throws -> [Evidencia]) { // Converte para grayscale lendo os bytes diretamente
guard entrada.recursos.contains(recurso) else { return } guard let contexto = CGContext(data: nil, width: largura, height: altura,
do { evidencias.append(contentsOf: try bloco()) } bitsPerComponent: 8, bytesPerRow: largura,
catch { avisos.append("\(recurso): \(error.localizedDescription)") } space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue),
let dados = contexto.data else {
return ["media": .numero(0), "contraste": .numero(0), "exposicao": .texto("indisponivel")]
} }
tentar("faces") { contexto.draw(imagem, in: CGRect(x: 0, y: 0, width: largura, height: altura))
let request = VNDetectFaceLandmarksRequest() let bytes = dados.assumingMemoryBound(to: UInt8.self)
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
// Calcula média de luminosidade
var soma: Double = 0
var somaQuadrados: Double = 0
var histograma = [Int](repeating: 0, count: 256)
for i in 0..<(largura * altura) {
let pixel = Double(bytes[i])
soma += pixel
somaQuadrados += pixel * pixel
histograma[Int(pixel)] += 1
}
let media = soma / totalPixels / 255.0 // normalizado 0-1
let variancia = (somaQuadrados / totalPixels) - (soma / totalPixels) * (soma / totalPixels)
let contraste = sqrt(variancia) / 255.0 // desvio padrão normalizado
// Classifica exposição
let exposicao: String
if media < 0.25 {
exposicao = "subexposto"
} else if media < 0.40 {
exposicao = "levemente_subexposto"
} else if media <= 0.65 {
exposicao = "equilibrado"
} else if media <= 0.80 {
exposicao = "levemente_sobreexposto"
} else {
exposicao = "sobreexposto"
}
// Detecta clipping (pixels no limite máximo = informação perdida)
let pixelsBrancos = Double(histograma[255] + histograma[254] + histograma[253])
let pixelsPretos = Double(histograma[0] + histograma[1] + histograma[2])
let ratioClippingBranco = pixelsBrancos / totalPixels
let ratioClippingPreto = pixelsPretos / totalPixels
return [
"media_brilho": .numero(media),
"contraste": .numero(contraste),
"exposicao": .texto(exposicao),
"clipping_branco": .numero(ratioClippingBranco),
"clipping_preto": .numero(ratioClippingPreto),
]
}
func construirRequest(_ recurso: String) -> VNRequest? {
switch recurso {
case "faces":
return VNDetectFaceLandmarksRequest()
case "qualidade_facial":
return VNDetectFaceCaptureQualityRequest()
case "pessoas":
let r = VNDetectHumanRectanglesRequest()
r.upperBodyOnly = false
return r
case "pose":
return VNDetectHumanBodyPoseRequest()
case "maos":
let r = VNDetectHumanHandPoseRequest()
r.maximumHandCount = 4
return r
case "ocr":
let r = VNRecognizeTextRequest()
r.recognitionLevel = .accurate
r.recognitionLanguages = ["pt-BR", "en-US"]
return r
case "categorias":
return VNClassifyImageRequest()
case "estetica":
return VNCalculateImageAestheticsScoresRequest()
case "codigos":
return VNDetectBarcodesRequest()
case "horizonte":
return VNDetectHorizonRequest()
case "retangulos":
return VNDetectRectanglesRequest()
case "contornos":
return VNDetectContoursRequest()
case "segmentacao_de_pessoas":
let r = VNGeneratePersonSegmentationRequest()
r.qualityLevel = .balanced
return r
case "brilho":
return nil // processado separadamente via CoreImage, sem Vision
default:
return nil
}
}
func converterResultados(_ observacoes: [VNObservation], para recurso: String) -> [Evidencia] {
switch recurso {
case "faces":
return observacoes.compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation else { return nil } guard let face = observacao as? VNFaceObservation else { return nil }
var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))] var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))]
// Orientação da cabeça (enquadramento)
valor["roll"] = .numero(Double(truncating: face.roll ?? 0))
valor["yaw"] = .numero(Double(truncating: face.yaw ?? 0))
valor["pitch"] = .numero(Double(truncating: face.pitch ?? 0))
if let landmarks = face.landmarks { if let landmarks = face.landmarks {
let grupos: [(String, VNFaceLandmarkRegion2D?)] = [ let grupos: [(String, VNFaceLandmarkRegion2D?)] = [
("olho_esquerdo", landmarks.leftEye), ("olho_direito", landmarks.rightEye), ("olho_esquerdo", landmarks.leftEye), ("olho_direito", landmarks.rightEye),
@@ -111,101 +247,67 @@ func analisarVision(_ entrada: Entrada) -> Saida {
} }
return Evidencia(tipo: "rosto", valor: valor, confianca: Double(face.confidence), modelo: "VNDetectFaceLandmarksRequest") return Evidencia(tipo: "rosto", valor: valor, confianca: Double(face.confidence), modelo: "VNDetectFaceLandmarksRequest")
} }
} case "qualidade_facial":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("qualidade_facial") {
let request = VNDetectFaceCaptureQualityRequest()
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation, let qualidade = face.faceCaptureQuality else { return nil } guard let face = observacao as? VNFaceObservation, let qualidade = face.faceCaptureQuality else { return nil }
return Evidencia(tipo: "qualidade_do_rosto", valor: ["bounding_box": .objeto(caixa(face.boundingBox)), return Evidencia(tipo: "qualidade_do_rosto", valor: ["bounding_box": .objeto(caixa(face.boundingBox)),
"score": .numero(Double(qualidade))], confianca: Double(face.confidence), modelo: "VNDetectFaceCaptureQualityRequest") "score": .numero(Double(qualidade))], confianca: Double(face.confidence), modelo: "VNDetectFaceCaptureQualityRequest")
} }
} case "pessoas":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("pessoas") {
let request = VNDetectHumanRectanglesRequest()
request.upperBodyOnly = false
return try executar(request, em: url).compactMap { observacao in
guard let pessoa = observacao as? VNHumanObservation else { return nil } guard let pessoa = observacao as? VNHumanObservation else { return nil }
return Evidencia(tipo: "pessoa", valor: ["bounding_box": .objeto(caixa(pessoa.boundingBox)), return Evidencia(tipo: "pessoa", valor: ["bounding_box": .objeto(caixa(pessoa.boundingBox)),
"ocupacao_do_quadro": .numero(pessoa.boundingBox.width * pessoa.boundingBox.height)], "ocupacao_do_quadro": .numero(pessoa.boundingBox.width * pessoa.boundingBox.height)],
confianca: Double(pessoa.confidence), modelo: "VNDetectHumanRectanglesRequest") confianca: Double(pessoa.confidence), modelo: "VNDetectHumanRectanglesRequest")
} }
} case "pose":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("pose") {
let request = VNDetectHumanBodyPoseRequest()
return try executar(request, em: url).compactMap { observacao in
guard let pose = observacao as? VNHumanBodyPoseObservation else { return nil } guard let pose = observacao as? VNHumanBodyPoseObservation else { return nil }
let pontos = try? pose.recognizedPoints(.all) let pontos = try? pose.recognizedPoints(.all)
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:] let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
return Evidencia(tipo: "pose", valor: ["pontos": .objeto(dados)], confianca: Double(pose.confidence), modelo: "VNDetectHumanBodyPoseRequest") return Evidencia(tipo: "pose", valor: ["pontos": .objeto(dados)], confianca: Double(pose.confidence), modelo: "VNDetectHumanBodyPoseRequest")
} }
} case "maos":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("maos") {
let request = VNDetectHumanHandPoseRequest()
request.maximumHandCount = 4
return try executar(request, em: url).compactMap { observacao in
guard let mao = observacao as? VNHumanHandPoseObservation else { return nil } guard let mao = observacao as? VNHumanHandPoseObservation else { return nil }
let pontos = try? mao.recognizedPoints(.all) let pontos = try? mao.recognizedPoints(.all)
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:] let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
return Evidencia(tipo: "mao", valor: ["pontos": .objeto(dados)], confianca: Double(mao.confidence), modelo: "VNDetectHumanHandPoseRequest") return Evidencia(tipo: "mao", valor: ["pontos": .objeto(dados)], confianca: Double(mao.confidence), modelo: "VNDetectHumanHandPoseRequest")
} }
} case "ocr":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("ocr") {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["pt-BR", "en-US"]
return try executar(request, em: url).compactMap { observacao in
guard let texto = observacao as? VNRecognizedTextObservation, guard let texto = observacao as? VNRecognizedTextObservation,
let candidato = texto.topCandidates(1).first else { return nil } let candidato = texto.topCandidates(1).first else { return nil }
return Evidencia(tipo: "ocr", valor: ["texto": .texto(candidato.string), return Evidencia(tipo: "ocr", valor: ["texto": .texto(candidato.string),
"bounding_box": .objeto(caixa(texto.boundingBox))], confianca: Double(candidato.confidence), modelo: "VNRecognizeTextRequest") "bounding_box": .objeto(caixa(texto.boundingBox))], confianca: Double(candidato.confidence), modelo: "VNRecognizeTextRequest")
} }
} case "categorias":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("categorias") {
let request = VNClassifyImageRequest()
return try executar(request, em: url).compactMap { observacao in
guard let categoria = observacao as? VNClassificationObservation, categoria.confidence >= 0.2 else { return nil } guard let categoria = observacao as? VNClassificationObservation, categoria.confidence >= 0.2 else { return nil }
return Evidencia(tipo: "categoria", valor: ["identificador": .texto(categoria.identifier)], return Evidencia(tipo: "categoria", valor: ["identificador": .texto(categoria.identifier)],
confianca: Double(categoria.confidence), modelo: "VNClassifyImageRequest") confianca: Double(categoria.confidence), modelo: "VNClassifyImageRequest")
} }
} case "estetica":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("estetica") {
let request = VNCalculateImageAestheticsScoresRequest()
return try executar(request, em: url).compactMap { observacao in
guard let score = observacao as? VNImageAestheticsScoresObservation else { return nil } guard let score = observacao as? VNImageAestheticsScoresObservation else { return nil }
return Evidencia(tipo: "estetica", valor: ["score_global": .numero(Double(score.overallScore))], return Evidencia(tipo: "estetica", valor: ["score_global": .numero(Double(score.overallScore))],
confianca: nil, modelo: "VNCalculateImageAestheticsScoresRequest") confianca: nil, modelo: "VNCalculateImageAestheticsScoresRequest")
} }
} case "codigos":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("codigos") {
let request = VNDetectBarcodesRequest()
return try executar(request, em: url).compactMap { observacao in
guard let codigo = observacao as? VNBarcodeObservation else { return nil } guard let codigo = observacao as? VNBarcodeObservation else { return nil }
return Evidencia(tipo: "codigo", valor: ["payload": .texto(codigo.payloadStringValue ?? ""), return Evidencia(tipo: "codigo", valor: ["payload": .texto(codigo.payloadStringValue ?? ""),
"simbologia": .texto(codigo.symbology.rawValue), "bounding_box": .objeto(caixa(codigo.boundingBox))], "simbologia": .texto(codigo.symbology.rawValue), "bounding_box": .objeto(caixa(codigo.boundingBox))],
confianca: Double(codigo.confidence), modelo: "VNDetectBarcodesRequest") confianca: Double(codigo.confidence), modelo: "VNDetectBarcodesRequest")
} }
} case "horizonte":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("horizonte") {
let request = VNDetectHorizonRequest()
return try executar(request, em: url).compactMap { observacao in
guard let horizonte = observacao as? VNHorizonObservation else { return nil } guard let horizonte = observacao as? VNHorizonObservation else { return nil }
return Evidencia(tipo: "horizonte", valor: ["angulo_radianos": .numero(Double(horizonte.angle))], return Evidencia(tipo: "horizonte", valor: ["angulo_radianos": .numero(Double(horizonte.angle))],
confianca: Double(horizonte.confidence), modelo: "VNDetectHorizonRequest") confianca: Double(horizonte.confidence), modelo: "VNDetectHorizonRequest")
} }
} case "retangulos":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("retangulos") {
let request = VNDetectRectanglesRequest()
return try executar(request, em: url).compactMap { observacao in
guard let retangulo = observacao as? VNRectangleObservation else { return nil } guard let retangulo = observacao as? VNRectangleObservation else { return nil }
return Evidencia(tipo: "retangulo", valor: ["bounding_box": .objeto(caixa(retangulo.boundingBox)), return Evidencia(tipo: "retangulo", valor: ["bounding_box": .objeto(caixa(retangulo.boundingBox)),
"cantos": .objeto(["superior_esquerdo": ponto(retangulo.topLeft), "cantos": .objeto(["superior_esquerdo": ponto(retangulo.topLeft),
@@ -214,25 +316,104 @@ func analisarVision(_ entrada: Entrada) -> Saida {
"inferior_direito": ponto(retangulo.bottomRight)])], "inferior_direito": ponto(retangulo.bottomRight)])],
confianca: Double(retangulo.confidence), modelo: "VNDetectRectanglesRequest") confianca: Double(retangulo.confidence), modelo: "VNDetectRectanglesRequest")
} }
} case "contornos":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("contornos") {
let request = VNDetectContoursRequest()
return try executar(request, em: url).compactMap { observacao in
guard let contornos = observacao as? VNContoursObservation else { return nil } guard let contornos = observacao as? VNContoursObservation else { return nil }
return Evidencia(tipo: "contornos", valor: ["quantidade_principal": .numero(Double(contornos.topLevelContours.count))], return Evidencia(tipo: "contornos", valor: ["quantidade_principal": .numero(Double(contornos.topLevelContours.count))],
confianca: Double(contornos.confidence), modelo: "VNDetectContoursRequest") confianca: Double(contornos.confidence), modelo: "VNDetectContoursRequest")
} }
} case "segmentacao_de_pessoas":
return observacoes.compactMap { observacao -> Evidencia? in
tentar("segmentacao_de_pessoas") {
let request = VNGeneratePersonSegmentationRequest()
request.qualityLevel = .balanced
return try executar(request, em: url).compactMap { observacao in
guard let mascara = observacao as? VNPixelBufferObservation else { return nil } guard let mascara = observacao as? VNPixelBufferObservation else { return nil }
return Evidencia(tipo: "segmentacao_de_pessoas", valor: ["ocupacao_do_quadro": .numero(coberturaDaMascara(mascara.pixelBuffer))], return Evidencia(tipo: "segmentacao_de_pessoas", valor: ["ocupacao_do_quadro": .numero(coberturaDaMascara(mascara.pixelBuffer))],
confianca: nil, modelo: "VNGeneratePersonSegmentationRequest") confianca: nil, modelo: "VNGeneratePersonSegmentationRequest")
} }
default:
return []
}
}
func analisarVision(_ entrada: Entrada) -> Saida {
guard let frame = entrada.frame else {
return Saida(evidencias: [], avisos: ["frame: caminho obrigatório para análise individual"])
}
let url = URL(fileURLWithPath: frame)
var evidencias: [Evidencia] = []
var avisos: [String] = []
// 0) Processa recursos que não usam Vision (ex: brilho via CoreImage)
for recurso in entrada.recursos {
if recurso == "brilho" {
if let imagem = try? carregarImagem(url) {
let dados = analisarBrilho(imagem)
evidencias.append(Evidencia(tipo: "brilho", valor: dados, confianca: nil, modelo: "CoreImageHistogram"))
} else {
avisos.append("brilho: não foi possível carregar a imagem")
}
}
}
// 1) Constrói um request por recurso solicitado, preservando a ordem
var recursosSolicitados: [String] = []
var requests: [VNRequest] = []
for recurso in entrada.recursos {
if recurso == "brilho" { continue } // já processado acima
if let request = construirRequest(recurso) {
recursosSolicitados.append(recurso)
requests.append(request)
}
}
guard !requests.isEmpty else {
return Saida(evidencias: evidencias, avisos: avisos)
}
// 2) Executa tudo em lote com um único handler (reutiliza a imagem e o CVPixelBuffer)
let (resultados, erros) = executarLote(requests, em: url)
// 3) Processa os resultados que funcionaram
var falhasParaRetry: [(indice: Int, recurso: String, requestOriginal: VNRequest)] = []
for (indice, recurso) in recursosSolicitados.enumerated() {
let observacoes = resultados[indice]
if !observacoes.isEmpty {
evidencias.append(contentsOf: converterResultados(observacoes, para: recurso))
} else if erros[indice] != nil {
falhasParaRetry.append((indice, recurso, requests[indice]))
avisos.append("\(recurso) (lote): falha na análise")
}
}
// 4) Retry seletivo: tenta novamente com CPU-only para requests que falharam no lote
if !falhasParaRetry.isEmpty {
do {
let imagem = try carregarImagem(url)
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
for falha in falhasParaRetry {
falha.requestOriginal.usesCPUOnly = true
}
let requestsRetry = falhasParaRetry.map { $0.requestOriginal }
try handler.perform(requestsRetry)
// Remove avisos anteriores dos recursos que foram retryados
for falha in falhasParaRetry {
avisos.removeAll { $0.hasPrefix("\(falha.recurso) (lote):") }
}
for (i, falha) in falhasParaRetry.enumerated() {
if let observacoes = requestsRetry[i].results, !observacoes.isEmpty {
evidencias.append(contentsOf: converterResultados(observacoes, para: falha.recurso))
} else {
// Remove o aviso de lote e registra a falha final
avisos.removeAll { $0.hasPrefix("\(falha.recurso) (lote):") }
avisos.append("\(falha.recurso): falhou mesmo com CPU-only")
}
}
} catch {
// Se o retry com CPU-only também falhou como lote, mantém os avisos originais
avisos.append("retry_cpu_only: \(error.localizedDescription)")
}
} }
return Saida(evidencias: evidencias, avisos: avisos) return Saida(evidencias: evidencias, avisos: avisos)
@@ -246,22 +427,54 @@ func featurePrint(_ url: URL) throws -> VNFeaturePrintObservation {
return resultado return resultado
} }
func featurePrintLote(_ urls: [URL]) -> [VNFeaturePrintObservation?] {
guard !urls.isEmpty else { return [] }
let imagem: CGImage
do { imagem = try carregarImagem(urls[0]) }
catch { return Array(repeating: nil, count: urls.count) }
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
let requests = urls.map { _ in VNGenerateImageFeaturePrintRequest() }
do {
try handler.perform(requests)
} catch {
return Array(repeating: nil, count: urls.count)
}
return requests.map { request in
request.results?.first as? VNFeaturePrintObservation
}
}
func analisarSequencia(_ entrada: Entrada) -> Saida { func analisarSequencia(_ entrada: Entrada) -> Saida {
let caminhos = entrada.frames ?? [] let caminhos = entrada.frames ?? []
guard caminhos.count >= 2 else { return Saida(evidencias: [], avisos: ["sequencia: informe ao menos dois frames"]) } guard caminhos.count >= 2 else { return Saida(evidencias: [], avisos: ["sequencia: informe ao menos dois frames"]) }
var evidencias: [Evidencia] = [] var evidencias: [Evidencia] = []
var avisos: [String] = [] var avisos: [String] = []
for indice in 0..<(caminhos.count - 1) {
// Para sequência, tenta usar feature print em lote para frames consecutivos
// Começa do frame 0 e processa pares (i, i+1)
var i = 0
while i < caminhos.count - 1 {
let urlAtual = URL(fileURLWithPath: caminhos[i])
let urlProximo = URL(fileURLWithPath: caminhos[i + 1])
do { do {
let fpAtual = try featurePrint(urlAtual)
let fpProximo = try featurePrint(urlProximo)
var distancia: Float = 0 var distancia: Float = 0
try featurePrint(URL(fileURLWithPath: caminhos[indice])).computeDistance( try fpAtual.computeDistance(&distancia, to: fpProximo)
&distancia, to: featurePrint(URL(fileURLWithPath: caminhos[indice + 1])))
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [ evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
"frame_inicial": .numero(Double(indice)), "frame_final": .numero(Double(indice + 1)), "frame_inicial": .numero(Double(i)), "frame_final": .numero(Double(i + 1)),
"distancia_feature_print": .numero(Double(distancia)), "distancia_feature_print": .numero(Double(distancia)),
"possivel_mudanca_de_cena": .booleano(distancia > 12), "possivel_mudanca_de_cena": .booleano(distancia > 12),
], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest")) ], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest"))
} catch { avisos.append("similaridade_visual[\(indice)]: \(error.localizedDescription)") } } catch {
avisos.append("similaridade_visual[\(i)]: \(error.localizedDescription)")
}
i += 1
} }
return Saida(evidencias: evidencias, avisos: avisos) return Saida(evidencias: evidencias, avisos: avisos)
} }
@@ -292,7 +505,9 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
switch evidencia.tipo { switch evidencia.tipo {
case "rosto": case "rosto":
let grupos = valor.objeto("landmarks")?.count ?? 0 let grupos = valor.objeto("landmarks")?.count ?? 0
return "rosto detectado (\(grupos) grupos de landmarks mapeados)" let roll = valor.numero("roll").map { String(format: "%.1f", $0 * 180 / .pi) } ?? "?"
let yaw = valor.numero("yaw").map { String(format: "%.1f", $0 * 180 / .pi) } ?? "?"
return "rosto detectado (\(grupos) landmarks), inclinação=\(roll)° rotação=\(yaw)°"
case "qualidade_do_rosto": case "qualidade_do_rosto":
guard let score = valor.numero("score") else { return "qualidade do rosto avaliada" } guard let score = valor.numero("score") else { return "qualidade do rosto avaliada" }
return "qualidade do rosto: score \(String(format: "%.2f", score))" return "qualidade do rosto: score \(String(format: "%.2f", score))"
@@ -335,6 +550,11 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
case "similaridade_visual": case "similaridade_visual":
let mudanca = valor.booleano("possivel_mudanca_de_cena") ?? false let mudanca = valor.booleano("possivel_mudanca_de_cena") ?? false
return mudanca ? "possível corte de cena entre os quadros" : "quadros visualmente semelhantes, sem corte de cena" return mudanca ? "possível corte de cena entre os quadros" : "quadros visualmente semelhantes, sem corte de cena"
case "brilho":
guard let media = valor.numero("media_brilho") else { return "análise de brilho" }
let exp = valor.texto("exposicao") ?? "?"
let contraste = valor.numero("contraste") ?? 0
return "brilho \(String(format: "%.0f", media * 100))%, contraste \(String(format: "%.0f", contraste * 100))%, exposição: \(exp)"
default: default:
return evidencia.tipo return evidencia.tipo
} }
@@ -360,7 +580,7 @@ func interpretar(_ saida: Saida) async -> String? {
let saida: Saida let saida: Saida
do { do {
let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados) let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
var resultado = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada) var resultado = (entrada.frames?.count ?? 0) > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
if entrada.resumir, let resumo = await interpretar(resultado) { if entrada.resumir, let resumo = await interpretar(resultado) {
resultado = Saida(evidencias: resultado.evidencias + [Evidencia( resultado = Saida(evidencias: resultado.evidencias + [Evidencia(
tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)],
@@ -1,7 +1,7 @@
"""Adapta o faster-whisper para a transcrição local do Scanner.""" """Adapta o faster-whisper para a transcrição local do Scanner."""
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
@@ -10,7 +10,8 @@ class ProviderDeTranscricaoLocal:
"""Executa transcrição local em lote, sem transmitir a mídia.""" """Executa transcrição local em lote, sem transmitir a mídia."""
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8", def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
idioma: str = "pt", tamanho_do_lote: int = 16) -> None: idioma: str = "pt", tamanho_do_lote: int = 16,
ao_progresso: Callable[[float], None] | None = None) -> None:
"""Carrega o modelo local e configura o tamanho do lote de inferência.""" """Carrega o modelo local e configura o tamanho do lote de inferência."""
if tamanho_do_lote < 1: if tamanho_do_lote < 1:
raise ValueError("O tamanho do lote deve ser maior que zero.") raise ValueError("O tamanho do lote deve ser maior que zero.")
@@ -22,17 +23,29 @@ class ProviderDeTranscricaoLocal:
self.pipeline = BatchedInferencePipeline(model=self.modelo) self.pipeline = BatchedInferencePipeline(model=self.modelo)
self.idioma = idioma self.idioma = idioma
self.tamanho_do_lote = tamanho_do_lote self.tamanho_do_lote = tamanho_do_lote
self.ao_progresso = ao_progresso
def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]: def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]:
"""Transcreve o áudio com VAD, timestamps e inferência em lote.""" """Transcreve o áudio com VAD, timestamps e inferência em lote."""
arquivo = Path(clipe.arquivo) arquivo = Path(clipe.arquivo)
if not arquivo.is_file(): if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}") raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
segmentos, _ = self.pipeline.transcribe( segmentos, informacoes = self.pipeline.transcribe(
str(arquivo), language=self.idioma, vad_filter=True, str(arquivo), language=self.idioma, vad_filter=True,
word_timestamps=True, batch_size=self.tamanho_do_lote, word_timestamps=True, batch_size=self.tamanho_do_lote,
) )
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip(), duracao = float(getattr(informacoes, "duration", 0.0) or 0.0)
None, tuple(PalavraDeTranscricao(w.word.strip(), float(w.start), float(w.end), resultado: list[SegmentoDeTranscricao] = []
getattr(w, "probability", None)) for segmento in segmentos:
for w in (s.words or []) if w.word.strip())) for s in segmentos] resultado.append(SegmentoDeTranscricao(
float(segmento.start), float(segmento.end), segmento.text.strip(),
None, tuple(PalavraDeTranscricao(
palavra.word.strip(), float(palavra.start), float(palavra.end),
getattr(palavra, "probability", None)
) for palavra in (segmento.words or []) if palavra.word.strip())
))
if self.ao_progresso and duracao > 0:
self.ao_progresso(min(99.0, max(0.0, float(segmento.end) / duracao * 100)))
if self.ao_progresso:
self.ao_progresso(100.0)
return resultado
@@ -57,6 +57,42 @@ class TesteDoProviderDeTranscricaoLocal(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
ProviderDeTranscricaoLocal("small", tamanho_do_lote=0) ProviderDeTranscricaoLocal("small", tamanho_do_lote=0)
def test_emite_progresso_conforme_os_segmentos_processados(self):
"""Atualiza o chamador enquanto o gerador do Whisper entrega segmentos."""
progresso = []
class ModeloFalso:
"""Representa o modelo externo durante o teste."""
def __init__(self, *argumentos, **opcoes):
pass
class PipelineFalso:
"""Entrega dois segmentos para simular um áudio em processamento."""
def __init__(self, model):
pass
def transcribe(self, *argumentos, **opcoes):
segmentos = [
SimpleNamespace(start=0.0, end=2.0, text=" um", words=[]),
SimpleNamespace(start=2.0, end=4.0, text=" dois", words=[]),
]
return iter(segmentos), SimpleNamespace(duration=4.0)
modulo_falso = types.SimpleNamespace(
BatchedInferencePipeline=PipelineFalso,
WhisperModel=ModeloFalso,
)
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "audio.wav"
arquivo.write_bytes(b"audio")
with patch.dict(sys.modules, {"faster_whisper": modulo_falso}):
provider = ProviderDeTranscricaoLocal("small", ao_progresso=progresso.append)
provider.transcrever(SimpleNamespace(arquivo=str(arquivo)))
self.assertEqual(progresso, [50.0, 99.0, 100.0])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
# Plano de edição de vídeo por voz
Documento vivo para integrar o Scanner, o banco SQLite, a tela **Editar vídeo**, a decisão assistida por IA e a aplicação segura do plano na timeline do Premiere Pro.
Status: desenho aprovado para implementação incremental.
## 1. Objetivo
Permitir que a tela **Editar vídeo** reutilize uma análise já executada pelo Scanner, sem transcrever o mesmo material novamente. A tela buscará no banco somente os dados necessários, montará um contexto compacto para a IA, receberá decisões editoriais em JSON, validará essas decisões pela engine e aplicará o plano na timeline ativa.
O fluxo não deve enviar o banco inteiro, o relatório completo ou o áudio para a IA. A IA recebe somente o contexto editorial solicitado.
## 1.1. Protocolo de edição solicitado pelo chat
Quando o usuário disser **“editar vídeo”**, o chat deve tratar isso como um
pedido de execução do fluxo deste documento.
O pedido deve separar três identificadores:
1. **Vídeo/análise**: qual análise do Scanner será usada. Preferir `video_id`
ou o caso exibido na tela, nunca confundir com o perfil editorial.
2. **Perfil editorial**: número ou identificador da personalidade/estilo da
edição. Esse número representa como editar, não qual arquivo editar.
3. **Tipo e objetivo do vídeo**: depoimento, entrevista, redes sociais,
educativo, institucional ou perfil personalizado.
Exemplo de pedido normalizado:
```json
{
"intencao": "editar_video",
"video_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
"perfil_editorial_id": 3,
"tipo_de_video": "depoimento",
"objetivo": "preservar autenticidade e remover repetições",
"intervalo": {"inicio": 0.0, "fim": null}
}
```
Se o número informado ainda não estiver cadastrado, o chat deve pedir a
descrição do perfil ou registrar o perfil antes de gerar decisões. O número
não deve ser interpretado como `video_id`, `clipe_id` ou número de faixa.
### Ordem de execução no chat
Quando os dados estiverem disponíveis, o chat deve:
1. identificar o vídeo/análise correta;
2. carregar o perfil editorial pelo número;
3. consultar o resumo do vídeo localmente;
4. consultar falas, clipes, cenas e métricas necessárias;
5. montar um contexto editorial compacto;
6. aplicar as regras do perfil e o tipo do vídeo;
7. gerar diretamente o plano de edição;
8. validar o plano contra a análise e a timeline;
9. entregar o plano JSON e um resumo das decisões;
10. deixar a aplicação no Premiere para a etapa de confirmação do usuário.
O chat não deve devolver apenas uma sugestão textual quando o pedido for
“editar vídeo”. Deve gerar um plano estruturado, salvo em arquivo, pronto para
ser lido pelo `LeitorDePlanoDeEdicao`.
### Leitura dos registros sem desperdício de contexto
O banco pode ser lido integralmente pela engine em lotes, sem enviar todos os
registros para o chat ou para a IA. A leitura deve ser progressiva:
- primeiro, resumo, faixas, clipes e contagens;
- depois, falas do intervalo ou do objetivo escolhido;
- depois, métricas de voz quando o perfil depender de ritmo, intensidade ou
pausas;
- depois, palavras somente para decisões que exigem precisão;
- por fim, cenas e evidências visuais relacionadas aos trechos candidatos.
“Ler na íntegra” significa garantir que a engine tenha acesso aos registros
necessários, não despejar o banco inteiro no contexto do modelo.
### Consultas criadas durante a edição
Quando surgir uma necessidade nova, a consulta deve ser criada como parte da
engine e documentada neste MD, contendo:
- nome da intenção consultada;
- parâmetros aceitos;
- SQL parametrizado;
- formato compacto de retorno;
- teste de regressão;
- perfil editorial que utiliza a consulta.
Consultas temporárias podem ser usadas para investigar um caso, mas não devem
virar parte do fluxo do chat sem serem transformadas em um método reutilizável
de `ConsultasDeAnalises` ou de um módulo de aplicação.
## 2. Vocabulário do domínio
- **Vídeo analisado**: registro de uma timeline no banco, identificado por `video_id`.
- **Faixa**: trilha de áudio ou vídeo pertencente ao vídeo analisado.
- **Clipe**: intervalo de uma faixa na timeline, com referência ao arquivo de origem.
- **Fala**: segmento de transcrição associado a um clipe, com início, fim e texto.
- **Palavra**: unidade sincronizada dentro de uma fala.
- **Métrica de voz**: sinal acústico calculado para uma fala, como energia, pitch, velocidade e pausas. Não é uma emoção classificada.
- **Cena**: intervalo visual identificado pelo Scanner.
- **Evidência**: observação visual ou sonora com intervalo e confiança.
- **Contexto editorial**: pacote compacto de fatos enviado à IA para apoiar uma decisão de edição.
- **Decisão editorial**: ação proposta pela IA, como corte, zoom, texto ou marcador.
- **Plano de edição**: conjunto ordenado e validado de decisões editoriais.
- **Aplicador**: módulo que executa o plano na timeline do Premiere.
Transcrição, análise, decisão e aplicação são etapas diferentes. Uma transcrição não deve ser interpretada automaticamente como uma decisão de corte.
## 3. Fluxo completo
```text
Premiere Pro
│
├─ Scanner: lê a timeline e salva análise
│ ├─ vídeos, faixas e clipes
│ ├─ transcrição e palavras
│ ├─ métricas acústicas opcionais
│ ├─ cenas e evidências visuais
│ └─ versão da análise
│
└─ Editar vídeo
├─ seleciona um vídeo analisado
├─ define objetivo e regras editoriais
├─ pede contexto editorial compacto à engine
├─ envia contexto à IA
├─ recebe plano JSON
├─ valida plano e origem
├─ mostra prévia das decisões
└─ aplica plano pela engine
```
O Scanner produz fatos. A IA decide. A engine valida e aplica. A interface coordena e apresenta.
## 4. Situação atual do código
### Já existe
- `code/cep-plugin/`: painel CEP com **Editar vídeo** e **Scanner**.
- `code/engine/persistencia/esquema.py`: tabelas SQLite de timeline, transcrição, palavras, cenas e evidências.
- `code/engine/persistencia/repositorio_de_analises_sqlite.py`: persistência das análises.
- `code/engine/persistencia/consultas.py`: consultas de leitura compactas.
- `code/engine/scanner/transcricao_da_timeline.py`: transcrição projetada para os clipes da timeline.
- `code/engine/editor/modelos.py`: `PlanoDeEdicao` e `AcaoDeEdicao`.
- `code/engine/editor/leitura/leitor_de_plano.py`: validação do plano externo.
- `code/engine/editor/aplicador_de_plano_de_edicao.py`: aplicação das ações.
- `code/engine/aplicar_plano_de_edicao.py`: entrada da engine para aplicar um plano.
- `code/cep-plugin/main.js`: geração de `dados-para-ia.json` e aplicação do plano recebido.
### Lacunas conhecidas
- `listar_falas_no_intervalo()` não retorna métricas acústicas por padrão.
- Editar vídeo ainda trabalha principalmente com `transcricao.json` e não seleciona diretamente uma análise do Scanner.
- O contexto editorial ainda não é um módulo explícito da engine.
- O plano atual identifica a origem principalmente por nome e tempo; com várias faixas e clipes, precisa também de `video_id` e `clipe_id`.
- Reprocessamentos precisam de uma política explícita para não duplicar segmentos no banco.
## 5. Consultas SQL compactas
As consultas ficam na engine. O painel não abre SQLite nem monta SQL.
### 5.1. Listar vídeos analisados
```sql
SELECT id AS video_id, nome, duracao, criado_em
FROM videos
ORDER BY criado_em DESC
LIMIT :limite;
```
### 5.2. Resumo do vídeo
```sql
SELECT
v.id AS video_id, v.nome, v.duracao, v.taxa_de_quadros,
v.largura, v.altura,
(SELECT COUNT(*) FROM faixas f WHERE f.video_id = v.id) AS total_faixas,
(SELECT COUNT(*) FROM clipes c WHERE c.video_id = v.id) AS total_clipes,
(SELECT COUNT(*) FROM segmentos_de_transcricao s WHERE s.video_id = v.id) AS total_falas,
(SELECT COUNT(*) FROM cenas c WHERE c.video_id = v.id) AS total_cenas,
(SELECT COUNT(*) FROM evidencias_visuais e WHERE e.video_id = v.id) AS total_evidencias
FROM videos v
WHERE v.id = :video_id;
```
### 5.3. Falas para edição
Consulta padrão, sem palavras e sem métricas:
```sql
SELECT s.id AS fala_id, s.clipe_id, s.inicio, s.fim, s.texto,
s.confianca, s.falante, s.emocao, s.confianca_emocao
FROM segmentos_de_transcricao s
WHERE s.video_id = :video_id
AND s.fim >= :inicio
AND (:fim IS NULL OR s.inicio <= :fim)
ORDER BY s.inicio, s.id;
```
Consulta com métricas acústicas:
```sql
SELECT s.id AS fala_id, s.clipe_id, s.inicio, s.fim, s.texto,
s.confianca, s.falante,
s.caracteristicas_acusticas AS metricas_de_voz
FROM segmentos_de_transcricao s
WHERE s.video_id = :video_id
AND s.fim >= :inicio
AND (:fim IS NULL OR s.inicio <= :fim)
AND s.caracteristicas_acusticas IS NOT NULL
AND s.caracteristicas_acusticas <> '{}'
ORDER BY s.inicio, s.id;
```
`metricas_de_voz` deve ser convertido de texto JSON para objeto dentro da engine. O SQL não deve conhecer a estrutura variável das métricas.
### 5.4. Palavras sob demanda
```sql
SELECT p.segmento_id AS fala_id, p.ordem, p.texto, p.inicio, p.fim,
p.confianca, p.falante
FROM palavras_de_transcricao p
WHERE p.segmento_id IN (:fala_ids)
ORDER BY p.segmento_id, p.ordem;
```
Na implementação, `:fala_ids` deve ser expandido com parâmetros SQLite individuais. Nunca interpolar valores da interface no SQL.
### 5.5. Cenas e evidências
```sql
SELECT clipe_id, inicio, fim, confianca
FROM cenas
WHERE video_id = :video_id
AND (:clipe_id IS NULL OR clipe_id = :clipe_id)
ORDER BY inicio;
```
```sql
SELECT clipe_id, tipo, inicio, fim, valor, confianca, provider, modelo
FROM evidencias_visuais
WHERE video_id = :video_id
AND (:clipe_id IS NULL OR clipe_id = :clipe_id)
AND (:tipo IS NULL OR tipo = :tipo)
AND (:confianca_minima IS NULL OR confianca IS NULL OR confianca >= :confianca_minima)
ORDER BY inicio;
```
## 6. Interface profunda da engine
O módulo principal deve esconder SQL, agrupamento, conversão de JSON, ordenação e redução de contexto.
Interface proposta:
```python
contexto = montador.montar(
video_id=video_id,
objetivo=objetivo_editorial,
inicio=inicio,
fim=fim,
incluir_metricas=True,
incluir_palavras=False,
)
```
Responsabilidades de `MontadorDeContextoEditorial`:
- validar `video_id` e intervalos;
- localizar o resumo do vídeo;
- buscar falas no intervalo;
- incluir métricas somente quando solicitado;
- buscar palavras somente quando solicitado;
- agrupar falas por clipe;
- anexar cenas e evidências relevantes;
- remover campos vazios;
- normalizar números e intervalos;
- produzir um contrato estável para a IA;
- informar a versão da análise usada.
O painel chama uma entrada da engine e recebe JSON. Não conhece tabelas nem SQL.
## 7. Contexto enviado à IA
```json
{
"schema": "contexto_editorial_v1",
"analysis": {
"video_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
"versao": "hash-da-analise",
"fonte": "scanner"
},
"video": { "nome": "Cópia de 0E6A8829", "duracao": 120.0 },
"objetivo": {
"tipo": "depoimento",
"instrucao": "Remover pausas longas e repetições sem perder autenticidade.",
"preservar": ["contexto", "frases completas", "pausas emocionais"],
"remover": ["silêncios longos", "repetições", "erros explícitos"]
},
"falas": [
{
"fala_id": 42,
"clipe_id": "000f476b",
"inicio": 12.45,
"fim": 15.82,
"texto": "Eu comecei esse processo no ano passado.",
"falante": "SPEAKER_00",
"metricas_de_voz": {
"energy_rms": 0.084,
"pitch_hz_median": 187.5,
"pitch_hz_std": 32.8,
"speaking_rate_wps": 2.4,
"longest_internal_pause_s": 0.42,
"gap_before_s": 0.18
}
}
],
"cenas": [],
"evidencias_visuais": []
}
```
Perfis de tamanho:
- `resumo`: metadados, contagens e intervalos principais;
- `fala`: falas, locutores e métricas, sem palavras;
- `precisao`: falas, palavras e evidências do intervalo selecionado;
- `completo`: somente para diagnóstico local, nunca como padrão para IA.
O fluxo normal começa com `resumo` ou `fala` e consulta `precisao` somente quando necessário.
## 8. Decisões e plano de edição
O plano mantém o contrato já validado pela engine e recebe metadados para impedir aplicação em outra análise:
```json
{
"schema": "plano_edicao_v2",
"source": "Cópia de 0E6A8829",
"analysis": {
"video_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
"versao": "hash-da-analise"
},
"actions": [
{
"id": "acao-001",
"kind": "cut",
"target": {
"clipe_id": "000f476b",
"arquivo_de_origem": "/caminho/video.mp4"
},
"start": 20.4,
"end": 23.8,
"reason": "Pausa longa sem conteúdo entre duas frases.",
"params": { "tipo": "silencio", "confianca": 0.91 }
}
]
}
```
Regras para a IA:
- não inventar tempos fora do material analisado;
- não cortar fala sem `reason`;
- usar segundos na origem;
- não usar tempo relativo ao JSON;
- não alterar a transcrição original;
- indicar confiança baixa quando houver dúvida;
- não aplicar o plano diretamente no Premiere.
## 9. Validação antes da aplicação
### Validação estrutural
Responsável: `LeitorDePlanoDeEdicao`.
- JSON válido;
- `source` preenchido;
- `actions` não vazio;
- tipo conhecido;
- início e fim numéricos;
- fim maior que início;
- motivo obrigatório.
### Validação contextual
Novo comportamento a adicionar:
- `video_id` do plano igual ao vídeo selecionado;
- versão da análise ainda válida;
- `clipe_id` existente;
- arquivo de origem compatível;
- intervalo contido no clipe ou na origem;
- nenhuma ação duplicada;
- nenhuma ação já aplicada;
- plano compatível com a sequência ativa.
## 10. Fluxo da tela Editar vídeo
1. Selecionar origem: `Transcrever este vídeo` ou `Usar análise do Scanner`.
2. Listar análises disponíveis e mostrar modelo, idioma, diarização, métricas e data.
3. Escolher objetivo: depoimento, entrevista, redes sociais ou personalizado.
4. Definir regras: remover silêncio, remover repetição, preservar pausas emocionais, locutores e intensidade.
5. Carregar contexto compacto pela engine.
6. Mostrar falas, tempos, locutores, métricas e status da análise.
7. Exportar contexto para IA ou chamar um Provider de IA futuramente.
8. Importar o plano devolvido.
9. Mostrar cada ação com intervalo, texto, motivo, confiança e validação.
10. Criar backup, validar e aplicar pela engine.
## 11. Classes e módulos planejados
### Domínio
`code/engine/editor/contexto_editorial/modelos.py`
- `ContextoEditorial`;
- `FalaEditorial`;
- `MetricaDeVozEditorial`;
- `ObjetivoEditorial`.
Essas classes não conhecem SQLite, JSON de transporte ou Premiere.
### Aplicação
`code/engine/editor/contexto_editorial/montador.py`
- `MontadorDeContextoEditorial`.
Essa é a interface profunda: recebe filtros editoriais e devolve contexto compacto, escondendo consultas, agrupamento e normalização.
### Persistência
Estender `ConsultasDeAnalises` com:
- `listar_videos_analisados()`;
- `consultar_contexto_editorial()`;
- `listar_falas_editoriais()`;
- `listar_palavras_das_falas()`.
### Transporte
Criar `code/engine/gerar_contexto_editorial.py` para ler pedido JSON, validar entrada, chamar o montador e escrever resposta JSON.
### Plano
Reutilizar `LeitorDePlanoDeEdicao`, `PlanoDeEdicao` e `AplicadorDePlanoDeEdicao`, adicionando a validação contextual antes do aplicador.
## 12. Versionamento e idempotência
Cada execução do Scanner deve ter uma identidade de análise. O editor deve guardar essa identidade no contexto e no plano.
Recomendações:
- usar `analises_versao` para registrar etapa e hash da entrada;
- incluir configuração, modelo, idioma e faixas no hash;
- invalidar o plano quando a timeline ou o vídeo mudar;
- substituir ou versionar segmentos ao reprocessar;
- não acumular silenciosamente duas transcrições iguais.
Antes do editor, corrigir a política de reprocessamento para evitar duplicidade em `segmentos_de_transcricao`.
## 13. Testes obrigatórios
- consultas filtram por vídeo e intervalo;
- métricas aparecem somente quando solicitadas;
- palavras não aparecem por padrão;
- contexto compacto agrupa falas por clipe;
- campos vazios são removidos;
- palavras são carregadas sob demanda;
- plano de outro vídeo é rejeitado;
- plano de análise antiga é rejeitado;
- ação fora do clipe é rejeitada;
- aplicação cria backup e não repete ação;
- interface seleciona análise, importa plano e mostra validação;
- cancelamento interrompe a geração/aplicação.
## 14. Ordem de implementação
- [ ] Consultas de vídeos analisados.
- [ ] Consulta de falas com opção de métricas.
- [ ] Consulta de palavras sob demanda.
- [ ] Modelos de `ContextoEditorial`.
- [ ] `MontadorDeContextoEditorial`.
- [ ] `gerar_contexto_editorial.py`.
- [ ] Versionamento e reprocessamento no banco.
- [ ] Origem “Usar análise do Scanner” na tela Editar vídeo.
- [ ] Renderização do contexto e das falas.
- [ ] Exportação do contexto para IA.
- [ ] Plano com `video_id`, `clipe_id` e versão.
- [ ] Validação contextual.
- [ ] Aplicação pela engine.
- [ ] Teste em cópia da sequência.
- [ ] Revisão final do código.
## 15. Critério de conclusão
O recurso estará pronto quando o usuário conseguir executar o Scanner uma vez, abrir Editar vídeo, escolher o vídeo analisado, buscar somente as falas necessárias, gerar contexto compacto, importar um plano, validá-lo contra a análise correta, revisar as ações e aplicá-las na timeline com backup, sem repetir a transcrição.
Este documento deve ser atualizado a cada etapa implementada, especialmente quando um contrato JSON ou uma consulta SQL mudar.