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:
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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
@@ -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.
|
||||||
Reference in New Issue
Block a user