diff --git a/pom.xml b/pom.xml index 5e4fbc7..4bccba9 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.swtc swtc 1.0-SNAPSHOT - + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + + + + io.swtc.Main + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + 17 @@ -63,30 +100,30 @@ 0.3.12 - - - org.lwjgl - lwjgl - 3.3.3 - - - org.lwjgl - lwjgl-opengl - 3.3.3 - + + + + + + + + + + + - - org.lwjgl - lwjgl - 3.3.3 - natives-windows - - - org.lwjgl - lwjgl-opengl - 3.3.3 - natives-windows - + + + + + + + + + + + + @@ -106,22 +143,35 @@ 2.20.1 - - - - org.jcodec - jcodec - 0.2.5 - + + + + + + + + + - - - org.jcodec - jcodec-javase - 0.2.5 - + + + + + + + + + org.bytedeco + javacv + 1.5.10 + + + + org.bytedeco + ffmpeg + 6.1.1-1.5.10 + windows-x86_64 + \ No newline at end of file diff --git a/src/main/java/io/swtc/Main.java b/src/main/java/io/swtc/Main.java index 253b598..c62e48e 100644 --- a/src/main/java/io/swtc/Main.java +++ b/src/main/java/io/swtc/Main.java @@ -8,15 +8,13 @@ import io.swtc.proccessing.ui.ShowError; public class Main { public static void main(String[] args) { - for (int i = 0; i < args.length; i++) { - System.out.println("Arg " + i + ": " + args[i]); - } +// for (int i = 0; i < args.length; i++) { +// System.out.println("Arg " + i + ": " + args[i]); +// } try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } catch (Exception e) { - ShowError.warning(null,"LaF Warn","LaF"); - } + } catch (Exception e) { /* Do nothing */ } SwingCCTVManager.main(null); } diff --git a/src/main/java/io/swtc/proccessing/CameraPanel.java b/src/main/java/io/swtc/proccessing/CameraPanel.java index 1657e8e..bf24ebf 100644 --- a/src/main/java/io/swtc/proccessing/CameraPanel.java +++ b/src/main/java/io/swtc/proccessing/CameraPanel.java @@ -1,11 +1,11 @@ package io.swtc.proccessing; import io.swtc.proccessing.ui.ShowError; +import io.swtc.recording.cv.AVRecorder; import javax.swing.*; import java.awt.*; import java.awt.event.*; -import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.util.Arrays; import java.util.concurrent.atomic.AtomicBoolean; @@ -16,6 +16,8 @@ public class CameraPanel extends JPanel { private volatile BufferedImage sourceImage; private volatile BufferedImage processedImage; private Function imageProcessor; + + private volatile AVRecorder recorder; // this is now javacv, jcodec is stoopid private final AtomicBoolean repaintScheduled = new AtomicBoolean(false); private double zoomLevel = 1.0; @@ -24,22 +26,25 @@ public class CameraPanel extends JPanel { private Point dragStartPoint; private static final double MIN_ZOOM = 1.0; - private static final double MAX_ZOOM = 10.0; // ten is enough if ur using 640x480 + private static final double MAX_ZOOM = 10.0; private static final double ZOOM_MULTIPLIER = 1.1; + private final GraphicsConfiguration graphicsConfig; + public CameraPanel() { setBackground(Color.BLACK); setPreferredSize(new Dimension(640, 480)); + graphicsConfig = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration(); + initInteractionListeners(); } private void initInteractionListeners() { MouseAdapter mouseHandler = new MouseAdapter() { @Override - public void mouseWheelMoved(MouseWheelEvent e) { - handleZoom(e); - } + public void mouseWheelMoved(MouseWheelEvent e) { handleZoom(e); } @Override public void mousePressed(MouseEvent e) { @@ -49,17 +54,14 @@ public class CameraPanel extends JPanel { @Override public void mouseClicked(MouseEvent e) { - if (e.getClickCount() == 2) - resetView(); + if (e.getClickCount() == 2) resetView(); } @Override public void mouseReleased(MouseEvent e) { setCursor(Cursor.getDefaultCursor()); } @Override - public void mouseDragged(MouseEvent e) { - handlePan(e); - } + public void mouseDragged(MouseEvent e) { handlePan(e); } @Override public void mouseExited(MouseEvent e) { setCursor(Cursor.getDefaultCursor()); } @@ -70,10 +72,20 @@ public class CameraPanel extends JPanel { addMouseMotionListener(mouseHandler); } + public void setExternalRecorder(AVRecorder recorder) { + this.recorder = recorder; + } + public void setImage(BufferedImage img) { - sourceImage = img; + this.sourceImage = img; updateProcessedImage(); + // Feed the AVRecorder using its 'accept' method if active + AVRecorder currentRecorder = this.recorder; + if (currentRecorder != null && currentRecorder.isRecording() && processedImage != null) { + currentRecorder.accept(processedImage); + } + scheduleRepaint(); } @@ -85,19 +97,46 @@ public class CameraPanel extends JPanel { } } + public BufferedImage getCurrentProcessedImage() { + return processedImage; + } + + public void resetView() { + zoomLevel = 1.0; + xOffset = 0; + yOffset = 0; + repaint(); + } + private void updateProcessedImage() { if (sourceImage == null) return; + BufferedImage temp; if (imageProcessor != null) { try { - processedImage = imageProcessor.apply(sourceImage); + temp = imageProcessor.apply(sourceImage); } catch (Exception e) { - ShowError.error(null,"Fucked up in rendering \n" + Arrays.toString(e.getStackTrace()),"Problem"); - processedImage = sourceImage; // Fallback + ShowError.error(null, "Error in image processing: \n" + Arrays.toString(e.getStackTrace()), "Processing Error"); + temp = sourceImage; } } else { - processedImage = sourceImage; + temp = sourceImage; } + + if (processedImage == null || + processedImage.getWidth() != temp.getWidth() || + processedImage.getHeight() != temp.getHeight()) { + + processedImage = graphicsConfig.createCompatibleImage( + temp.getWidth(), + temp.getHeight(), + temp.getTransparency() + ); + } + + Graphics2D g2d = processedImage.createGraphics(); + g2d.drawImage(temp, 0, 0, null); + g2d.dispose(); } private void scheduleRepaint() { @@ -109,24 +148,46 @@ public class CameraPanel extends JPanel { } } + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + if (processedImage == null) return; + + Graphics2D g2d = (Graphics2D) g.create(); + try { + g2d.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR + ); + + g2d.translate((int)xOffset, (int)yOffset); + + double scaleX = (double) getWidth() / processedImage.getWidth(); + double scaleY = (double) getHeight() / processedImage.getHeight(); + + g2d.scale(scaleX * zoomLevel, scaleY * zoomLevel); + g2d.drawImage(processedImage, 0, 0, null); + + } finally { + g2d.dispose(); + } + } + private void handleZoom(MouseWheelEvent e) { if (processedImage == null) return; double oldZoom = zoomLevel; - if (e.getWheelRotation() < 0) { - zoomLevel *= ZOOM_MULTIPLIER; // Zoom In + zoomLevel *= ZOOM_MULTIPLIER; } else { - zoomLevel /= ZOOM_MULTIPLIER; // Zoom Out + zoomLevel /= ZOOM_MULTIPLIER; } - // clamp shit zoomLevel = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevel)); if (oldZoom != zoomLevel) { double xRel = e.getX() - xOffset; double yRel = e.getY() - yOffset; - double zoomFactor = zoomLevel / oldZoom; xOffset = e.getX() - (xRel * zoomFactor); @@ -140,15 +201,10 @@ public class CameraPanel extends JPanel { private void handlePan(MouseEvent e) { if (processedImage == null || dragStartPoint == null) return; - // Calculate delta - int dx = e.getX() - dragStartPoint.x; - int dy = e.getY() - dragStartPoint.y; - - xOffset += dx; - yOffset += dy; + xOffset += e.getX() - dragStartPoint.x; + yOffset += e.getY() - dragStartPoint.y; dragStartPoint = e.getPoint(); - checkBounds(); repaint(); } @@ -167,42 +223,4 @@ public class CameraPanel extends JPanel { yOffset = getHeight() - viewedHeight; } } - - @Override - protected void paintComponent(Graphics g) { - super.paintComponent(g); - - if (processedImage == null) return; - - Graphics2D g2d = (Graphics2D) g; - - AffineTransform originalTransform = g2d.getTransform(); - - g2d.setRenderingHint( - RenderingHints.KEY_INTERPOLATION, - RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR - ); - - g2d.translate(xOffset, yOffset); - - double scaleX = (double) getWidth() / processedImage.getWidth(); - double scaleY = (double) getHeight() / processedImage.getHeight(); - - g2d.scale(scaleX * zoomLevel, scaleY * zoomLevel); - - g2d.drawImage(processedImage, 0, 0, null); - - g2d.setTransform(originalTransform); - } - - public BufferedImage getCurrentProcessedImage() { - return processedImage; - } - - public void resetView() { - zoomLevel = 1.0; - xOffset = 0; - yOffset = 0; - repaint(); - } } \ No newline at end of file diff --git a/src/main/java/io/swtc/proccessing/ui/iframe/CameraInternalFrame.java b/src/main/java/io/swtc/proccessing/ui/iframe/CameraInternalFrame.java index 4eb03ea..16a0349 100644 --- a/src/main/java/io/swtc/proccessing/ui/iframe/CameraInternalFrame.java +++ b/src/main/java/io/swtc/proccessing/ui/iframe/CameraInternalFrame.java @@ -5,23 +5,13 @@ import io.swtc.proccessing.WebcamCaptureLoop; import io.swtc.proccessing.CameraPanel; import io.swtc.proccessing.ui.IconSetter; import io.swtc.proccessing.ui.ShowError; -import io.swtc.recording.RecordingManager; -import io.swtc.recording.VideoRecorder; -import javax.imageio.ImageIO; import javax.swing.*; -import javax.swing.border.EmptyBorder; import java.awt.*; -import java.awt.image.BufferedImage; -import java.awt.image.RescaleOp; -import java.io.File; -import java.io.IOException; import java.util.function.Consumer; public class CameraInternalFrame extends JInternalFrame { private final WebcamCaptureLoop captureLoop; private final CameraPanel cameraPanel; - private final VideoRecorder videoRecorder; - private final RecordingManager recordingManager; public CameraInternalFrame(Webcam webcam, Consumer onOpenEffects) { super(webcam.getName(), true, true, true, true); @@ -30,12 +20,8 @@ public class CameraInternalFrame extends JInternalFrame { setFrameIcon(new ImageIcon(ico)); this.cameraPanel = new CameraPanel(); - this.videoRecorder = new VideoRecorder(); - this.recordingManager = new RecordingManager(cameraPanel); // Initialize recorder - this.captureLoop = new WebcamCaptureLoop(webcam, img -> - SwingUtilities.invokeLater(() -> cameraPanel.setImage(img)) - ); + this.captureLoop = new WebcamCaptureLoop(webcam, cameraPanel::setImage); setupUI(onOpenEffects); captureLoop.start(); @@ -49,7 +35,7 @@ public class CameraInternalFrame extends JInternalFrame { tabbedPane.addChangeListener(e -> { int index = tabbedPane.getSelectedIndex(); - if (index == 1) { // the tab index for capture is 1 + if (index == 1) { tabbedPane.setSelectedIndex(0); openRecordingFrame(); } else if (index == 2) { @@ -63,17 +49,16 @@ public class CameraInternalFrame extends JInternalFrame { } private void openRecordingFrame() { - RecordingFrame rf = new RecordingFrame(this.getTitle(),cameraPanel, videoRecorder); + RecordingFrame rf = new RecordingFrame(this.getTitle(), cameraPanel); JDesktopPane desktopPane = getDesktopPane(); if (desktopPane != null) { desktopPane.add(rf); rf.setVisible(true); - } - - try { - rf.setSelected(true); - } catch (java.beans.PropertyVetoException veto) { - ShowError.error(null,"VetoException"+veto.getMessage(),"veto"); + try { + rf.setSelected(true); + } catch (java.beans.PropertyVetoException veto) { + ShowError.error(null, "Focus Error: " + veto.getMessage(), "Error"); + } } } @@ -81,9 +66,6 @@ public class CameraInternalFrame extends JInternalFrame { @Override public void dispose() { - if (videoRecorder.isRecording()) { - try { videoRecorder.stopRecording(); } catch (IOException ignored) {} - } captureLoop.stop(); super.dispose(); } diff --git a/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java b/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java index 5fd4947..53cc3ce 100644 --- a/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java +++ b/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java @@ -3,7 +3,10 @@ package io.swtc.proccessing.ui.iframe; import io.swtc.proccessing.CameraPanel; import io.swtc.proccessing.ui.IconSetter; import io.swtc.proccessing.ui.ShowError; -import io.swtc.recording.VideoRecorder; +import io.swtc.proccessing.ui.sections.recording.ExportSection; +import io.swtc.recording.cv.AVRecorder; +import io.swtc.recording.cv.RecorderConfig; +import io.swtc.recording.evidence.USBExportManager; import javax.swing.*; import java.awt.*; @@ -11,51 +14,72 @@ import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import javax.imageio.ImageIO; +import java.nio.file.Path; public class RecordingFrame extends JInternalFrame { - private final VideoRecorder videoRecorder; + private AVRecorder avRecorder; + private ExportSection exportSection; private final CameraPanel cameraPanel; private JButton recordBtn; private JLabel statusLabel; private JLabel statsLabel; - private File outputDirectory = new File(System.getProperty("user.home")); + private File outputDirectory; private File currentFile; private final Timer statsTimer; private long startTime; - - // Cache for string building to avoid object allocation every second private final StringBuilder sb = new StringBuilder(32); - public RecordingFrame(String cameraName, CameraPanel cameraPanel, VideoRecorder videoRecorder) { + public RecordingFrame(String cameraName, CameraPanel cameraPanel) { super(cameraName + " Capture", true, true, false, true); + setupDirectory(); + Image ico = IconSetter.getIcon(); setFrameIcon(new ImageIcon(ico)); this.cameraPanel = cameraPanel; - this.videoRecorder = videoRecorder; initializeUI(); + // Timer for UI updates only (1 FPS is enough for the clock) this.statsTimer = new Timer(1000, e -> updateStats()); this.statsTimer.setCoalesce(true); pack(); } + private void setupDirectory() { + File videoDir = new File(System.getProperty("user.home"), "Videos"); + outputDirectory = new File(videoDir, "swtcctv-rec"); + + if (!outputDirectory.exists()) { + boolean created = outputDirectory.mkdirs(); + if (!created) { + System.err.println("Could not create recording directory: " + outputDirectory.getAbsolutePath()); + } + } + } + private void initializeUI() { JPanel mainContent = new JPanel(); mainContent.setLayout(new BoxLayout(mainContent, BoxLayout.Y_AXIS)); mainContent.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - mainContent.add(createStoragePanel()); + exportSection = new ExportSection(this, outputDirectory, statusLabel); + + JPanel actionPanel = createActionPanel(); + + JPanel statsPanel = createStatsPanel(); + + mainContent.add(exportSection); mainContent.add(Box.createVerticalStrut(10)); - mainContent.add(createActionPanel()); + mainContent.add(statsPanel); mainContent.add(Box.createVerticalStrut(10)); - mainContent.add(createStatsPanel()); + mainContent.add(actionPanel); + getContentPane().add(mainContent); } @@ -65,11 +89,9 @@ public class RecordingFrame extends JInternalFrame { panel.setBorder(BorderFactory.createTitledBorder("Session Info")); statusLabel = new JLabel("Status: Idle"); - statsLabel = new JLabel("Length: 00:00 | Size: 0.00 MB"); statsLabel.setFont(new Font("Monospaced", Font.PLAIN, 12)); - - statsLabel.setPreferredSize(new Dimension(220, 20)); + statsLabel.setPreferredSize(new Dimension(240, 20)); panel.add(statusLabel); panel.add(statsLabel); @@ -77,7 +99,7 @@ public class RecordingFrame extends JInternalFrame { } private void updateStats() { - if (!videoRecorder.isRecording() || currentFile == null) return; + if (avRecorder == null || !avRecorder.isRecording() || currentFile == null) return; long elapsedSecs = (System.currentTimeMillis() - startTime) / 1000; long minutes = elapsedSecs / 60; @@ -90,14 +112,15 @@ public class RecordingFrame extends JInternalFrame { .append(minutes < 10 ? "0" : "").append(minutes).append(":") .append(seconds < 10 ? "0" : "").append(seconds) .append(" | Size: ") - .append(Math.round(sizeInMb * 100.0) / 100.0) + .append(String.format("%.2f", sizeInMb)) .append(" MB"); statsLabel.setText(sb.toString()); } private void toggleRecording() { - if (!videoRecorder.isRecording()) { + // Updated check for the new recorder state + if (avRecorder == null || !avRecorder.isRecording()) { startRec(); } else { stopRec(); @@ -105,38 +128,70 @@ public class RecordingFrame extends JInternalFrame { } private void startRec() { + BufferedImage sample = cameraPanel.getCurrentProcessedImage(); + if (sample == null) { + ShowError.warning(this, "No camera feed detected. Start camera first.", "Warning"); + return; + } + try { + if (!outputDirectory.exists() && !outputDirectory.mkdirs()) { + throw new IOException("Failed to create directory: " + outputDirectory); + } + currentFile = new File(outputDirectory, "vid_" + System.currentTimeMillis() + ".mp4"); - videoRecorder.startRecording(cameraPanel, currentFile); + + // 1. Define the production config + RecorderConfig config = new RecorderConfig( + currentFile, + sample.getWidth(), + sample.getHeight(), + 20, // Frame Rate + 25, // CRF (Quality) + "ultrafast" + ); + + // 2. Initialize the HA Recorder + avRecorder = new AVRecorder(config); + avRecorder.start(); + + // 3. Link to CameraPanel (Ensure CameraPanel calls .accept(img)) + cameraPanel.setExternalRecorder(avRecorder); startTime = System.currentTimeMillis(); - statsTimer.start(); // Only run timer when needed + statsTimer.start(); recordBtn.setText("Stop Recording"); - statusLabel.setText("Currently Recording"); - } catch (IOException ex) { - ShowError.error(null,"Error starting Recording" + ex.getMessage(), "Error"); + recordBtn.setForeground(Color.RED); + statusLabel.setText("Recording..."); + } catch (Exception ex) { + ShowError.error(this, "Failed to start Recorder: " + ex.getMessage(), "Error"); + ex.printStackTrace(); } } private void stopRec() { - try { - videoRecorder.stopRecording(); - statsTimer.stop(); - - recordBtn.setText("Started Recording"); - recordBtn.setForeground(null); - statusLabel.setText("Finished recording"); - } catch (IOException ex) { - ShowError.error(null,"RecordStop Error"+ex.getMessage(), "Error"); + if (avRecorder != null) { + statusLabel.setText("Finalizing file..."); + avRecorder.stop(); + cameraPanel.setExternalRecorder(null); } + + statsTimer.stop(); + recordBtn.setText("Start Recording"); + recordBtn.setForeground(null); + + String fileName = (currentFile != null) ? currentFile.getName() : "N/A"; + statusLabel.setText("File saved: " + fileName); } private JPanel createStoragePanel() { JPanel panel = new JPanel(new BorderLayout(5, 5)); - panel.setBorder(BorderFactory.createTitledBorder("Storage")); + panel.setBorder(BorderFactory.createTitledBorder("Storage Folder")); + JTextField pathField = new JTextField(outputDirectory.getAbsolutePath()); pathField.setEditable(false); + JButton browseBtn = new JButton("..."); browseBtn.addActionListener(e -> { JFileChooser chooser = new JFileChooser(outputDirectory); @@ -146,17 +201,70 @@ public class RecordingFrame extends JInternalFrame { pathField.setText(outputDirectory.getAbsolutePath()); } }); + panel.add(pathField, BorderLayout.CENTER); panel.add(browseBtn, BorderLayout.EAST); return panel; } + private void exportToUSB() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Select USB destination"); + + if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return; + + Path usbRoot = chooser.getSelectedFile().toPath(); + Path exportTarget = usbRoot.resolve("CCTV_EXPORT"); + + try { + long size = USBExportManager.calculateDirectorySize(outputDirectory.toPath()); + + if (!USBExportManager.hasEnoughSpace(usbRoot, size)) { + ShowError.error(this, "Not enough space on USB device.", "Export failed"); + return; + } + + JProgressBar bar = new JProgressBar(0, 100); + JOptionPane pane = new JOptionPane(bar, + JOptionPane.INFORMATION_MESSAGE, + JOptionPane.DEFAULT_OPTION, + null, + new Object[]{}); + + JDialog dialog = pane.createDialog(this, "Exporting..."); + dialog.setModal(false); + dialog.setVisible(true); + + USBExportManager.exportAsync( + outputDirectory.toPath(), + exportTarget, + stats -> bar.setValue(stats.percent()), + () -> { + dialog.dispose(); + statusLabel.setText("Export completed"); + }, + ex -> { + dialog.dispose(); + ShowError.error(this, ex.getMessage(), "Export error"); + } + ); + + } catch (IOException ex) { + ShowError.error(this, ex.getMessage(), "Export error"); + } + } + + private JPanel createActionPanel() { JPanel panel = new JPanel(new GridLayout(1, 2, 5, 5)); recordBtn = new JButton("Start Recording"); recordBtn.addActionListener(e -> toggleRecording()); + JButton snapBtn = new JButton("Snapshot"); snapBtn.addActionListener(e -> takeSnapshot()); + + // Export is now handled by the ExportSection component panel.add(recordBtn); panel.add(snapBtn); return panel; @@ -166,13 +274,13 @@ public class RecordingFrame extends JInternalFrame { BufferedImage img = cameraPanel.getCurrentProcessedImage(); if (img != null) { try { + if (!outputDirectory.exists()) outputDirectory.mkdirs(); File file = new File(outputDirectory, "snap_" + System.currentTimeMillis() + ".png"); ImageIO.write(img, "PNG", file); - statusLabel.setText("Saved a Snapshot"); + statusLabel.setText("Snapshot: " + file.getName()); } catch (IOException ex) { - ShowError.error(null,"Snapshot failed"+ex.getMessage(),"Snapshot Error"); + ShowError.error(this, "Snapshot failed: " + ex.getMessage(), "Error"); } } } - } \ No newline at end of file diff --git a/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java b/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java new file mode 100644 index 0000000..fe149d1 --- /dev/null +++ b/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java @@ -0,0 +1,135 @@ +package io.swtc.proccessing.ui.sections.recording; + +import io.swtc.proccessing.ui.ShowError; +import io.swtc.recording.evidence.USBExportManager; + +import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +public class ExportSection extends JPanel { + private final Component parent; + private File sourceDirectory; + private final JLabel statusLabel; + private final JTextField pathField; + + public ExportSection(Component parent, File initialSource, JLabel statusLabel) { + this.parent = parent; + this.sourceDirectory = initialSource; + this.statusLabel = new JLabel("", SwingConstants.CENTER); + + + setLayout(new BorderLayout()); + + this.pathField = new JTextField(sourceDirectory.getAbsolutePath()); + this.pathField.setEditable(false); + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Evidence-Export", createTransferTab()); + tabbedPane.addTab("Storage Settings", createSettingsTab()); + + add(tabbedPane, BorderLayout.CENTER); + } + + private static JLabel getStatusLabel(JLabel statusLabel) { + return statusLabel; + } + + private JPanel createTransferTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + + JButton exportBtn = new JButton("Export all Evidence"); + exportBtn.addActionListener(e -> startUsbExport()); + + JLabel infoLabel = new JLabel("" + + "Export Evidence to a USB Drive!" + + ""); + + gbc.gridy = 0; + panel.add(exportBtn, gbc); + gbc.gridy = 1; + gbc.insets = new Insets(10, 0, 0, 0); + panel.add(infoLabel, gbc); + + return panel; + } + + private JPanel createSettingsTab() { + JPanel panel = new JPanel(new BorderLayout(5, 5)); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + JButton browseBtn = new JButton("Change Folder"); + browseBtn.addActionListener(e -> changeSourceDirectory()); + + panel.add(new JLabel("Recording Path:"), BorderLayout.NORTH); + panel.add(pathField, BorderLayout.CENTER); + panel.add(browseBtn, BorderLayout.SOUTH); + + return panel; + } + + private void changeSourceDirectory() { + JFileChooser chooser = new JFileChooser(sourceDirectory); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + if (chooser.showOpenDialog(parent) == JFileChooser.APPROVE_OPTION) { + this.sourceDirectory = chooser.getSelectedFile(); + this.pathField.setText(sourceDirectory.getAbsolutePath()); + } + } + + private void startUsbExport() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Select USB Destination"); + + if (chooser.showSaveDialog(parent) != JFileChooser.APPROVE_OPTION) return; + + Path usbRoot = chooser.getSelectedFile().toPath(); + Path exportTarget = usbRoot.resolve(""); + + try { + long size = USBExportManager.calculateDirectorySize(sourceDirectory.toPath()); + + if (!USBExportManager.hasEnoughSpace(usbRoot, size)) { + ShowError.error(parent, "Not enough space on USB device.", "Export failed"); + return; + } + + JProgressBar bar = new JProgressBar(0, 100); + bar.setStringPainted(true); + + JDialog dialog = new JDialog(SwingUtilities.getWindowAncestor(parent), "Exporting Data..."); + dialog.setLayout(new BorderLayout()); + dialog.add(new JLabel("Copying files ...", JLabel.CENTER), BorderLayout.NORTH); + dialog.add(bar, BorderLayout.CENTER); + dialog.setSize(300, 100); + dialog.setLocationRelativeTo(parent); + dialog.setModal(true); + + USBExportManager.exportAsync( + sourceDirectory.toPath(), + exportTarget, + stats -> SwingUtilities.invokeLater(() -> bar.setValue(stats.percent())), + () -> { + dialog.dispose(); + statusLabel.setText("Export completed successfully."); + }, + ex -> { + dialog.dispose(); + ShowError.error(parent, "Export error: " + ex.getMessage(), "Error"); + } + ); + + dialog.setVisible(true); + + } catch (IOException ex) { + ShowError.error(parent, "IO Error: " + ex.getMessage(), "Export error"); + } + } +} diff --git a/src/main/java/io/swtc/recording/RecordingManager.java b/src/main/java/io/swtc/recording/RecordingManager.java deleted file mode 100644 index 906e78a..0000000 --- a/src/main/java/io/swtc/recording/RecordingManager.java +++ /dev/null @@ -1,70 +0,0 @@ -package io.swtc.recording; - -import io.swtc.proccessing.CameraPanel; -import javax.swing.*; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.File; -import java.util.function.Consumer; - -public class RecordingManager { - private final VideoRecorder recorder; - private final CameraPanel cameraPanel; - - // Reuse a single buffer to avoid constant memory allocation (GC pressure) - private BufferedImage reuseBuffer; - - public RecordingManager(CameraPanel cameraPanel) { - this.cameraPanel = cameraPanel; - this.recorder = new VideoRecorder(); - } - - public BufferedImage fastConvertToRGB(BufferedImage source) { - if (source.getType() == BufferedImage.TYPE_INT_RGB) { - return source; - } - - // Initialize or resize reuseBuffer only when necessary - if (reuseBuffer == null || - reuseBuffer.getWidth() != source.getWidth() || - reuseBuffer.getHeight() != source.getHeight()) { - reuseBuffer = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB); - } - - // Graphics2D.drawImage is much faster than manual setRGB loops - Graphics2D g = reuseBuffer.createGraphics(); - g.drawImage(source, 0, 0, null); - g.dispose(); - - return reuseBuffer; - } - - public void toggleRecording(File outputFile, Runnable onStart, Consumer onStop, Consumer onError) { - if (!recorder.isRecording()) { - new SwingWorker() { - @Override - protected Void doInBackground() throws Exception { - recorder.startRecording(cameraPanel, outputFile); - return null; - } - @Override - protected void done() { - try { get(); if (onStart != null) onStart.run(); } - catch (Exception e) { if (onError != null) onError.accept(e); } - } - }.execute(); - } else { - new SwingWorker() { - @Override - protected File doInBackground() throws Exception { - return recorder.stopRecording(); - } - @Override - protected void done() { - try { File f = get(); if (onStop != null) onStop.accept(f); } - catch (Exception e) { if (onError != null) onError.accept(e); } - } - }.execute(); - } - } -} \ No newline at end of file diff --git a/src/main/java/io/swtc/recording/VideoRecorder.java b/src/main/java/io/swtc/recording/VideoRecorder.java deleted file mode 100644 index 352bfec..0000000 --- a/src/main/java/io/swtc/recording/VideoRecorder.java +++ /dev/null @@ -1,87 +0,0 @@ -package io.swtc.recording; - -import io.swtc.proccessing.CameraPanel; -import org.jcodec.api.awt.AWTSequenceEncoder; -import org.jcodec.common.io.NIOUtils; -import org.jcodec.common.io.SeekableByteChannel; -import org.jcodec.common.model.Rational; - -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.util.concurrent.*; - -public class VideoRecorder { - private volatile boolean recording = false; - private File outputFile; - private AWTSequenceEncoder encoder; - private SeekableByteChannel channel; - private static final int FPS = 30; - - private LinkedBlockingQueue frameQueue; - private ExecutorService workerThread; - - public void startRecording(CameraPanel panel, File output) throws IOException { - this.outputFile = output; - this.channel = NIOUtils.writableFileChannel(output.getAbsolutePath()); - this.encoder = new AWTSequenceEncoder(channel, Rational.R(FPS, 1)); - - this.frameQueue = new LinkedBlockingQueue<>(60); - this.recording = true; - - workerThread = Executors.newSingleThreadExecutor(); - workerThread.submit(this::processQueue); - - new Thread(() -> { - while (recording) { - try { - BufferedImage img = panel.getCurrentProcessedImage(); - if (img != null) { - BufferedImage copy = snapshotToRGB(img); - frameQueue.offer(copy); - } - Thread.sleep(1000 / FPS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - }).start(); - } - - private void processQueue() { - while (recording || !frameQueue.isEmpty()) { - try { - BufferedImage img = frameQueue.poll(500, TimeUnit.MILLISECONDS); - if (img != null) { - encoder.encodeImage(img); - } - } catch (Exception e) { - e.printStackTrace(); - } - } - } - - private BufferedImage snapshotToRGB(BufferedImage source) { - BufferedImage rgb = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB); - Graphics2D g = rgb.createGraphics(); - g.drawImage(source, 0, 0, null); - g.dispose(); - return rgb; - } - - public File stopRecording() throws IOException { - recording = false; - workerThread.shutdown(); - try { - workerThread.awaitTermination(5, TimeUnit.SECONDS); - if (encoder != null) encoder.finish(); - if (channel != null) channel.close(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return outputFile; - } - - public boolean isRecording() { return recording; } -} \ No newline at end of file diff --git a/src/main/java/io/swtc/recording/cv/AVRecorder.java b/src/main/java/io/swtc/recording/cv/AVRecorder.java new file mode 100644 index 0000000..f19d45c --- /dev/null +++ b/src/main/java/io/swtc/recording/cv/AVRecorder.java @@ -0,0 +1,60 @@ +package io.swtc.recording.cv; + +import io.swtc.proccessing.ui.ShowError; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class AVRecorder { + private final BlockingQueue queue = new LinkedBlockingQueue<>(120); + private final RecorderConfig config; + private volatile boolean running = false; + + public AVRecorder(RecorderConfig config) { + this.config = config; + } + + public void start() { + running = true; + Thread worker = new Thread(this::runLoop, "Recording-Worker"); + worker.start(); + } + + private void runLoop() { + FrameProccessor processor = new FrameProccessor(); + MediaSink sink = null; + + try { + while (running || !queue.isEmpty()) { + BufferedImage img = queue.poll(500, TimeUnit.MILLISECONDS); + if (img == null) continue; + + if (sink == null) { + sink = new MediaSink(config); + } + + sink.write(processor.convert(img)); + } + } catch (Exception e) { + ShowError.error(null,"Error in AVRecorder", "AVRecorder isn't responding!"); + } finally { + if (sink != null) sink.stop(); + processor.close(); + } + } + + public void accept(BufferedImage img) { + if (!queue.offer(img)) { + System.err.println("Recorder lag: Frame dropped"); + } + } + + public boolean isRecording() { return running; } + + public void stop() { + running = false; + } +} \ No newline at end of file diff --git a/src/main/java/io/swtc/recording/cv/FrameProccessor.java b/src/main/java/io/swtc/recording/cv/FrameProccessor.java new file mode 100644 index 0000000..b4e0a87 --- /dev/null +++ b/src/main/java/io/swtc/recording/cv/FrameProccessor.java @@ -0,0 +1,32 @@ +package io.swtc.recording.cv; + +import org.bytedeco.javacv.Frame; +import org.bytedeco.javacv.Java2DFrameConverter; + +import java.awt.image.BufferedImage; +import java.util.Objects; + +public class FrameProccessor { + private final Java2DFrameConverter converter = new Java2DFrameConverter(); + private BufferedImage reuseImg; + + public Frame convert(BufferedImage rawImg) { + if (Objects.isNull(reuseImg)) { + reuseImg = new BufferedImage( + rawImg.getWidth(), + rawImg.getHeight(), + BufferedImage.TYPE_3BYTE_BGR // default java BufferedImage Type + ); + + var g = reuseImg.createGraphics(); + g.drawImage(rawImg, 0, 0, null); + g.dispose(); + } + return converter.getFrame(reuseImg); + } + + public void close() { + converter.close(); + if (!Objects.isNull(reuseImg)) { reuseImg.flush(); } + } +} diff --git a/src/main/java/io/swtc/recording/cv/MediaSink.java b/src/main/java/io/swtc/recording/cv/MediaSink.java new file mode 100644 index 0000000..bf440d2 --- /dev/null +++ b/src/main/java/io/swtc/recording/cv/MediaSink.java @@ -0,0 +1,45 @@ +package io.swtc.recording.cv; + +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.javacv.FFmpegFrameRecorder; + +import org.bytedeco.ffmpeg.global.avutil; +import org.bytedeco.javacv.Frame; + +public class MediaSink { + private final FFmpegFrameRecorder recorder; + private final long startNanos; + + public MediaSink(RecorderConfig config) throws Exception { + this.recorder = new FFmpegFrameRecorder(config.outputFile(), config.width(), config.height()); + this.startNanos = System.nanoTime(); + + avutil.av_log_set_level(avutil.AV_LOG_ERROR); + + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + recorder.setFormat("mp4"); + recorder.setPixelFormat(avutil.AV_PIX_FMT_BGR24); + recorder.setFrameRate(config.fps()); + recorder.setVideoOption("pixel_format", "yuv420p"); + recorder.setVideoOption("preset", config.preset()); + recorder.setVideoOption("crf", String.valueOf(config.crf())); + recorder.setGopSize(config.fps() * 2); + + recorder.start(); + } + + public void write(Frame frame) throws Exception { + long pts = (System.nanoTime() - startNanos) / 1000; + if (pts > recorder.getTimestamp()) { + recorder.setTimestamp(pts); + recorder.record(frame); + } + } + + public void stop() { + try { + recorder.stop(); + recorder.release(); + } catch (Exception ignored) { /* Do absolutley nothing ;) */ } + } +} \ No newline at end of file diff --git a/src/main/java/io/swtc/recording/cv/RecorderConfig.java b/src/main/java/io/swtc/recording/cv/RecorderConfig.java new file mode 100644 index 0000000..e41619c --- /dev/null +++ b/src/main/java/io/swtc/recording/cv/RecorderConfig.java @@ -0,0 +1,12 @@ +package io.swtc.recording.cv; + +import java.io.File; + +public record RecorderConfig( + File outputFile, + int width, + int height, + int fps, + int crf, // Quality, 18 is high and 28 is low + String preset // ultrafast +) { /* Record */ } diff --git a/src/main/java/io/swtc/recording/evidence/ExportStats.java b/src/main/java/io/swtc/recording/evidence/ExportStats.java new file mode 100644 index 0000000..cf5c321 --- /dev/null +++ b/src/main/java/io/swtc/recording/evidence/ExportStats.java @@ -0,0 +1,42 @@ +package io.swtc.recording.evidence; + +import java.util.concurrent.TimeUnit; + +public record ExportStats( + long totalBytes, + long copiedBytes, + long startTimeNanos +) { + + public int percent() { + return totalBytes == 0 ? 0 : + (int) ((copiedBytes * 100) / totalBytes); + } + + /* timeLeft, ill implement this in the future (in the UI) i think + * this is the proper way to do this? idk + * */ + public String timeLeft() { + if (copiedBytes == 0) return "0"; + + long elapsedNanos = System.nanoTime() - startTimeNanos; + + double bytesPerNano = (double) copiedBytes / elapsedNanos; + long remainingBytes = totalBytes - copiedBytes; + + long remainingNanos = (long) (remainingBytes / bytesPerNano); + + return formatDuration(remainingNanos); + } + + private static String formatDuration(long nanos) { + long seconds = TimeUnit.NANOSECONDS.toSeconds(nanos); + long mins = seconds / 60; + long secs = seconds % 60; + + if (mins > 0) { + return mins + "m " + secs + "s"; + } + return secs + "s"; + } +} diff --git a/src/main/java/io/swtc/recording/evidence/USBExportManager.java b/src/main/java/io/swtc/recording/evidence/USBExportManager.java new file mode 100644 index 0000000..a24d220 --- /dev/null +++ b/src/main/java/io/swtc/recording/evidence/USBExportManager.java @@ -0,0 +1,114 @@ +package io.swtc.recording.evidence; + +import javax.swing.*; +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + + +public class USBExportManager { + + public static long calculateDirectorySize(Path dir) throws IOException { + AtomicLong size = new AtomicLong(); + + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + size.addAndGet(attrs.size()); + return FileVisitResult.CONTINUE; + } + }); + + return size.get(); + } + + public static boolean hasEnoughSpace(Path target, long requiredBytes) throws IOException { + FileStore store = Files.getFileStore(target); + return store.getUsableSpace() >= requiredBytes; + } + + public static void exportDirectory( + Path sourceDir, + Path usbTargetDir, + Consumer progressCallback + ) throws IOException { + + if (!Files.isDirectory(sourceDir)) { + throw new IOException("Source is not a directory: " + sourceDir); + } + + Files.createDirectories(usbTargetDir); + + long totalBytes = calculateDirectorySize(sourceDir); + AtomicLong copiedBytes = new AtomicLong(); + + Files.walkFileTree(sourceDir, new SimpleFileVisitor<>() { + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + + Path targetDir = usbTargetDir.resolve(sourceDir.relativize(dir)); + Files.createDirectories(targetDir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + + Path targetFile = usbTargetDir.resolve(sourceDir.relativize(file)); + + Files.copy( + file, + targetFile, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES + ); + + copiedBytes.addAndGet(attrs.size()); + + long startTime = System.nanoTime(); + + if (progressCallback != null) { + progressCallback.accept( + new ExportStats(totalBytes, copiedBytes.get(), startTime) + ); + } + + return FileVisitResult.CONTINUE; + } + }); + } + + + + public static void exportAsync( + Path sourceDir, + Path usbTargetDir, + Consumer progressCallback, + Runnable onDone, + Consumer onError + ) { + + new Thread(() -> { + try { + exportDirectory(sourceDir, usbTargetDir, stats -> + SwingUtilities.invokeLater(() -> + progressCallback.accept(stats)) + ); + + if (onDone != null) { + SwingUtilities.invokeLater(onDone); + } + + } catch (Exception ex) { + if (onError != null) { + SwingUtilities.invokeLater(() -> onError.accept(ex)); + } + } + }, "USB-Export-Thread").start(); + } +}