From 28fbfadd6e71adee6dc5da23c8da1de53e007b31 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 14:57:00 +0200 Subject: [PATCH] Add cinematic edit clip discovery --- docs/cinematic-video-editing-service-plan.md | 2 +- .../videoclips/editing/EditClipDiscovery.java | 83 +++++++++++++++++++ .../editing/EditClipDiscoveryTest.java | 58 +++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/example/videoclips/editing/EditClipDiscovery.java create mode 100644 src/test/java/org/example/videoclips/editing/EditClipDiscoveryTest.java diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index bb51522..7bce010 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/editing/EditClipDiscovery.java b/src/main/java/org/example/videoclips/editing/EditClipDiscovery.java new file mode 100644 index 0000000..78f0f1e --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditClipDiscovery.java @@ -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 VIDEO_EXTENSIONS = Set.of("mp4", "mov", "m4v", "webm", "mkv"); + + public List discover(Path inputDirectory) { + if (!Files.isDirectory(inputDirectory)) { + throw new BadRequestException("Edit project input directory does not exist: " + inputDirectory); + } + try (Stream 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 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); + } +} diff --git a/src/test/java/org/example/videoclips/editing/EditClipDiscoveryTest.java b/src/test/java/org/example/videoclips/editing/EditClipDiscoveryTest.java new file mode 100644 index 0000000..6f2d73b --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/EditClipDiscoveryTest.java @@ -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 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 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"))); + } +}