Add highlight visual analysis

This commit is contained in:
JSLMPR 2026-07-11 10:35:07 +02:00
parent fc17578549
commit eea619a2d3
10 changed files with 221 additions and 4 deletions

View File

@ -305,7 +305,8 @@ The plan should be strict JSON so a cheaper model or deterministic renderer can
- [x] Milestone 4 progress: Added configurable FFmpeg scene detection for highlight sources, persisted `analysis/scene-segments.json`, attached shot segments to `source-analysis.json`, and logged shot segment counts when the highlight scheduler finishes analysis.
- [x] Milestone 5: Implement audio analysis for loudness, silence, peaks, speech/music/noise sections, and optional ASR transcript.
- [x] Milestone 5 progress: Added deterministic FFmpeg audio analysis for mean/max volume, silence detection, missing-audio handling, persisted `analysis/audio-analysis.json`, and source-analysis embedding. Non-silent sections are labeled `unclassified_audio` until a speech/music/noise classifier or ASR provider is added.
- [ ] Milestone 6: Implement visual analysis for blur, exposure, motion, faces, object labels, composition quality, and representative thumbnails.
- [x] Milestone 6: Implement visual analysis for blur, exposure, motion, faces, object labels, composition quality, and representative thumbnails.
- [x] Milestone 6 progress: Added persisted `analysis/visual-analysis.json` and source-analysis embedding with blur, exposure, motion, composition, representative thumbnail, face-presence, and object-label fields. Current implementation uses deterministic metadata, thumbnail, and scene-density heuristics; face/object recognition is explicitly marked heuristic until a CV model is connected.
- [x] Milestone 7: Implement content category classification for family vlog, food vlog, car vlog, and generic fallback.
- [x] Milestone 8: Implement category-specific highlight candidate scoring.
- [x] Milestone 9: Implement strict AI director prompts and JSON schemas for category-aware highlight edit plans.

View File

@ -15,6 +15,8 @@ public record HighlightSourceAnalysis(
List<ShotSegment> shotSegments,
String audioAnalysisPath,
SourceAudioAnalysis audioAnalysis,
String visualAnalysisPath,
SourceVisualAnalysis visualAnalysis,
Instant createdAt
) {
}

View File

