diff --git a/ClipCreatorPlugin/README.md b/ClipCreatorPlugin/README.md index f409a98f..87339791 100755 --- a/ClipCreatorPlugin/README.md +++ b/ClipCreatorPlugin/README.md @@ -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: @@ -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. @@ -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: diff --git a/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorPlugin.java b/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorPlugin.java index fee859e4..6bafb1df 100755 --- a/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorPlugin.java +++ b/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorPlugin.java @@ -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(); @@ -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 tsFilesToMerge = getSegmentFilesWithinTimeRange(playList, startTime, endTime, m3u8File); if (tsFilesToMerge.size() == 0) { diff --git a/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorSettings.java b/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorSettings.java index 6a56f190..50973c66 100644 --- a/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorSettings.java +++ b/ClipCreatorPlugin/src/main/java/io/antmedia/plugin/ClipCreatorSettings.java @@ -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; } @@ -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; + } + } diff --git a/ClipCreatorPlugin/src/main/java/io/antmedia/rest/ClipCreatorRestService.java b/ClipCreatorPlugin/src/main/java/io/antmedia/rest/ClipCreatorRestService.java index e371e3a9..0b2bff24 100755 --- a/ClipCreatorPlugin/src/main/java/io/antmedia/rest/ClipCreatorRestService.java +++ b/ClipCreatorPlugin/src/main/java/io/antmedia/rest/ClipCreatorRestService.java @@ -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", diff --git a/ClipCreatorPlugin/src/test/java/io/antmedia/test/plugin/ClipCreatorPluginTest.java b/ClipCreatorPlugin/src/test/java/io/antmedia/test/plugin/ClipCreatorPluginTest.java index 2fb46959..566def53 100644 --- a/ClipCreatorPlugin/src/test/java/io/antmedia/test/plugin/ClipCreatorPluginTest.java +++ b/ClipCreatorPlugin/src/test/java/io/antmedia/test/plugin/ClipCreatorPluginTest.java @@ -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()); diff --git a/ClipCreatorPlugin/src/test/java/io/antmedia/test/rest/RestServiceTest.java b/ClipCreatorPlugin/src/test/java/io/antmedia/test/rest/RestServiceTest.java index c0a3fe3a..b9f209e6 100644 --- a/ClipCreatorPlugin/src/test/java/io/antmedia/test/rest/RestServiceTest.java +++ b/ClipCreatorPlugin/src/test/java/io/antmedia/test/rest/RestServiceTest.java @@ -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";