diff --git a/mycore-iview2/pom.xml b/mycore-iview2/pom.xml index 1e2bb2b761..3ea7aa77aa 100644 --- a/mycore-iview2/pom.xml +++ b/mycore-iview2/pom.xml @@ -124,6 +124,11 @@ junit-jupiter-params test + + org.mockito + mockito-core + test + org.mycore mycore-base diff --git a/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java b/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java new file mode 100644 index 0000000000..fb1c06460e --- /dev/null +++ b/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java @@ -0,0 +1,112 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.iview2.backend; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import javax.imageio.ImageIO; + +import org.mycore.common.MCRException; +import org.mycore.common.config.MCRConfiguration2; +import org.mycore.common.digest.MCRDigest; +import org.mycore.datamodel.niofs.MCRContentTypes; +import org.mycore.datamodel.niofs.MCRFileAttributes; +import org.mycore.datamodel.niofs.MCRPath; +import org.mycore.imagetiler.MCRImage; +import org.mycore.iview2.frontend.MCRIView2Commands; +import org.mycore.iview2.services.MCRIView2Tools; +import org.mycore.media.services.MCRVideoFrameGenerator; +import org.mycore.services.queuedjob.MCRJob; +import org.mycore.services.queuedjob.MCRJobAction; + +/** Stores a full-resolution video frame as an iView image under the video's path. */ +public class MCRVideoFrameJobAction extends MCRJobAction { + + public static final String DERIVATE_PARAMETER = "derivate"; + + public static final String PATH_PARAMETER = "path"; + + public MCRVideoFrameJobAction(MCRJob job) { + super(job); + } + + @Override + public boolean isActivated() { + return true; + } + + @Override + public String name() { + return getClass().getName(); + } + + @Override + public void execute() { + MCRPath video = MCRPath.getPath(job.getParameter(DERIVATE_PARAMETER), job.getParameter(PATH_PARAMETER)); + try { + if (!Files.isRegularFile(video)) { + return; + } + String contentType = MCRContentTypes.probeContentType(video); + if (contentType == null || !contentType.startsWith("video/")) { + return; + } + MCRVideoFrameGenerator generator = MCRConfiguration2.getInstanceOfOrThrow( + MCRVideoFrameGenerator.class, "MCR.Media.VideoFrameGenerator"); + if (!generator.checkRequirements(video)) { + return; + } + // A replaced video changes its digest. The job of the new content then creates the frame. + MCRDigest digest = digest(video); + BufferedImage frame = generator.generateFrame(video); + Path image = Files.createTempFile("MyCoRe-Video-Frame-", ".png"); + try { + if (!ImageIO.write(frame, "png", image.toFile())) { + throw new IOException("No PNG writer available"); + } + // A queued video may have been deleted or replaced while FFmpeg was running. + if (!Files.isRegularFile(video) || !digest.equals(digest(video))) { + return; + } + MCRImage tiledImage = MCRImage.getInstance(image, video.getOwner(), video.getOwnerRelativePath()); + tiledImage.setTileDir(MCRIView2Tools.getTileDir()); + tiledImage.tile(); + if (!Files.isRegularFile(video)) { + MCRIView2Commands.deleteImageTiles(video.getOwner(), video.getOwnerRelativePath()); + } + } finally { + Files.deleteIfExists(image); + } + } catch (IOException e) { + throw new MCRException("Could not generate iView image for video " + video, e); + } + } + + private static MCRDigest digest(MCRPath video) throws IOException { + return Files.readAttributes(video, MCRFileAttributes.class).digest(); + } + + @Override + public void rollback() { + // Files are cleaned up in execute(); a retry regenerates the frame. + } +} diff --git a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java new file mode 100644 index 0000000000..be63d7641e --- /dev/null +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -0,0 +1,108 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.iview2.events; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.mycore.common.MCRException; +import org.mycore.common.config.MCRConfiguration2; +import org.mycore.common.events.MCREvent; +import org.mycore.common.events.MCREventHandlerBase; +import org.mycore.datamodel.niofs.MCRContentTypes; +import org.mycore.datamodel.niofs.MCRPath; +import org.mycore.iview2.backend.MCRVideoFrameJobAction; +import org.mycore.iview2.frontend.MCRIView2Commands; +import org.mycore.iview2.services.MCRIView2Tools; +import org.mycore.media.services.MCRVideoFrameGenerator; +import org.mycore.services.queuedjob.MCRJob; +import org.mycore.services.queuedjob.MCRJobQueueManager; +import org.mycore.services.queuedjob.MCRJobStatus; + +/** Creates iView images for every video file, independent of the derivate's main document. */ +public class MCRVideoFrameEventHandler extends MCREventHandlerBase { + + private static final Logger LOGGER = LogManager.getLogger(); + + private static final String VIDEO_FRAME_GENERATOR_PROPERTY = "MCR.Media.VideoFrameGenerator"; + + @Override + public void handlePathCreated(MCREvent evt, Path file, BasicFileAttributes attrs) { + if (!(file instanceof MCRPath path) || !attrs.isRegularFile()) { + return; + } + try { + String contentType = MCRContentTypes.probeContentType(path); + if (contentType != null && contentType.startsWith("video/") && checkRequirements(path)) { + MCRJob job = new MCRJob(MCRVideoFrameJobAction.class); + job.setParameter(MCRVideoFrameJobAction.DERIVATE_PARAMETER, path.getOwner()); + job.setParameter(MCRVideoFrameJobAction.PATH_PARAMETER, path.getOwnerRelativePath()); + MCRJobQueueManager manager = MCRJobQueueManager.getInstance(); + if (manager.getJobDAO().getJobCount(MCRVideoFrameJobAction.class, job.getParameters(), + List.of(MCRJobStatus.NEW, MCRJobStatus.ERROR)) > 0) { + LOGGER.info("There is already a video frame job for {}. Skipping.", file); + } else if (!manager.getJobQueue(MCRVideoFrameJobAction.class).offer(job)) { + LOGGER.warn("Video frame job queue did not accept job for {}", file); + } + } + } catch (IOException | RuntimeException e) { + // The poster is optional and must not abort the file event. + LOGGER.warn("Could not enqueue video frame generation for {}", file, e); + } + } + + private boolean checkRequirements(MCRPath path) { + return MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, + VIDEO_FRAME_GENERATOR_PROPERTY).checkRequirements(path); + } + + @Override + public void handlePathUpdated(MCREvent evt, Path file, BasicFileAttributes attrs) { + handlePathCreated(evt, file, attrs); + } + + @Override + public void handlePathRepaired(MCREvent evt, Path file, BasicFileAttributes attrs) { + // A repair only generates missing frames. Delete the tiles to force a new frame. + if (file instanceof MCRPath path && hasTiles(path)) { + return; + } + handlePathCreated(evt, file, attrs); + } + + boolean hasTiles(MCRPath path) { + return MCRIView2Tools.hasTiles(path.getOwner(), path.getOwnerRelativePath()); + } + + @Override + public void handlePathDeleted(MCREvent evt, Path file, BasicFileAttributes attrs) { + if (file instanceof MCRPath path) { + // MIME detection need not work after deletion. The tiling handler also uses this cleanup. + try { + MCRIView2Commands.deleteImageTiles(path.getOwner(), path.getOwnerRelativePath()); + } catch (IOException e) { + throw new MCRException("Could not delete video frame tiles for " + file, e); + } + } + } +} diff --git a/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIView2Tools.java b/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIView2Tools.java index a70a2598ab..42c0da9307 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIView2Tools.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIView2Tools.java @@ -56,6 +56,8 @@ import org.mycore.datamodel.niofs.MCRPath; import org.mycore.imagetiler.MCRImage; import org.mycore.imagetiler.MCRTiledPictureProps; +import org.mycore.iview2.backend.MCRTileFileProvider; +import org.mycore.iview2.backend.MCRTileInfo; import jakarta.activation.FileTypeMap; import jakarta.persistence.EntityManager; @@ -152,6 +154,20 @@ public static boolean isFileSupported(String filename) { filename.toLowerCase(Locale.ROOT))); } + /** + * Checks whether an iView tile file exists for a file in a derivate. + * + * @param derivateId the derivate ID + * @param path the file path within the derivate + * @return true if a regular tile file exists + */ + public static boolean hasTiles(String derivateId, String path) { + MCRTileFileProvider provider = MCRConfiguration2 + .getSingleInstanceOfOrThrow(MCRTileFileProvider.class, "MCR.IIIFImage.Iview.TileFileProvider"); + return provider.getTileFile(new MCRTileInfo(derivateId, path, null)) + .filter(Files::isRegularFile).isPresent(); + } + /** * Checks for a given derivate id whether all files in that derivate are tiled. * diff --git a/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIview2URIResolver.java b/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIview2URIResolver.java index 812a80c578..2ee2c1eff2 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIview2URIResolver.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/services/MCRIview2URIResolver.java @@ -18,6 +18,9 @@ package org.mycore.iview2.services; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + import javax.xml.transform.Source; import javax.xml.transform.TransformerException; import javax.xml.transform.URIResolver; @@ -59,13 +62,26 @@ public class MCRIview2URIResolver implements URIResolver { */ @Override public Source resolve(String href, String base) throws TransformerException { - String[] params = href.split(":"); + String[] params = href.split(":", 3); if (params.length != 3) { throw new TransformerException("Invalid href: " + href); } switch (params[1]) { + case "hasTiles" -> { + int separator = params[2].indexOf('/'); + if (separator <= 0 || separator == params[2].length() - 1) { + throw new TransformerException("Invalid href: " + href); + } + String derivateId = params[2].substring(0, separator); + try { + String path = URLDecoder.decode(params[2].substring(separator + 1), StandardCharsets.UTF_8); + return new JDOMSource(new Element(String.valueOf(MCRIView2Tools.hasTiles(derivateId, path)))); + } catch (IllegalArgumentException e) { + throw new TransformerException("Invalid encoded path in href: " + href, e); + } + } case "isCompletelyTiled" -> { boolean completelyTiled = MCRIView2Tools.isCompletelyTiled(params[2]); return new JDOMSource(new Element(String.valueOf(completelyTiled))); diff --git a/mycore-iview2/src/main/resources/components/iview2/config/mycore.properties b/mycore-iview2/src/main/resources/components/iview2/config/mycore.properties index 020f853154..80eb9527e0 100644 --- a/mycore-iview2/src/main/resources/components/iview2/config/mycore.properties +++ b/mycore-iview2/src/main/resources/components/iview2/config/mycore.properties @@ -56,7 +56,10 @@ MCR.IIIFImage.Iview.ThumbnailForPdfEventHandler.Derivate.Types=derivate_types:co MCR.EventHandler.MCRDerivate.080.Class=org.mycore.iview2.events.MCRThumbnailForPdfEventHandler MCR.Media.Thumbnail.Generators=%MCR.Media.Thumbnail.Generators%,org.mycore.iview2.services.MCRImageThumbnailGenerator -MCR.QueuedJob.OrdinaryJobs=%MCR.QueuedJob.OrdinaryJobs%,org.mycore.iview2.backend.MCRPDFThumbnailJobAction +MCR.QueuedJob.OrdinaryJobs=%MCR.QueuedJob.OrdinaryJobs%,org.mycore.iview2.backend.MCRPDFThumbnailJobAction,org.mycore.iview2.backend.MCRVideoFrameJobAction # Maximum input file size in bytes for PDF thumbnail generation. If empty, no limit is applied. MCR.IView2.PDF.Thumbnail.MaxInputFileSize= + +# Generate an iView image from an embedded cover or representative full-resolution frame of every video file. +MCR.EventHandler.MCRPath.025.Class=org.mycore.iview2.events.MCRVideoFrameEventHandler diff --git a/mycore-iview2/src/main/resources/xslt/functions/iview2.xsl b/mycore-iview2/src/main/resources/xslt/functions/iview2.xsl index 8631d59ddf..a8ed3556f3 100644 --- a/mycore-iview2/src/main/resources/xslt/functions/iview2.xsl +++ b/mycore-iview2/src/main/resources/xslt/functions/iview2.xsl @@ -11,4 +11,12 @@ + + + + + + + diff --git a/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java new file mode 100644 index 0000000000..4c36c47fef --- /dev/null +++ b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java @@ -0,0 +1,212 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.iview2.events; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; + +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.awt.image.BufferedImage; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mycore.datamodel.niofs.MCRContentTypes; +import org.mycore.datamodel.niofs.MCRPath; +import org.mycore.common.config.MCRConfiguration2; +import org.mycore.iview2.backend.MCRVideoFrameJobAction; +import org.mycore.iview2.frontend.MCRIView2Commands; +import org.mycore.media.services.MCRVideoFrameGenerator; +import org.mycore.services.queuedjob.MCRJob; +import org.mycore.services.queuedjob.MCRJobDAO; +import org.mycore.services.queuedjob.MCRJobQueue; +import org.mycore.services.queuedjob.MCRJobQueueManager; + +class MCRVideoFrameEventHandlerTest { + + @Test + void queuesEveryVideoIncludingNestedFilesAndUpdates() throws Exception { + MCRPath first = video("/first.mp4"); + MCRPath second = video("/folder/second.webm"); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + when(attributes.isRegularFile()).thenReturn(true); + MCRJobQueue queue = mock(MCRJobQueue.class); + MCRJobQueueManager manager = mock(MCRJobQueueManager.class); + when(manager.getJobQueue(MCRVideoFrameJobAction.class)).thenReturn(queue); + when(manager.getJobDAO()).thenReturn(mock(MCRJobDAO.class)); + try (var managers = mockStatic(MCRJobQueueManager.class); + var contentTypes = mockStatic(MCRContentTypes.class); + var configuration = mockStatic(MCRConfiguration2.class)) { + managers.when(MCRJobQueueManager::getInstance).thenReturn(manager); + contentTypes.when(() -> MCRContentTypes.probeContentType(first)).thenReturn("video/mp4"); + contentTypes.when(() -> MCRContentTypes.probeContentType(second)).thenReturn("video/webm"); + configuration.when(() -> MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, + "MCR.Media.VideoFrameGenerator")).thenReturn(new AvailableGenerator()); + MCRVideoFrameEventHandler handler = handlerWithTiles(false); + handler.handlePathCreated(null, first, attributes); + handler.handlePathCreated(null, second, attributes); + handler.handlePathUpdated(null, second, attributes); + handler.handlePathRepaired(null, second, attributes); + ArgumentCaptor jobs = ArgumentCaptor.forClass(MCRJob.class); + verify(queue, times(4)).offer(jobs.capture()); + assertEquals("/first.mp4", jobs.getAllValues().getFirst() + .getParameter(MCRVideoFrameJobAction.PATH_PARAMETER)); + for (MCRJob job : jobs.getAllValues().subList(1, 4)) { + assertEquals("/folder/second.webm", job.getParameter(MCRVideoFrameJobAction.PATH_PARAMETER)); + assertEquals("test_derivate_00000001", job.getParameter(MCRVideoFrameJobAction.DERIVATE_PARAMETER)); + } + } + } + + @Test + void ignoresDirectoriesLocalPathsAndNonVideos() throws Exception { + MCRPath path = video("/document.pdf"); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + try (var managers = mockStatic(MCRJobQueueManager.class); + var contentTypes = mockStatic(MCRContentTypes.class)) { + MCRVideoFrameEventHandler handler = new MCRVideoFrameEventHandler(); + handler.handlePathCreated(null, path, attributes); + handler.handlePathCreated(null, Path.of("local.mp4"), attributes); + when(attributes.isRegularFile()).thenReturn(true); + contentTypes.when(() -> MCRContentTypes.probeContentType(path)).thenReturn("application/pdf"); + handler.handlePathCreated(null, path, attributes); + contentTypes.when(() -> MCRContentTypes.probeContentType(path)).thenReturn(null); + handler.handlePathCreated(null, path, attributes); + managers.verifyNoInteractions(); + } + } + + @Test + void doesNotQueueVideoWhenGeneratorRequirementsAreMissing() throws Exception { + MCRPath path = video("/unavailable.mp4"); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + when(attributes.isRegularFile()).thenReturn(true); + MCRJobQueueManager manager = mock(MCRJobQueueManager.class); + MCRJobQueue queue = mock(MCRJobQueue.class); + when(manager.getJobQueue(MCRVideoFrameJobAction.class)).thenReturn(queue); + try (var managers = mockStatic(MCRJobQueueManager.class); + var contentTypes = mockStatic(MCRContentTypes.class); + var configuration = mockStatic(MCRConfiguration2.class)) { + managers.when(MCRJobQueueManager::getInstance).thenReturn(manager); + contentTypes.when(() -> MCRContentTypes.probeContentType(path)).thenReturn("video/mp4"); + configuration.when(() -> MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, + "MCR.Media.VideoFrameGenerator")).thenReturn(new UnavailableGenerator()); + new MCRVideoFrameEventHandler().handlePathCreated(null, path, attributes); + verify(queue, times(0)).offer(org.mockito.ArgumentMatchers.any(MCRJob.class)); + } + } + + @Test + void doesNotQueueVideoWhenJobAlreadyExists() throws Exception { + MCRPath path = video("/queued.mp4"); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + when(attributes.isRegularFile()).thenReturn(true); + MCRJobQueue queue = mock(MCRJobQueue.class); + MCRJobDAO dao = mock(MCRJobDAO.class); + when(dao.getJobCount(eq(MCRVideoFrameJobAction.class), anyMap(), anyList())).thenReturn(1); + MCRJobQueueManager manager = mock(MCRJobQueueManager.class); + when(manager.getJobQueue(MCRVideoFrameJobAction.class)).thenReturn(queue); + when(manager.getJobDAO()).thenReturn(dao); + try (var managers = mockStatic(MCRJobQueueManager.class); + var contentTypes = mockStatic(MCRContentTypes.class); + var configuration = mockStatic(MCRConfiguration2.class)) { + managers.when(MCRJobQueueManager::getInstance).thenReturn(manager); + contentTypes.when(() -> MCRContentTypes.probeContentType(path)).thenReturn("video/mp4"); + configuration.when(() -> MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, + "MCR.Media.VideoFrameGenerator")).thenReturn(new AvailableGenerator()); + handlerWithTiles(false).handlePathRepaired(null, path, attributes); + verify(dao).getJobCount(eq(MCRVideoFrameJobAction.class), eq(Map.of( + MCRVideoFrameJobAction.DERIVATE_PARAMETER, "test_derivate_00000001", + MCRVideoFrameJobAction.PATH_PARAMETER, "/queued.mp4")), anyList()); + verify(queue, times(0)).offer(any(MCRJob.class)); + } + } + + @Test + void repairSkipsVideoWithExistingTiles() { + MCRPath path = video("/tiled.mp4"); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + when(attributes.isRegularFile()).thenReturn(true); + try (var managers = mockStatic(MCRJobQueueManager.class); + var contentTypes = mockStatic(MCRContentTypes.class)) { + handlerWithTiles(true).handlePathRepaired(null, path, attributes); + managers.verifyNoInteractions(); + contentTypes.verifyNoInteractions(); + } + } + + @Test + void deletesTilesWithoutReadingDeletedVideo() { + MCRPath path = video("/folder/deleted.mp4"); + try (var commands = mockStatic(MCRIView2Commands.class); + var contentTypes = mockStatic(MCRContentTypes.class)) { + // Delete events carry no file attributes. + new MCRVideoFrameEventHandler().handlePathDeleted(null, path, null); + commands.verify(() -> MCRIView2Commands.deleteImageTiles("test_derivate_00000001", "/folder/deleted.mp4")); + contentTypes.verifyNoInteractions(); + } + } + + private MCRVideoFrameEventHandler handlerWithTiles(boolean tiled) { + return new MCRVideoFrameEventHandler() { + @Override + boolean hasTiles(MCRPath path) { + return tiled; + } + }; + } + + private MCRPath video(String relativePath) { + MCRPath path = mock(MCRPath.class); + when(path.getOwner()).thenReturn("test_derivate_00000001"); + when(path.getOwnerRelativePath()).thenReturn(relativePath); + return path; + } + + private static class UnavailableGenerator implements MCRVideoFrameGenerator { + + @Override + public BufferedImage generateFrame(Path video) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean checkRequirements(Path video) { + return false; + } + } + + private static final class AvailableGenerator extends UnavailableGenerator { + + @Override + public boolean checkRequirements(Path video) { + return true; + } + } + +} diff --git a/mycore-iview2/src/test/java/org/mycore/iview2/services/MCRIview2URIResolverTest.java b/mycore-iview2/src/test/java/org/mycore/iview2/services/MCRIview2URIResolverTest.java new file mode 100644 index 0000000000..56ebd3d9c8 --- /dev/null +++ b/mycore-iview2/src/test/java/org/mycore/iview2/services/MCRIview2URIResolverTest.java @@ -0,0 +1,100 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.iview2.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mycore.common.util.MCRTestCaseXSLTUtil.prepareTestDocument; +import static org.mycore.common.util.MCRTestCaseXSLTUtil.transform; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; + +import javax.xml.transform.TransformerException; + +import org.jdom2.Element; +import org.jdom2.transform.JDOMSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mycore.common.MCRTestConfiguration; +import org.mycore.common.MCRTestProperty; +import org.mycore.iview2.backend.MCRTileFileProvider; +import org.mycore.iview2.backend.MCRTileInfo; +import org.mycore.test.MyCoReTest; + +@MyCoReTest +@MCRTestConfiguration(properties = { + @MCRTestProperty(key = "MCR.IIIFImage.Iview.TileFileProvider.Class", + classNameOf = MCRIview2URIResolverTest.TestTileFileProvider.class) +}) +class MCRIview2URIResolverTest { + + private static final String DERIVATE = "test_derivate_00000001"; + + private static final String VIDEO = "/folder/Grüße + 100% #1: clip.mp4"; + + @TempDir + Path directory; + + @Test + void xsltChecksExistingAndMissingTilesWithEncodedPaths() throws Exception { + TestTileFileProvider.tileFile = directory.resolve("frame.iview2"); + assertEquals("false", hasTiles(VIDEO)); + Files.createFile(TestTileFileProvider.tileFile); + assertEquals("true", hasTiles(VIDEO)); + assertEquals("false", hasTiles("/another.mp4")); + Files.delete(TestTileFileProvider.tileFile); + Files.createDirectory(TestTileFileProvider.tileFile); + assertEquals("false", hasTiles(VIDEO)); + } + + @Test + void rejectsMalformedRequests() { + MCRIview2URIResolver resolver = new MCRIview2URIResolver(); + for (String uri : new String[] { "iview2:hasTiles", "iview2:hasTiles:" + DERIVATE, + "iview2:hasTiles:" + DERIVATE + "/", "iview2:hasTiles:/video.mp4", + "iview2:hasTiles:" + DERIVATE + "/bad%path", "iview2:unknown:value" }) { + assertThrows(TransformerException.class, () -> resolver.resolve(uri, null), uri); + } + } + + @Test + void preservesFileSupportQueries() throws Exception { + JDOMSource result = (JDOMSource) new MCRIview2URIResolver().resolve("iview2:isFileSupported:image.png", null); + assertEquals("true", ((Element) result.getNodes().getFirst()).getName()); + } + + private String hasTiles(String path) throws Exception { + return transform(prepareTestDocument("has-tiles"), "/xslt/functions/iview2Test.xsl", + Map.of("derivateId", DERIVATE, "path", path)).getRootElement().getText(); + } + + public static class TestTileFileProvider implements MCRTileFileProvider { + + static Path tileFile; + + @Override + public Optional getTileFile(MCRTileInfo tileInfo) { + assertEquals(DERIVATE, tileInfo.derivate()); + return VIDEO.equals(tileInfo.imagePath()) ? Optional.of(tileFile) : Optional.empty(); + } + } +} diff --git a/mycore-iview2/src/test/resources/xslt/functions/iview2Test.xsl b/mycore-iview2/src/test/resources/xslt/functions/iview2Test.xsl new file mode 100644 index 0000000000..d1ee7ae504 --- /dev/null +++ b/mycore-iview2/src/test/resources/xslt/functions/iview2Test.xsl @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/mycore-media/pom.xml b/mycore-media/pom.xml index f9dada0b75..73a831a812 100644 --- a/mycore-media/pom.xml +++ b/mycore-media/pom.xml @@ -46,6 +46,10 @@ jakarta.ws.rs jakarta.ws.rs-api + + net.bramp.ffmpeg + ffmpeg + org.apache.logging.log4j log4j-api diff --git a/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java new file mode 100644 index 0000000000..72c435a310 --- /dev/null +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java @@ -0,0 +1,255 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.media.services; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.imageio.ImageIO; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.mycore.common.config.annotation.MCRProperty; +import org.mycore.datamodel.niofs.MCRPath; + +import net.bramp.ffmpeg.FFmpeg; +import net.bramp.ffmpeg.FFprobe; +import net.bramp.ffmpeg.RunProcessFunction; +import net.bramp.ffmpeg.builder.FFmpegBuilder; +import net.bramp.ffmpeg.probe.FFmpegProbeResult; +import net.bramp.ffmpeg.probe.FFmpegStream; +import net.bramp.ffmpeg.shared.CodecType; + +/** + * Extracts an embedded cover or a representative video frame at full resolution. + * Prefers a non-blank opening frame; otherwise uses FFmpeg's thumbnail filter after seeking. + */ +public class MCRFFmpegVideoFrameGenerator implements MCRVideoFrameGenerator { + + private static final Logger LOGGER = LogManager.getLogger(); + + private String executable = "ffmpeg"; + + private String probeExecutable = "ffprobe"; + + private long timeout; + + private int thumbnailFrames; + + @MCRProperty(name = "Executable", required = false) + public void setExecutable(String executable) { + this.executable = executable; + } + + @MCRProperty(name = "ProbeExecutable", required = false) + public void setProbeExecutable(String probeExecutable) { + this.probeExecutable = probeExecutable; + } + + /** Sets the maximum runtime in seconds of a single FFmpeg or FFprobe process. */ + @MCRProperty(name = "Timeout") + public void setTimeout(String timeout) { + this.timeout = Long.parseLong(timeout); + } + + /** Sets the number of frames FFmpeg's thumbnail filter compares to select a representative frame. */ + @MCRProperty(name = "ThumbnailFrames") + public void setThumbnailFrames(String thumbnailFrames) { + this.thumbnailFrames = Integer.parseInt(thumbnailFrames); + } + + @Override + public boolean checkRequirements(Path video) { + try { + return video != null && Files.isRegularFile(video) && Files.isReadable(video) + && new FFmpeg(executable, processFunction()).isFFmpeg() + && new FFprobe(probeExecutable, processFunction()).isFFprobe(); + } catch (IOException e) { + LOGGER.warn("Video frame generation is unavailable for {} because FFmpeg or FFprobe cannot be used", + video, e); + return false; + } + } + + @Override + public BufferedImage generateFrame(Path video) throws IOException { + Path localVideo = localPath(video); + if (localVideo != null) { + return generateFrame(video, localVideo); + } + // FFmpeg needs a local file, so videos on other file systems are copied first. + Path input = Files.createTempFile("MyCoRe-Video-", ".video"); + try { + Files.copy(video, input, StandardCopyOption.REPLACE_EXISTING); + return generateFrame(video, input); + } finally { + Files.deleteIfExists(input); + } + } + + /** Returns a file FFmpeg can read directly, or {@code null} if the video has to be copied. */ + private Path localPath(Path video) throws IOException { + if (video instanceof MCRPath mcrPath) { + try { + Path physicalPath = mcrPath.toPhysicalPath(); + if (physicalPath != null && Files.isRegularFile(physicalPath)) { + return physicalPath; + } + } catch (UnsupportedOperationException e) { + // OCFL stores provide no physical path. + LOGGER.debug("No physical path for {}", video, e); + } + return null; + } + return FileSystems.getDefault().equals(video.getFileSystem()) ? video : null; + } + + private BufferedImage generateFrame(Path video, Path input) throws IOException { + Path output = Files.createTempFile("MyCoRe-Video-Frame-", ".png"); + try { + FFmpeg ffmpeg = new FFmpeg(executable, processFunction()); + FFmpegProbeResult probe = new FFprobe(probeExecutable, processFunction()).probe(input.toString()); + FFmpegStream stream = selectStream(probe); + if (stream == null) { + throw new IOException("No video stream or embedded cover found in " + video); + } + if (!isCover(stream)) { + BufferedImage firstFrame = extractFrame(ffmpeg, input, output, stream, 0, false); + if (firstFrame != null && !isBlank(firstFrame)) { + return firstFrame; + } + } + long offset = isCover(stream) ? 0 : startOffset(probe, stream); + BufferedImage frame = extractFrame(ffmpeg, input, output, stream, offset, !isCover(stream)); + // Very short videos may have no frame after seeking. Retry from the beginning. + if (frame == null && offset > 0) { + frame = extractFrame(ffmpeg, input, output, stream, 0, !isCover(stream)); + } + if (frame == null) { + throw new IOException("FFmpeg produced no readable video frame for " + video); + } + return frame; + } finally { + Files.deleteIfExists(output); + } + } + + /** Rejects almost entirely black or white opening frames, without resizing the image. */ + private boolean isBlank(BufferedImage image) { + int black = 0; + int white = 0; + int samples = 0; + int stepX = Math.max(1, image.getWidth() / 100); + int stepY = Math.max(1, image.getHeight() / 100); + for (int y = 0; y < image.getHeight(); y += stepY) { + for (int x = 0; x < image.getWidth(); x += stepX) { + int rgb = image.getRGB(x, y); + int red = (rgb >> 16) & 0xff; + int green = (rgb >> 8) & 0xff; + int blue = rgb & 0xff; + if (red <= 16 && green <= 16 && blue <= 16) { + black++; + } + if (red >= 239 && green >= 239 && blue >= 239) { + white++; + } + samples++; + } + } + return black >= samples * 0.98 || white >= samples * 0.98; + } + + private FFmpegStream selectStream(FFmpegProbeResult probe) { + return probe.getStreams().stream() + .filter(stream -> stream.codec_type == CodecType.VIDEO && isCover(stream)) + .findFirst() + .orElseGet(() -> probe.getStreams().stream() + .filter(stream -> stream.codec_type == CodecType.VIDEO) + .findFirst().orElse(null)); + } + + private boolean isCover(FFmpegStream stream) { + return stream.disposition != null && stream.disposition.attached_pic; + } + + private long startOffset(FFmpegProbeResult probe, FFmpegStream stream) { + double duration = stream.duration; + if (!Double.isFinite(duration) || duration <= 0) { + duration = probe.getFormat() == null ? 0 : probe.getFormat().duration; + } + // Use 10% for short videos, at most 10 seconds; unknown duration starts at zero. + return Double.isFinite(duration) && duration > 0 ? (long) (Math.min(10, duration * 0.1) * 1000) : 0; + } + + private BufferedImage extractFrame(FFmpeg ffmpeg, Path input, Path output, FFmpegStream stream, + long offset, boolean representative) throws IOException { + Files.deleteIfExists(output); + FFmpegBuilder builder = new FFmpegBuilder(); + builder.setInput(input.toString()).setStartOffset(offset, TimeUnit.MILLISECONDS); + var result = builder.overrideOutputFiles(true) + .addOutput(output.toString()) + .setFormat("image2") + .setVideoCodec("png") + .setFrames(1) + .disableAudio() + .disableSubtitle() + .addExtraArgs("-map", "0:" + stream.index); + if (representative) { + result.addExtraArgs("-vf", "thumbnail=" + thumbnailFrames); + } + ffmpeg.run(builder); + return Files.isRegularFile(output) && Files.size(output) > 0 ? ImageIO.read(output.toFile()) : null; + } + + private TimeoutProcessFunction processFunction() { + return new TimeoutProcessFunction(timeout); + } + + /** Starts processes like FFmpeg's default and kills them when they run longer than the timeout. */ + static final class TimeoutProcessFunction extends RunProcessFunction { + + private final long timeout; + + TimeoutProcessFunction(long timeout) { + this.timeout = timeout; + } + + @Override + public Process run(List args) throws IOException { + Process process = super.run(args); + // The future completes when the process exits and fails only if the timeout elapses first. + process.onExit() + .orTimeout(timeout, TimeUnit.SECONDS) + .whenComplete((finished, timeoutError) -> { + if (timeoutError != null) { + LOGGER.warn("Killing {} after {} seconds", args, timeout); + process.destroyForcibly(); + } + }); + return process; + } + } + +} diff --git a/mycore-media/src/main/java/org/mycore/media/services/MCRVideoFrameGenerator.java b/mycore-media/src/main/java/org/mycore/media/services/MCRVideoFrameGenerator.java new file mode 100644 index 0000000000..4bd1432e9b --- /dev/null +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRVideoFrameGenerator.java @@ -0,0 +1,46 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.media.services; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; + +/** Extracts a full-resolution video frame without resizing it. */ +public interface MCRVideoFrameGenerator { + + /** + * Checks whether this generator can process the supplied video. + * + * @param video the source video + * @return {@code true} if frame generation can be attempted + */ + default boolean checkRequirements(Path video) { + return true; + } + + /** + * Extracts a frame from a video, including videos on non-default file systems. + * + * @param video the source video + * @return the decoded frame at its original resolution + * @throws IOException if the video cannot be read or no frame can be extracted + */ + BufferedImage generateFrame(Path video) throws IOException; +} diff --git a/mycore-media/src/main/resources/components/media/config/mycore.properties b/mycore-media/src/main/resources/components/media/config/mycore.properties index 443931809d..f6cabecb70 100644 --- a/mycore-media/src/main/resources/components/media/config/mycore.properties +++ b/mycore-media/src/main/resources/components/media/config/mycore.properties @@ -21,3 +21,13 @@ MCR.Media.Thumbnail.Generators=%MCR.Media.Thumbnail.Generators%,org.mycore.media #Thumbnail default size, in Pixel, for shortest side MCR.Media.Thumbnail.DefaultSize=512 + +# Full-resolution video frame extraction (Configurable Instance). +# FFmpeg and FFprobe must be installed; executable settings may also be absolute paths. +MCR.Media.VideoFrameGenerator.Class=org.mycore.media.services.MCRFFmpegVideoFrameGenerator +MCR.Media.VideoFrameGenerator.Executable=ffmpeg +MCR.Media.VideoFrameGenerator.ProbeExecutable=ffprobe +# Maximum runtime in seconds of a single FFmpeg or FFprobe process; longer running processes are killed. +MCR.Media.VideoFrameGenerator.Timeout=60 +# Number of frames compared to select a representative frame; FFmpeg keeps them all in memory at full resolution. +MCR.Media.VideoFrameGenerator.ThumbnailFrames=100 diff --git a/mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java b/mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java new file mode 100644 index 0000000000..739e971cbe --- /dev/null +++ b/mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java @@ -0,0 +1,213 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.media.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mycore.common.MCRTestConfiguration; +import org.mycore.common.MCRTestProperty; +import org.mycore.common.config.MCRConfiguration2; +import org.mycore.test.MyCoReTest; + +import net.bramp.ffmpeg.FFmpeg; +import net.bramp.ffmpeg.FFprobe; + +@MyCoReTest +class MCRFFmpegVideoFrameGeneratorTest { + + @TempDir + Path directory; + + @Test + void preservesResolutionOnNonDefaultFileSystem() throws Exception { + requireFFmpeg(); + Path video = directory.resolve("video.mkv"); + new FFmpeg().run(List.of("-f", "lavfi", "-i", "color=c=red:s=640x360", + "-frames:v", "1", "-c:v", "ffv1", video.toString())); + try (var fileSystem = FileSystems.newFileSystem(directory.resolve("videos.zip"), Map.of("create", "true"))) { + Path storedVideo = fileSystem.getPath("/video with spaces.mkv"); + Files.copy(video, storedVideo); + BufferedImage frame = configuredGenerator().generateFrame(storedVideo); + assertEquals(640, frame.getWidth()); + assertEquals(360, frame.getHeight()); + // Verify actual decoded image content, not merely the dimensions. + int red = (frame.getRGB(320, 180) >> 16) & 0xff; + assertTrue(red > 240); + } + } + + @Test + void prefersAttachedCoverAtOriginalResolution() throws Exception { + requireFFmpeg(); + Path cover = directory.resolve("cover.png"); + BufferedImage coverImage = new BufferedImage(640, 360, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < coverImage.getHeight(); y++) { + for (int x = 0; x < coverImage.getWidth(); x++) { + coverImage.setRGB(x, y, 0xff0000); + } + } + ImageIO.write(coverImage, "png", cover.toFile()); + Path video = directory.resolve("covered.mp4"); + new FFmpeg().run(List.of("-f", "lavfi", "-i", "color=c=blue:s=320x180:d=1", + "-i", cover.toString(), "-map", "0:v", "-map", "1:v", "-c:v:0", "mpeg4", + "-c:v:1", "png", "-disposition:v:1", "attached_pic", video.toString())); + BufferedImage frame = configuredGenerator().generateFrame(video); + assertEquals(640, frame.getWidth()); + assertEquals(360, frame.getHeight()); + assertTrue(((frame.getRGB(320, 180) >> 16) & 0xff) > 240); + } + + @Test + void selectsRepresentativeFrameAfterSeeking() throws Exception { + requireFFmpeg(); + Path video = directory.resolve("representative.mkv"); + // At the 10% seek position the video is still red. Most of the following frames are blue. + new FFmpeg().run(List.of("-f", "lavfi", "-i", "color=c=red:s=320x180:r=10:d=2", + "-f", "lavfi", "-i", "color=c=blue:s=320x180:r=10:d=8", + "-filter_complex", "[0:v][1:v]concat=n=2:v=1:a=0[v]", "-map", "[v]", + "-c:v", "ffv1", video.toString())); + BufferedImage frame = configuredGenerator().generateFrame(video); + assertEquals(320, frame.getWidth()); + assertEquals(180, frame.getHeight()); + assertTrue(((frame.getRGB(160, 90) >> 16) & 0xff) > 240); + } + + @Test + void preservesOpeningFrame() throws Exception { + requireFFmpeg(); + BufferedImage frame = configuredGenerator().generateFrame(videoWithOpening("red")); + assertEquals(320, frame.getWidth()); + assertEquals(180, frame.getHeight()); + assertTrue(((frame.getRGB(160, 90) >> 16) & 0xff) > 240); + } + + @Test + void fallsBackForBlackOrWhiteOpening() throws Exception { + requireFFmpeg(); + for (String color : List.of("black", "white")) { + BufferedImage frame = configuredGenerator().generateFrame(videoWithOpening(color)); + assertTrue((frame.getRGB(160, 90) & 0xff) > 240); + assertTrue(((frame.getRGB(160, 90) >> 16) & 0xff) < 16); + } + } + + private Path videoWithOpening(String color) throws IOException { + Path video = directory.resolve(color + ".mkv"); + new FFmpeg().run(List.of("-f", "lavfi", "-i", "color=c=" + color + ":s=320x180:r=10:d=2", + "-f", "lavfi", "-i", "color=c=blue:s=320x180:r=10:d=8", + "-filter_complex", "[0:v][1:v]concat=n=2:v=1:a=0[v]", "-map", "[v]", + "-c:v", "ffv1", video.toString())); + return video; + } + + @Test + void rejectsAudioWithoutCover() throws Exception { + requireFFmpeg(); + Path audio = directory.resolve("audio.wav"); + new FFmpeg().run(List.of("-f", "lavfi", "-i", "sine=duration=0.1", audio.toString())); + IOException exception = assertThrows(IOException.class, () -> configuredGenerator().generateFrame(audio)); + assertTrue(exception.getMessage().contains("No video stream")); + } + + @Test + @MCRTestConfiguration(properties = { + @MCRTestProperty(key = "MCR.Media.VideoFrameGenerator.ProbeExecutable", string = "/missing/ffprobe") + }) + void injectsProbeExecutable() throws Exception { + requireFFmpeg(); + Path video = directory.resolve("video.mp4"); + Files.writeString(video, "input"); + IOException exception = assertThrows(IOException.class, () -> configuredGenerator().generateFrame(video)); + assertTrue(exception.getMessage().contains("/missing/ffprobe")); + } + + @Test + void rejectsInvalidVideo() throws Exception { + requireFFmpeg(); + Path video = directory.resolve("broken.mp4"); + Files.writeString(video, "not a video"); + assertThrows(IOException.class, () -> configuredGenerator().generateFrame(video)); + } + + @Test + @MCRTestConfiguration(properties = { + @MCRTestProperty(key = "MCR.Media.VideoFrameGenerator.Executable", string = "/missing/ffmpeg") + }) + void injectsExecutable() throws Exception { + Path video = directory.resolve("video.mp4"); + Files.writeString(video, "input"); + assertFalse(configuredGenerator().checkRequirements(video)); + IOException exception = assertThrows(IOException.class, () -> configuredGenerator().generateFrame(video)); + assertTrue(exception.getMessage().contains("/missing/ffmpeg")); + } + + @Test + @MCRTestConfiguration(properties = { + @MCRTestProperty(key = "MCR.Media.VideoFrameGenerator.Class", classNameOf = CustomGenerator.class) + }) + void replacesGeneratorThroughConfiguration() { + assertInstanceOf(CustomGenerator.class, configuredGenerator()); + } + + @Test + void killsProcessAfterTimeout() throws Exception { + assumeTrue(Files.isExecutable(Path.of("/bin/sleep")), "sleep is required for the timeout test"); + Process process = new MCRFFmpegVideoFrameGenerator.TimeoutProcessFunction(1).run(List.of("/bin/sleep", "60")); + assertTrue(process.waitFor(10, TimeUnit.SECONDS)); + } + + private MCRVideoFrameGenerator configuredGenerator() { + return MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, "MCR.Media.VideoFrameGenerator"); + } + + private void requireFFmpeg() { + try { + assumeTrue(new FFmpeg().isFFmpeg(), "FFmpeg is required for frame extraction tests"); + assumeTrue(new FFprobe().isFFprobe(), "FFprobe is required for frame extraction tests"); + } catch (IOException e) { + assumeTrue(false, "FFmpeg is not installed: " + e.getMessage()); + } + } + + public static class CustomGenerator implements MCRVideoFrameGenerator { + + @Override + public BufferedImage generateFrame(Path video) { + return new BufferedImage(640, 360, BufferedImage.TYPE_INT_RGB); + } + } +} diff --git a/pom.xml b/pom.xml index a64cdc8ced..6ac2b48837 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,7 @@ Applications based on MyCoRe use a common core, which provides the functionality 2025.02 true ${user.dir}/checkstyle-suppressions.xml + 0.9.1 1.12.0 5.0.1 3.0 @@ -1283,6 +1284,11 @@ Applications based on MyCoRe use a common core, which provides the functionality + + net.bramp.ffmpeg + ffmpeg + ${ffmpeg.version} + org.apache.abdera abdera-core