Add cinematic edit clip discovery

This commit is contained in:
JSLMPR 2026-07-10 14:57:00 +02:00
parent 8d8180dc61
commit 28fbfadd6e
3 changed files with 142 additions and 1 deletions

View File

@ -785,7 +785,7 @@ It writes the script only and requires an imported audio file.
2. [x] Add filesystem project store for `project.json`, `analysis.json`, `edit-plan.json`, and render output.
3. [x] Add edit project domain records and status enum.
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
5. [ ] Add clip discovery for project input directories.
5. [x] Add clip discovery for project input directories.
6. [ ] Add `ffprobe` analysis and validation for all input clips.
7. [ ] Add thumbnail extraction.
8. [ ] Add contact sheet generation.

View File

@ -0,0 +1,83 @@
package org.example.videoclips.editing;
import org.example.videoclips.application.BadRequestException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class EditClipDiscovery {
private static final Set<String> VIDEO_EXTENSIONS = Set.of("mp4", "mov", "m4v", "webm", "mkv");
public List<Path> discover(Path inputDirectory) {
if (!Files.isDirectory(inputDirectory)) {
throw new BadRequestException("Edit project input directory does not exist: " + inputDirectory);
}
try (Stream<Path> files = Files.list(inputDirectory)) {
return files
.filter(this::isCandidateClip)
.sorted(candidateComparator())
.toList();
} catch (IOException ex) {
throw new IllegalStateException("Unable to scan edit project input directory: " + inputDirectory, ex);
}
}
boolean isCandidateClip(Path path) {
if (!Files.isRegularFile(path)) {
return false;
}
String fileName = path.getFileName().toString();
if (fileName.startsWith(".")
|| fileName.endsWith(".tmp")
|| fileName.endsWith(".part")
|| fileName.endsWith(".download")) {
return false;
}
int extensionSeparator = fileName.lastIndexOf('.');
String extension = extensionSeparator >= 0
? fileName.substring(extensionSeparator + 1).toLowerCase(Locale.ROOT)
: "";
return VIDEO_EXTENSIONS.contains(extension);
}
private Comparator<Path> candidateComparator() {
return Comparator.comparing(path -> naturalSortKey(path.getFileName().toString()));
}
private String naturalSortKey(String value) {
StringBuilder key = new StringBuilder();
StringBuilder number = new StringBuilder();
for (int i = 0; i < value.length(); i++) {
char current = value.charAt(i);
if (Character.isDigit(current)) {
number.append(current);
} else {
appendNumber(key, number);
key.append(Character.toLowerCase(current));
}
}
appendNumber(key, number);
return key.toString();
}
private void appendNumber(StringBuilder key, StringBuilder number) {
if (number.isEmpty()) {
return;
}
key.append(String.format("%020d", number.length()));
key.append(new BigInteger(number.toString()));
number.setLength(0);
}
}

View File

@ -0,0 +1,58 @@
package org.example.videoclips.editing;
import org.example.videoclips.application.BadRequestException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class EditClipDiscoveryTest {
@TempDir
Path tempDir;
private final EditClipDiscovery discovery = new EditClipDiscovery();
@Test
void discoversVideoClipsInNaturalFilenameOrder() throws Exception {
Files.writeString(tempDir.resolve("clip_10.mp4"), "video");
Files.writeString(tempDir.resolve("clip_2.mp4"), "video");
Files.writeString(tempDir.resolve("clip_1.mp4"), "video");
Files.writeString(tempDir.resolve("clip_3.MOV"), "video");
List<String> clips = discovery.discover(tempDir)
.stream()
.map(path -> path.getFileName().toString())
.toList();
assertThat(clips).containsExactly("clip_1.mp4", "clip_2.mp4", "clip_3.MOV", "clip_10.mp4");
}
@Test
void ignoresNonClipsAndTemporaryFiles() throws Exception {
Files.createDirectory(tempDir.resolve("folder.mp4"));
Files.writeString(tempDir.resolve(".hidden.mp4"), "video");
Files.writeString(tempDir.resolve("upload.mp4.tmp"), "video");
Files.writeString(tempDir.resolve("upload.mp4.part"), "video");
Files.writeString(tempDir.resolve("upload.mp4.download"), "video");
Files.writeString(tempDir.resolve("notes.txt"), "notes");
Files.writeString(tempDir.resolve("valid.webm"), "video");
List<String> clips = discovery.discover(tempDir)
.stream()
.map(path -> path.getFileName().toString())
.toList();
assertThat(clips).containsExactly("valid.webm");
}
@Test
void rejectsMissingInputDirectory() {
assertThrows(BadRequestException.class, () -> discovery.discover(tempDir.resolve("missing")));
}
}