Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions ClipCreatorPlugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ To enable the Clip Creator Plugin to function correctly, follow these configurat
- Enable HLS Streaming:
Go to your app settings and ensure HLS streaming is enabled. The plugin uses HLS segments to create MP4 clips.

- Set Playlist Type to `Event`:
In advanced settings, set `hlsPlayListType` to `event` to specify the type of HLS playlist.
- Choose your HLS playlist mode based on stream lifetime:

**For short streams** (minutes to a few hours), `hlsPlayListType=event` works fine — ffmpeg keeps every segment, so any past moment is clip-able.

**For long-running streams** (hours to days, or 24/7) leave `hlsPlayListType` empty (live mode) and set `hlsListSize` to the retention window you want, in segments. With `hlsTime=2`, `hlsListSize=43200` retains 24 h of clip-able history. ffmpeg's `delete_segments` flag (in the AMS default `hlsflags`) automatically deletes old `.ts` files and prunes the m3u8 — no manual cleanup needed.

Avoid `event` mode for 24/7 streams: it disables `delete_segments`, so both the m3u8 and `.ts` files grow without bound.

- Adjust Clip Interval:
In advanced app settings, configure the custom settings:
Expand All @@ -58,6 +63,19 @@ In advanced app settings, configure the custom settings:
```
Replace 1800 with your preferred interval in seconds (default is 600 seconds or 10 minutes).

- Plugin settings reference:
```
"customSettings": {
"plugin.clip-creator": {
"enabled": true,
"mp4CreationIntervalSeconds": 600,
"deleteHLSFilesAfterCreatedMp4": false,
"maxClipDurationSeconds": 21600
}
}
```
`maxClipDurationSeconds` (default 21600 / 6 h) is the hard upper bound on the clip range accepted by the timestamp-range endpoint described below.


## How to Use the Clip Creator Plugin
Once installed and configured, the plugin automatically creates MP4 clips for any active HLS-enabled streams in your `/streams` directory at the set interval.
Expand Down Expand Up @@ -108,7 +126,36 @@ For example if last MP4 is generated at 14:00 and method is called at 14:05, dur

If there is no MP4 created so far by the plugin, maximum duration of created clip by this endpoint will be around `mp4CreationIntervalSeconds`

### 3. Stop Periodic Clip Creation
### 3. Create MP4 Clip for a UTC Timestamp Range
Create an MP4 from HLS segments between two UTC timestamps. Useful when an external system orchestrates *when* clips are taken (e.g. "give me the slice from 14:00:00 to 14:45:00 UTC for stream X").

POST Request:
```
https://{YOUR_SERVER}:{PORT}/{APP}/rest/clip-creator/mp4/{STREAM_ID}/range?startTimestamp={ms}&endTimestamp={ms}&returnFile={bool}
```

Example CURL Command:
```
curl -X POST "https://{YOUR_SERVER}:{PORT}/{APP}/rest/clip-creator/mp4/{STREAM_ID}/range?startTimestamp=1727644047000&endTimestamp=1727644107000&returnFile=false" -H "Content-Type: application/json"
```

Parameters:
- `startTimestamp` (required): inclusive UTC milliseconds since epoch.
- `endTimestamp` (required): inclusive UTC milliseconds since epoch. Must be `>` `startTimestamp` and not in the future.
- `returnFile`: same contract as `/mp4/{STREAM_ID}` — `false` returns JSON with the new vodId, `true` returns the MP4 file content.

Validation responses:
- `400` if `endTimestamp <= startTimestamp`.
- `400` if `endTimestamp` is in the future.
- `400` if the requested duration exceeds `maxClipDurationSeconds` (default 21600).
- `417` if no broadcast exists for the stream id.
- `417` if no segments are found in the range — typically because the requested range falls outside the HLS retention window (`hlsListSize × hlsTime`) or the stream had no data then.

Notes:
- Range clips do NOT affect the periodic recorder's bookkeeping; `lastMp4CreateTime` is left untouched, so the next periodic interval still slices from where it would have.
- Concurrent range requests serialize webapp-wide (single mutex shared with the periodic recorder). If high-throughput concurrent clipping is needed, file an issue.

### 4. Stop Periodic Clip Creation
Pause the periodic MP4 creation:

DELETE Request:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,30 @@ public synchronized Mp4CreationResponse convertHlsToMp4(Broadcast broadcast, boo
return convertHlsToMp4(broadcast, updateLastMp4CreateTime, System.currentTimeMillis());
}

// `synchronized` here is a webapp-wide mutex shared with convertHlsToMp4Range —
// concurrent conversions across all streams serialize. Acceptable for v1; revisit
// with a per-stream lock if range-endpoint throughput becomes a problem under load.
public synchronized Mp4CreationResponse convertHlsToMp4(Broadcast broadcast, boolean updateLastMp4CreateTime, long endTime) {

Long startTime = lastMp4CreateTimeMSForStream.get(broadcast.getStreamId());
if (startTime == null) {
startTime = endTime - (clipCreatorSettings.getMp4CreationIntervalSeconds() * 1000);
}

return doConvert(broadcast, startTime, endTime, updateLastMp4CreateTime);
}

/**
* Create an MP4 clip from HLS segments whose program-date-time falls between two UTC timestamps.
* Does NOT touch lastMp4CreateTimeMSForStream — range clips are out-of-band and must not interfere
* with the periodic recorder's bookkeeping.
*/
public synchronized Mp4CreationResponse convertHlsToMp4Range(Broadcast broadcast, long startTimeMs, long endTimeMs) {
return doConvert(broadcast, startTimeMs, endTimeMs, false);
}

private Mp4CreationResponse doConvert(Broadcast broadcast, long startTime, long endTime, boolean updateLastMp4CreateTime) {

Mp4CreationResponse response = new Mp4CreationResponse();
String streamId = broadcast.getStreamId();

Expand All @@ -327,13 +349,6 @@ public synchronized Mp4CreationResponse convertHlsToMp4(Broadcast broadcast, boo
return response;
}

Long startTime = lastMp4CreateTimeMSForStream.get(streamId);


if (startTime == null) {
startTime = endTime - (clipCreatorSettings.getMp4CreationIntervalSeconds() * 1000);
}

ArrayList<File> tsFilesToMerge = getSegmentFilesWithinTimeRange(playList, startTime, endTime, m3u8File);

if (tsFilesToMerge.size() == 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ public class ClipCreatorSettings {

//10 minutes
private int mp4CreationIntervalSeconds = 600;

private boolean deleteHLSFilesAfterCreatedMp4 = false;

private boolean enabled = true;

//6 hours — hard upper bound on (endTimestamp - startTimestamp) for the range endpoint
private int maxClipDurationSeconds = 21600;

public int getMp4CreationIntervalSeconds() {
return mp4CreationIntervalSeconds;
}
Expand Down Expand Up @@ -45,5 +48,12 @@ public void setDeleteHLSFilesAfterCreatedMp4(boolean deleteHLSFilesAfterCreatedM
this.deleteHLSFilesAfterCreatedMp4 = deleteHLSFilesAfterCreatedMp4;
}


public int getMaxClipDurationSeconds() {
return maxClipDurationSeconds;
}

public void setMaxClipDurationSeconds(int maxClipDurationSeconds) {
this.maxClipDurationSeconds = maxClipDurationSeconds;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,69 @@ public Response createMp4(

}

@Operation(description = "Create an MP4 clip from HLS segments between two UTC timestamps for the given stream. " +
"The segments must still be present on disk — i.e. the requested range must lie within the HLS retention window " +
"(controlled by the app's hlsListSize × hlsTime when running in live mode with hls_flags=delete_segments).")
@POST
@Path("/mp4/{streamId}/range")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_OCTET_STREAM})
public Response createMp4Range(
@Parameter(description = "streamId of the broadcast that the clip will be created for", required = true) @PathParam("streamId") String streamId,
@Parameter(description = "Inclusive start of the clip range, in UTC milliseconds since epoch", required = true) @QueryParam("startTimestamp") long startTimestamp,
@Parameter(description = "Inclusive end of the clip range, in UTC milliseconds since epoch", required = true) @QueryParam("endTimestamp") long endTimestamp,
@Parameter(description = "If true, returns the MP4 file content. If false, returns a JSON Result with the vodId.") @QueryParam("returnFile") @DefaultValue("false") boolean returnFile)
{
ClipCreatorPlugin clipCreator = getPluginApp();

if (endTimestamp <= startTimestamp) {
return Response.status(Status.BAD_REQUEST)
.entity(new Result(false, "endTimestamp must be greater than startTimestamp")).build();
}

long now = System.currentTimeMillis();
if (endTimestamp > now) {
return Response.status(Status.BAD_REQUEST)
.entity(new Result(false, "endTimestamp must not be in the future")).build();
}

int maxClipDurationSeconds = clipCreator.getClipCreatorSettings().getMaxClipDurationSeconds();
long requestedDurationSeconds = (endTimestamp - startTimestamp) / 1000;
if (requestedDurationSeconds > maxClipDurationSeconds) {
return Response.status(Status.BAD_REQUEST)
.entity(new Result(false, "Requested clip duration " + requestedDurationSeconds
+ "s exceeds maxClipDurationSeconds=" + maxClipDurationSeconds
+ ". Raise the plugin setting if you need longer clips.")).build();
}

Broadcast broadcast = clipCreator.getDataStore().get(streamId);
if (broadcast == null) {
return Response.status(Status.EXPECTATION_FAILED)
.entity(new Result(false, "No broadcast exists for stream " + streamId)).build();
}

Mp4CreationResponse response = clipCreator.convertHlsToMp4Range(broadcast, startTimestamp, endTimestamp);
if (response == null || !response.isSuccess()) {
String message = response != null && response.getMessage() != null
? response.getMessage()
: "MP4 creation failed";
return Response.status(Status.EXPECTATION_FAILED)
.entity(new Result(false, message
+ " (the requested range may be outside the HLS retention window or the stream had no data then)"))
.build();
}

if (returnFile) {
return Response.ok(response.getFile())
.header("Content-Disposition", "attachment; filename=\"" + response.getFile().getName() + "\"")
.header("X-vodId", response.getVodId())
.build();
}

Result result = new Result(true, "MP4 created successfully for stream " + streamId);
result.setDataId(response.getVodId());
return Response.ok(result).build();
}

@Operation(description = "Delete the mp4 files in the disk that are not recorded in the database. If there are unmatched files, it may delete them according to the parameter ")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "If there are unmatched files or not deleted them Result#success is false. If operations are successfull, its value is true. It gives extra information in the message field",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,51 @@ public void testConvertHlsToMp4() throws Exception {

}

@Test
public void testConvertHlsToMp4Range() throws Exception {
String streamId = "testStream";
ClipCreatorPlugin plugin = Mockito.spy(new ClipCreatorPlugin());
DataStore dataStore = new InMemoryDataStore("db");
plugin.setDataStore(dataStore);

AntMediaApplicationAdapter mockApplication = mock(AntMediaApplicationAdapter.class);
doReturn(mockApplication).when(plugin).getApplication();
doNothing().when(mockApplication).muxingFinished(any(), any(), any(), anyLong(), anyLong(), anyInt(), anyString(), anyString());

Broadcast broadcast = new Broadcast();
broadcast.setStreamId(streamId);
plugin.setStreamsFolder("target/resources_tmp_range");

File streamFolder = new File(plugin.getStreamsFolder());
streamFolder.mkdirs();

File file = new File("src/test/resources");
File[] files = file.listFiles();
for (File tmpFile : files) {
Files.copy(tmpFile, new File(streamFolder, tmpFile.getName()));
}

plugin.setClipCreatorSettings(new ClipCreatorSettings());

// PDTs in the test fixture playlist (see testGetSegmentFilesWithinTimeRange) span ~1727644047000.
// Pick a range that covers them so segments are found and an MP4 is produced.
long startMs = 1727644047000L;
long endMs = 1727644051862L;

Mp4CreationResponse response = plugin.convertHlsToMp4Range(broadcast, startMs, endMs);

assertTrue(response.isSuccess());
assertNotNull(response.getFile());
assertTrue(response.getFile().exists());
assertTrue(response.getFile().getTotalSpace() > 0);

// Critical behavioral guarantee: range clipping must NOT update the periodic recorder's bookkeeping
assertFalse(plugin.getLastMp4CreateTimeForStream().containsKey(streamId));

assertTrue(response.getFile().delete());
delete(streamFolder);
}

@Test
public void testGetSegmentFilesWithinTimeRange() throws IOException {
ClipCreatorPlugin plugin = Mockito.spy(new ClipCreatorPlugin());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,106 @@ public void testCreateMp4_Mp4CreationFails() throws Exception {

}

@Test
public void testCreateMp4Range_BadOrder() {
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(21600);
long now = System.currentTimeMillis();
Response response = restService.createMp4Range("s", now - 1000, now - 5000, false);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
assertTrue(((Result) response.getEntity()).getMessage().contains("greater than startTimestamp"));
}

@Test
public void testCreateMp4Range_FutureEnd() {
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(21600);
long now = System.currentTimeMillis();
Response response = restService.createMp4Range("s", now - 1000, now + 60_000, false);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
assertTrue(((Result) response.getEntity()).getMessage().contains("future"));
}

@Test
public void testCreateMp4Range_OverCap() {
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(60);
long now = System.currentTimeMillis();
Response response = restService.createMp4Range("s", now - 120_000, now - 1000, false);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
assertTrue(((Result) response.getEntity()).getMessage().contains("maxClipDurationSeconds"));
}

@Test
public void testCreateMp4Range_NoBroadcast() {
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(21600);
long now = System.currentTimeMillis();
Response response = restService.createMp4Range("missing", now - 60_000, now - 1000, false);
assertEquals(Response.Status.EXPECTATION_FAILED.getStatusCode(), response.getStatus());
assertEquals("No broadcast exists for stream missing", ((Result) response.getEntity()).getMessage());
}

@Test
public void testCreateMp4Range_ConversionFails() throws Exception {
String streamId = "rangeStream";
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(21600);

Broadcast broadcast = new Broadcast();
broadcast.setStreamId(streamId);
clipCreatorPlugin.getDataStore().save(broadcast);

Mp4CreationResponse failed = mock(Mp4CreationResponse.class);
when(failed.isSuccess()).thenReturn(false);
when(failed.getMessage()).thenReturn("No segment file found for stream " + streamId);
when(clipCreatorPlugin.convertHlsToMp4Range(any(), anyLong(), anyLong())).thenReturn(failed);

long now = System.currentTimeMillis();
Response response = restService.createMp4Range(streamId, now - 60_000, now - 1000, false);
assertEquals(Response.Status.EXPECTATION_FAILED.getStatusCode(), response.getStatus());
String msg = ((Result) response.getEntity()).getMessage();
assertTrue(msg.contains("No segment file"));
assertTrue(msg.contains("retention window"));
}

@Test
public void testCreateMp4Range_SuccessJson() throws Exception {
String streamId = "rangeStream";
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(21600);

Broadcast broadcast = new Broadcast();
broadcast.setStreamId(streamId);
clipCreatorPlugin.getDataStore().save(broadcast);

Mp4CreationResponse ok = new Mp4CreationResponse(new File("range.mp4"), "vodRangeId");
ok.setSuccess(true);
when(clipCreatorPlugin.convertHlsToMp4Range(any(), anyLong(), anyLong())).thenReturn(ok);

long now = System.currentTimeMillis();
Response response = restService.createMp4Range(streamId, now - 60_000, now - 1000, false);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
Result result = (Result) response.getEntity();
assertTrue(result.isSuccess());
assertEquals("vodRangeId", result.getDataId());
}

@Test
public void testCreateMp4Range_SuccessFile() throws Exception {
String streamId = "rangeStream";
when(clipCreatorSettings.getMaxClipDurationSeconds()).thenReturn(21600);

Broadcast broadcast = new Broadcast();
broadcast.setStreamId(streamId);
clipCreatorPlugin.getDataStore().save(broadcast);

File mp4 = new File("range.mp4");
Mp4CreationResponse ok = new Mp4CreationResponse(mp4, "vodRangeId");
ok.setSuccess(true);
when(clipCreatorPlugin.convertHlsToMp4Range(any(), anyLong(), anyLong())).thenReturn(ok);

long now = System.currentTimeMillis();
Response response = restService.createMp4Range(streamId, now - 60_000, now - 1000, true);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
assertEquals("attachment; filename=\"" + mp4.getName() + "\"", response.getHeaderString("Content-Disposition"));
assertEquals("vodRangeId", response.getHeaderString("X-vodId"));
}

@Test
public void testCreateMp4_Success() throws Exception {
String streamId = "testStream";
Expand Down
Loading