forked from jsl/video_editing_poc
Add cinematic edit project API
This commit is contained in:
parent
0d4752897d
commit
8d8180dc61
|
|
@ -784,7 +784,7 @@ It writes the script only and requires an imported audio file.
|
||||||
1. [x] Add editing configuration under `video-clipping.editing`.
|
1. [x] Add editing configuration under `video-clipping.editing`.
|
||||||
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. [ ] 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. [ ] 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.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.example.videoclips.api;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
import org.example.videoclips.application.BadRequestException;
|
||||||
import org.example.videoclips.application.ForbiddenException;
|
import org.example.videoclips.application.ForbiddenException;
|
||||||
import org.example.videoclips.application.NotFoundException;
|
import org.example.videoclips.application.NotFoundException;
|
||||||
import org.example.videoclips.application.PayloadTooLargeException;
|
import org.example.videoclips.application.PayloadTooLargeException;
|
||||||
|
|
@ -35,6 +36,11 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
|
||||||
return problem(HttpStatus.NOT_FOUND, "not-found", ex.getMessage(), request.getRequestURI(), null);
|
return problem(HttpStatus.NOT_FOUND, "not-found", ex.getMessage(), request.getRequestURI(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(BadRequestException.class)
|
||||||
|
ProblemDetail handleBadRequest(BadRequestException ex, HttpServletRequest request) {
|
||||||
|
return problem(HttpStatus.BAD_REQUEST, "bad-request", ex.getMessage(), request.getRequestURI(), null);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(ForbiddenException.class)
|
@ExceptionHandler(ForbiddenException.class)
|
||||||
ProblemDetail handleForbidden(ForbiddenException ex, HttpServletRequest request) {
|
ProblemDetail handleForbidden(ForbiddenException ex, HttpServletRequest request) {
|
||||||
return problem(HttpStatus.FORBIDDEN, "forbidden", ex.getMessage(), request.getRequestURI(), null);
|
return problem(HttpStatus.FORBIDDEN, "forbidden", ex.getMessage(), request.getRequestURI(), null);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.example.videoclips.api;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.example.videoclips.api.dto.CreateEditProjectRequest;
|
||||||
|
import org.example.videoclips.editing.EditProjectService;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/v1/edit-projects")
|
||||||
|
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class EditProjectController {
|
||||||
|
|
||||||
|
private final EditProjectService editProjectService;
|
||||||
|
|
||||||
|
public EditProjectController(EditProjectService editProjectService) {
|
||||||
|
this.editProjectService = editProjectService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Object createProject(@Valid @RequestBody CreateEditProjectRequest request) {
|
||||||
|
return editProjectService.createProject(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{projectId}")
|
||||||
|
public Object getProject(@PathVariable String projectId) {
|
||||||
|
return editProjectService.getProject(projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.example.videoclips.api.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CreateEditProjectRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
@NotBlank String inputDirectory,
|
||||||
|
@Min(15) @Max(600) Integer targetDurationSeconds,
|
||||||
|
@NotBlank String style,
|
||||||
|
Boolean voiceoverEnabled,
|
||||||
|
Boolean musicEnabled,
|
||||||
|
Boolean soundEffectsEnabled
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package org.example.videoclips.api.dto;
|
||||||
|
|
||||||
|
import org.example.videoclips.editing.EditProjectStatus;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record EditProjectResponse(
|
||||||
|
String projectId,
|
||||||
|
String name,
|
||||||
|
EditProjectStatus status,
|
||||||
|
String inputDirectory,
|
||||||
|
String outputDirectory,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt,
|
||||||
|
String failureReason
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.example.videoclips.application;
|
||||||
|
|
||||||
|
public class BadRequestException extends RuntimeException {
|
||||||
|
|
||||||
|
public BadRequestException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
|
import org.example.videoclips.api.dto.CreateEditProjectRequest;
|
||||||
|
import org.example.videoclips.api.dto.EditProjectResponse;
|
||||||
|
import org.example.videoclips.application.BadRequestException;
|
||||||
|
import org.example.videoclips.application.NotFoundException;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class EditProjectService {
|
||||||
|
|
||||||
|
private final EditProjectStore store;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public EditProjectService(EditProjectStore store) {
|
||||||
|
this(store, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
EditProjectService(EditProjectStore store, Clock clock) {
|
||||||
|
this.store = store;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EditProjectResponse createProject(CreateEditProjectRequest request) {
|
||||||
|
Path inputDirectory = Path.of(request.inputDirectory()).normalize();
|
||||||
|
if (!Files.isDirectory(inputDirectory)) {
|
||||||
|
throw new BadRequestException("Edit project input directory does not exist: " + request.inputDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
|
String projectId = nextProjectId(slug(request.name()));
|
||||||
|
Path outputDirectory = store.createProject(projectId);
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
EditProject project = new EditProject(
|
||||||
|
projectId,
|
||||||
|
request.name(),
|
||||||
|
EditProjectStatus.CREATED,
|
||||||
|
inputDirectory.toString(),
|
||||||
|
outputDirectory.toString(),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
store.writeJson(projectId, "project.json", project);
|
||||||
|
return response(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EditProjectResponse getProject(String projectId) {
|
||||||
|
if (!Files.exists(store.projectFile(projectId))) {
|
||||||
|
throw new NotFoundException("Edit project not found: " + projectId);
|
||||||
|
}
|
||||||
|
return response(store.readJson(projectId, "project.json", EditProject.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextProjectId(String baseProjectId) {
|
||||||
|
String candidate = baseProjectId;
|
||||||
|
int suffix = 1;
|
||||||
|
while (Files.exists(store.projectDirectory(candidate))) {
|
||||||
|
candidate = baseProjectId + "-" + suffix++;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String slug(String name) {
|
||||||
|
String normalized = name.toLowerCase(Locale.ROOT)
|
||||||
|
.replaceAll("[^a-z0-9._-]+", "-")
|
||||||
|
.replaceAll("^-+", "")
|
||||||
|
.replaceAll("-+$", "");
|
||||||
|
return normalized.isBlank() ? "edit-project" : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EditProjectResponse response(EditProject project) {
|
||||||
|
return new EditProjectResponse(
|
||||||
|
project.id(),
|
||||||
|
project.name(),
|
||||||
|
project.status(),
|
||||||
|
project.inputDirectory(),
|
||||||
|
project.outputDirectory(),
|
||||||
|
project.createdAt(),
|
||||||
|
project.updatedAt(),
|
||||||
|
project.failureReason()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package org.example.videoclips.api;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"video-clipping.editing.project-directory=target/edit-project-controller-test/projects"
|
||||||
|
})
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class EditProjectControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsAndReadsEditProject() throws Exception {
|
||||||
|
Path inputDirectory = Files.createDirectories(Path.of("target/edit-project-controller-test/input-"
|
||||||
|
+ UUID.randomUUID()));
|
||||||
|
|
||||||
|
String response = mockMvc.perform(post("/v1/edit-projects")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"name": "Porsche Session %s",
|
||||||
|
"inputDirectory": "%s",
|
||||||
|
"targetDurationSeconds": 60,
|
||||||
|
"style": "cinematic-porsche-promo",
|
||||||
|
"voiceoverEnabled": true,
|
||||||
|
"musicEnabled": true,
|
||||||
|
"soundEffectsEnabled": true
|
||||||
|
}
|
||||||
|
""".formatted(UUID.randomUUID(), inputDirectory)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.projectId").exists())
|
||||||
|
.andExpect(jsonPath("$.status").value("CREATED"))
|
||||||
|
.andExpect(jsonPath("$.inputDirectory").value(inputDirectory.toString()))
|
||||||
|
.andReturn()
|
||||||
|
.getResponse()
|
||||||
|
.getContentAsString();
|
||||||
|
|
||||||
|
String projectId = JsonTestHelper.read(response, "$.projectId");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/edit-projects/{projectId}", projectId))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.projectId").value(projectId))
|
||||||
|
.andExpect(jsonPath("$.status").value("CREATED"))
|
||||||
|
.andExpect(jsonPath("$.outputDirectory").value("target/edit-project-controller-test/projects/"
|
||||||
|
+ projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingInputDirectory() throws Exception {
|
||||||
|
mockMvc.perform(post("/v1/edit-projects")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"name": "Missing Input",
|
||||||
|
"inputDirectory": "target/edit-project-controller-test/missing",
|
||||||
|
"targetDurationSeconds": 60,
|
||||||
|
"style": "cinematic-porsche-promo"
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/bad-request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidCreateRequest() throws Exception {
|
||||||
|
mockMvc.perform(post("/v1/edit-projects")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"inputDirectory": "",
|
||||||
|
"targetDurationSeconds": 10,
|
||||||
|
"style": ""
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNotFoundForMissingProject() throws Exception {
|
||||||
|
mockMvc.perform(get("/v1/edit-projects/{projectId}", "missing-project"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue