package com.github.sarxos.webcam.ds.fswebcam; import java.awt.Dimension; import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.imageio.ImageIO; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.github.sarxos.webcam.WebcamDevice; import com.github.sarxos.webcam.WebcamDevice.Configurable; import com.github.sarxos.webcam.WebcamExceptionHandler; import com.github.sarxos.webcam.WebcamResolution; public class FsWebcamDevice implements WebcamDevice, Configurable { public static final String PARAM_KEY_COMPRESSION = "compression"; public static final String PARAM_KEY_FORMAT = "format"; public static final String PARAM_KEY_SKIP = "skip"; public static final String PARAM_KEY_FRAMES = "frames"; public static final String PARAM_KEY_LOG = "log"; public static final String PARAM_KEY_VERBOSE = "verbose"; public static final class ExecutorThreadFactory implements ThreadFactory { private final AtomicInteger number = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName(String.format("process-reader-%d", number.incrementAndGet())); t.setUncaughtExceptionHandler(WebcamExceptionHandler.getInstance()); t.setDaemon(true); return t; } } public static final class StreamReader implements Runnable { private final BufferedReader br; private final boolean err; public StreamReader(InputStream is, boolean err) { LOG.debug("New stream reader"); this.br = new BufferedReader(new InputStreamReader(is)); this.err = err; } @Override public void run() { try { try { String line; while ((line = br.readLine()) != null) { LOG.debug("FsWebcam: {} {}", err ? "ERROR" : "", line); } } catch (IOException e) { LOG.debug(String.format("Exception when reading %s output", err ? "STDERR" : "stdout"), e); } } finally { try { br.close(); } catch (IOException e) { LOG.error("Exception when closing buffered reader", e); } } } } private static final Logger LOG = LoggerFactory.getLogger(FsWebcamDevice.class); private static final Runtime RT = Runtime.getRuntime(); private static final ExecutorThreadFactory THREAD_FACTORY = new ExecutorThreadFactory(); private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(THREAD_FACTORY); private static final Dimension[] RESOLUTIONS = new Dimension[] { WebcamResolution.QQVGA.getSize(), WebcamResolution.QVGA.getSize(), WebcamResolution.VGA.getSize(), }; private final File vfile; private final String name; private long counter; private Dimension resolution = null; private Process process = null; private File pipe = null; private ByteArrayOutputStream baos = new ByteArrayOutputStream(); private DataInputStream dis = null; private AtomicBoolean open = new AtomicBoolean(false); private AtomicBoolean disposed = new AtomicBoolean(false); private String logFilePathString; private int frames = 1; private int skip = 0; private String format = "jpeg"; private int compression = -1; private boolean verbose = false; protected FsWebcamDevice(File vfile) { this.vfile = vfile; this.name = vfile.getAbsolutePath(); } @Override public String getName() { return name; } @Override public Dimension[] getResolutions() { return RESOLUTIONS; } @Override public Dimension getResolution() { if (resolution == null) { resolution = getResolutions()[0]; } return resolution; } private String getResolutionString() { Dimension d = getResolution(); return String.format("%dx%d", d.width, d.height); } @Override public void setResolution(Dimension resolution) { this.resolution = resolution; } private synchronized byte[] readBytes() { if (!open.get()) { return null; } baos.reset(); int b, c; try { // search for SOI while (true) { if ((b = dis.readUnsignedByte()) == 0xFF) { if ((c = dis.readUnsignedByte()) == 0xD8) { baos.write(b); baos.write(c); break; // SOI found } } } // read until EOI do { baos.write(c = dis.readUnsignedByte()); if (c == 0xFF) { baos.write(c = dis.readUnsignedByte()); if (c == 0xD9) { break; // EOI found } } } while (true); } catch (IOException e) { throw new RuntimeException(e); } return baos.toByteArray(); } @Override public BufferedImage getImage() { counter++; if (!open.get()) { return null; } BufferedImage image = null; try { executeFsWebcamProcess(); try { dis = new DataInputStream(new FileInputStream(pipe)); } catch (FileNotFoundException e) { throw new RuntimeException(e); } ByteArrayInputStream bais = new ByteArrayInputStream(readBytes()); try { image = ImageIO.read(bais); } catch (IOException e) { process.destroy(); throw new RuntimeException(e); } finally { try { bais.close(); } catch (IOException e) { throw new RuntimeException(e); } } process.waitFor(); if (LOG.isDebugEnabled()) { LOG.debug("Image #"+counter+" done"); } } catch (IOException e) { LOG.error("Process #"+counter+" IO exception", e); } catch (InterruptedException e) { process.destroy(); } finally { try { if (dis != null) dis.close(); } catch (IOException e) { throw new RuntimeException(e); } // w/a for bug in java 1.6 - waitFor requires Thread.interrupted() // call in finally block to reset thread flags if (Thread.interrupted()) { throw new RuntimeException("Thread has been interrupted #"+counter); } } return image; } private void executeFsWebcamProcess() throws IOException { //@formatter:off List<String> c = new ArrayList<String>(24); c.add("/usr/bin/fswebcam"); c.add("--skip"); // number of skipped images c.add(String.valueOf(skip)); c.add("--frames"); // number of images merged to the single output (default 1) c.add(String.valueOf(frames)); c.add("--"+format); // format jpeg | png c.add(String.valueOf(compression)); c.add("--no-banner"); // only image - no texts, banners, etc c.add("--no-shadow"); c.add("--no-title"); c.add("--no-subtitle"); c.add("--no-timestamp"); c.add("--no-info"); c.add("--no-underlay"); c.add("--no-overlay"); c.add("--resolution"); // resolution c.add(getResolutionString()); if (verbose) { c.add("--verbose"); } if (logFilePathString != null) { c.add("--log"); // log file c.add(logFilePathString); } c.add("--device"); // input video file c.add(this.vfile.getAbsolutePath()); c.add(pipe.getAbsolutePath()); // output file (pipe) //@formatter:on String[] cmd = c.toArray(new String[c.size()]); if (LOG.isDebugEnabled()) { StringBuilder sb = new StringBuilder(); for (String cc : cmd) { sb.append(cc).append(' '); } LOG.debug("Invoking command: #"+counter+" \n"+ sb.toString()); } process = RT.exec(cmd); // print process output EXECUTOR.execute(new StreamReader(process.getInputStream(), false)); EXECUTOR.execute(new StreamReader(process.getErrorStream(), true)); } @Override public synchronized void open() { if (disposed.get()) { return; } if (!open.compareAndSet(false, true)) { return; } pipe = new File("/tmp/fswebcam-pipe-" + vfile.getName() + ".mjpeg"); if (pipe.exists()) { if (!pipe.delete()) { throw new RuntimeException("Cannot remove streaming pipe " + pipe); } } LOG.debug("Creating pipe: mkfifo {}", pipe.getAbsolutePath()); Process p = null; try { p = RT.exec(new String[] { "mkfifo", pipe.getAbsolutePath() }); EXECUTOR.execute(new StreamReader(p.getInputStream(), false)); EXECUTOR.execute(new StreamReader(p.getErrorStream(), true)); p.waitFor(); } catch (IOException e) { throw new RuntimeException(e); } catch (InterruptedException e) { return; } finally { p.destroy(); } } @Override public synchronized void close() { if (!open.compareAndSet(true, false)) { return; } if (dis != null) { try { dis.close(); } catch (IOException e) { throw new RuntimeException(e); } } if (process != null) { process.destroy(); } try { process.waitFor(); } catch (InterruptedException e) { throw new RuntimeException(e); } if (!pipe.delete()) { pipe.deleteOnExit(); } } @Override public void dispose() { if (disposed.compareAndSet(false, true) && open.get()) { close(); } } @Override public boolean isOpen() { return open.get(); } @Override public String toString() { return "video device " + name; } /** * Call this method to set device specific parameters. * Should be called before {@link #open()} method. * For details about config options, please see fswebcam manual. * <ul> * <li>verbose - Boolean type - If true, fswebcam command-line option --verbose is set. * <li>log - String type - If set, it's passed to fswebcam as value of command-line option --log. * <li>frames - Integer type - If set, it's passed to fswebcam as value of command-line option --frames. * <li>skip - Integer type - If set, it's passed to fswebcam as value of command-line option --skip. * <li>format - String type - Possible values are: "jpeg" (default) | "png". Passed to fswebcam as option: --[format] * <li>compression - Integer type - Passed to fswebcam together with format --[format] [compression]. Default is -1, which means automatic. * </ul> * All Boolean or Integer types may be also specified as String values. E.g. both "true" String or Boolean.TRUE are valid values. */ @Override public void setParameters(Map<String, ?> parameters) { if (parameters != null) { Object value = null; value = parameters.get(PARAM_KEY_VERBOSE); if (value != null) { verbose = Boolean.parseBoolean(String.valueOf(value)); } value = parameters.get(PARAM_KEY_LOG); if (value != null) { logFilePathString = String.valueOf(value); } value = parameters.get(PARAM_KEY_FRAMES); if (value != null) { frames = Integer.parseInt(String.valueOf(value)); } value = parameters.get(PARAM_KEY_SKIP); if (value != null) { skip = Integer.parseInt(String.valueOf(value)); } value = parameters.get(PARAM_KEY_FORMAT); if (value != null) { format = String.valueOf(value); } value = parameters.get(PARAM_KEY_COMPRESSION); if (value != null) { compression = Integer.parseInt(String.valueOf(value)); } } } }