diff --git a/docs/production-cinematic-highlight-editing-plan.md b/docs/production-cinematic-highlight-editing-plan.md index caf3736..26f2ac5 100644 --- a/docs/production-cinematic-highlight-editing-plan.md +++ b/docs/production-cinematic-highlight-editing-plan.md @@ -306,7 +306,7 @@ The plan should be strict JSON so a cheaper model or deterministic renderer can - [x] Milestone 9: Implement strict AI director prompts and JSON schemas for category-aware highlight edit plans. - [x] Milestone 10: Implement edit plan validation so impossible timestamps, missing assets, invalid overlays, and unsupported effects fail before render. - [x] Milestone 11: Implement asset provider interfaces for voiceover, music, SFX, fonts, and LUTs. -- [ ] Milestone 12: Add a local licensed asset library with category tags and deterministic asset selection. +- [x] Milestone 12: Add a local licensed asset library with category tags and deterministic asset selection. - [ ] Milestone 13: Implement renderer v2 with transitions, speed ramps, dynamic crops, overlays, visual effects, audio ducking, loudness normalization, and render manifests. - [ ] Milestone 14: Implement QA checks for black frames, silence, clipping, missing assets, duration mismatch, unsafe text placement, and failed FFmpeg filters. - [ ] Milestone 15: Add an operator runbook for placing a video, running the service locally, choosing a model, reviewing the director plan, and finding final clips. diff --git a/src/main/java/org/example/videoclips/editing/EditAssetLibrary.java b/src/main/java/org/example/videoclips/editing/EditAssetLibrary.java new file mode 100644 index 0000000..16d7f98 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditAssetLibrary.java @@ -0,0 +1,8 @@ +package org.example.videoclips.editing; + +import java.util.Optional; + +public interface EditAssetLibrary { + + Optional select(EditAssetSelectionRequest request); +} diff --git a/src/main/java/org/example/videoclips/editing/EditAssetSelectionRequest.java b/src/main/java/org/example/videoclips/editing/EditAssetSelectionRequest.java new file mode 100644 index 0000000..13c8904 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditAssetSelectionRequest.java @@ -0,0 +1,9 @@ +package org.example.videoclips.editing; + +public record EditAssetSelectionRequest( + EditAssetType type, + ContentCategory category, + String mood, + double durationSeconds +) { +} diff --git a/src/main/java/org/example/videoclips/editing/LocalEditAssetLibrary.java b/src/main/java/org/example/videoclips/editing/LocalEditAssetLibrary.java new file mode 100644 index 0000000..7e83955 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/LocalEditAssetLibrary.java @@ -0,0 +1,65 @@ +package org.example.videoclips.editing; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class LocalEditAssetLibrary implements EditAssetLibrary { + + private final EditAssetProvider provider; + + public LocalEditAssetLibrary(EditAssetProvider provider) { + this.provider = provider; + } + + @Override + public Optional select(EditAssetSelectionRequest request) { + if (request == null || request.type() == null) { + return Optional.empty(); + } + Set desiredTags = tokens(request.mood()); + List candidates = provider.list(request.type(), request.category()); + return candidates.stream() + .max(Comparator.comparingInt((ResolvedEditAsset asset) -> score(asset, request, desiredTags)) + .thenComparing(ResolvedEditAsset::assetKey, Comparator.reverseOrder())); + } + + private int score(ResolvedEditAsset asset, EditAssetSelectionRequest request, Set desiredTags) { + int score = 0; + if (request.category() != null && request.category().equals(asset.category())) { + score += 100; + } + if (!"untracked-local-asset".equals(asset.license())) { + score += 25; + } + Set assetTags = asset.tags().stream() + .flatMap(tag -> tokens(tag).stream()) + .collect(Collectors.toSet()); + for (String desiredTag : desiredTags) { + if (assetTags.contains(desiredTag)) { + score += 10; + } + if (asset.assetKey().toLowerCase(Locale.ROOT).contains(desiredTag)) { + score += 5; + } + } + return score; + } + + private Set tokens(String value) { + if (value == null || value.isBlank()) { + return Set.of(); + } + return java.util.Arrays.stream(value.toLowerCase(Locale.ROOT).split("[^a-z0-9]+")) + .filter(token -> !token.isBlank()) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/org/example/videoclips/editing/LocalEditAssetProvider.java b/src/main/java/org/example/videoclips/editing/LocalEditAssetProvider.java index 6a7fe91..594baf7 100644 --- a/src/main/java/org/example/videoclips/editing/LocalEditAssetProvider.java +++ b/src/main/java/org/example/videoclips/editing/LocalEditAssetProvider.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -57,12 +58,12 @@ public class LocalEditAssetProvider implements EditAssetProvider { List extensions = extensions(request.type()); Path direct = root.resolve(request.assetKey()).normalize(); if (direct.startsWith(root) && Files.isRegularFile(direct) && hasExtension(direct, extensions)) { - return Optional.of(asset(request.type(), direct)); + return Optional.of(asset(request.type(), direct, categoryFromRoot(root, request.type()))); } for (String extension : extensions) { Path candidate = root.resolve(request.assetKey() + "." + extension).normalize(); if (candidate.startsWith(root) && Files.isRegularFile(candidate)) { - return Optional.of(asset(request.type(), candidate)); + return Optional.of(asset(request.type(), candidate, categoryFromRoot(root, request.type()))); } } return Optional.empty(); @@ -72,16 +73,16 @@ public class LocalEditAssetProvider implements EditAssetProvider { try (Stream files = Files.list(root)) { return files.filter(Files::isRegularFile) .filter(path -> hasExtension(path, extensions(type))) - .map(path -> asset(type, path)) + .map(path -> asset(type, path, categoryFromRoot(root, type))) .toList(); } catch (IOException ex) { throw new IllegalStateException("Unable to list edit assets in: " + root, ex); } } - private ResolvedEditAsset asset(EditAssetType type, Path path) { + private ResolvedEditAsset asset(EditAssetType type, Path path, ContentCategory category) { return new ResolvedEditAsset(type, stripExtension(path.getFileName().toString()), path.toString(), - license(path), "local-filesystem"); + license(path), "local-filesystem", category, tags(path)); } private String license(Path path) { @@ -97,6 +98,36 @@ public class LocalEditAssetProvider implements EditAssetProvider { } } + private List tags(Path path) { + Path sidecar = path.resolveSibling(path.getFileName() + ".tags.txt"); + if (!Files.isRegularFile(sidecar)) { + return List.of(); + } + try { + return Arrays.stream(Files.readString(sidecar).split("[,\\n]")) + .map(String::strip) + .filter(tag -> !tag.isBlank()) + .distinct() + .sorted() + .toList(); + } catch (IOException ex) { + throw new IllegalStateException("Unable to read edit asset tags sidecar: " + sidecar, ex); + } + } + + private ContentCategory categoryFromRoot(Path root, EditAssetType type) { + Path base = root(type); + Path relative = base.relativize(root.normalize()); + if (relative.getNameCount() != 1) { + return null; + } + try { + return ContentCategory.valueOf(relative.getFileName().toString().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return null; + } + } + private List candidateRoots(EditAssetType type, ContentCategory category) { Path base = root(type); if (category == null) { diff --git a/src/main/java/org/example/videoclips/editing/ResolvedEditAsset.java b/src/main/java/org/example/videoclips/editing/ResolvedEditAsset.java index 7ea0a94..c1332fc 100644 --- a/src/main/java/org/example/videoclips/editing/ResolvedEditAsset.java +++ b/src/main/java/org/example/videoclips/editing/ResolvedEditAsset.java @@ -5,6 +5,21 @@ public record ResolvedEditAsset( String assetKey, String path, String license, - String provider + String provider, + ContentCategory category, + java.util.List tags ) { + public ResolvedEditAsset { + tags = tags == null ? java.util.List.of() : java.util.List.copyOf(tags); + } + + public ResolvedEditAsset( + EditAssetType type, + String assetKey, + String path, + String license, + String provider + ) { + this(type, assetKey, path, license, provider, null, java.util.List.of()); + } } diff --git a/src/test/java/org/example/videoclips/editing/LocalEditAssetLibraryTest.java b/src/test/java/org/example/videoclips/editing/LocalEditAssetLibraryTest.java new file mode 100644 index 0000000..2dfc5b7 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/LocalEditAssetLibraryTest.java @@ -0,0 +1,69 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.config.VideoClippingProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LocalEditAssetLibraryTest { + + @TempDir + Path tempDir; + + @Test + void selectsLicensedCategoryAssetWithMatchingTags() throws Exception { + VideoClippingProperties properties = properties(); + Path base = Files.createDirectories(tempDir.resolve("music")); + Path car = Files.createDirectories(base.resolve("car_vlog")); + Files.writeString(base.resolve("ambient.wav"), "base"); + Files.writeString(base.resolve("ambient.wav.tags.txt"), "soft, calm"); + Files.writeString(car.resolve("premium-drive.wav"), "car"); + Files.writeString(car.resolve("premium-drive.wav.license.txt"), "licensed-local-track"); + Files.writeString(car.resolve("premium-drive.wav.tags.txt"), "premium, drive, energetic"); + LocalEditAssetLibrary library = new LocalEditAssetLibrary(new LocalEditAssetProvider(properties)); + + var selected = library.select(new EditAssetSelectionRequest( + EditAssetType.MUSIC, ContentCategory.CAR_VLOG, "premium energetic drive", 30)); + + assertThat(selected).hasValueSatisfying(asset -> { + assertThat(asset.assetKey()).isEqualTo("premium-drive"); + assertThat(asset.category()).isEqualTo(ContentCategory.CAR_VLOG); + assertThat(asset.license()).isEqualTo("licensed-local-track"); + }); + } + + @Test + void selectionIsDeterministicForEqualScores() throws Exception { + VideoClippingProperties properties = properties(); + Path sfx = Files.createDirectories(tempDir.resolve("sfx")); + Files.writeString(sfx.resolve("alpha.wav"), "a"); + Files.writeString(sfx.resolve("beta.wav"), "b"); + LocalEditAssetLibrary library = new LocalEditAssetLibrary(new LocalEditAssetProvider(properties)); + + var selected = library.select(new EditAssetSelectionRequest(EditAssetType.SFX, null, "", 1)); + + assertThat(selected).hasValueSatisfying(asset -> assertThat(asset.assetKey()).isEqualTo("alpha")); + } + + @Test + void returnsEmptyWhenNoLocalAssetsExist() { + LocalEditAssetLibrary library = new LocalEditAssetLibrary(new LocalEditAssetProvider(properties())); + + assertThat(library.select(new EditAssetSelectionRequest( + EditAssetType.LUT, ContentCategory.FOOD_VLOG, "warm food", 0))).isEmpty(); + } + + private VideoClippingProperties properties() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().getAssets().setMusicFolder(tempDir.resolve("music").toString()); + properties.getEditing().getAssets().setSfxFolder(tempDir.resolve("sfx").toString()); + properties.getEditing().getAssets().setFontsFolder(tempDir.resolve("fonts").toString()); + properties.getEditing().getAssets().setLutsFolder(tempDir.resolve("luts").toString()); + properties.getEditing().getAssets().setVoiceoverFolder(tempDir.resolve("voiceover").toString()); + return properties; + } +} diff --git a/src/test/java/org/example/videoclips/editing/LocalEditAssetProviderTest.java b/src/test/java/org/example/videoclips/editing/LocalEditAssetProviderTest.java index d2df522..5d5459d 100644 --- a/src/test/java/org/example/videoclips/editing/LocalEditAssetProviderTest.java +++ b/src/test/java/org/example/videoclips/editing/LocalEditAssetProviderTest.java @@ -22,6 +22,7 @@ class LocalEditAssetProviderTest { Files.writeString(base.resolve("drive.wav"), "base"); Files.writeString(car.resolve("drive.wav"), "car"); Files.writeString(car.resolve("drive.wav.license.txt"), "licensed-local-car-track"); + Files.writeString(car.resolve("drive.wav.tags.txt"), "premium, car, energetic"); LocalEditAssetProvider provider = new LocalEditAssetProvider(properties); @@ -34,6 +35,8 @@ class LocalEditAssetProviderTest { assertThat(asset.path()).endsWith("music/car_vlog/drive.wav"); assertThat(asset.license()).isEqualTo("licensed-local-car-track"); assertThat(asset.provider()).isEqualTo("local-filesystem"); + assertThat(asset.category()).isEqualTo(ContentCategory.CAR_VLOG); + assertThat(asset.tags()).containsExactly("car", "energetic", "premium"); }); }