forked from jsl/video_editing_poc
Generated clips now have a persisted object location instead of being metadata-only. I added objectKey to src/main/java/org/example/videoclips/domain/Clip.java, mapped it through JPA in src/main/
java/org/example/videoclips/persistence/entity/ClipEntity.java and src/main/java/org/example/videoclips/persistence/JpaVideoClippingMapper.java, and added the schema migration at src/main/ resources/db/migration/V5__clip_object_key.sql. The clipper contract now returns both metadata and the local file path for each generated clip through src/main/java/org/example/videoclips/processing/GeneratedClip.java and the updated src/main/ java/org/example/videoclips/processing/VideoClipperPort.java. Both adapters were updated: - src/main/java/org/example/videoclips/processing/StubVideoClipperAdapter.java now creates stub local output files - src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java now returns deterministic local output paths for generated segments On the storage side, ObjectStoragePort gained uploadGeneratedClip(...), with implementations in: - src/main/java/org/example/videoclips/storage/InMemoryObjectStorageAdapter.java - src/main/java/org/example/videoclips/storage/S3ObjectStorageAdapter.java And src/main/java/org/example/videoclips/processing/ClipProcessor.java now uploads each generated local file through storage before saving the clip record, so the job path is finally staged input -> process -> uploaded output -> persisted clip metadata. I also exposed objectKey in the clip response via src/main/java/org/example/videoclips/application/VideoAssetService.java and extended the MockMvc test to assert it exists in src/test/java/org/ example/videoclips/api/VideoAssetControllerTest.java.
This commit is contained in:
parent
bd620fc773
commit
66e998ee2a
|
|
@ -416,6 +416,7 @@ public class VideoAssetService {
|
|||
private Map<String, Object> toClipResponse(Clip clip) {
|
||||
return Map.of(
|
||||
"clipId", clip.id(),
|
||||
"objectKey", clip.objectKey(),
|
||||
"clipIndex", clip.clipIndex(),
|
||||
"startSeconds", clip.startSeconds(),
|
||||
"durationSeconds", clip.durationSeconds(),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ public record Clip(
|
|||
String id,
|
||||
String jobId,
|
||||
String assetId,
|
||||
String objectKey,
|
||||
int clipIndex,
|
||||
long startSeconds,
|
||||
long durationSeconds,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ public class JpaVideoClippingMapper {
|
|||
entity.setId(clip.id());
|
||||
entity.setJobId(clip.jobId());
|
||||
entity.setAssetId(clip.assetId());
|
||||
entity.setObjectKey(clip.objectKey());
|
||||
entity.setClipIndex(clip.clipIndex());
|
||||
entity.setStartSeconds(clip.startSeconds());
|
||||
entity.setDurationSeconds(clip.durationSeconds());
|
||||
|
|
@ -128,6 +129,7 @@ public class JpaVideoClippingMapper {
|
|||
entity.getId(),
|
||||
entity.getJobId(),
|
||||
entity.getAssetId(),
|
||||
entity.getObjectKey(),
|
||||
entity.getClipIndex(),
|
||||
entity.getStartSeconds(),
|
||||
entity.getDurationSeconds(),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ public class ClipEntity {
|
|||
@Column(nullable = false, length = 64)
|
||||
private String assetId;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String objectKey;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int clipIndex;
|
||||
|
||||
|
|
@ -62,6 +65,14 @@ public class ClipEntity {
|
|||
this.assetId = assetId;
|
||||
}
|
||||
|
||||
public String getObjectKey() {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
public void setObjectKey(String objectKey) {
|
||||
this.objectKey = objectKey;
|
||||
}
|
||||
|
||||
public int getClipIndex() {
|
||||
return clipIndex;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public class ClipProcessor {
|
|||
Path localInputPath = Path.of(properties.getFfmpeg().getInputDirectory()).resolve(asset.sourceObjectKey());
|
||||
objectStoragePort.materializeSourceObject(asset.sourceObjectKey(), localInputPath);
|
||||
recordEvent(jobId, "SOURCE_MATERIALIZED", "Source object staged to " + localInputPath);
|
||||
List<Clip> clips = videoClipperPort.generateClips(
|
||||
List<GeneratedClip> generatedClips = videoClipperPort.generateClips(
|
||||
jobId,
|
||||
asset.id(),
|
||||
asset.sourceObjectKey(),
|
||||
|
|
@ -66,15 +66,17 @@ public class ClipProcessor {
|
|||
segmentDuration,
|
||||
job.accuracyMode()
|
||||
);
|
||||
for (int index = 0; index < clips.size(); index++) {
|
||||
int progress = (int) Math.min(99, ((index + 1L) * 100) / clips.size());
|
||||
for (int index = 0; index < generatedClips.size(); index++) {
|
||||
GeneratedClip generatedClip = generatedClips.get(index);
|
||||
objectStoragePort.uploadGeneratedClip(generatedClip.localPath(), generatedClip.clip().objectKey());
|
||||
repository.saveClip(generatedClip.clip());
|
||||
int progress = (int) Math.min(99, ((index + 1L) * 100) / generatedClips.size());
|
||||
repository.saveJob(requireJob(jobId).withProgress(progress));
|
||||
recordEvent(jobId, "PROGRESS", "Prepared clip " + (index + 1) + " of " + clips.size());
|
||||
recordEvent(jobId, "PROGRESS", "Prepared clip " + (index + 1) + " of " + generatedClips.size());
|
||||
}
|
||||
clips.forEach(repository::saveClip);
|
||||
repository.saveJob(requireJob(jobId).withStatus(ClipJobStatus.SUCCEEDED).withProgress(100).withFinishedAt(Instant.now()));
|
||||
repository.saveAsset(requireAsset(asset.id()).withStatus(VideoAsset.Status.READY));
|
||||
recordEvent(jobId, "SUCCEEDED", "Generated " + clips.size() + " clips");
|
||||
recordEvent(jobId, "SUCCEEDED", "Generated " + generatedClips.size() + " clips");
|
||||
} catch (Exception ex) {
|
||||
ClipJob job = repository.findJobById(jobId);
|
||||
if (job != null) {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort {
|
|||
}
|
||||
|
||||
@Override
|
||||
public List<Clip> generateClips(
|
||||
public List<GeneratedClip> generateClips(
|
||||
String jobId,
|
||||
String assetId,
|
||||
String sourceObjectKey,
|
||||
|
|
@ -59,7 +59,7 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort {
|
|||
throw new IllegalStateException("FFmpeg process interrupted", ex);
|
||||
}
|
||||
|
||||
return buildClipManifest(jobId, assetId, sourceDurationSeconds, segmentDurationSeconds, accuracyMode);
|
||||
return buildClipManifest(jobId, assetId, outputDir, sourceDurationSeconds, segmentDurationSeconds, accuracyMode);
|
||||
}
|
||||
|
||||
private Path resolveInputFile(String sourceObjectKey) {
|
||||
|
|
@ -103,30 +103,34 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort {
|
|||
return command;
|
||||
}
|
||||
|
||||
private List<Clip> buildClipManifest(
|
||||
private List<GeneratedClip> buildClipManifest(
|
||||
String jobId,
|
||||
String assetId,
|
||||
Path outputDir,
|
||||
long sourceDurationSeconds,
|
||||
int segmentDurationSeconds,
|
||||
AccuracyMode accuracyMode
|
||||
) {
|
||||
List<Clip> clips = new ArrayList<>();
|
||||
List<GeneratedClip> clips = new ArrayList<>();
|
||||
long start = 0;
|
||||
int index = 0;
|
||||
long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L;
|
||||
while (start < sourceDurationSeconds) {
|
||||
long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start);
|
||||
clips.add(new Clip(
|
||||
"clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12),
|
||||
String clipId = "clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
Path localPath = outputDir.resolve("clip_%05d.mp4".formatted(index));
|
||||
clips.add(new GeneratedClip(new Clip(
|
||||
clipId,
|
||||
jobId,
|
||||
assetId,
|
||||
"clips/" + jobId + "/" + index + ".mp4",
|
||||
index,
|
||||
start,
|
||||
duration,
|
||||
"video/mp4",
|
||||
duration * bytesPerSecond,
|
||||
Instant.now()
|
||||
));
|
||||
), localPath));
|
||||
start += segmentDurationSeconds;
|
||||
index++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package org.example.videoclips.processing;
|
||||
|
||||
import org.example.videoclips.domain.Clip;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public record GeneratedClip(
|
||||
Clip clip,
|
||||
Path localPath
|
||||
) {
|
||||
}
|
||||
|
|
@ -5,6 +5,9 @@ import org.example.videoclips.domain.Clip;
|
|||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
|
@ -15,7 +18,7 @@ import java.util.UUID;
|
|||
public class StubVideoClipperAdapter implements VideoClipperPort {
|
||||
|
||||
@Override
|
||||
public List<Clip> generateClips(
|
||||
public List<GeneratedClip> generateClips(
|
||||
String jobId,
|
||||
String assetId,
|
||||
String sourceObjectKey,
|
||||
|
|
@ -23,23 +26,33 @@ public class StubVideoClipperAdapter implements VideoClipperPort {
|
|||
int segmentDurationSeconds,
|
||||
AccuracyMode accuracyMode
|
||||
) {
|
||||
List<Clip> clips = new ArrayList<>();
|
||||
List<GeneratedClip> clips = new ArrayList<>();
|
||||
long start = 0;
|
||||
int index = 0;
|
||||
long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L;
|
||||
while (start < sourceDurationSeconds) {
|
||||
long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start);
|
||||
clips.add(new Clip(
|
||||
"clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12),
|
||||
String clipId = "clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
String objectKey = "clips/" + jobId + "/" + index + ".mp4";
|
||||
Path localPath = Path.of("tmp/stub-output", jobId, "clip_%05d.mp4".formatted(index));
|
||||
try {
|
||||
Files.createDirectories(localPath.getParent());
|
||||
Files.writeString(localPath, "stub-clip-content-" + clipId);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to create stub clip output", ex);
|
||||
}
|
||||
clips.add(new GeneratedClip(new Clip(
|
||||
clipId,
|
||||
jobId,
|
||||
assetId,
|
||||
objectKey,
|
||||
index,
|
||||
start,
|
||||
duration,
|
||||
"video/mp4",
|
||||
duration * bytesPerSecond,
|
||||
Instant.now()
|
||||
));
|
||||
), localPath));
|
||||
start += segmentDurationSeconds;
|
||||
index++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
package org.example.videoclips.processing;
|
||||
|
||||
import org.example.videoclips.domain.AccuracyMode;
|
||||
import org.example.videoclips.domain.Clip;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface VideoClipperPort {
|
||||
|
||||
List<Clip> generateClips(
|
||||
List<GeneratedClip> generateClips(
|
||||
String jobId,
|
||||
String assetId,
|
||||
String sourceObjectKey,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ public class InMemoryObjectStorageAdapter implements ObjectStoragePort {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uploadGeneratedClip(Path sourcePath, String objectKey) {
|
||||
try {
|
||||
Path targetPath = Path.of("tmp/in-memory-storage").resolve(objectKey);
|
||||
Files.createDirectories(targetPath.getParent());
|
||||
Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to upload generated clip to in-memory storage", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createClipDownloadUrl(String clipId, Instant expiresAt) {
|
||||
return "https://storage.example/clips/" + clipId + "?expiresAt=" + expiresAt.toString() + "&signature=demo";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public interface ObjectStoragePort {
|
|||
|
||||
void materializeSourceObject(String sourceObjectKey, Path targetPath);
|
||||
|
||||
void uploadGeneratedClip(Path sourcePath, String objectKey);
|
||||
|
||||
String createClipDownloadUrl(String clipId, Instant expiresAt);
|
||||
|
||||
record UploadPartDescriptor(int partNumber, String url) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
|
|||
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
|
||||
import software.amazon.awssdk.services.s3.model.CompletedPart;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
|
|
@ -104,6 +105,16 @@ public class S3ObjectStorageAdapter implements ObjectStoragePort {
|
|||
ResponseTransformer.toFile(targetPath));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uploadGeneratedClip(Path sourcePath, String objectKey) {
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(properties.getS3().getBucket())
|
||||
.key(objectKey)
|
||||
.contentType("video/mp4")
|
||||
.build(),
|
||||
sourcePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createClipDownloadUrl(String clipId, Instant expiresAt) {
|
||||
String objectKey = "clips/" + clipId + ".mp4";
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ create table if not exists clips (
|
|||
id varchar(64) primary key,
|
||||
job_id varchar(64) not null,
|
||||
asset_id varchar(64) not null,
|
||||
object_key varchar(255) not null,
|
||||
clip_index integer not null,
|
||||
start_seconds bigint not null,
|
||||
duration_seconds bigint not null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
alter table clips
|
||||
add column if not exists object_key varchar(255);
|
||||
|
||||
update clips
|
||||
set object_key = 'clips/' || job_id || '/' || clip_index || '.mp4'
|
||||
where object_key is null;
|
||||
|
||||
alter table clips
|
||||
alter column object_key set not null;
|
||||
|
|
@ -120,7 +120,8 @@ class VideoAssetControllerTest {
|
|||
|
||||
mockMvc.perform(get("/v1/clip-jobs/{jobId}/clips", jobId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.clips.length()", greaterThanOrEqualTo(3)));
|
||||
.andExpect(jsonPath("$.clips.length()", greaterThanOrEqualTo(3)))
|
||||
.andExpect(jsonPath("$.clips[0].objectKey").exists());
|
||||
|
||||
mockMvc.perform(get("/v1/clip-jobs/{jobId}/events", jobId))
|
||||
.andExpect(status().isOk())
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
stub-video-content
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_247296e47c86
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_30378c1a8d2c
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_f859bc77404b
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_247296e47c86
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_30378c1a8d2c
|
||||
|
|
@ -0,0 +1 @@
|
|||
stub-clip-content-clip_f859bc77404b
|
||||
Loading…
Reference in New Issue