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`.
|
||||
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. [ ] 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.
|
||||
6. [ ] Add `ffprobe` analysis and validation for all input clips.
|
||||
7. [ ] Add thumbnail extraction.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package org.example.videoclips.api;
|
|||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.example.videoclips.application.BadRequestException;
|
||||
import org.example.videoclips.application.ForbiddenException;
|
||||
import org.example.videoclips.application.NotFoundException;
|
||||
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);
|
||||
}
|
||||
|
||||
@ExceptionHandler(BadRequestException.class)
|
||||
ProblemDetail handleBadRequest(BadRequestException ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.BAD_REQUEST, "bad-request", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ForbiddenException.class)
|
||||
ProblemDetail handleForbidden(ForbiddenException ex, HttpServletRequest request) {
|
||||
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