Add local cinematic asset library

This commit is contained in:
JSLMPR 2026-07-11 01:02:38 +02:00
parent e3d69ad209
commit 376bc41685
8 changed files with 207 additions and 7 deletions

View File

@ -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.

View File

@ -0,0 +1,8 @@
package org.example.videoclips.editing;
import java.util.Optional;
public interface EditAssetLibrary {
Optional<ResolvedEditAsset> select(EditAssetSelectionRequest request);
}

View File

@ -0,0 +1,9 @@
package org.example.videoclips.editing;
public record EditAssetSelectionRequest(
EditAssetType type,
ContentCategory category,
String mood,
double durationSeconds
) {
}

View File

@ -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<ResolvedEditAsset> select(EditAssetSelectionRequest request) {
if (request == null || request.type() == null) {
return Optional.empty();
}
Set<String> desiredTags = tokens(request.mood());
List<ResolvedEditAsset> 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<String> 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<String> 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<String> 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());
}
}

View File

@ -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<String> 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<Path> 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<String> 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<Path> candidateRoots(EditAssetType type, ContentCategory category) {
Path base = root(type);
if (category == null) {

View File

@ -5,6 +5,21 @@ public record ResolvedEditAsset(
String assetKey,
String path,
String license,
String provider
String provider,
ContentCategory category,
java.util.List<String> 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());
}
}

View File

@ -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;
}
}

View File

@ -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");
});
}