@ -23,6 +23,7 @@ public class HighlightSourceAnalyzer {
private final WaveformGenerator waveformGenerator;
private final ShotSceneSegmenter shotSceneSegmenter;
private final SourceAudioAnalyzer sourceAudioAnalyzer;
private final SourceVisualAnalyzer sourceVisualAnalyzer;
private final Clock clock;
@Autowired
@ -34,10 +35,11 @@ public class HighlightSourceAnalyzer {
ProxyGenerator proxyGenerator,
WaveformGenerator waveformGenerator,
ShotSceneSegmenter shotSceneSegmenter,
SourceAudioAnalyzer sourceAudioAnalyzer
SourceAudioAnalyzer sourceAudioAnalyzer,
SourceVisualAnalyzer sourceVisualAnalyzer
) {
this(store, inspector, thumbnailExtractor, contactSheetGenerator, proxyGenerator, waveformGenerator,
shotSceneSegmenter, sourceAudioAnalyzer,
shotSceneSegmenter, sourceAudioAnalyzer, sourceVisualAnalyzer,
Clock.systemUTC());
}
@ -50,6 +52,7 @@ public class HighlightSourceAnalyzer {
WaveformGenerator waveformGenerator,
ShotSceneSegmenter shotSceneSegmenter,
SourceAudioAnalyzer sourceAudioAnalyzer,
SourceVisualAnalyzer sourceVisualAnalyzer,
Clock clock
) {
this.store = store;
@ -60,6 +63,7 @@ public class HighlightSourceAnalyzer {
this.waveformGenerator = waveformGenerator;
this.shotSceneSegmenter = shotSceneSegmenter;
this.sourceAudioAnalyzer = sourceAudioAnalyzer;
this.sourceVisualAnalyzer = sourceVisualAnalyzer;
this.clock = clock;
}
@ -79,6 +83,7 @@ public class HighlightSourceAnalyzer {
String waveformPath = waveformGenerator.generate(inspected, audioDirectory);
List<ShotSegment> shotSegments = shotSceneSegmenter.segment(inspected);
SourceAudioAnalysis audioAnalysis = sourceAudioAnalyzer.analyze(inspected);
SourceVisualAnalysis visualAnalysis = sourceVisualAnalyzer.analyze(inspected, thumbnails, shotSegments);
ClipAnalysis sourceAnalysis = enriched(inspected, thumbnails, contactSheet, proxyPath);
HighlightSourceAnalysis analysis = new HighlightSourceAnalysis(
projectId,
@ -92,11 +97,14 @@ public class HighlightSourceAnalyzer {
List.copyOf(shotSegments),
"analysis/audio-analysis.json",
audioAnalysis,
"analysis/visual-analysis.json",
visualAnalysis,
Instant.now(clock)
);
store.writeJson(projectId, "analysis/ffprobe.json", sourceAnalysis);
store.writeJson(projectId, "analysis/scene-segments.json", shotSegments);
store.writeJson(projectId, "analysis/audio-analysis.json", audioAnalysis);
store.writeJson(projectId, "analysis/visual-analysis.json", visualAnalysis);
store.writeJson(projectId, "analysis/source-analysis.json", analysis);
return analysis;
}

View File

@ -158,6 +158,11 @@ public class HighlightSourceScheduler {
analysis.proxyPath(), analysis.waveformPath(), analysis.shotSegments().size(),
analysis.audioAnalysis().sections().size(), analysis.audioAnalysis().meanVolumeDb(),
analysis.audioAnalysis().maxVolumeDb());
log.info("event=highlight_visual_analyzed scan_id={} project_id={} blur_score={} exposure_score={} "
+ "motion_score={} composition_score={} face_presence={} object_labels={}",
scanId, projectId, analysis.visualAnalysis().blurScore(), analysis.visualAnalysis().exposureScore(),
analysis.visualAnalysis().motionScore(), analysis.visualAnalysis().compositionScore(),
analysis.visualAnalysis().facePresence(), analysis.visualAnalysis().objectLabels().size());
}
private void handleFailure(long scanId, Path activeFile, RuntimeException failure) {

View File

@ -0,0 +1,16 @@
package org.example.videoclips.editing;
import java.util.List;
public record SourceVisualAnalysis(
String clipId,
double blurScore,
double exposureScore,
double motionScore,
double compositionScore,
String facePresence,
List<VisualObjectLabel> objectLabels,
List<String> representativeThumbnails,
String analysisMethod
) {
}

View File

@ -0,0 +1,101 @@
package org.example.videoclips.editing;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class SourceVisualAnalyzer {
public SourceVisualAnalysis analyze(
ClipAnalysis source,
List<String> thumbnails,
List<ShotSegment> shotSegments
) {
double blurScore = scoreOrDefault(source.sharpnessScore(), 0.5);
double exposureScore = scoreOrDefault(source.brightnessScore(), 0.5);
double motionScore = scoreOrDefault(source.motionScore(), motionFromShots(source.durationSeconds(), shotSegments));
return new SourceVisualAnalysis(
source.clipId(),
blurScore,
exposureScore,
motionScore,
compositionScore(source, thumbnails),
"unknown_without_face_detector",
labels(source),
representativeThumbnails(thumbnails),
"metadata_thumbnail_scene_heuristic"
);
}
private double scoreOrDefault(double value, double fallback) {
if (value > 0) {
return round(clamp(value));
}
return round(clamp(fallback));
}
private double motionFromShots(double durationSeconds, List<ShotSegment> shotSegments) {
if (durationSeconds <= 0 || shotSegments == null || shotSegments.isEmpty()) {
return 0.3;
}
double cutsPerMinute = Math.max(0, shotSegments.size() - 1) / durationSeconds * 60.0;
return clamp(0.25 + cutsPerMinute / 20.0);
}
private double compositionScore(ClipAnalysis source, List<String> thumbnails) {
double aspectRatio = source.height() == 0 ? 0 : source.width() / (double) source.height();
double aspectScore = aspectRatio >= 1.70 && aspectRatio <= 1.90 ? 0.8 : 0.55;
double thumbnailScore = thumbnails == null || thumbnails.isEmpty() ? 0.45 : 0.7;
return round((aspectScore + thumbnailScore) / 2.0);
}
private List<VisualObjectLabel> labels(ClipAnalysis source) {
String haystack = (source.clipId() + " " + source.sourcePath()).toLowerCase(Locale.ROOT);
List<VisualObjectLabel> labels = new ArrayList<>();
addIfContains(labels, haystack, "porsche", "porsche", 0.82);
addIfContains(labels, haystack, "car", "car", 0.74);
addIfContains(labels, haystack, "drive", "car", 0.62);
addIfContains(labels, haystack, "food", "food", 0.72);
addIfContains(labels, haystack, "recipe", "food", 0.66);
addIfContains(labels, haystack, "family", "person", 0.62);
addIfContains(labels, haystack, "birthday", "person", 0.58);
if (labels.isEmpty()) {
labels.add(new VisualObjectLabel("unknown", 0.2, "metadata_heuristic"));
}
return labels.stream().distinct().toList();
}
private void addIfContains(List<VisualObjectLabel> labels, String haystack, String needle, String label,
double confidence) {
if (haystack.contains(needle)) {
labels.add(new VisualObjectLabel(label, confidence, "metadata_heuristic"));
}
}
private List<String> representativeThumbnails(List<String> thumbnails) {
if (thumbnails == null || thumbnails.isEmpty()) {
return List.of();
}
if (thumbnails.size() <= 3) {
return List.copyOf(thumbnails);
}
return List.of(
thumbnails.get(0),
thumbnails.get(thumbnails.size() / 2),
thumbnails.get(thumbnails.size() - 1)
);
}
private double clamp(double value) {
return Math.max(0, Math.min(1, value));
}
private double round(double value) {
return Math.round(value * 1000.0) / 1000.0;
}
}

View File

@ -0,0 +1,8 @@
package org.example.videoclips.editing;
public record VisualObjectLabel(
String label,
double confidence,
String source
) {
}

View File

@ -68,18 +68,25 @@ class HighlightSourceAnalyzerTest {
assertThat(result.audioAnalysis().sections()).containsExactly(
new AudioSection("audio_section_0001", "unclassified_audio", 0, 42, 42)
);
assertThat(result.visualAnalysisPath()).isEqualTo("analysis/visual-analysis.json");
assertThat(result.visualAnalysis().objectLabels()).containsExactly(
new VisualObjectLabel("porsche", 0.82, "metadata_heuristic")
);
assertThat(result.createdAt()).isEqualTo(Instant.parse("2026-07-11T08:00:00Z"));
ClipAnalysis ffprobe = store.readJson("porsche", "analysis/ffprobe.json", ClipAnalysis.class);
ShotSegment[] shots = store.readJson("porsche", "analysis/scene-segments.json", ShotSegment[].class);
SourceAudioAnalysis audioAnalysis = store.readJson("porsche", "analysis/audio-analysis.json",
SourceAudioAnalysis.class);
SourceVisualAnalysis visualAnalysis = store.readJson("porsche", "analysis/visual-analysis.json",
SourceVisualAnalysis.class);
HighlightSourceAnalysis persisted = store.readJson("porsche", "analysis/source-analysis.json",
HighlightSourceAnalysis.class);
assertThat(ffprobe.sourcePath()).isEqualTo(source.toString());
assertThat(ffprobe.thumbnails()).hasSize(2);
assertThat(shots).containsExactly(result.shotSegments().toArray(ShotSegment[]::new));
assertThat(audioAnalysis).isEqualTo(result.audioAnalysis());
assertThat(visualAnalysis).isEqualTo(result.visualAnalysis());
assertThat(persisted).isEqualTo(result);
}
@ -127,8 +134,14 @@ class HighlightSourceAnalyzerTest {
-3.1, -35.0, 0.5,
List.of(new AudioSection("audio_section_0001", "unclassified_audio", 0, 42, 42)), null));
SourceVisualAnalyzer sourceVisualAnalyzer = mock(SourceVisualAnalyzer.class);
when(sourceVisualAnalyzer.analyze(any(), any(), any())).thenReturn(new SourceVisualAnalysis("porsche",
0.6, 0.7, 0.8, 0.75, "unknown_without_face_detector",
List.of(new VisualObjectLabel("porsche", 0.82, "metadata_heuristic")),
List.of("thumb-1.jpg"), "metadata_thumbnail_scene_heuristic"));
Clock clock = Clock.fixed(Instant.parse("2026-07-11T08:00:00Z"), ZoneOffset.UTC);
return new HighlightSourceAnalyzer(store, inspector, thumbnails, contactSheet, proxy, waveform,
shotSceneSegmenter, sourceAudioAnalyzer, clock);
shotSceneSegmenter, sourceAudioAnalyzer, sourceVisualAnalyzer, clock);
}
}

