/* * ShootOFF - Software for Laser Dry Fire Training * Copyright (C) 2016 phrack * * This program 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. * * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. */ package com.shootoff.camera.recorders; import java.awt.image.BufferedImage; import java.io.File; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.shootoff.Closeable; import com.shootoff.camera.CameraManager; import com.xuggle.mediatool.IMediaReader; import com.xuggle.mediatool.IMediaWriter; import com.xuggle.mediatool.MediaListenerAdapter; import com.xuggle.mediatool.ToolFactory; import com.xuggle.mediatool.event.IVideoPictureEvent; import com.xuggle.xuggler.ICodec; import com.xuggle.xuggler.IPixelFormat; import com.xuggle.xuggler.IVideoPicture; import com.xuggle.xuggler.video.ConverterFactory; import com.xuggle.xuggler.video.IConverter; public class RollingRecorder implements Closeable { private final Logger logger = LoggerFactory.getLogger(RollingRecorder.class); private final ICodec.ID codec; private final String extension; private final String sessionName; private final String cameraName; private long startTime; private long timestamp; private long timeOffset = 0; private File relativeVideoFile; private File videoFile; private IMediaWriter videoWriter; private final Object videoWriterLock = new Object(); private boolean isFirstShotFrame = true; private boolean forking = false; private boolean recording = true; private final List<IVideoPicture> bufferedFrames = new ArrayList<>(); private final int recordWidth; private final int recordHeight; public RollingRecorder(ICodec.ID codec, String extension, String sessionName, String cameraName, CameraManager cameraManager) { this.codec = codec; this.extension = extension; this.sessionName = sessionName; this.cameraName = cameraName; recordWidth = cameraManager.getFeedWidth(); recordHeight = cameraManager.getFeedHeight(); startTime = System.currentTimeMillis(); relativeVideoFile = new File( sessionName + File.separator + "rolling" + String.valueOf(System.nanoTime()) + extension); videoFile = new File(System.getProperty("shootoff.sessions") + File.separator + relativeVideoFile.getPath()); videoWriter = ToolFactory.makeWriter(videoFile.getPath()); videoWriter.addVideoStream(0, 0, codec, recordWidth, recordHeight); logger.debug("Started recording new rolling video: {}", videoFile.getName()); } public void recordFrame(BufferedImage frame) { final BufferedImage image = ConverterFactory.convertToType(frame, BufferedImage.TYPE_3BYTE_BGR); final IConverter converter = ConverterFactory.createConverter(image, IPixelFormat.Type.YUV420P); timestamp = (System.currentTimeMillis() - startTime) + timeOffset; final IVideoPicture f = converter.toPicture(image, timestamp * 1000); f.setKeyFrame(isFirstShotFrame); f.setQuality(0); if (forking) { synchronized (bufferedFrames) { bufferedFrames.add(f); } } else { isFirstShotFrame = false; synchronized (videoWriterLock) { if (recording) videoWriter.encodeVideo(0, f); } if (timestamp >= ShotRecorder.RECORD_LENGTH * 3) { logger.debug("Rolling video file {}, timestamp = {} ms", relativeVideoFile.getPath(), timestamp); fork(false); } } } private ForkContext fork(boolean keepOld) { forking = true; synchronized (videoWriterLock) { if (videoWriter.isOpen()) videoWriter.close(); } File relativeVideoFile; if (!keepOld) { relativeVideoFile = new File( sessionName + File.separator + "rolling" + String.valueOf(System.nanoTime()) + extension); } else { relativeVideoFile = new File(sessionName + File.separator + String.valueOf(System.nanoTime()) + extension); } final File videoFile = new File( System.getProperty("shootoff.sessions") + File.separator + relativeVideoFile.getPath()); final IMediaReader reader = ToolFactory.makeReader(this.videoFile.getPath()); reader.open(); final long startCutTimestamp = (reader.getContainer().getDuration() / 1000) - ShotRecorder.RECORD_LENGTH; final Cutter cutter = new Cutter(videoFile, codec, startCutTimestamp, recordWidth, recordHeight); reader.addListener(cutter); logger.debug("Forking video file {} to {}, keepOld = {}, start cutting at = {} ms", this.relativeVideoFile.getPath(), relativeVideoFile.getPath(), keepOld, startCutTimestamp); while (reader.readPacket() == null) ; final ForkContext context = new ForkContext(relativeVideoFile, videoFile, cutter.getLastTimestamp(), cutter.getMediaWriter()); if (keepOld) { // We aren't rolling this file because it got too big, // the video is being forked (probably because there was // a shot) final File rollingRelativeVideoFile = new File( sessionName + File.separator + "rolling" + String.valueOf(System.nanoTime()) + extension); final File rollingVideoFile = new File( System.getProperty("shootoff.sessions") + File.separator + rollingRelativeVideoFile.getPath()); final IMediaReader r = ToolFactory.makeReader(this.videoFile.getPath()); r.open(); final Cutter copy = new Cutter(rollingVideoFile, codec, 0, recordWidth, recordHeight); r.addListener(copy); while (r.readPacket() == null) ; timeOffset = copy.getLastTimestamp(); if (!this.videoFile.delete()) { logger.warn("Failed to delete expired rolling video file: {}, keepOld = {}", this.videoFile.getPath(), keepOld); } synchronized (videoWriterLock) { videoWriter = copy.getMediaWriter(); } this.relativeVideoFile = rollingRelativeVideoFile; this.videoFile = rollingVideoFile; } else { // Start adding new frames to the new video as it's the new // canonical video file to peel end frames off of. if (!this.videoFile.delete()) { logger.warn("Failed to delete expired rolling video file: {}, keepOld = {}", this.videoFile.getPath(), keepOld); } this.relativeVideoFile = relativeVideoFile; this.videoFile = videoFile; synchronized (videoWriterLock) { videoWriter = cutter.getMediaWriter(); } timeOffset = cutter.getLastTimestamp(); } synchronized (bufferedFrames) { final Iterator<IVideoPicture> it = bufferedFrames.iterator(); while (it.hasNext()) { synchronized (videoWriterLock) { videoWriter.encodeVideo(0, it.next()); } it.remove(); } } startTime = System.currentTimeMillis(); forking = false; return context; } public ShotRecorder fork() { final ForkContext context = fork(true); return new ShotRecorder(context.getRelativeVideoFile(), context.getVideoFile(), context.getLastTimestamp(), context.getVideoWriter(), cameraName); } private static class ForkContext { private final File relativeVideoFile; private final File videoFile; private final long lastTimestamp; private final IMediaWriter videoWriter; public ForkContext(File relativeVideoFile, File videoFile, long lastTimestamp, IMediaWriter videoWriter) { this.relativeVideoFile = relativeVideoFile; this.videoFile = videoFile; this.lastTimestamp = lastTimestamp; this.videoWriter = videoWriter; } public File getRelativeVideoFile() { return relativeVideoFile; } public File getVideoFile() { return videoFile; } public long getLastTimestamp() { return lastTimestamp; } public IMediaWriter getVideoWriter() { return videoWriter; } } /** * Cut the end of a video off into its own file starting at * startingTimestamp. For example, if you have a 15 second video and the * startingTimestamp is at 10 seconds, this will create a new video that has * the last 5 seconds of the original video. * * @author phrack */ private static class Cutter extends MediaListenerAdapter { private final IMediaWriter writer; private final long startingTimestamp; private long startTimestamp = -1; private long lastTimestamp; public Cutter(File newVideoFile, ICodec.ID codec, long startingTimestamp /* ms */, int recordWidth, int recordHeight) { this.startingTimestamp = startingTimestamp * 1000; writer = ToolFactory.makeWriter(newVideoFile.getPath()); writer.addVideoStream(0, 0, codec, recordWidth, recordHeight); } @Override public void onVideoPicture(IVideoPictureEvent event) { // < 0 means the file we are rolling off of has < RECORD_LENGTH // seconds of footage if (event.getTimeStamp() >= startingTimestamp || startingTimestamp < 0) { final IVideoPicture picture = event.getPicture(); if (startTimestamp == -1) { startTimestamp = picture.getTimeStamp(); } lastTimestamp = picture.getTimeStamp() - startTimestamp; picture.setTimeStamp(lastTimestamp); writer.encodeVideo(0, picture); } } public IMediaWriter getMediaWriter() { return writer; } public long getLastTimestamp() { return lastTimestamp / 1000; } } @Override public void close() { recording = false; synchronized (videoWriterLock) { videoWriter.close(); } if (!videoFile.delete()) { logger.warn("Failed to delete expired rolling video file on close: {}", videoFile.getPath()); } } }