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