/**
 * This package provides classes to handle video in Processing. The API is compatible with the built-in video library of Processing. 
 * GSVideo uses the multimedia toolkit GStreamer (http://www.gstreamer.net/)  through the gstreamer-java bindings by Wayne Meissener:
 * http://code.google.com/p/gstreamer-java/ 
 * @author Andres Colubri
 * @version 0.8
 *
 * Copyright (c) 2008 Andres Colubri
 *
 * This source 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 2 of the License, or
 * (at your option) any later version.
 * 
 * This code 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.
 * 
 * A copy of the GNU General Public License is available on the World
 * Wide Web at <http://www.gnu.org/copyleft/gpl.html>. You can also
 * obtain it by writing to the Free Software Foundation,
 * Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
 */

package codeanticode.gsvideo;

import processing.core.*;

import java.nio.*;
import java.util.ArrayList;
import java.lang.reflect.*;
import org.gstreamer.*;
import org.gstreamer.elements.*;
import org.gstreamer.interfaces.PropertyProbe;
import org.gstreamer.interfaces.Property;

/**
 * Class for storing and manipulating video frames from an attached capture
 * device such as a camera.
 */
public class GSCapture extends PImage implements PConstants {
  protected Method captureEventMethod;
  protected boolean available;
  protected String fps;  
  protected int captureWidth;
  protected int captureHeight;
  protected Object eventHandler;   
  protected RGBDataAppSink videoSink = null;
  protected int[] copyPixels = null;
  protected Pipeline gpipe;
  protected ArrayList<int[]> suppResList;
  protected ArrayList<String> suppFpsList;
  
  protected boolean firstFrame = true;
  
  /**
   * Basic constructor: tries to auto-detect all the capture parameters,
   * with the exception of the resolution.
   */
  public GSCapture(PApplet parent, int requestWidth, int requestHeight) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    platformInit(requestWidth, requestHeight, new String[] {}, new int[] {},
           new String[] {}, new String[] {}, "", false);
  }

  /**
   * Constructor that takes resolution and framerate indicated as a single number.
   */  
  public GSCapture(PApplet parent, int requestWidth, int requestHeight, int frameRate) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    platformInit(requestWidth, requestHeight, new String[] {}, new int[] {},
           new String[] {}, new String[] {}, frameRate + "/1", false);
  }

  /**
   * This constructor allows to specify the camera name. In Linux, for example, this
   * should be a string of the form /dev/video0, /dev/video1, etc.
   */   
  public GSCapture(PApplet parent, int requestWidth, int requestHeight, String cameraName) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    platformInit(requestWidth, requestHeight, new String[] {}, new int[] {},
                 new String[] { devicePropertyName() }, new String[] { cameraName }, 
                 "", false);
  }

  /**
   * This constructor allows to specify the camera name and the desired framerate.
   */     
  public GSCapture(PApplet parent, int requestWidth, int requestHeight, int frameRate, 
                   String cameraName) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    platformInit(requestWidth, requestHeight, new String[] {}, new int[] {},
                 new String[] { devicePropertyName() }, new String[] { cameraName }, 
                 frameRate + "/1", false);
  }  
  
  /**
   * This constructor lets to indicate which source element to use (i.e.: v4l2src, 
   * osxvideosrc, dshowvideosrc, ksvideosrc, etc).
   */   
  public GSCapture(PApplet parent, int requestWidth, int requestHeight, int frameRate, 
                   String sourceName, String cameraName) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    init(requestWidth, requestHeight, sourceName, new String[] {}, new int[] {}, 
         new String[] { devicePropertyName() }, new String[] { cameraName }, 
         frameRate + "/1", false);
  }

  /**
   * This constructor accepts an arbitrary list of string properties for the source element.
   * The camera name could be one of these properties. The framerate must be specified
   * as a fraction string: 30/1, 15/2, etc.
   */    
  public GSCapture(PApplet parent, int requestWidth, int requestHeight, String frameRate,
                   String sourceName, String[] strPropNames, String[] strPropValues) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    init(requestWidth, requestHeight, sourceName, new String[] {}, new int[] {},
         strPropNames, strPropValues, frameRate, false);
  }

  /**
   * This constructor accepts an arbitrary list of string properties for the source element,
   * as well as a list of integer properties. This could be useful if a camera cannot by
   * specified by name but by index. Framerate must be a fraction string: 30/1, 15/2, etc.
   */   
  public GSCapture(PApplet parent, int requestWidth, int requestHeight, String frameRate,
                   String sourceName, String[] strPropNames, String[] strPropValues,
                   String[] intPropNames, int[] intPropValues) {
    super(requestWidth, requestHeight, RGB);
    this.parent = parent;
    init(requestWidth, requestHeight, sourceName, intPropNames, intPropValues,
         strPropNames, strPropValues, frameRate, false);
  }

  /**
   * Releases the gstreamer resources associated to this capture object.
   * It shouldn't be used after this.
   */
  public void delete() {
    if (gpipe != null) {
      try {
        if (gpipe.isPlaying()) {
          gpipe.stop();
        }
      } catch (IllegalStateException e) {
        System.err.println("error when deleting player, maybe some native resource is already disposed"); 
      } catch (Exception e) {
        e.printStackTrace();
      }
      
      copyPixels = null;
      pixels = null;      
      
      if (videoSink != null) {
        videoSink.removeListener();
        videoSink.dispose();
        videoSink = null;
      }      
      
      gpipe.dispose();
      gpipe = null;
    }
  }  

  /**
   * Same as delete.
   */    
  public void dispose() {
    delete();
  }  
  
  
  /**
   * Uses a generic object as handler of the movie. This object should have a
   * movieEvent method that receives a GSMovie argument. This method will
   * be called upon a new frame read event. 
   * 
   */
  public void setEventHandlerObject(Object obj) {
    eventHandler = obj;

    try {
      captureEventMethod = parent.getClass().getMethod("captureEvent",
          new Class[] { GSCapture.class });
    } catch (Exception e) {
      // no such method, or an error.. which is fine, just ignore
    }
  }
  
  /**
   * Returns "true" when a new video frame is available to read.
   * 
   * @return boolean
   */
  public boolean available() {
    return available;
  }

  /**
   * Resumes the capture pipeline.
   */
  public void play() {
    gpipe.setState(State.PLAYING);
  }

  /**
   * Stops the capture pipeline.
   */
  public void pause() {
    gpipe.setState(State.PAUSED);
  }  
  
  /**
   * Reads the current video frame.
   * 
   * This method() and invokeEvent() are now synchronized, so that invokeEvent()
   * can't be called whilst we're busy reading. Problematic frame error
   * fixed by Charl P. Botha <charlbotha.com>
   */
  public synchronized void read() {
    // We loadPixels() first to ensure that at least we always have a non-null
    // pixels array, even if without any valid image inside.
    loadPixels();
    
    if (copyPixels == null) {
      return;
    }    
    
    if (firstFrame) {
      super.init(captureWidth, captureHeight, RGB);
      loadPixels();
      firstFrame = false;
    }
    
    int[] temp = pixels;
    pixels = copyPixels;
    updatePixels();
    copyPixels = temp;    
    
    available = false;
  }
  
  /**
   * Returns a list with the resolutions supported by the capture device.
   * Each element of the list is in turn an array of two int, first being
   * the width and second the height.
   * 
   * @return int[][]
   */  
  public int[][] resolutions() {
    int n = suppResList.size();
    int[][] res = new int[n][2];
    for (int i = 0; i < n; i++) {
      int[] wh = (int[])suppResList.get(i);
      res[i] = new int[] {wh[0], wh[1]};
    }
    return res;
  }

  /**
   * Returns a list with the framerates supported by the capture device,
   * expressed as a string like: 30/1, 15/2, etc.
   * 
   * @return String[]
   */  
  public String[] framerates() {
    int n = suppFpsList.size();
    String[] res = new String[n];
    for (int i = 0; i < n; i++) {
      res[i] = (String)suppFpsList.get(i);
    }
    return res;
  }  
  
  /**
   * Returns a list of available capture devices.
   * 
   * @return String[]
   */  
  static public String[] list() {
    if (PApplet.platform == LINUX) {
      return list("v4l2src");
    } else if (PApplet.platform == WINDOWS) {
      return list("dshowvideosrc");
    } else if (PApplet.platform == MACOSX) {
      return list("osxvideosrc");
    } else {
      return null;
    }
  }

  /**
   * Get a list of all available captures as a String array. i.e.
   * println(Capture.list()) will show you the goodies.
   * 
   * @param sourceName String
   * @return String[]
   */
  static public String[] list(String sourceName) {
    return list(sourceName, devicePropertyName());
  }
  
  static protected String[] list(String sourceName, String propertyName) {
    GSVideo.init();
    String[] valuesListing = new String[0];
    Element videoSource = ElementFactory.make(sourceName, "Source");
    PropertyProbe probe = PropertyProbe.wrap(videoSource);
    if (probe != null) {
      Property property = probe.getProperty(propertyName);
      if (property != null) {
        Object[] values = probe.getValues(property);
        if (values != null) {
          valuesListing = new String[values.length];
          for (int i = 0; i < values.length; i++)
            if (values[i] instanceof String)
              valuesListing[i] = (String) values[i];
        }
      }
    }
    return valuesListing;
  }

  /**
   * invokeEvent() and read() are synchronized so that they can not be
   * called simultaneously. when they were not synchronized, this caused
   * the infamous problematic frame crash.
   * found and fixed by Charl P. Botha <charlbotha.com>
   */
  protected synchronized void invokeEvent(int w, int h, IntBuffer buffer) {
    available = true;
    captureWidth = w;
    captureHeight = h;
    if (copyPixels == null) {
      copyPixels = new int[w * h];
    }
    buffer.rewind();    
    try {
      buffer.get(copyPixels);
    } catch (BufferUnderflowException e) {
      e.printStackTrace();
      copyPixels = null;
      return;
    }
    
    // Creates a movieEvent.
    if (captureEventMethod != null) {
      try {
        captureEventMethod.invoke(parent, new Object[] { this });
      } catch (Exception e) {
        System.err.println("error, disabling captureEvent() for capture object");
        e.printStackTrace();
        captureEventMethod = null;
      }
    }
  }

  // Tries to guess the best correct source elements for each platform.
  protected void platformInit(int requestWidth, int requestHeight,
                              String[] intPropNames, int[] intPropValues, 
                              String[] strPropNames, String[] strPropValues, 
                              String frameRate, boolean addDecoder) {
    if (PApplet.platform == LINUX) {
      init(requestWidth, requestHeight, "v4l2src", intPropNames, intPropValues,
          strPropNames, strPropValues, frameRate, addDecoder);
    } else if (PApplet.platform == WINDOWS) {
      init(requestWidth, requestHeight, "ksvideosrc", intPropNames,
          intPropValues, strPropNames, strPropValues, frameRate, addDecoder);
      //init(requestWidth, requestHeight, "dshowvideosrc", intPropNames,
      //    intPropValues, strPropNames, strPropValues, frameRate, addDecoder);
    } else if (PApplet.platform == MACOSX) {
      init(requestWidth, requestHeight, "osxvideosrc", intPropNames,
          intPropValues, strPropNames, strPropValues, frameRate, addDecoder);
    } else {
      parent.die("Error: unrecognized platform.", null);
    }
  }

  // The main initialization here.
  protected void init(int requestWidth, int requestHeight, String sourceName,
      String[] intPropNames, int[] intPropValues, String[] strPropNames,
      String[] strPropValues, String frameRate, boolean addDecoder) {
    gpipe = null;

    GSVideo.init();

    // register methods
    parent.registerDispose(this);

    setEventHandlerObject(parent);

    gpipe = new Pipeline("GSCapturePipeline");

    Element videoSource = ElementFactory.make(sourceName, "Source");

    if (intPropNames.length != intPropValues.length) {
      parent.die("Error: number of integer property names is different from number of values.",
          null);
    }

    for (int i = 0; i < intPropNames.length; i++) {
      videoSource.set(intPropNames[i], intPropValues[i]);
    }

    if (strPropNames.length != strPropValues.length) {
      parent.die("Error: number of string property names is different from number of values.",
        null);
    }

    for (int i = 0; i < strPropNames.length; i++) {
      videoSource.set(strPropNames[i], strPropValues[i]);
    }
    
    // If the framerate string is empty we left the source element
    // to use the default value.
    fps = frameRate;
    String fpsStr = "";
    if (!fps.equals("")) {
      fpsStr = ", framerate=" + fps;
    }

    Element conv = ElementFactory.make("ffmpegcolorspace", "ColorConverter");

    Element videofilter = ElementFactory.make("capsfilter", "ColorFilter");
    videofilter.setCaps(new Caps("video/x-raw-rgb, width=" + requestWidth + 
                                 ", height=" + requestHeight + 
                                 ", bpp=32, depth=24" + fpsStr));

    videoSink = new RGBDataAppSink("rgb", 
        new RGBDataAppSink.Listener() {
          public void rgbFrame(int w, int h, IntBuffer buffer) {
            invokeEvent(w, h, buffer);
          }
        });    
    
    // Setting direct buffer passing in the video sink, so no new buffers are created
    // and disposed by the GC on each frame (thanks to Octavi Estape for pointing 
    // out this one).
    videoSink.setPassDirectBuffer(GSVideo.passDirectBuffer);

    if (addDecoder) {
      Element decoder = ElementFactory.make("decodebin2", "Decoder");
      gpipe.addMany(videoSource, decoder, conv, videofilter, videoSink);
      Element.linkMany(videoSource, decoder, conv, videofilter, videoSink);
    } else {
      gpipe.addMany(videoSource, conv, videofilter, videoSink);
      Element.linkMany(videoSource, conv, videofilter, videoSink);
    }

    // No need for videoSink.dispose(), because the addMany() doesn't increment the
    // refcount of the videoSink object.
    
    play();
    
    // The pipeline needs to be in playing state to be able to
    // report the supported resolutions and framerates of the 
    // capture device.
    getSuppResAndFpsList();
    
    boolean suppRes = !(0 < suppResList.size()); // Default value is true if resolution list empty.
    for (int i = 0; i < suppResList.size(); i++) {
      int[] wh = (int[])suppResList.get(i);
      if (requestWidth == wh[0] && requestHeight == wh[1]) {
        suppRes = true;
        break;
      }
    }
    
    if (!suppRes) {
      System.err.println("The requested resolution of " + requestWidth + "x" + requestHeight + " is not supported by the capture device.");
      System.err.println("Use one of the following resolutions instead:");
      for (int i = 0; i < suppResList.size(); i++) {
        int[] wh = (int[])suppResList.get(i);
        System.err.println(wh[0] + "x" + wh[1]);
      }
    }
    
    boolean suppFps = !(0 < suppFpsList.size()); // Default value is true if fps list empty.
    for (int i = 0; i < suppFpsList.size(); i++) {
      String str = (String)suppFpsList.get(i);
      if (frameRate.equals("") || frameRate.equals(str)) {
        suppFps = true;
        break;
      }
    }
    
    if (!suppFps) {
      System.err.println("The requested framerate of " + frameRate + " is not supported by the capture device.");
      System.err.println("Use one of the following framerates instead:");
      for (int i = 0; i < suppFpsList.size(); i++) {
        String str = (String)suppFpsList.get(i);
        System.err.println(str);
      }
    }    
  }

  protected void getSuppResAndFpsList() {
    suppResList = new ArrayList<int[]>();
    suppFpsList = new ArrayList<String>();
    
    for (Element src : gpipe.getSources()) {
      for (Pad pad : src.getPads()) {
        Caps caps = pad.getCaps();
        int n = caps.size(); 
        for (int i = 0; i < n; i++) {           
          Structure str = caps.getStructure(i);
          
          int w = ((Integer)str.getValue("width")).intValue();
          int h = ((Integer)str.getValue("height")).intValue();
          
          boolean newRes = true;
          // Making sure we didn't add this resolution already. 
          // Different caps could have same resolution.
          for (int j = 0; j < suppResList.size(); j++) {
            int[] wh = (int[])suppResList.get(j);
            if (w == wh[0] && h == wh[1]) {
              newRes = false;
              break;
            }
          }
          if (newRes) {
            suppResList.add(new int[] {w, h});
          }          
          
          if (PApplet.platform == WINDOWS) {
            // In Windows the getValueList() method doesn't seem to
            // return a valid list of fraction values, so working on
            // the string representation of the caps structure.
            String str2 = str.toString();
            
            int n0 = str2.indexOf("framerate=(fraction)");
            if (-1 < n0) {
              String temp = str2.substring(n0 + 20, str2.length());
              int n1 = temp.indexOf("[");
              int n2 = temp.indexOf("]");
              if (-1 < n1 && -1 < n2) {
                // A list of fractions enclosed between '[' and ']'
                temp = temp.substring(n1 + 1, n2);  
                String[] fractions = temp.split(",");
                for (int k = 0; k < fractions.length; k++) {
                  addFpsStr(fractions[k].trim());
                }
              } else {
                // A single fraction
                int n3 = temp.indexOf(",");
                int n4 = temp.indexOf(";");
                if (-1 < n3 || -1 < n4) {
                  int n5 = -1;
                  if (n3 == -1) {
                    n5 = n4;
                  } else if (n4 == -1) {
                    n5 = n3;
                  } else {
                    n5 = PApplet.min(n3, n4);
                  }
                  
                  temp = temp.substring(0, n5);
                  addFpsStr(temp.trim());
                }
              }
            }
          } else {
            boolean sigleFrac = false;
            try {
              Fraction fr = str.getFraction("framerate");
              addFps(fr);
              sigleFrac = true;
            } catch (Exception e) { 
            }
            
            if (!sigleFrac) { 
              ValueList flist = str.getValueList("framerate");
              // All the framerates are put together, but this is not
              // entirely accurate since there might be some of them'
              // that work only for certain resolutions.
              for (int k = 0; k < flist.getSize(); k++) {
                Fraction fr = flist.getFraction(k);
                addFps(fr);
              }
            }            
          }          
        }
      }
    }
  }

  protected void addFps(Fraction fr) {
    int frn = fr.numerator;
    int frd = fr.denominator;
    addFpsStr(frn + "/" + frd);
  }
  
  protected void addFpsStr(String frstr) {
    boolean newFps = true;
    for (int j = 0; j < suppFpsList.size(); j++) {
      String frstr0 = (String)suppFpsList.get(j);
      if (frstr.equals(frstr0)) {
        newFps = false;
        break;
      }
    }
    if (newFps) {
      suppFpsList.add(frstr);
    }      
  }
  
  static protected String devicePropertyName() {
    // TODO: Check the property names
    if (PApplet.platform == LINUX) {
      return "device"; // Is this correct?
    } else if (PApplet.platform == WINDOWS) {
      return "device-name";
    } else if (PApplet.platform == MACOSX) {
      return "device";
    } else {
      return "";
    }
  }
  
  static protected String indexPropertyName() {
    // TODO: Check the property names
    if (PApplet.platform == LINUX) {
      return "device-index"; // Is this correct? Probably not.
    } else if (PApplet.platform == WINDOWS) {
      return "device-index";
    } else if (PApplet.platform == MACOSX) {
      return "device-index"; // Is this correct? Probably not.
    } else {
      return "";
    }
  }
}
