From 9fd8e8e5df1643bd237750225b297971a2f1abbd Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 8 Sep 2026 18:46:00 +0200 Subject: [PATCH 01/12] MCR-3836 Generate an iView image from a video frame --- mycore-iview2/pom.xml | 5 + .../backend/MCRVideoFrameJobAction.java | 104 +++++++++ .../events/MCRVideoFrameEventHandler.java | 90 ++++++++ .../iview2/services/MCRIView2Tools.java | 18 ++ .../iview2/services/MCRIview2URIResolver.java | 18 +- .../iview2/config/mycore.properties | 5 +- .../main/resources/xslt/functions/iview2.xsl | 8 + .../events/MCRVideoFrameEventHandlerTest.java | 157 ++++++++++++++ .../services/MCRIview2URIResolverTest.java | 100 +++++++++ .../resources/xslt/functions/iview2Test.xsl | 18 ++ mycore-media/pom.xml | 4 + .../MCRFFmpegVideoFrameGenerator.java | 180 +++++++++++++++ .../services/MCRVideoFrameGenerator.java | 46 ++++ .../components/media/config/mycore.properties | 6 + .../MCRFFmpegVideoFrameGeneratorTest.java | 205 ++++++++++++++++++ pom.xml | 6 + 16 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java create mode 100644 mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java create mode 100644 mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java create mode 100644 mycore-iview2/src/test/java/org/mycore/iview2/services/MCRIview2URIResolverTest.java create mode 100644 mycore-iview2/src/test/resources/xslt/functions/iview2Test.xsl create mode 100644 mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java create mode 100644 mycore-media/src/main/java/org/mycore/media/services/MCRVideoFrameGenerator.java create mode 100644 mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java 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..9a5ca19989 --- /dev/null +++ b/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java @@ -0,0 +1,104 @@ +/* + * 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.datamodel.niofs.MCRContentTypes; +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; + } + 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 while FFmpeg was running. + if (!Files.isRegularFile(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); + } + } + + @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..41255118d1 --- /dev/null +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -0,0 +1,90 @@ +/* + * 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 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.media.services.MCRVideoFrameGenerator; +import org.mycore.services.queuedjob.MCRJob; +import org.mycore.services.queuedjob.MCRJobQueueManager; + +/** Creates iView images for every video file, independent of the derivate's main document. */ +public class MCRVideoFrameEventHandler extends MCREventHandlerBase { + + 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.getInstance().getJobQueue(MCRVideoFrameJobAction.class).add(job); + } + } catch (IOException e) { + throw new MCRException("Could not enqueue video frame generation for " + file, e); + } + } + + private boolean checkRequirements(MCRPath path) { + try { + return MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, + VIDEO_FRAME_GENERATOR_PROPERTY).checkRequirements(path); + } catch (RuntimeException e) { + throw new MCRException("Could not check video frame generator requirements", e); + } + } + + @Override + public void handlePathUpdated(MCREvent evt, Path file, BasicFileAttributes attrs) { + handlePathCreated(evt, file, attrs); + } + + @Override + public void handlePathRepaired(MCREvent evt, Path file, BasicFileAttributes attrs) { + handlePathCreated(evt, file, attrs); + } + + @Override + public void handlePathDeleted(MCREvent evt, Path file, BasicFileAttributes attrs) { + if (file instanceof MCRPath path && attrs.isRegularFile()) { + // 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..e44b8a12b0 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,9 @@ import org.mycore.datamodel.niofs.MCRPath; import org.mycore.imagetiler.MCRImage; import org.mycore.imagetiler.MCRTiledPictureProps; +import org.mycore.iview2.backend.MCRDefaultTileFileProvider; +import org.mycore.iview2.backend.MCRTileFileProvider; +import org.mycore.iview2.backend.MCRTileInfo; import jakarta.activation.FileTypeMap; import jakarta.persistence.EntityManager; @@ -152,6 +155,21 @@ 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 + .getInstanceOf(MCRTileFileProvider.class, "MCR.IIIFImage.Iview.TileFileProvider") + .orElseGet(MCRDefaultTileFileProvider::new); + 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..fb066afcb0 --- /dev/null +++ b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java @@ -0,0 +1,157 @@ +/* + * 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 java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.awt.image.BufferedImage; + +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.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); + 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 = new MCRVideoFrameEventHandler(); + 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)).add(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)).add(org.mockito.ArgumentMatchers.any(MCRJob.class)); + } + } + + @Test + void deletesTilesWithoutReadingDeletedVideo() { + MCRPath path = video("/folder/deleted.mp4"); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + when(attributes.isRegularFile()).thenReturn(true); + try (var commands = mockStatic(MCRIView2Commands.class); + var contentTypes = mockStatic(MCRContentTypes.class)) { + new MCRVideoFrameEventHandler().handlePathDeleted(null, path, attributes); + commands.verify(() -> MCRIView2Commands.deleteImageTiles("test_derivate_00000001", "/folder/deleted.mp4")); + contentTypes.verifyNoInteractions(); + } + } + + 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..4d532ef585 --- /dev/null +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java @@ -0,0 +1,180 @@ +/* + * 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.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +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 net.bramp.ffmpeg.FFmpeg; +import net.bramp.ffmpeg.FFprobe; +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"; + + @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; + } + + @Override + public boolean checkRequirements(Path video) { + try { + return video != null && Files.isRegularFile(video) && Files.isReadable(video) + && new FFmpeg(executable).isFFmpeg() && new FFprobe(probeExecutable).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 { + // FFmpeg needs a local file, while derivates may use another NIO file system. + Path input = Files.createTempFile("MyCoRe-Video-", ".video"); + try { + Files.copy(video, input, StandardCopyOption.REPLACE_EXISTING); + Path output = Files.createTempFile("MyCoRe-Video-Frame-", ".png"); + try { + FFmpeg ffmpeg = new FFmpeg(executable); + FFmpegProbeResult probe = new FFprobe(probeExecutable).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); + } + } finally { + Files.deleteIfExists(input); + } + } + + /** 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=100"); + } + ffmpeg.run(builder); + return Files.isRegularFile(output) && Files.size(output) > 0 ? ImageIO.read(output.toFile()) : null; + } + +} 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..c6319354a3 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,9 @@ 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 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..f6b84cd5e3 --- /dev/null +++ b/mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java @@ -0,0 +1,205 @@ +/* + * 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 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()); + } + + 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 From 8f88ebd5507fea60d25819d42e68d1c1db4abed1 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 10:50:03 +0100 Subject: [PATCH 02/12] MCR-3836 Generate an iView image from a video frame log errors in video frame event handler instead of aborting the file event --- .../events/MCRVideoFrameEventHandler.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 index 41255118d1..92d4b25f96 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -22,6 +22,8 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; +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; @@ -37,6 +39,8 @@ /** 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 @@ -52,18 +56,15 @@ public void handlePathCreated(MCREvent evt, Path file, BasicFileAttributes attrs job.setParameter(MCRVideoFrameJobAction.PATH_PARAMETER, path.getOwnerRelativePath()); MCRJobQueueManager.getInstance().getJobQueue(MCRVideoFrameJobAction.class).add(job); } - } catch (IOException e) { - throw new MCRException("Could not enqueue video frame generation for " + file, e); + } 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) { - try { - return MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, - VIDEO_FRAME_GENERATOR_PROPERTY).checkRequirements(path); - } catch (RuntimeException e) { - throw new MCRException("Could not check video frame generator requirements", e); - } + return MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, + VIDEO_FRAME_GENERATOR_PROPERTY).checkRequirements(path); } @Override From 28f8a21689b3b8b3b451e7cfd7fa11cd5ab1dfde Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 10:50:03 +0100 Subject: [PATCH 03/12] MCR-3836 Generate an iView image from a video frame use offer instead of add to enqueue video frame jobs --- .../org/mycore/iview2/events/MCRVideoFrameEventHandler.java | 4 +++- .../mycore/iview2/events/MCRVideoFrameEventHandlerTest.java | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 index 92d4b25f96..f4db2c9b8e 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -54,7 +54,9 @@ public void handlePathCreated(MCREvent evt, Path file, BasicFileAttributes attrs MCRJob job = new MCRJob(MCRVideoFrameJobAction.class); job.setParameter(MCRVideoFrameJobAction.DERIVATE_PARAMETER, path.getOwner()); job.setParameter(MCRVideoFrameJobAction.PATH_PARAMETER, path.getOwnerRelativePath()); - MCRJobQueueManager.getInstance().getJobQueue(MCRVideoFrameJobAction.class).add(job); + if (!MCRJobQueueManager.getInstance().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. 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 index fb066afcb0..3a6e78ee06 100644 --- a/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java +++ b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java @@ -40,6 +40,7 @@ import org.mycore.services.queuedjob.MCRJob; import org.mycore.services.queuedjob.MCRJobQueue; import org.mycore.services.queuedjob.MCRJobQueueManager; + class MCRVideoFrameEventHandlerTest { @Test @@ -65,7 +66,7 @@ void queuesEveryVideoIncludingNestedFilesAndUpdates() throws Exception { handler.handlePathUpdated(null, second, attributes); handler.handlePathRepaired(null, second, attributes); ArgumentCaptor jobs = ArgumentCaptor.forClass(MCRJob.class); - verify(queue, times(4)).add(jobs.capture()); + 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)) { @@ -109,7 +110,7 @@ void doesNotQueueVideoWhenGeneratorRequirementsAreMissing() throws Exception { configuration.when(() -> MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, "MCR.Media.VideoFrameGenerator")).thenReturn(new UnavailableGenerator()); new MCRVideoFrameEventHandler().handlePathCreated(null, path, attributes); - verify(queue, times(0)).add(org.mockito.ArgumentMatchers.any(MCRJob.class)); + verify(queue, times(0)).offer(org.mockito.ArgumentMatchers.any(MCRJob.class)); } } From fd14780b28200c261b85a16f9b5fa2a22eac5c38 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 10:52:30 +0100 Subject: [PATCH 04/12] MCR-3836 Generate an iView image from a video frame skip enqueueing when a video frame job already exists --- .../events/MCRVideoFrameEventHandler.java | 8 ++++- .../events/MCRVideoFrameEventHandlerTest.java | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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 index f4db2c9b8e..f783e4bb5d 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -21,6 +21,7 @@ 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; @@ -35,6 +36,7 @@ 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 { @@ -54,7 +56,11 @@ public void handlePathCreated(MCREvent evt, Path file, BasicFileAttributes attrs MCRJob job = new MCRJob(MCRVideoFrameJobAction.class); job.setParameter(MCRVideoFrameJobAction.DERIVATE_PARAMETER, path.getOwner()); job.setParameter(MCRVideoFrameJobAction.PATH_PARAMETER, path.getOwnerRelativePath()); - if (!MCRJobQueueManager.getInstance().getJobQueue(MCRVideoFrameJobAction.class).offer(job)) { + 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); } } 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 index 3a6e78ee06..89045d749a 100644 --- a/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java +++ b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java @@ -24,10 +24,15 @@ 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; @@ -38,6 +43,7 @@ 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; @@ -52,6 +58,7 @@ void queuesEveryVideoIncludingNestedFilesAndUpdates() throws Exception { 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)) { @@ -114,6 +121,32 @@ void doesNotQueueVideoWhenGeneratorRequirementsAreMissing() throws Exception { } } + @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()); + new MCRVideoFrameEventHandler().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 deletesTilesWithoutReadingDeletedVideo() { MCRPath path = video("/folder/deleted.mp4"); From dc6eb066170adc40818f07fd04a2bc989f4c0f96 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 10:57:26 +0100 Subject: [PATCH 05/12] MCR-3836 Generate an iView image from a video frame skip repair of videos that already have tiles --- .../events/MCRVideoFrameEventHandler.java | 12 +++++++++ .../events/MCRVideoFrameEventHandlerTest.java | 26 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) 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 index f783e4bb5d..714eedb56b 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -19,6 +19,7 @@ package org.mycore.iview2.events; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.List; @@ -31,8 +32,10 @@ import org.mycore.common.events.MCREventHandlerBase; import org.mycore.datamodel.niofs.MCRContentTypes; import org.mycore.datamodel.niofs.MCRPath; +import org.mycore.imagetiler.MCRImage; 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; @@ -82,9 +85,18 @@ public void handlePathUpdated(MCREvent evt, Path file, BasicFileAttributes 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 Files.exists( + MCRImage.getTiledFile(MCRIView2Tools.getTileDir(), path.getOwner(), path.getOwnerRelativePath())); + } + @Override public void handlePathDeleted(MCREvent evt, Path file, BasicFileAttributes attrs) { if (file instanceof MCRPath path && attrs.isRegularFile()) { 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 index 89045d749a..312eb795be 100644 --- a/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java +++ b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java @@ -67,7 +67,7 @@ void queuesEveryVideoIncludingNestedFilesAndUpdates() throws Exception { contentTypes.when(() -> MCRContentTypes.probeContentType(second)).thenReturn("video/webm"); configuration.when(() -> MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, "MCR.Media.VideoFrameGenerator")).thenReturn(new AvailableGenerator()); - MCRVideoFrameEventHandler handler = new MCRVideoFrameEventHandler(); + MCRVideoFrameEventHandler handler = handlerWithTiles(false); handler.handlePathCreated(null, first, attributes); handler.handlePathCreated(null, second, attributes); handler.handlePathUpdated(null, second, attributes); @@ -139,7 +139,7 @@ void doesNotQueueVideoWhenJobAlreadyExists() throws Exception { contentTypes.when(() -> MCRContentTypes.probeContentType(path)).thenReturn("video/mp4"); configuration.when(() -> MCRConfiguration2.getInstanceOfOrThrow(MCRVideoFrameGenerator.class, "MCR.Media.VideoFrameGenerator")).thenReturn(new AvailableGenerator()); - new MCRVideoFrameEventHandler().handlePathRepaired(null, path, attributes); + 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()); @@ -147,6 +147,19 @@ void doesNotQueueVideoWhenJobAlreadyExists() throws Exception { } } + @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"); @@ -160,6 +173,15 @@ void deletesTilesWithoutReadingDeletedVideo() { } } + 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"); From 622150800f6641df748cda9a3a8e69f39c54d62b Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 11:15:53 +0100 Subject: [PATCH 06/12] MCR-3836 Generate an iView image from a video frame kill FFmpeg processes after a configurable timeout --- .../MCRFFmpegVideoFrameGenerator.java | 46 +++++++++++++++++-- .../components/media/config/mycore.properties | 2 + .../MCRFFmpegVideoFrameGeneratorTest.java | 8 ++++ 3 files changed, 53 insertions(+), 3 deletions(-) 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 index 4d532ef585..b96aace90e 100644 --- a/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java @@ -23,6 +23,7 @@ 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; @@ -33,6 +34,7 @@ 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; @@ -50,6 +52,8 @@ public class MCRFFmpegVideoFrameGenerator implements MCRVideoFrameGenerator { private String probeExecutable = "ffprobe"; + private long timeout; + @MCRProperty(name = "Executable", required = false) public void setExecutable(String executable) { this.executable = executable; @@ -60,11 +64,18 @@ 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); + } + @Override public boolean checkRequirements(Path video) { try { return video != null && Files.isRegularFile(video) && Files.isReadable(video) - && new FFmpeg(executable).isFFmpeg() && new FFprobe(probeExecutable).isFFprobe(); + && 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); @@ -80,8 +91,8 @@ public BufferedImage generateFrame(Path video) throws IOException { Files.copy(video, input, StandardCopyOption.REPLACE_EXISTING); Path output = Files.createTempFile("MyCoRe-Video-Frame-", ".png"); try { - FFmpeg ffmpeg = new FFmpeg(executable); - FFmpegProbeResult probe = new FFprobe(probeExecutable).probe(input.toString()); + 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); @@ -177,4 +188,33 @@ private BufferedImage extractFrame(FFmpeg ffmpeg, Path input, Path output, FFmpe 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/resources/components/media/config/mycore.properties b/mycore-media/src/main/resources/components/media/config/mycore.properties index c6319354a3..5677ef27eb 100644 --- a/mycore-media/src/main/resources/components/media/config/mycore.properties +++ b/mycore-media/src/main/resources/components/media/config/mycore.properties @@ -27,3 +27,5 @@ MCR.Media.Thumbnail.DefaultSize=512 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 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 index f6b84cd5e3..739e971cbe 100644 --- a/mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java +++ b/mycore-media/src/test/java/org/mycore/media/services/MCRFFmpegVideoFrameGeneratorTest.java @@ -32,6 +32,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import javax.imageio.ImageIO; @@ -182,6 +183,13 @@ 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"); } From 501b32c36c7028e20c97644810160b254260ed7d Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 11:22:17 +0100 Subject: [PATCH 07/12] MCR-3836 Generate an iView image from a video frame read videos of local stores directly instead of copying them --- .../backend/MCRVideoFrameJobAction.java | 4 +- .../MCRFFmpegVideoFrameGenerator.java | 62 +++++++++++-------- 2 files changed, 38 insertions(+), 28 deletions(-) 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 index 9a5ca19989..93a6452fb9 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java @@ -73,7 +73,9 @@ public void execute() { if (!generator.checkRequirements(video)) { return; } - BufferedImage frame = generator.generateFrame(video); + // FFmpeg can read files of local stores directly, other stores are copied by the generator. + Path physicalPath = video.toPhysicalPath(); + BufferedImage frame = generator.generateFrame(physicalPath != null ? physicalPath : video); Path image = Files.createTempFile("MyCoRe-Video-Frame-", ".png"); try { if (!ImageIO.write(frame, "png", image.toFile())) { 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 index b96aace90e..f1cd210963 100644 --- a/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java @@ -20,6 +20,7 @@ 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; @@ -85,39 +86,46 @@ && new FFmpeg(executable, processFunction()).isFFmpeg() @Override public BufferedImage generateFrame(Path video) throws IOException { - // FFmpeg needs a local file, while derivates may use another NIO file system. + if (FileSystems.getDefault().equals(video.getFileSystem())) { + return generateFrame(video, video); + } + // 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); - 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 generateFrame(video, input); + } finally { + Files.deleteIfExists(input); + } + } + + 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; } - return frame; - } finally { - Files.deleteIfExists(output); } + 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(input); + Files.deleteIfExists(output); } } From 7038aff9de4e63dd9ddbc568bb0834541f2752ee Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 11:28:39 +0100 Subject: [PATCH 08/12] MCR-3836 Generate an iView image from a video frame resolve the physical video path in the generator and support stores without one --- .../backend/MCRVideoFrameJobAction.java | 4 +--- .../MCRFFmpegVideoFrameGenerator.java | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) 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 index 93a6452fb9..9a5ca19989 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java @@ -73,9 +73,7 @@ public void execute() { if (!generator.checkRequirements(video)) { return; } - // FFmpeg can read files of local stores directly, other stores are copied by the generator. - Path physicalPath = video.toPhysicalPath(); - BufferedImage frame = generator.generateFrame(physicalPath != null ? physicalPath : video); + BufferedImage frame = generator.generateFrame(video); Path image = Files.createTempFile("MyCoRe-Video-Frame-", ".png"); try { if (!ImageIO.write(frame, "png", image.toFile())) { 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 index f1cd210963..73c5d0bb62 100644 --- a/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java @@ -32,6 +32,7 @@ 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; @@ -86,8 +87,9 @@ && new FFmpeg(executable, processFunction()).isFFmpeg() @Override public BufferedImage generateFrame(Path video) throws IOException { - if (FileSystems.getDefault().equals(video.getFileSystem())) { - return generateFrame(video, video); + 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"); @@ -99,6 +101,23 @@ public BufferedImage generateFrame(Path video) throws IOException { } } + /** 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 { From 1800719117d457de214d18ad8e306a9ce4bc4421 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 11:29:38 +0100 Subject: [PATCH 09/12] MCR-3836 Generate an iView image from a video frame make the number of thumbnail frames configurable --- .../media/services/MCRFFmpegVideoFrameGenerator.java | 10 +++++++++- .../components/media/config/mycore.properties | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 index 73c5d0bb62..72c435a310 100644 --- a/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java +++ b/mycore-media/src/main/java/org/mycore/media/services/MCRFFmpegVideoFrameGenerator.java @@ -56,6 +56,8 @@ public class MCRFFmpegVideoFrameGenerator implements MCRVideoFrameGenerator { private long timeout; + private int thumbnailFrames; + @MCRProperty(name = "Executable", required = false) public void setExecutable(String executable) { this.executable = executable; @@ -72,6 +74,12 @@ 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 { @@ -209,7 +217,7 @@ private BufferedImage extractFrame(FFmpeg ffmpeg, Path input, Path output, FFmpe .disableSubtitle() .addExtraArgs("-map", "0:" + stream.index); if (representative) { - result.addExtraArgs("-vf", "thumbnail=100"); + result.addExtraArgs("-vf", "thumbnail=" + thumbnailFrames); } ffmpeg.run(builder); return Files.isRegularFile(output) && Files.size(output) > 0 ? ImageIO.read(output.toFile()) : null; 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 5677ef27eb..f6cabecb70 100644 --- a/mycore-media/src/main/resources/components/media/config/mycore.properties +++ b/mycore-media/src/main/resources/components/media/config/mycore.properties @@ -29,3 +29,5 @@ 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 From c039345e6d9b8ff82a0107493c03e4e7a0110d02 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 11:33:34 +0100 Subject: [PATCH 10/12] MCR-3836 Generate an iView image from a video frame discard frames of videos replaced while the job was running --- .../iview2/backend/MCRVideoFrameJobAction.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index 9a5ca19989..fb1c06460e 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/backend/MCRVideoFrameJobAction.java @@ -27,7 +27,9 @@ 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; @@ -73,14 +75,16 @@ public void execute() { 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 while FFmpeg was running. - if (!Files.isRegularFile(video)) { + // 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()); @@ -97,6 +101,10 @@ public void execute() { } } + 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. From 9446151dbf0f8bc31322684574d290e9d0d95c56 Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 11:57:50 +0100 Subject: [PATCH 11/12] MCR-3836 Generate an iView image from a video frame cache the tile file provider and use it for the repair check --- .../org/mycore/iview2/events/MCRVideoFrameEventHandler.java | 5 +---- .../main/java/org/mycore/iview2/services/MCRIView2Tools.java | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) 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 index 714eedb56b..8b6a8c0360 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -19,7 +19,6 @@ package org.mycore.iview2.events; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.List; @@ -32,7 +31,6 @@ import org.mycore.common.events.MCREventHandlerBase; import org.mycore.datamodel.niofs.MCRContentTypes; import org.mycore.datamodel.niofs.MCRPath; -import org.mycore.imagetiler.MCRImage; import org.mycore.iview2.backend.MCRVideoFrameJobAction; import org.mycore.iview2.frontend.MCRIView2Commands; import org.mycore.iview2.services.MCRIView2Tools; @@ -93,8 +91,7 @@ public void handlePathRepaired(MCREvent evt, Path file, BasicFileAttributes attr } boolean hasTiles(MCRPath path) { - return Files.exists( - MCRImage.getTiledFile(MCRIView2Tools.getTileDir(), path.getOwner(), path.getOwnerRelativePath())); + return MCRIView2Tools.hasTiles(path.getOwner(), path.getOwnerRelativePath()); } @Override 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 e44b8a12b0..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,7 +56,6 @@ import org.mycore.datamodel.niofs.MCRPath; import org.mycore.imagetiler.MCRImage; import org.mycore.imagetiler.MCRTiledPictureProps; -import org.mycore.iview2.backend.MCRDefaultTileFileProvider; import org.mycore.iview2.backend.MCRTileFileProvider; import org.mycore.iview2.backend.MCRTileInfo; @@ -164,8 +163,7 @@ public static boolean isFileSupported(String filename) { */ public static boolean hasTiles(String derivateId, String path) { MCRTileFileProvider provider = MCRConfiguration2 - .getInstanceOf(MCRTileFileProvider.class, "MCR.IIIFImage.Iview.TileFileProvider") - .orElseGet(MCRDefaultTileFileProvider::new); + .getSingleInstanceOfOrThrow(MCRTileFileProvider.class, "MCR.IIIFImage.Iview.TileFileProvider"); return provider.getTileFile(new MCRTileInfo(derivateId, path, null)) .filter(Files::isRegularFile).isPresent(); } From a066a1696102adeac8e2af0cb464872b74821e4a Mon Sep 17 00:00:00 2001 From: Sebastian Hofmann Date: Tue, 27 Oct 2026 15:22:06 +0100 Subject: [PATCH 12/12] MCR-3836 Generate an iView image from a video frame do not read file attributes in delete events --- .../org/mycore/iview2/events/MCRVideoFrameEventHandler.java | 2 +- .../mycore/iview2/events/MCRVideoFrameEventHandlerTest.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 index 8b6a8c0360..be63d7641e 100644 --- a/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java +++ b/mycore-iview2/src/main/java/org/mycore/iview2/events/MCRVideoFrameEventHandler.java @@ -96,7 +96,7 @@ boolean hasTiles(MCRPath path) { @Override public void handlePathDeleted(MCREvent evt, Path file, BasicFileAttributes attrs) { - if (file instanceof MCRPath path && attrs.isRegularFile()) { + 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()); 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 index 312eb795be..4c36c47fef 100644 --- a/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java +++ b/mycore-iview2/src/test/java/org/mycore/iview2/events/MCRVideoFrameEventHandlerTest.java @@ -163,11 +163,10 @@ void repairSkipsVideoWithExistingTiles() { @Test void deletesTilesWithoutReadingDeletedVideo() { MCRPath path = video("/folder/deleted.mp4"); - BasicFileAttributes attributes = mock(BasicFileAttributes.class); - when(attributes.isRegularFile()).thenReturn(true); try (var commands = mockStatic(MCRIView2Commands.class); var contentTypes = mockStatic(MCRContentTypes.class)) { - new MCRVideoFrameEventHandler().handlePathDeleted(null, path, attributes); + // Delete events carry no file attributes. + new MCRVideoFrameEventHandler().handlePathDeleted(null, path, null); commands.verify(() -> MCRIView2Commands.deleteImageTiles("test_derivate_00000001", "/folder/deleted.mp4")); contentTypes.verifyNoInteractions(); }