mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
feat(convert): add PDF to Video converter (FFmpeg) with MP4/WebM support (#4704)
# Description of Changes This pull request introduces support for FFmpeg as a new external tool in the application. It adds configuration options for FFmpeg session limits and timeouts, updates the process execution and tool-checking utilities to handle FFmpeg, and expands endpoint configuration to include FFmpeg-dependent features. Corresponding unit tests have also been added to ensure FFmpeg detection works as expected. **FFmpeg Integration and Configuration:** * Added FFmpeg session limit and timeout configuration options to `ApplicationProperties`, with default values and getter methods. [[1]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R631) [[2]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R672-R675) [[3]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R702) [[4]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R743-R746) * Updated `ProcessExecutor` to recognize FFmpeg as a process type, and to use the new session limit and timeout configuration for FFmpeg processes. [[1]](diffhunk://#diff-8424a11112fff55cc28467c4d531e451a485911ed1aeb0aea772c9fa7dc3aa6aL305-R316) [[2]](diffhunk://#diff-8424a11112fff55cc28467c4d531e451a485911ed1aeb0aea772c9fa7dc3aa6aR74-R78) [[3]](diffhunk://#diff-8424a11112fff55cc28467c4d531e451a485911ed1aeb0aea772c9fa7dc3aa6aR133-R137) **Tool Detection and Exception Handling:** * Implemented `isFfmpegAvailable()` in `CheckProgramInstall` to detect FFmpeg installation, with caching for efficiency. [[1]](diffhunk://#diff-7b61807107c689e3824a5f8fd42c27ab072a67a5666f24445bd6895937351690R14) [[2]](diffhunk://#diff-7b61807107c689e3824a5f8fd42c27ab072a67a5666f24445bd6895937351690R60-R78) * Added a specific exception factory method for missing FFmpeg in `ExceptionUtils`. **Endpoint Configuration:** * Registered the new `pdf-to-video` endpoint under the "Convert", "Java", and "FFmpeg" groups, and updated the tool group logic to include "FFmpeg". [[1]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437R265) [[2]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437R395) [[3]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437R452-R454) [[4]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437L496-R502) **Testing Enhancements:** * Added and updated unit tests in `CheckProgramInstallTest` to verify FFmpeg detection, including scenarios for installed, not installed, and caching behavior. [[1]](diffhunk://#diff-0eaf917d935710f0f5e18f12db600be47b8439d628d65a97a3db34133231790eR29) [[2]](diffhunk://#diff-0eaf917d935710f0f5e18f12db600be47b8439d628d65a97a3db34133231790eR38-R45) [[3]](diffhunk://#diff-0eaf917d935710f0f5e18f12db600be47b8439d628d65a97a3db34133231790eR67-R75) [[4]](diffhunk://#diff-0eaf917d935710f0f5e18f12db600be47b8439d628d65a97a3db34133231790eR222-R262) --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -647,6 +647,7 @@ public class ApplicationProperties {
|
||||
private int tesseractSessionLimit;
|
||||
private int ghostscriptSessionLimit;
|
||||
private int ocrMyPdfSessionLimit;
|
||||
private int ffmpegSessionLimit;
|
||||
|
||||
public int getQpdfSessionLimit() {
|
||||
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
|
||||
@@ -687,6 +688,10 @@ public class ApplicationProperties {
|
||||
public int getOcrMyPdfSessionLimit() {
|
||||
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
|
||||
}
|
||||
|
||||
public int getFfmpegSessionLimit() {
|
||||
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -713,6 +718,7 @@ public class ApplicationProperties {
|
||||
private long qpdfTimeoutMinutes;
|
||||
private long ghostscriptTimeoutMinutes;
|
||||
private long ocrMyPdfTimeoutMinutes;
|
||||
private long ffmpegTimeoutMinutes;
|
||||
|
||||
public long getTesseractTimeoutMinutes() {
|
||||
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
|
||||
@@ -753,6 +759,10 @@ public class ApplicationProperties {
|
||||
public long getOcrMyPdfTimeoutMinutes() {
|
||||
return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getFfmpegTimeoutMinutes() {
|
||||
return ffmpegTimeoutMinutes > 0 ? ffmpegTimeoutMinutes : 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ public class CheckProgramInstall {
|
||||
private static final List<String> PYTHON_COMMANDS = Arrays.asList("python3", "python");
|
||||
private static boolean pythonAvailableChecked = false;
|
||||
private static String availablePythonCommand = null;
|
||||
private static boolean ffmpegAvailableChecked = false;
|
||||
private static boolean ffmpegAvailable = false;
|
||||
|
||||
/**
|
||||
* Checks which Python command is available and returns it.
|
||||
@@ -56,4 +58,25 @@ public class CheckProgramInstall {
|
||||
public static boolean isPythonAvailable() {
|
||||
return getAvailablePythonCommand() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if FFmpeg is available on the system.
|
||||
*
|
||||
* @return true if FFmpeg is installed and accessible, false otherwise.
|
||||
*/
|
||||
public static boolean isFfmpegAvailable() {
|
||||
if (!ffmpegAvailableChecked) {
|
||||
try {
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
ffmpegAvailable = true;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
ffmpegAvailable = false;
|
||||
} finally {
|
||||
ffmpegAvailableChecked = true;
|
||||
}
|
||||
}
|
||||
return ffmpegAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,14 @@ public class ExceptionUtils {
|
||||
if (context != null && !context.isEmpty()) {
|
||||
message =
|
||||
String.format(
|
||||
"Error %s: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.",
|
||||
"Error %s: PDF file appears to be corrupted or damaged. Please try"
|
||||
+ " using the 'Repair PDF' feature first to fix the file before"
|
||||
+ " proceeding with this operation.",
|
||||
context);
|
||||
} else {
|
||||
message =
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
|
||||
+ " feature first to fix the file before proceeding with this operation.";
|
||||
}
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
@@ -51,7 +54,8 @@ public class ExceptionUtils {
|
||||
*/
|
||||
public static IOException createMultiplePdfCorruptedException(Exception cause) {
|
||||
String message =
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.";
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the"
|
||||
+ " 'Repair PDF' feature on each file first before attempting to merge them.";
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
@@ -63,7 +67,10 @@ public class ExceptionUtils {
|
||||
*/
|
||||
public static IOException createPdfEncryptionException(Exception cause) {
|
||||
String message =
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.";
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF"
|
||||
+ " was created with incompatible encryption methods. Please try using the"
|
||||
+ " 'Repair PDF' feature first, or contact the document creator for a new"
|
||||
+ " copy.";
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
@@ -75,7 +82,8 @@ public class ExceptionUtils {
|
||||
*/
|
||||
public static IOException createPdfPasswordException(Exception cause) {
|
||||
String message =
|
||||
"The PDF Document is passworded and either the password was not provided or was incorrect";
|
||||
"The PDF Document is passworded and either the password was not provided or was"
|
||||
+ " incorrect";
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
@@ -180,6 +188,15 @@ public class ExceptionUtils {
|
||||
"error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion");
|
||||
}
|
||||
|
||||
public static IOException createFfmpegRequiredException() {
|
||||
return createIOException(
|
||||
"error.toolRequired",
|
||||
"{0} is required for {1}",
|
||||
null,
|
||||
"FFmpeg",
|
||||
"PDF to Video Slideshow conversion");
|
||||
}
|
||||
|
||||
/** Create file operation exceptions. */
|
||||
public static IOException createFileNotFoundException(String fileId) {
|
||||
return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId);
|
||||
@@ -345,9 +362,11 @@ public class ExceptionUtils {
|
||||
int pageNumber, int dpi, Throwable cause) {
|
||||
String message =
|
||||
MessageFormat.format(
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. "
|
||||
+ "This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). "
|
||||
+ "Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.",
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1}"
|
||||
+ " DPI. This can occur when the resulting image exceeds Java's"
|
||||
+ " array/memory limits (e.g., NegativeArraySizeException). Please use"
|
||||
+ " a lower DPI value (recommended: 150 or less) or process the"
|
||||
+ " document in smaller chunks.",
|
||||
pageNumber, dpi);
|
||||
return new RuntimeException(message, cause);
|
||||
}
|
||||
@@ -378,9 +397,11 @@ public class ExceptionUtils {
|
||||
public static RuntimeException createOutOfMemoryDpiException(int dpi, Throwable cause) {
|
||||
String message =
|
||||
MessageFormat.format(
|
||||
"Out of memory or image-too-large error while rendering PDF at {0} DPI. "
|
||||
+ "This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). "
|
||||
+ "Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.",
|
||||
"Out of memory or image-too-large error while rendering PDF at {0} DPI."
|
||||
+ " This can occur when the resulting image exceeds Java's array/memory"
|
||||
+ " limits (e.g., NegativeArraySizeException). Please use a lower DPI"
|
||||
+ " value (recommended: 150 or less) or process the document in smaller"
|
||||
+ " chunks.",
|
||||
dpi);
|
||||
return new RuntimeException(message, cause);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getInstallAppSessionLimit();
|
||||
case FFMPEG ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getFfmpegSessionLimit();
|
||||
case TESSERACT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
@@ -125,6 +130,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getInstallAppTimeoutMinutes();
|
||||
case FFMPEG ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getFfmpegTimeoutMinutes();
|
||||
case TESSERACT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
@@ -302,7 +312,8 @@ public class ProcessExecutor {
|
||||
TESSERACT,
|
||||
QPDF,
|
||||
GHOSTSCRIPT,
|
||||
OCR_MY_PDF
|
||||
OCR_MY_PDF,
|
||||
FFMPEG
|
||||
}
|
||||
|
||||
@Setter
|
||||
|
||||
@@ -2,6 +2,7 @@ package stirling.software.common.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -45,11 +46,15 @@ class ApplicationPropertiesLogicTest {
|
||||
String expectedLibre = Paths.get(expectedBase, "libreoffice").toString();
|
||||
assertEquals(expectedLibre, tfm.getLibreofficeDir());
|
||||
|
||||
tfm.setBaseTmpDir("/custom/base");
|
||||
assertEquals("/custom/base", normalize.apply(tfm.getBaseTmpDir()));
|
||||
tfm.setBaseTmpDir(File.separator + "custom" + File.separator + "base");
|
||||
assertEquals(
|
||||
File.separator + "custom" + File.separator + "base",
|
||||
normalize.apply(tfm.getBaseTmpDir()));
|
||||
|
||||
tfm.setLibreofficeDir("/opt/libre");
|
||||
assertEquals("/opt/libre", normalize.apply(tfm.getLibreofficeDir()));
|
||||
tfm.setLibreofficeDir(File.separator + "opt" + File.separator + "libre");
|
||||
assertEquals(
|
||||
File.separator + "opt" + File.separator + "libre",
|
||||
normalize.apply(tfm.getLibreofficeDir()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -240,12 +245,13 @@ class ApplicationPropertiesLogicTest {
|
||||
void collection_isValid_true_when_non_empty_even_if_element_is_blank() {
|
||||
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
|
||||
|
||||
// Aktuelles Verhalten: prüft NUR !isEmpty(), nicht Inhalt
|
||||
// Current behavior: checks ONLY !isEmpty(), not the content
|
||||
Collection<String> oneBlank = new ArrayList<>();
|
||||
oneBlank.add(" ");
|
||||
|
||||
assertTrue(
|
||||
oauth2.isValid(oneBlank, "scopes"),
|
||||
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
|
||||
"Documents current behavior: non-empty list is considered valid, even if an element"
|
||||
+ " is empty/blank");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class CheckProgramInstallTest {
|
||||
|
||||
private MockedStatic<ProcessExecutor> mockProcessExecutor;
|
||||
private ProcessExecutor mockExecutor;
|
||||
private ProcessExecutor mockFfmpegExecutor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
@@ -34,10 +35,14 @@ class CheckProgramInstallTest {
|
||||
|
||||
// Set up mock for ProcessExecutor
|
||||
mockExecutor = Mockito.mock(ProcessExecutor.class);
|
||||
mockFfmpegExecutor = Mockito.mock(ProcessExecutor.class);
|
||||
mockProcessExecutor = mockStatic(ProcessExecutor.class);
|
||||
mockProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(mockExecutor);
|
||||
mockProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG))
|
||||
.thenReturn(mockFfmpegExecutor);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -59,6 +64,15 @@ class CheckProgramInstallTest {
|
||||
CheckProgramInstall.class.getDeclaredField("availablePythonCommand");
|
||||
availablePythonCommandField.setAccessible(true);
|
||||
availablePythonCommandField.set(null, null);
|
||||
|
||||
Field ffmpegAvailableCheckedField =
|
||||
CheckProgramInstall.class.getDeclaredField("ffmpegAvailableChecked");
|
||||
ffmpegAvailableCheckedField.setAccessible(true);
|
||||
ffmpegAvailableCheckedField.set(null, false);
|
||||
|
||||
Field ffmpegAvailableField = CheckProgramInstall.class.getDeclaredField("ffmpegAvailable");
|
||||
ffmpegAvailableField.setAccessible(true);
|
||||
ffmpegAvailableField.set(null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -204,4 +218,45 @@ class CheckProgramInstallTest {
|
||||
// Verify getAvailablePythonCommand was called internally
|
||||
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFfmpegAvailable_WhenInstalled() throws Exception {
|
||||
resetStaticFields();
|
||||
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenReturn(result);
|
||||
|
||||
assertTrue(CheckProgramInstall.isFfmpegAvailable());
|
||||
verify(mockFfmpegExecutor)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFfmpegAvailable_WhenNotInstalled() throws Exception {
|
||||
resetStaticFields();
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenThrow(new IOException("Command not found"));
|
||||
|
||||
assertFalse(CheckProgramInstall.isFfmpegAvailable());
|
||||
verify(mockFfmpegExecutor)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFfmpegAvailable_CachesResult() throws Exception {
|
||||
resetStaticFields();
|
||||
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenReturn(result);
|
||||
|
||||
assertTrue(CheckProgramInstall.isFfmpegAvailable());
|
||||
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenThrow(new IOException("Command not found"));
|
||||
|
||||
assertTrue(CheckProgramInstall.isFfmpegAvailable());
|
||||
|
||||
verify(mockFfmpegExecutor, times(1))
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user