forked from jsl/video_editing_poc
Add highlight visual analysis
This commit is contained in:
parent
fc17578549
commit
eea619a2d3
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ public record HighlightSourceAnalysis(
|
|||
List<ShotSegment> shotSegments,
|
||||
String audioAnalysisPath,
|
||||
SourceAudioAnalysis audioAnalysis,
|
||||
String visualAnalysisPath,
|
||||
SourceVisualAnalysis visualAnalysis,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
public record VisualObjectLabel(
|
||||
String label,
|
||||
double confidence,
|
||||
String source
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue