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:
JSLMPR 2026-07-09 00:16:49 +02:00
parent bd620fc773
commit 66e998ee2a
22 changed files with 107 additions and 22 deletions

View File

@ -416,6 +416,7 @@ public class VideoAssetService {
private Map<String, Object> toClipResponse(Clip clip) { private Map<String, Object> toClipResponse(Clip clip) {
return Map.of( return Map.of(
"clipId", clip.id(), "clipId", clip.id(),
"objectKey", clip.objectKey(),
"clipIndex", clip.clipIndex(), "clipIndex", clip.clipIndex(),
"startSeconds", clip.startSeconds(), "startSeconds", clip.startSeconds(),
"durationSeconds", clip.durationSeconds(), "durationSeconds", clip.durationSeconds(),

View File

@ -6,6 +6,7 @@ public record Clip(
String id, String id,
String jobId, String jobId,
String assetId, String assetId,
String objectKey,
int clipIndex, int clipIndex,
long startSeconds, long startSeconds,
long durationSeconds, long durationSeconds,

View File

@ -114,6 +114,7 @@ public class JpaVideoClippingMapper {
entity.setId(clip.id()); entity.setId(clip.id());
entity.setJobId(clip.jobId()); entity.setJobId(clip.jobId());
entity.setAssetId(clip.assetId()); entity.setAssetId(clip.assetId());
entity.setObjectKey(clip.objectKey());
entity.setClipIndex(clip.clipIndex()); entity.setClipIndex(clip.clipIndex());
entity.setStartSeconds(clip.startSeconds()); entity.setStartSeconds(clip.startSeconds());
entity.setDurationSeconds(clip.durationSeconds()); entity.setDurationSeconds(clip.durationSeconds());
@ -128,6 +129,7 @@ public class JpaVideoClippingMapper {
entity.getId(), entity.getId(),
entity.getJobId(), entity.getJobId(),
entity.getAssetId(), entity.getAssetId(),
entity.getObjectKey(),
entity.getClipIndex(), entity.getClipIndex(),
entity.getStartSeconds(), entity.getStartSeconds(),
entity.getDurationSeconds(), entity.getDurationSeconds(),

View File

@ -20,6 +20,9 @@ public class ClipEntity {
@Column(nullable = false, length = 64) @Column(nullable = false, length = 64)
private String assetId; private String assetId;
@Column(nullable = false, length = 255)
private String objectKey;
@Column(nullable = false) @Column(nullable = false)
private int clipIndex; private int clipIndex;
@ -62,6 +65,14 @@ public class ClipEntity {
this.assetId = assetId; this.assetId = assetId;
} }
public String getObjectKey() {
return objectKey;
}
public void setObjectKey(String objectKey) {
this.objectKey = objectKey;
}
public int getClipIndex() { public int getClipIndex() {
return clipIndex; return clipIndex;
} }

View File

@ -58,7 +58,7 @@ public class ClipProcessor {
Path localInputPath = Path.of(properties.getFfmpeg().getInputDirectory()).resolve(asset.sourceObjectKey()); Path localInputPath = Path.of(properties.getFfmpeg().getInputDirectory()).resolve(asset.sourceObjectKey());
objectStoragePort.materializeSourceObject(asset.sourceObjectKey(), localInputPath); objectStoragePort.materializeSourceObject(asset.sourceObjectKey(), localInputPath);
recordEvent(jobId, "SOURCE_MATERIALIZED", "Source object staged to " + localInputPath); recordEvent(jobId, "SOURCE_MATERIALIZED", "Source object staged to " + localInputPath);
List<Clip> clips = videoClipperPort.generateClips( List<GeneratedClip> generatedClips = videoClipperPort.generateClips(
jobId, jobId,
asset.id(), asset.id(),
asset.sourceObjectKey(), asset.sourceObjectKey(),
@ -66,15 +66,17 @@ public class ClipProcessor {
segmentDuration, segmentDuration,
job.accuracyMode() job.accuracyMode()
); );
for (int index = 0; index < clips.size(); index++) { for (int index = 0; index < generatedClips.size(); index++) {
int progress = (int) Math.min(99, ((index + 1L) * 100) / clips.size()); 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)); 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.saveJob(requireJob(jobId).withStatus(ClipJobStatus.SUCCEEDED).withProgress(100).withFinishedAt(Instant.now()));
repository.saveAsset(requireAsset(asset.id()).withStatus(VideoAsset.Status.READY)); 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) { } catch (Exception ex) {
ClipJob job = repository.findJobById(jobId); ClipJob job = repository.findJobById(jobId);
if (job != null) { if (job != null) {

View File

@ -25,7 +25,7 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort {
} }
@Override @Override
public List<Clip> generateClips( public List<GeneratedClip> generateClips(
String jobId, String jobId,
String assetId, String assetId,
String sourceObjectKey, String sourceObjectKey,
@ -59,7 +59,7 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort {
throw new IllegalStateException("FFmpeg process interrupted", ex); 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) { private Path resolveInputFile(String sourceObjectKey) {
@ -103,30 +103,34 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort {
return command; return command;
} }
private List<Clip> buildClipManifest( private List<GeneratedClip> buildClipManifest(
String jobId, String jobId,
String assetId, String assetId,
Path outputDir,
long sourceDurationSeconds, long sourceDurationSeconds,
int segmentDurationSeconds, int segmentDurationSeconds,
AccuracyMode accuracyMode AccuracyMode accuracyMode
) { ) {
List<Clip> clips = new ArrayList<>(); List<GeneratedClip> clips = new ArrayList<>();
long start = 0; long start = 0;
int index = 0; int index = 0;
long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L; long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L;
while (start < sourceDurationSeconds) { while (start < sourceDurationSeconds) {
long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start); long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start);
clips.add(new Clip( String clipId = "clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
"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, jobId,
assetId, assetId,
"clips/" + jobId + "/" + index + ".mp4",
index, index,
start, start,
duration, duration,
"video/mp4", "video/mp4",
duration * bytesPerSecond, duration * bytesPerSecond,
Instant.now() Instant.now()
)); ), localPath));
start += segmentDurationSeconds; start += segmentDurationSeconds;
index++; index++;
} }

View File

@ -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
) {
}

View File

@ -5,6 +5,9 @@ import org.example.videoclips.domain.Clip;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; 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.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -15,7 +18,7 @@ import java.util.UUID;
public class StubVideoClipperAdapter implements VideoClipperPort { public class StubVideoClipperAdapter implements VideoClipperPort {
@Override @Override
public List<Clip> generateClips( public List<GeneratedClip> generateClips(
String jobId, String jobId,
String assetId, String assetId,
String sourceObjectKey, String sourceObjectKey,
@ -23,23 +26,33 @@ public class StubVideoClipperAdapter implements VideoClipperPort {
int segmentDurationSeconds, int segmentDurationSeconds,
AccuracyMode accuracyMode AccuracyMode accuracyMode
) { ) {
List<Clip> clips = new ArrayList<>(); List<GeneratedClip> clips = new ArrayList<>();
long start = 0; long start = 0;
int index = 0; int index = 0;
long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L; long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L;
while (start < sourceDurationSeconds) { while (start < sourceDurationSeconds) {
long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start); long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start);
clips.add(new Clip( String clipId = "clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
"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, jobId,
assetId, assetId,
objectKey,
index, index,
start, start,
duration, duration,
"video/mp4", "video/mp4",
duration * bytesPerSecond, duration * bytesPerSecond,
Instant.now() Instant.now()
)); ), localPath));
start += segmentDurationSeconds; start += segmentDurationSeconds;
index++; index++;
} }

View File

@ -1,13 +1,11 @@
package org.example.videoclips.processing; package org.example.videoclips.processing;
import org.example.videoclips.domain.AccuracyMode; import org.example.videoclips.domain.AccuracyMode;
import org.example.videoclips.domain.Clip;
import java.util.List; import java.util.List;
public interface VideoClipperPort { public interface VideoClipperPort {
List<Clip> generateClips( List<GeneratedClip> generateClips(
String jobId, String jobId,
String assetId, String assetId,
String sourceObjectKey, String sourceObjectKey,

View File

@ -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 @Override
public String createClipDownloadUrl(String clipId, Instant expiresAt) { public String createClipDownloadUrl(String clipId, Instant expiresAt) {
return "https://storage.example/clips/" + clipId + "?expiresAt=" + expiresAt.toString() + "&signature=demo"; return "https://storage.example/clips/" + clipId + "?expiresAt=" + expiresAt.toString() + "&signature=demo";

View File

@ -12,6 +12,8 @@ public interface ObjectStoragePort {
void materializeSourceObject(String sourceObjectKey, Path targetPath); void materializeSourceObject(String sourceObjectKey, Path targetPath);
void uploadGeneratedClip(Path sourcePath, String objectKey);
String createClipDownloadUrl(String clipId, Instant expiresAt); String createClipDownloadUrl(String clipId, Instant expiresAt);
record UploadPartDescriptor(int partNumber, String url) { record UploadPartDescriptor(int partNumber, String url) {

View File

@ -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.CompletedMultipartUpload;
import software.amazon.awssdk.services.s3.model.CompletedPart; import software.amazon.awssdk.services.s3.model.CompletedPart;
import software.amazon.awssdk.services.s3.model.GetObjectRequest; 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.model.UploadPartRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
@ -104,6 +105,16 @@ public class S3ObjectStorageAdapter implements ObjectStoragePort {
ResponseTransformer.toFile(targetPath)); 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 @Override
public String createClipDownloadUrl(String clipId, Instant expiresAt) { public String createClipDownloadUrl(String clipId, Instant expiresAt) {
String objectKey = "clips/" + clipId + ".mp4"; String objectKey = "clips/" + clipId + ".mp4";

View File

@ -44,6 +44,7 @@ create table if not exists clips (
id varchar(64) primary key, id varchar(64) primary key,
job_id varchar(64) not null, job_id varchar(64) not null,
asset_id varchar(64) not null, asset_id varchar(64) not null,
object_key varchar(255) not null,
clip_index integer not null, clip_index integer not null,
start_seconds bigint not null, start_seconds bigint not null,
duration_seconds bigint not null, duration_seconds bigint not null,

View File

@ -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;

View File

@ -120,7 +120,8 @@ class VideoAssetControllerTest {
mockMvc.perform(get("/v1/clip-jobs/{jobId}/clips", jobId)) mockMvc.perform(get("/v1/clip-jobs/{jobId}/clips", jobId))
.andExpect(status().isOk()) .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)) mockMvc.perform(get("/v1/clip-jobs/{jobId}/events", jobId))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@ -0,0 +1 @@
stub-video-content

View File

@ -0,0 +1 @@
stub-clip-content-clip_247296e47c86

View File

@ -0,0 +1 @@
stub-clip-content-clip_30378c1a8d2c

View File

@ -0,0 +1 @@
stub-clip-content-clip_f859bc77404b

View File

@ -0,0 +1 @@
stub-clip-content-clip_247296e47c86

View File

@ -0,0 +1 @@
stub-clip-content-clip_30378c1a8d2c

View File

@ -0,0 +1 @@
stub-clip-content-clip_f859bc77404b