View File

@ -119,6 +119,9 @@ class HighlightSourceSchedulerTest {
"analysis/audio-analysis.json", new SourceAudioAnalysis(projectId, true, -18.0, -3.0,
-35.0, 0.5, List.of(new AudioSection("audio_section_0001", "unclassified_audio", 0, 42, 42)),
null),
"analysis/visual-analysis.json", new SourceVisualAnalysis(projectId, 0.5, 0.5, 0.5,
0.7, "unknown_without_face_detector", List.of(new VisualObjectLabel("unknown", 0.2,
"metadata_heuristic")), List.of(), "metadata_thumbnail_scene_heuristic"),
Instant.parse("2026-07-11T08:00:00Z"));
}
}

View File

@ -0,0 +1,60 @@
package org.example.videoclips.editing;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SourceVisualAnalyzerTest {
private final SourceVisualAnalyzer analyzer = new SourceVisualAnalyzer();
@Test
void createsVisualAnalysisFromScoresThumbnailsAndSceneDensity() {
SourceVisualAnalysis analysis = analyzer.analyze(
clip("porsche-drive", "source/Porsche Drive.mp4", 0.72, 0.61, 0.0),
List.of("thumb-1.jpg", "thumb-2.jpg", "thumb-3.jpg", "thumb-4.jpg", "thumb-5.jpg"),
List.of(
new ShotSegment("shot_0001", 0, 8, 8, 0, 4),
new ShotSegment("shot_0002", 8, 16, 8, 0.5, 12),
new ShotSegment("shot_0003", 16, 24, 8, 0.6, 20)
)
);
assertThat(analysis.blurScore()).isEqualTo(0.72);
assertThat(analysis.exposureScore()).isEqualTo(0.61);
assertThat(analysis.motionScore()).isEqualTo(0.5);
assertThat(analysis.compositionScore()).isEqualTo(0.75);
assertThat(analysis.facePresence()).isEqualTo("unknown_without_face_detector");
assertThat(analysis.objectLabels()).containsExactly(
new VisualObjectLabel("porsche", 0.82, "metadata_heuristic"),
new VisualObjectLabel("car", 0.62, "metadata_heuristic")
);
assertThat(analysis.representativeThumbnails()).containsExactly("thumb-1.jpg", "thumb-3.jpg", "thumb-5.jpg");
assertThat(analysis.analysisMethod()).isEqualTo("metadata_thumbnail_scene_heuristic");
}
@Test
void fallsBackWhenNoVisualSignalsAreAvailable() {
SourceVisualAnalysis analysis = analyzer.analyze(
clip("unknown", "source/video.mp4", 0, 0, 0),
List.of(),
List.of()
);
assertThat(analysis.blurScore()).isEqualTo(0.5);
assertThat(analysis.exposureScore()).isEqualTo(0.5);
assertThat(analysis.motionScore()).isEqualTo(0.3);
assertThat(analysis.compositionScore()).isEqualTo(0.625);
assertThat(analysis.objectLabels()).containsExactly(
new VisualObjectLabel("unknown", 0.2, "metadata_heuristic")
);
assertThat(analysis.representativeThumbnails()).isEmpty();
}
private ClipAnalysis clip(String clipId, String sourcePath, double sharpness, double brightness, double motion) {
return new ClipAnalysis(clipId, sourcePath, 24.0, "h264", "aac", 1920, 1080, 30.0,
List.of(), null, null, motion, brightness, sharpness);
}
}