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) {
return Map.of(
"clipId", clip.id(),
"objectKey", clip.objectKey(),
"clipIndex", clip.clipIndex(),
"startSeconds", clip.startSeconds(),
"durationSeconds", clip.durationSeconds(),

View File

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

View File

@ -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(),

View File

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

View File

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

View File

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

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.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++;
}

View File

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

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
public String createClipDownloadUrl(String clipId, Instant expiresAt) {
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 uploadGeneratedClip(Path sourcePath, String objectKey);
String createClipDownloadUrl(String clipId, Instant expiresAt);
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.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";

View File

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

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))
.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())

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