Add local cinematic asset library
This commit is contained in:
parent
e3d69ad209
commit
376bc41685
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface EditAssetLibrary {
|
||||
|
||||
Optional<ResolvedEditAsset> select(EditAssetSelectionRequest request);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
public record EditAssetSelectionRequest(
|
||||
EditAssetType type,
|
||||
ContentCategory category,
|
||||
String mood,
|
||||
double durationSeconds
|
||||
) {
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue