forked from jsl/video_editing_poc
Add cinematic edit clip discovery
This commit is contained in:
parent
8d8180dc61
commit
28fbfadd6e
|
|
@ -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.
|
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.
|
3. [x] Add edit project domain records and status enum.
|
||||||
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
|
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.
|
6. [ ] Add `ffprobe` analysis and validation for all input clips.
|
||||||
7. [ ] Add thumbnail extraction.
|
7. [ ] Add thumbnail extraction.
|
||||||
8. [ ] Add contact sheet generation.
|
8. [ ] Add contact sheet generation.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue