/*
 * VideoPanel.java 15 fevr. 2010
 *
 * Sweet Home 3D, Copyright (c) 2010 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * 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 2 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, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.swing;

import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.ComponentOrientation;
import java.awt.Composite;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Image;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Window;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.ItemEvent;
import java.awt.event.ItemListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.Ellipse2D;
import java.awt.geom.GeneralPath;
import java.awt.geom.Line2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.net.URL;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Dictionary;
import java.util.GregorianCalendar;
import java.util.Hashtable;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javax.imageio.ImageIO;
import javax.media.Buffer;
import javax.media.Format;
import javax.media.MediaLocator;
import javax.media.Time;
import javax.media.format.VideoFormat;
import javax.media.protocol.ContentDescriptor;
import javax.media.protocol.PullBufferDataSource;
import javax.media.protocol.PullBufferStream;
import javax.swing.ActionMap;
import javax.swing.BorderFactory;
import javax.swing.BoundedRangeModel;
import javax.swing.DefaultListCellRenderer;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.JSeparator;
import javax.swing.JSlider;
import javax.swing.JSpinner;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SpinnerDateModel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.ToolTipManager;
import javax.swing.UIManager;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.vecmath.Point3f;

import com.eteks.sweethome3d.j3d.Component3DManager;
import com.eteks.sweethome3d.j3d.PhotoRenderer;
import com.eteks.sweethome3d.model.AspectRatio;
import com.eteks.sweethome3d.model.Camera;
import com.eteks.sweethome3d.model.Home;
import com.eteks.sweethome3d.model.HomeEnvironment;
import com.eteks.sweethome3d.model.ObserverCamera;
import com.eteks.sweethome3d.model.Selectable;
import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.tools.OperatingSystem;
import com.eteks.sweethome3d.tools.ResourceURLContent;
import com.eteks.sweethome3d.viewcontroller.ContentManager;
import com.eteks.sweethome3d.viewcontroller.DialogView;
import com.eteks.sweethome3d.viewcontroller.Object3DFactory;
import com.eteks.sweethome3d.viewcontroller.VideoController;
import com.eteks.sweethome3d.viewcontroller.View;

/**
 * A panel used for video creation.
 * @author Emmanuel Puybaret
 */
public class VideoPanel extends JPanel implements DialogView {
  private enum ActionType {START_VIDEO_CREATION, STOP_VIDEO_CREATION, SAVE_VIDEO, CLOSE,
      DELETE_CAMERA_PATH, PLAYBACK, PAUSE, RECORD, SEEK_BACKWARD, SEEK_FORWARD, SKIP_BACKWARD, SKIP_FORWARD, DELETE_LAST_RECORD}

  private static final String VIDEO_DIALOG_X_VISUAL_PROPERTY = "com.eteks.sweethome3d.swing.VideoPanel.VideoDialogX";
  private static final String VIDEO_DIALOG_Y_VISUAL_PROPERTY = "com.eteks.sweethome3d.swing.VideoPanel.VideoDialogY";

  private static final int MINIMUM_DELAY_BEFORE_DISCARDING_WITHOUT_WARNING = 30000;

  private static final VideoFormat [] VIDEO_FORMATS = {
      new VideoFormat(VideoFormat.JPEG, new Dimension(176, 132), Format.NOT_SPECIFIED, Format.byteArray, 12), // 4/3
      new VideoFormat(VideoFormat.JPEG, new Dimension(320, 240), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(480, 360), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(640, 480), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(720, 540), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(1024, 768), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(1280, 960), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(720, 405), Format.NOT_SPECIFIED, Format.byteArray, 25), // 16/9
      new VideoFormat(VideoFormat.JPEG, new Dimension(1280, 536), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(1280, 720), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(1920, 800), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(1920, 1080), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(3840, 2160), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(4096, 1728), Format.NOT_SPECIFIED, Format.byteArray, 25),
      new VideoFormat(VideoFormat.JPEG, new Dimension(7680, 4320), Format.NOT_SPECIFIED, Format.byteArray, 25)};

  private static final String TIP_CARD      = "tip";
  private static final String PROGRESS_CARD = "progress";

  private final Home            home;
  private final UserPreferences preferences;
  private final Object3DFactory object3dFactory;
  private final VideoController controller;
  private PlanComponent         planComponent;
  private JToolBar              videoToolBar;
  private JButton               playbackPauseButton;
  private Timer                 playbackTimer;
  private ListIterator<Camera>  cameraPathIterator;
  private CardLayout            statusLayout;
  private JPanel                statusPanel;
  private JLabel                tipLabel;
  private JLabel                progressLabel;
  private JProgressBar          progressBar;
  private JLabel                videoFormatLabel;
  private JComboBox             videoFormatComboBox;
  private String                videoFormatComboBoxFormat;
  private JLabel                qualityLabel;
  private JSlider               qualitySlider;
  private Component             advancedComponentsSeparator;
  private JLabel                dateLabel;
  private JSpinner              dateSpinner;
  private JLabel                timeLabel;
  private JSpinner              timeSpinner;
  private JLabel                dayNightLabel;
  private JCheckBox             ceilingLightEnabledCheckBox;
  private String                dialogTitle;
  private ExecutorService       videoCreationExecutor;
  private long                  videoCreationStartTime;
  private File                  videoFile;
  private JButton               createButton;
  private JButton               saveButton;
  private JButton               closeButton;

  private static VideoPanel     currentVideoPanel; // Support only one video panel opened at a time

  /**
   * Creates a video panel with default object 3D factory.
   */
  public VideoPanel(Home home,
                    UserPreferences preferences,
                    VideoController controller) {
    this(home, preferences, null, controller);
  }

  public VideoPanel(Home home,
                    UserPreferences preferences,
                    Object3DFactory object3dFactory,
                    VideoController controller) {
    super(new GridBagLayout());
    this.home = home;
    this.preferences = preferences;
    this.object3dFactory = object3dFactory;
    this.controller = controller;
    createActions(preferences);
    createComponents(home, preferences, controller);
    setMnemonics(preferences);
    layoutComponents();

    preferences.addPropertyChangeListener(UserPreferences.Property.LANGUAGE, new LanguageChangeListener(this));
  }

  /**
   * Creates actions for variables.
   */
  private void createActions(UserPreferences preferences) {
    final ActionMap actions = getActionMap();
    actions.put(ActionType.PLAYBACK,
        new ResourceAction(preferences, VideoPanel.class, ActionType.PLAYBACK.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            playback();
          }
        });
    actions.put(ActionType.PAUSE,
        new ResourceAction(preferences, VideoPanel.class, ActionType.PAUSE.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            pausePlayback();
          }
        });
    actions.put(ActionType.RECORD,
        new ResourceAction(preferences, VideoPanel.class, ActionType.RECORD.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            recordCameraLocation();
          }
        });
    actions.put(ActionType.SEEK_BACKWARD,
        new ResourceAction(preferences, VideoPanel.class, ActionType.SEEK_BACKWARD.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            seekBackward();
          }
        });
    actions.put(ActionType.SEEK_FORWARD,
        new ResourceAction(preferences, VideoPanel.class, ActionType.SEEK_FORWARD.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            seekForward();
          }
        });
    actions.put(ActionType.SKIP_BACKWARD,
        new ResourceAction(preferences, VideoPanel.class, ActionType.SKIP_BACKWARD.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            skipBackward();
          }
        });
    actions.put(ActionType.SKIP_FORWARD,
        new ResourceAction(preferences, VideoPanel.class, ActionType.SKIP_FORWARD.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            skipForward();
          }
        });
    actions.put(ActionType.DELETE_LAST_RECORD,
        new ResourceAction(preferences, VideoPanel.class, ActionType.DELETE_LAST_RECORD.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            deleteLastRecordedCameraLocation();
          }
        });
    actions.put(ActionType.DELETE_CAMERA_PATH,
        new ResourceAction(preferences, VideoPanel.class, ActionType.DELETE_CAMERA_PATH.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            deleteCameraPath();
          }
        });

    actions.put(ActionType.START_VIDEO_CREATION,
        new ResourceAction(preferences, VideoPanel.class, ActionType.START_VIDEO_CREATION.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            startVideoCreation();
          }
        });
    actions.put(ActionType.STOP_VIDEO_CREATION,
        new ResourceAction(preferences, VideoPanel.class, ActionType.STOP_VIDEO_CREATION.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            stopVideoCreation(true);
          }
        });
    actions.put(ActionType.SAVE_VIDEO,
        new ResourceAction(preferences, VideoPanel.class, ActionType.SAVE_VIDEO.name()) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            saveVideo();
          }
        });
    actions.put(ActionType.CLOSE,
        new ResourceAction(preferences, VideoPanel.class, ActionType.CLOSE.name(), true) {
          @Override
          public void actionPerformed(ActionEvent ev) {
            close();
          }
        });
  }

  /**
   * Creates and initializes components.
   */
  private void createComponents(final Home home,
                                final UserPreferences preferences,
                                final VideoController controller) {
    final Dimension preferredSize = new Dimension(getToolkit().getScreenSize().width <= 1024 ? 324 : 404, 404);
    this.planComponent = new PlanComponent(home, preferences, null) {
        private void updateScale() {
          if (getWidth() > 0 && getHeight() > 0) {
            // Adapt scale to always view the home
            float oldScale = getScale();
            Dimension preferredSize = super.getPreferredSize();
            Insets insets = getInsets();
            float planWidth = (preferredSize.width - insets.left - insets.right) / oldScale;
            float planHeight = (preferredSize.height - insets.top - insets.bottom) / oldScale;
            setScale(Math.min((getWidth() - insets.left - insets.right) / planWidth,
                (getHeight() - insets.top - insets.bottom) / planHeight));
          }
        }

        @Override
        public Dimension getPreferredSize() {
          return preferredSize;
        }

        @Override
        public void revalidate() {
          super.revalidate();
          updateScale();
        }

        @Override
        public void setBounds(int x, int y, int width, int height) {
          super.setBounds(x, y, width, height);
          updateScale();
        }

        @Override
        protected List<Selectable> getPaintedItems() {
          List<Selectable> paintedItems = super.getPaintedItems();
          // Take into account camera locations in plan bounds
          for (Camera camera : controller.getCameraPath()) {
            paintedItems.add(new ObserverCamera(camera.getX(), camera.getY(), camera.getZ(),
                camera.getYaw(), camera.getPitch(), camera.getFieldOfView()));
          }
          return paintedItems;
        }

        @Override
        protected Rectangle2D getItemBounds(Graphics g, Selectable item) {
          if (item instanceof ObserverCamera) {
            return new Rectangle2D.Float(((ObserverCamera)item).getX() - 1, ((ObserverCamera)item).getY() - 1, 2, 2);
          } else {
            return super.getItemBounds(g, item);
          }
        }

        @Override
        protected void paintComponent(Graphics g) {
          Graphics2D g2D = (Graphics2D)g;
          g2D.setColor(getBackground());
          g2D.fillRect(0, 0, getWidth(), getHeight());
          super.paintComponent(g);
        }

        @Override
        protected void paintHomeItems(Graphics g, float planScale, Color backgroundColor, Color foregroundColor,
                                      PaintMode paintMode) throws InterruptedIOException {
          Graphics2D g2D = (Graphics2D)g;
          Composite oldComposite = g2D.getComposite();
          g2D.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.25f));
          super.paintHomeItems(g, planScale, backgroundColor, foregroundColor, paintMode);

          // Paint recorded camera path
          g2D.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1));
          g2D.setColor(getSelectionColor());
          float cameraCircleRadius = 7 / getScale();
          Ellipse2D ellipse = new Ellipse2D.Float(-cameraCircleRadius, -cameraCircleRadius,
              2 * cameraCircleRadius, 2 * cameraCircleRadius);
          List<Camera> cameraPath = controller.getCameraPath();
          for (int i = 0; i < cameraPath.size(); i++) {
            Camera camera = cameraPath.get(i);
            AffineTransform previousTransform = g2D.getTransform();
            g2D.translate(camera.getX(), camera.getY());
            g2D.rotate(camera.getYaw());
            // Paint camera location
            g2D.fill(ellipse);
            // Paint field of sight angle
            double sin = (float)Math.sin(camera.getFieldOfView() / 2);
            double cos = (float)Math.cos(camera.getFieldOfView() / 2);
            float xStartAngle = (float)(1.2f * cameraCircleRadius * sin);
            float yStartAngle = (float)(1.2f * cameraCircleRadius * cos);
            float xEndAngle = (float)(2.5f * cameraCircleRadius * sin);
            float yEndAngle = (float)(2.5f * cameraCircleRadius * cos);
            GeneralPath cameraFieldOfViewAngle = new GeneralPath();
            g2D.setStroke(new BasicStroke(1 / getScale()));
            cameraFieldOfViewAngle.moveTo(xStartAngle, yStartAngle);
            cameraFieldOfViewAngle.lineTo(xEndAngle, yEndAngle);
            cameraFieldOfViewAngle.moveTo(-xStartAngle, yStartAngle);
            cameraFieldOfViewAngle.lineTo(-xEndAngle, yEndAngle);
            g2D.draw(cameraFieldOfViewAngle);
            g2D.setTransform(previousTransform);

            if (i > 0) {
              g2D.setStroke(new BasicStroke(2 / getScale()));
              g2D.draw(new Line2D.Float(camera.getX(), camera.getY(),
                  cameraPath.get(i - 1).getX(), cameraPath.get(i - 1).getY()));
            }
          }
          g2D.setComposite(oldComposite);
        }
      };
    this.planComponent.setSelectedItemsOutlinePainted(false);
    this.planComponent.setBackgroundPainted(false);
    this.planComponent.setBorder(BorderFactory.createEtchedBorder());
    this.controller.addPropertyChangeListener(VideoController.Property.CAMERA_PATH,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent evt) {
            planComponent.revalidate();
            updatePlaybackTimer();
          }
        });

    // Create tool bar to play recorded animation in 3D view
    this.videoToolBar = new JToolBar();
    this.videoToolBar.setFloatable(false);
    final ActionMap actionMap = getActionMap();
    this.videoToolBar.add(actionMap.get(ActionType.DELETE_CAMERA_PATH));
    this.videoToolBar.addSeparator();
    this.videoToolBar.add(actionMap.get(ActionType.SKIP_BACKWARD));
    this.videoToolBar.add(actionMap.get(ActionType.SEEK_BACKWARD));
    this.videoToolBar.add(actionMap.get(ActionType.RECORD));
    this.playbackPauseButton = new JButton(actionMap.get(ActionType.PLAYBACK));
    this.videoToolBar.add(this.playbackPauseButton);
    this.videoToolBar.add(actionMap.get(ActionType.SEEK_FORWARD));
    this.videoToolBar.add(actionMap.get(ActionType.SKIP_FORWARD));
    this.videoToolBar.addSeparator();
    this.videoToolBar.add(actionMap.get(ActionType.DELETE_LAST_RECORD));
    for (int i = 0; i < videoToolBar.getComponentCount(); i++) {
      Component component = this.videoToolBar.getComponent(i);
      if (component instanceof JButton) {
        JButton button = (JButton)component;
        button.setBorderPainted(true);
        button.setFocusable(true);
      }
    }

    this.tipLabel = new JLabel();
    Font toolTipFont = UIManager.getFont("ToolTip.font");
    this.tipLabel.setFont(toolTipFont);

    this.progressLabel = new JLabel();
    this.progressLabel.setFont(toolTipFont);
    this.progressLabel.setHorizontalAlignment(JLabel.CENTER);

    this.progressBar = new JProgressBar();
    this.progressBar.setIndeterminate(true);
    this.progressBar.getModel().addChangeListener(new ChangeListener() {
        private long timeAfterFirstImage;

        public void stateChanged(ChangeEvent ev) {
          int progressValue = progressBar.getValue();
          progressBar.setIndeterminate(progressValue <= progressBar.getMinimum() + 1);
          if (progressValue == progressBar.getMinimum()
              || progressValue == progressBar.getMaximum()) {
            progressLabel.setText("");
            if (progressValue == progressBar.getMinimum()) {
              int framesCount = progressBar.getMaximum() - progressBar.getMinimum();
              String progressLabelFormat = preferences.getLocalizedString(VideoPanel.class, "progressStartLabel.format");
              progressLabel.setText(String.format(progressLabelFormat, framesCount,
                  formatDuration(framesCount * 1000 / controller.getFrameRate())));
            }
          } else if (progressValue == progressBar.getMinimum() + 1) {
            this.timeAfterFirstImage = System.currentTimeMillis();
          } else {
            // Update progress label once the second image is generated
            // (the first one can take more time because of initialization process)
            String progressLabelFormat = preferences.getLocalizedString(VideoPanel.class, "progressLabel.format");
            long estimatedRemainingTime = (System.currentTimeMillis() - this.timeAfterFirstImage)
                / (progressValue - 1 - progressBar.getMinimum())
                * (progressBar.getMaximum() - progressValue - 1);
            String estimatedRemainingTimeText = formatDuration(estimatedRemainingTime);
            progressLabel.setText(String.format(progressLabelFormat,
                progressValue, progressBar.getMaximum(), estimatedRemainingTimeText));
          }
        }

        /**
         * Returns a localized string of <code>duration</code> in millis.
         */
        private String formatDuration(long duration) {
          long durationInSeconds = duration / 1000;
          if (duration - durationInSeconds * 1000 >= 500) {
            durationInSeconds++;
          }
          String estimatedRemainingTimeText;
          if (durationInSeconds < 60) {
            estimatedRemainingTimeText = String.format(preferences.getLocalizedString(
                VideoPanel.class, "seconds.format"), durationInSeconds);
          } else if (durationInSeconds < 3600) {
            estimatedRemainingTimeText = String.format(preferences.getLocalizedString(
                VideoPanel.class, "minutesSeconds.format"), durationInSeconds / 60, durationInSeconds % 60);
          } else {
            long hours = durationInSeconds / 3600;
            long minutes = (durationInSeconds % 3600) / 60;
            estimatedRemainingTimeText = String.format(preferences.getLocalizedString(
                VideoPanel.class, "hoursMinutes.format"), hours, minutes);
          }
          return estimatedRemainingTimeText;
        }
      });

    // Create video format label and combo box bound to WIDTH, HEIGHT, ASPECT_RATIO and FRAME_RATE controller properties
    this.videoFormatLabel = new JLabel();
    this.videoFormatComboBox = new JComboBox(VIDEO_FORMATS);
    this.videoFormatComboBox.setMaximumRowCount(VIDEO_FORMATS.length);
    this.videoFormatComboBox.setRenderer(new DefaultListCellRenderer() {
        @Override
        public Component getListCellRendererComponent(JList list, Object value,
                                                      int index, boolean isSelected, boolean cellHasFocus) {
          VideoFormat videoFormat = (VideoFormat)value;
          String aspectRatio;
          Dimension size = videoFormat.getSize();
          switch (getAspectRatio(size.width, size.height)) {
            case RATIO_4_3 :
              aspectRatio = preferences.getLocalizedString(
                  PhotoSizeAndQualityPanel.class, "aspectRatioComboBox.4_3Ratio.text");
              break;
            case RATIO_24_10 :
              aspectRatio = preferences.getLocalizedString(
                  PhotoSizeAndQualityPanel.class, "aspectRatioComboBox.2.40_1Ratio.text");
              break;
            default :
            case RATIO_16_9 :
              aspectRatio = preferences.getLocalizedString(
                  PhotoSizeAndQualityPanel.class, "aspectRatioComboBox.16_9Ratio.text");
              break;
          }
          Dimension videoSize = size;
          String displayedValue = String.format(videoFormatComboBoxFormat, videoSize.width, videoSize.height,
              aspectRatio, (int)videoFormat.getFrameRate());
          return super.getListCellRendererComponent(list, displayedValue, index, isSelected,
              cellHasFocus);
        }
      });
    this.videoFormatComboBox.addItemListener(new ItemListener() {
        public void itemStateChanged(ItemEvent ev) {
          VideoFormat videoFormat = (VideoFormat)videoFormatComboBox.getSelectedItem();
          Dimension size = videoFormat.getSize();
          controller.setWidth(videoFormat.getSize().width);
          controller.setAspectRatio(getAspectRatio(size.width, size.height));
          controller.setFrameRate((int)videoFormat.getFrameRate());
        }
      });
    PropertyChangeListener propertyChangeListener = new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          videoFormatComboBox.setSelectedItem(getVideoFormat(controller.getWidth(), controller.getAspectRatio(), controller.getFrameRate()));
        }
      };
    controller.addPropertyChangeListener(VideoController.Property.WIDTH, propertyChangeListener);
    controller.addPropertyChangeListener(VideoController.Property.HEIGHT, propertyChangeListener);
    controller.addPropertyChangeListener(VideoController.Property.ASPECT_RATIO, propertyChangeListener);
    controller.addPropertyChangeListener(VideoController.Property.FRAME_RATE, propertyChangeListener);

    // Quality label and slider bound to QUALITY controller property
    this.qualityLabel = new JLabel();
    Dimension imageSize;
    try {
      imageSize = SwingTools.getImageSizeInPixels(new ResourceURLContent(PhotoSizeAndQualityPanel.class,
            "resources/quality0.jpg"));
    } catch (IOException ex) {
      // Shouldn't happen since resource exists
      imageSize = null;
    }
    float resolutionScale = SwingTools.getResolutionScale();
    final int imageWidth = (int)(imageSize.width * resolutionScale);
    final int imageHeight = (int)(imageSize.height * resolutionScale);
    this.qualitySlider = new JSlider(1, controller.getQualityLevelCount()) {
        @Override
        public String getToolTipText(MouseEvent ev) {
          float valueUnderMouse = getSliderValueAt(this, ev.getX(), preferences);
          float valueToTick = valueUnderMouse - (float)Math.floor(valueUnderMouse);
          if (valueToTick < 0.25f || valueToTick > 0.75f) {
            // Display a tooltip that explains the different quality levels
            URL imageUrl = new ResourceURLContent(VideoPanel.class, "resources/quality" + Math.round(valueUnderMouse - qualitySlider.getMinimum()) + ".jpg").getURL();
            String imageHtmlCell = "<td><img border='1' width='" + imageWidth + "' height='" + imageHeight + "' src='" + imageUrl + "'></td>";
            String description = preferences.getLocalizedString(PhotoSizeAndQualityPanel.class, "quality" + Math.round(valueUnderMouse - qualitySlider.getMinimum()) + "DescriptionLabel.text");
            boolean leftToRightOrientation = qualitySlider.getComponentOrientation().isLeftToRight();
            String descriptionHtmlCell = "<td align='" + (leftToRightOrientation ? "left" : "right") + "'>" + description + "</td>";
            return "<html><table><tr valign='middle'>"
                + (leftToRightOrientation ? imageHtmlCell + descriptionHtmlCell : descriptionHtmlCell + imageHtmlCell)
                + "</tr></table>";
          } else {
            return null;
          }
        }
      };
    // Add a listener that displays also the tool tip when user clicks on the slider
    this.qualitySlider.addMouseListener(new MouseAdapter() {
        @Override
        public void mousePressed(final MouseEvent ev) {
          EventQueue.invokeLater(new Runnable() {
              public void run() {
                float valueUnderMouse = getSliderValueAt(qualitySlider, ev.getX(), preferences);
                if (qualitySlider.getValue() == Math.round(valueUnderMouse)) {
                  ToolTipManager toolTipManager = ToolTipManager.sharedInstance();
                  int initialDelay = toolTipManager.getInitialDelay();
                  toolTipManager.setInitialDelay(Math.min(initialDelay, 150));
                  toolTipManager.mouseMoved(ev);
                  toolTipManager.setInitialDelay(initialDelay);
                }
              }
            });
        }
      });
    this.qualitySlider.setPaintLabels(true);
    this.qualitySlider.setPaintTicks(true);
    this.qualitySlider.setMajorTickSpacing(1);
    this.qualitySlider.setSnapToTicks(true);
    final boolean offScreenImageSupported = Component3DManager.getInstance().isOffScreenImageSupported();
    this.qualitySlider.addChangeListener(new ChangeListener() {
        public void stateChanged(ChangeEvent ev) {
          if (!offScreenImageSupported) {
            // Can't support 2 first quality levels if offscreen image isn't supported
            qualitySlider.setValue(Math.max(qualitySlider.getMinimum() + 2, qualitySlider.getValue()));
          }
          controller.setQuality(qualitySlider.getValue() - qualitySlider.getMinimum());
        }
      });
    controller.addPropertyChangeListener(VideoController.Property.QUALITY,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            qualitySlider.setValue(qualitySlider.getMinimum() + controller.getQuality());
            updateAdvancedComponents();
          }
        });
    this.qualitySlider.setValue(this.qualitySlider.getMinimum() + controller.getQuality());

    this.advancedComponentsSeparator = new JSeparator();

    // Create date and time labels and spinners bound to TIME controller property
    Date time = new Date(Camera.convertTimeToTimeZone(controller.getTime(), TimeZone.getDefault().getID()));
    this.dateLabel = new JLabel();
    final SpinnerDateModel dateSpinnerModel = new SpinnerDateModel();
    dateSpinnerModel.setValue(time);
    this.dateSpinner = new JSpinner(dateSpinnerModel);
    String datePattern = ((SimpleDateFormat)DateFormat.getDateInstance(DateFormat.SHORT)).toPattern();
    if (datePattern.indexOf("yyyy") == -1) {
      datePattern = datePattern.replace("yy", "yyyy");
    }
    JSpinner.DateEditor dateEditor = new JSpinner.DateEditor(this.dateSpinner, datePattern);
    this.dateSpinner.setEditor(dateEditor);
    SwingTools.addAutoSelectionOnFocusGain(dateEditor.getTextField());

    this.timeLabel = new JLabel();
    final SpinnerDateModel timeSpinnerModel = new SpinnerDateModel();
    timeSpinnerModel.setValue(time);
    this.timeSpinner = new JSpinner(timeSpinnerModel);
    // From http://en.wikipedia.org/wiki/12-hour_clock#Use_by_country
    String [] twelveHoursCountries = {
        "AU",  // Australia
        "BD",  // Bangladesh
        "CA",  // Canada (excluding Quebec, in French)
        "CO",  // Colombia
        "EG",  // Egypt
        "HN",  // Honduras
        "JO",  // Jordan
        "MX",  // Mexico
        "MY",  // Malaysia
        "NI",  // Nicaragua
        "NZ",  // New Zealand
        "PH",  // Philippines
        "PK",  // Pakistan
        "SA",  // Saudi Arabia
        "SV",  // El Salvador
        "US",  // United States
        "VE"}; // Venezuela
    SimpleDateFormat timeInstance;
    if ("en".equals(Locale.getDefault().getLanguage())) {
      if (Arrays.binarySearch(twelveHoursCountries, Locale.getDefault().getCountry()) >= 0) {
        timeInstance = (SimpleDateFormat)DateFormat.getTimeInstance(DateFormat.SHORT, Locale.US); // 12 hours notation
      } else {
        timeInstance = (SimpleDateFormat)DateFormat.getTimeInstance(DateFormat.SHORT, Locale.UK); // 24 hours notation
      }
    } else {
      timeInstance = (SimpleDateFormat)DateFormat.getTimeInstance(DateFormat.SHORT);
    }
    JSpinner.DateEditor timeEditor = new JSpinner.DateEditor(this.timeSpinner, timeInstance.toPattern());
    this.timeSpinner.setEditor(timeEditor);
    SwingTools.addAutoSelectionOnFocusGain(timeEditor.getTextField());

    final PropertyChangeListener timeChangeListener = new PropertyChangeListener() {
      public void propertyChange(PropertyChangeEvent ev) {
        Date date = new Date(Camera.convertTimeToTimeZone(controller.getTime(), TimeZone.getDefault().getID()));
        dateSpinnerModel.setValue(date);
        timeSpinnerModel.setValue(date);
      }
    };
    controller.addPropertyChangeListener(VideoController.Property.TIME, timeChangeListener);
    final ChangeListener dateTimeChangeListener = new ChangeListener() {
        public void stateChanged(ChangeEvent ev) {
          controller.removePropertyChangeListener(VideoController.Property.TIME, timeChangeListener);
          // Merge date and time
          GregorianCalendar dateCalendar = new GregorianCalendar();
          dateCalendar.setTime((Date)dateSpinnerModel.getValue());
          GregorianCalendar timeCalendar = new GregorianCalendar();
          timeCalendar.setTime((Date)timeSpinnerModel.getValue());
          Calendar utcCalendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
          utcCalendar.set(GregorianCalendar.YEAR, dateCalendar.get(GregorianCalendar.YEAR));
          utcCalendar.set(GregorianCalendar.MONTH, dateCalendar.get(GregorianCalendar.MONTH));
          utcCalendar.set(GregorianCalendar.DAY_OF_MONTH, dateCalendar.get(GregorianCalendar.DAY_OF_MONTH));
          utcCalendar.set(GregorianCalendar.HOUR_OF_DAY, timeCalendar.get(GregorianCalendar.HOUR_OF_DAY));
          utcCalendar.set(GregorianCalendar.MINUTE, timeCalendar.get(GregorianCalendar.MINUTE));
          utcCalendar.set(GregorianCalendar.SECOND, timeCalendar.get(GregorianCalendar.SECOND));
          controller.setTime(utcCalendar.getTimeInMillis());
          controller.addPropertyChangeListener(VideoController.Property.TIME, timeChangeListener);
        }
      };
    dateSpinnerModel.addChangeListener(dateTimeChangeListener);
    timeSpinnerModel.addChangeListener(dateTimeChangeListener);

    this.dayNightLabel = new JLabel();
    final ImageIcon dayIcon = SwingTools.getScaledImageIcon(VideoPanel.class.getResource("resources/day.png"));
    final ImageIcon nightIcon = SwingTools.getScaledImageIcon(VideoPanel.class.getResource("resources/night.png"));
    PropertyChangeListener dayNightListener = new PropertyChangeListener() {
        public void propertyChange(PropertyChangeEvent ev) {
          if (home.getCompass().getSunElevation(
                Camera.convertTimeToTimeZone(controller.getTime(), home.getCompass().getTimeZone())) > 0) {
            dayNightLabel.setIcon(dayIcon);
          } else {
            dayNightLabel.setIcon(nightIcon);
          }
        }
      };
    controller.addPropertyChangeListener(VideoController.Property.TIME, dayNightListener);
    home.getCompass().addPropertyChangeListener(dayNightListener);
    dayNightListener.propertyChange(null);

    this.ceilingLightEnabledCheckBox = new JCheckBox();
    this.ceilingLightEnabledCheckBox.setSelected(controller.getCeilingLightColor() > 0);
    controller.addPropertyChangeListener(VideoController.Property.CEILING_LIGHT_COLOR,
        new PropertyChangeListener() {
          public void propertyChange(PropertyChangeEvent ev) {
            ceilingLightEnabledCheckBox.setSelected(controller.getCeilingLightColor() > 0);
          }
        });
    this.ceilingLightEnabledCheckBox.addItemListener(new ItemListener() {
        public void itemStateChanged(ItemEvent ev) {
          controller.setCeilingLightColor(ceilingLightEnabledCheckBox.isSelected() ? 0xD0D0D0 : 0);
        }
      });

    this.createButton = new JButton(actionMap.get(ActionType.START_VIDEO_CREATION));
    this.saveButton = new JButton(actionMap.get(ActionType.SAVE_VIDEO));
    this.closeButton = new JButton(actionMap.get(ActionType.CLOSE));

    setComponentTexts(preferences);
    updatePlaybackTimer();

    this.videoFormatComboBox.setSelectedItem(getVideoFormat(controller.getWidth(), controller.getAspectRatio(), controller.getFrameRate()));
  }

  private VideoFormat getVideoFormat(int width, AspectRatio aspectRatio, int frameRate) {
    for (VideoFormat videoFormat : VIDEO_FORMATS) {
      if (videoFormat.getSize().width == width
          && getAspectRatio(videoFormat.getSize().width, videoFormat.getSize().height) == aspectRatio
          && videoFormat.getFrameRate() == frameRate) {
        return videoFormat;
      }
    }
    return VIDEO_FORMATS [0];
  }

  /**
   * Returns the slider value matching a given x.
   */
  private float getSliderValueAt(JSlider qualitySlider, int x, UserPreferences preferences) {
    int fastLabelOffset = OperatingSystem.isLinux()
        ? 0
        : new JLabel(SwingTools.getLocalizedLabelText(preferences,
              VideoPanel.class, "fastLabel.text")).getPreferredSize().width / 2;
    int bestLabelOffset = OperatingSystem.isLinux()
        ? 0
        : new JLabel(SwingTools.getLocalizedLabelText(preferences,
              VideoPanel.class, "bestLabel.text")).getPreferredSize().width / 2;
    int sliderWidth = qualitySlider.getWidth() - fastLabelOffset - bestLabelOffset;
    return qualitySlider.getMinimum()
        + (float)(qualitySlider.getComponentOrientation().isLeftToRight()
                    ? x - fastLabelOffset
                    : sliderWidth - x + bestLabelOffset)
        / sliderWidth * (qualitySlider.getMaximum() - qualitySlider.getMinimum());
  }

  /**
   * Sets the texts of the components.
   */
  private void setComponentTexts(UserPreferences preferences) {
    this.tipLabel.setText(preferences.getLocalizedString(VideoPanel.class, "tipLabel.text"));
    this.videoFormatLabel.setText(preferences.getLocalizedString(
        VideoPanel.class, "videoFormatLabel.text"));
    this.videoFormatComboBoxFormat = preferences.getLocalizedString(
        VideoPanel.class, "videoFormatComboBox.format");
    this.qualityLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        VideoPanel.class, "qualityLabel.text"));
    this.dateLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        VideoPanel.class, "dateLabel.text"));
    this.timeLabel.setText(SwingTools.getLocalizedLabelText(preferences,
        VideoPanel.class, "timeLabel.text"));
    this.ceilingLightEnabledCheckBox.setText(SwingTools.getLocalizedLabelText(preferences,
        VideoPanel.class, "ceilingLightEnabledCheckBox.text"));
    JLabel fastLabel = new JLabel(SwingTools.getLocalizedLabelText(preferences,
        VideoPanel.class, "fastLabel.text"));
    if (!Component3DManager.getInstance().isOffScreenImageSupported()) {
      fastLabel.setEnabled(false);
    }
    JLabel bestLabel = new JLabel(SwingTools.getLocalizedLabelText(preferences,
        VideoPanel.class, "bestLabel.text"));
    Dictionary<Integer,JComponent> qualitySliderLabelTable = new Hashtable<Integer,JComponent>();
    qualitySliderLabelTable.put(this.qualitySlider.getMinimum(), fastLabel);
    qualitySliderLabelTable.put(this.qualitySlider.getMaximum(), bestLabel);
    this.qualitySlider.setLabelTable(qualitySliderLabelTable);
    this.dialogTitle = preferences.getLocalizedString(VideoPanel.class, "createVideo.title");
    Window window = SwingUtilities.getWindowAncestor(this);
    if (window != null) {
      ((JDialog)window).setTitle(this.dialogTitle);
    }
    // Buttons text changes automatically through their action
  }

  /**
   * Sets components mnemonics and label / component associations.
   */
  private void setMnemonics(UserPreferences preferences) {
    if (!OperatingSystem.isMacOSX()) {
      this.videoFormatLabel.setDisplayedMnemonic(
          KeyStroke.getKeyStroke(preferences.getLocalizedString(
              VideoPanel.class, "videoFormatLabel.mnemonic")).getKeyCode());
      this.videoFormatLabel.setLabelFor(this.videoFormatComboBox);
      this.qualityLabel.setDisplayedMnemonic(
          KeyStroke.getKeyStroke(preferences.getLocalizedString(
              VideoPanel.class, "qualityLabel.mnemonic")).getKeyCode());
      this.qualityLabel.setLabelFor(this.qualitySlider);
      this.dateLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          VideoPanel.class, "dateLabel.mnemonic")).getKeyCode());
      this.dateLabel.setLabelFor(this.dateSpinner);
      this.timeLabel.setDisplayedMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          VideoPanel.class, "timeLabel.mnemonic")).getKeyCode());
      this.timeLabel.setLabelFor(this.timeSpinner);
      this.ceilingLightEnabledCheckBox.setMnemonic(KeyStroke.getKeyStroke(preferences.getLocalizedString(
          VideoPanel.class, "ceilingLightEnabledCheckBox.mnemonic")).getKeyCode());
    }
  }

  /**
   * Preferences property listener bound to this panel with a weak reference to avoid
   * strong link between user preferences and this panel.
   */
  public static class LanguageChangeListener implements PropertyChangeListener {
    private final WeakReference<VideoPanel> videoPanel;

    public LanguageChangeListener(VideoPanel videoPanel) {
      this.videoPanel = new WeakReference<VideoPanel>(videoPanel);
    }

    public void propertyChange(PropertyChangeEvent ev) {
      // If video panel was garbage collected, remove this listener from preferences
      VideoPanel videoPanel = this.videoPanel.get();
      UserPreferences preferences = (UserPreferences)ev.getSource();
      if (videoPanel == null) {
        preferences.removePropertyChangeListener(UserPreferences.Property.LANGUAGE, this);
      } else {
        videoPanel.applyComponentOrientation(ComponentOrientation.getOrientation(Locale.getDefault()));
        videoPanel.setComponentTexts(preferences);
        videoPanel.setMnemonics(preferences);
      }
    }
  }

  /**
   * Layouts panel components in panel with their labels.
   */
  private void layoutComponents() {
    int labelAlignment = OperatingSystem.isMacOSX()
        ? GridBagConstraints.LINE_END
        : GridBagConstraints.LINE_START;
    // Add tip and progress bar to a card panel
    this.statusLayout = new CardLayout();
    this.statusPanel = new JPanel(this.statusLayout);
    this.statusPanel.add(this.tipLabel, TIP_CARD);
    this.tipLabel.setMinimumSize(this.tipLabel.getPreferredSize());
    JPanel progressPanel = new JPanel(new BorderLayout(5, 2));
    progressPanel.add(this.progressBar, BorderLayout.NORTH);
    progressPanel.add(this.progressLabel);
    this.statusPanel.add(progressPanel, PROGRESS_CARD);
    int standardGap = Math.round(5 * SwingTools.getResolutionScale());
        // First row
    add(this.planComponent, new GridBagConstraints(
        0, 0, 4, 1, 1, 1, labelAlignment,
        GridBagConstraints.BOTH, new Insets(0, 0, standardGap, 0), 0, 0));
    // Second row
    add(this.videoToolBar, new GridBagConstraints(
        0, 1, 4, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(0, 0, standardGap, 0), 0, 0));
    // Third row
    add(this.statusPanel, new GridBagConstraints(
        0, 2, 4, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(0, 0, 10, 0), 0, 0));
    // Fourth row
    // Add a dummy label at left and right
    add(new JLabel(), new GridBagConstraints(
        0, 3, 1, 6, 0.5f, 0, GridBagConstraints.CENTER,
        GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0));
    add(new JLabel(), new GridBagConstraints(
        3, 3, 1, 6, 0.5f, 0, GridBagConstraints.CENTER,
        GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0));
    add(this.videoFormatLabel, new GridBagConstraints(
        1, 3, 1, 1, 0, 0, labelAlignment,
        GridBagConstraints.NONE, new Insets(0, 0, standardGap, standardGap), 0, 0));
    add(this.videoFormatComboBox, new GridBagConstraints(
        2, 3, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, 10), -50, 0));
    // Fifth row
    add(this.qualityLabel, new GridBagConstraints(
        1, 4, 1, 1, 0, 0, labelAlignment,
        GridBagConstraints.NONE, new Insets(0, 0, 2, standardGap), 0, 0));
    add(this.qualitySlider, new GridBagConstraints(
        2, 4, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, 2, 0), 0, 0));
    // Sixth row
    add(this.advancedComponentsSeparator, new GridBagConstraints(
        1, 6, 4, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.HORIZONTAL, new Insets(3, 0, 3, 0), 0, 0));
    // Seventh row
    JPanel advancedPanel = new JPanel(new GridBagLayout());
    advancedPanel.add(this.dateLabel, new GridBagConstraints(
        1, 7, 1, 1, 0, 0, labelAlignment,
        GridBagConstraints.NONE, new Insets(0, 0, standardGap, standardGap), 0, 0));
    advancedPanel.add(this.dateSpinner, new GridBagConstraints(
        2, 7, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, 10), 1, 0));
    advancedPanel.add(this.timeLabel, new GridBagConstraints(
        3, 7, 1, 1, 0, 0, labelAlignment,
        GridBagConstraints.NONE, new Insets(0, 0, standardGap, standardGap), 0, 0));
    advancedPanel.add(this.timeSpinner, new GridBagConstraints(
        4, 7, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, standardGap), 0, 0));
    advancedPanel.add(this.dayNightLabel, new GridBagConstraints(
        5, 7, 1, 1, 0, 0, GridBagConstraints.LINE_START,
        GridBagConstraints.HORIZONTAL, new Insets(0, 0, standardGap, 0), 0, 0));
    // Last row
    advancedPanel.add(this.ceilingLightEnabledCheckBox, new GridBagConstraints(
        1, 8, 5, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(0, 0, 0, 0), 0, 0));

    add(advancedPanel, new GridBagConstraints(
        0, 7, 4, 1, 0, 0, GridBagConstraints.CENTER,
        GridBagConstraints.NONE, new Insets(0, 0, 0, 0), 0, 0));
  }

  private void updateAdvancedComponents() {
    Component root = SwingUtilities.getRoot(this);
    if (root != null) {
      boolean highQuality = controller.getQuality() >= 2;
      boolean advancedComponentsVisible = this.advancedComponentsSeparator.isVisible();
      if (advancedComponentsVisible != highQuality) {
        int componentsHeight = this.advancedComponentsSeparator.getPreferredSize().height + 6
            + this.dateSpinner.getPreferredSize().height + 5
            + this.ceilingLightEnabledCheckBox.getPreferredSize().height;
        this.advancedComponentsSeparator.setVisible(highQuality);
        this.dateLabel.setVisible(highQuality);
        this.dateSpinner.setVisible(highQuality);
        this.timeLabel.setVisible(highQuality);
        this.timeSpinner.setVisible(highQuality);
        this.dayNightLabel.setVisible(highQuality);
        this.ceilingLightEnabledCheckBox.setVisible(highQuality);
        root.setSize(root.getWidth(),
            root.getHeight() + (advancedComponentsVisible ? -componentsHeight : componentsHeight));
      }
    }
  }

  /**
   * Displays this panel in a non modal dialog.
   */
  public void displayView(View parentView) {
    if (currentVideoPanel == this) {
      SwingUtilities.getWindowAncestor(VideoPanel.this).toFront();
    } else {
      if (currentVideoPanel != null) {
        currentVideoPanel.close();
      }
      final JOptionPane optionPane = new JOptionPane(this,
          JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION,
          null, new Object [] {this.createButton, this.saveButton, this.closeButton}, this.createButton);
      final JDialog dialog = optionPane.createDialog(SwingUtilities.getRootPane((Component)parentView), this.dialogTitle);
      dialog.applyComponentOrientation(parentView != null
          ? ((JComponent)parentView).getComponentOrientation()
          : ComponentOrientation.getOrientation(Locale.getDefault()));
      dialog.setModal(false);

      Component homeRoot = SwingUtilities.getRoot((Component)parentView);
      Point dialogLocation = null;
      if (homeRoot != null) {
        // Restore location if it exists
        Number x = this.home.getNumericProperty(VIDEO_DIALOG_X_VISUAL_PROPERTY);
        Number y = this.home.getNumericProperty(VIDEO_DIALOG_Y_VISUAL_PROPERTY);

        int windowRightBorder = homeRoot.getX() + homeRoot.getWidth();
        Dimension screenSize = getToolkit().getScreenSize();
        Insets screenInsets = getToolkit().getScreenInsets(getGraphicsConfiguration());
        int screenRightBorder = screenSize.width - screenInsets.right;
        // Check dialog isn't too high
        int screenHeight = screenSize.height - screenInsets.top - screenInsets.bottom;
        if (OperatingSystem.isLinux() && screenHeight == screenSize.height) {
          // Let's consider that under Linux at least an horizontal bar exists
          screenHeight -= 30;
        }
        int screenBottomBorder = screenSize.height - screenInsets.bottom;
        int dialogWidth = dialog.getWidth();
        if (dialog.getHeight() > screenHeight) {
          dialog.setSize(dialogWidth, screenHeight);
        }
        int dialogHeight = dialog.getHeight();
        if (x != null && y != null
            && x.intValue() + dialogWidth <= screenRightBorder
            && y.intValue() + dialogHeight <= screenBottomBorder) {
          dialogLocation = new Point(x.intValue(), y.intValue());
        } else if (screenRightBorder - windowRightBorder > dialogWidth / 2
            || dialogHeight == screenHeight) {
          // If there some space left at the right of the window
          // move the dialog to the right of window
          dialogLocation = new Point(Math.min(windowRightBorder + 5, screenRightBorder - dialogWidth),
              Math.max(Math.min(homeRoot.getY() + dialog.getInsets().top,
                  screenSize.height - dialogHeight - screenInsets.bottom), screenInsets.top));
        }
      }
      if (dialogLocation != null
          && SwingTools.isRectangleVisibleAtScreen(new Rectangle(dialogLocation, dialog.getSize()))) {
        dialog.setLocationByPlatform(false);
        dialog.setLocation(dialogLocation);
      } else {
        dialog.setLocationByPlatform(true);
      }

      dialog.addWindowListener(new WindowAdapter() {
        public void windowClosed(WindowEvent ev) {
          stopVideoCreation(false);
          if (playbackTimer != null) {
            pausePlayback();
          }
          if (videoFile != null) {
            videoFile.delete();
          }
          currentVideoPanel = null;
        }
      });

      updateAdvancedComponents();
      ToolTipManager.sharedInstance().registerComponent(this.qualitySlider);
      dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
      dialog.addComponentListener(new ComponentAdapter() {
          @Override
          public void componentHidden(ComponentEvent ev) {
            if (optionPane.getValue() != null
                && optionPane.getValue() != JOptionPane.UNINITIALIZED_VALUE) {
              close();
            }
          }

          @Override
          public void componentMoved(ComponentEvent ev) {
            controller.setHomeProperty(VIDEO_DIALOG_X_VISUAL_PROPERTY, String.valueOf(dialog.getX()));
            controller.setHomeProperty(VIDEO_DIALOG_Y_VISUAL_PROPERTY, String.valueOf(dialog.getY()));
          }
        });
      dialog.setVisible(true);
      currentVideoPanel = this;
    }
  }

  /**
   * Records the location and the angles of the current camera.
   */
  private void recordCameraLocation() {
    List<Camera> cameraPath = this.controller.getCameraPath();
    Camera camera = this.home.getCamera();
    Camera lastCamera = null;
    if (cameraPath.size() > 0) {
      lastCamera = cameraPath.get(cameraPath.size() - 1);
    }
    if (lastCamera == null
        || !compareCameraLocation(lastCamera, camera)) {
      // Record only new locations
      cameraPath = new ArrayList<Camera>(cameraPath);
      Camera recordedCamera = camera.clone();
      recordedCamera.setLens(Camera.Lens.PINHOLE);
      recordedCamera.setTime(this.controller.getTime());
      cameraPath.add(recordedCamera);
      this.controller.setCameraPath(cameraPath);
    }
  }

  /**
   * Returns <code>true</code> if the given cameras are at the same location.
   */
  private boolean compareCameraLocation(Camera camera1, Camera camera2) {
    return camera1.getX() == camera2.getX()
        && camera1.getY() == camera2.getY()
        && camera1.getZ() == camera2.getZ()
        && camera1.getYaw() == camera2.getYaw()
        && camera1.getPitch() == camera2.getPitch()
        && camera1.getFieldOfView() == camera2.getFieldOfView()
        && camera1.getTime() == camera2.getTime();
  }

  /**
   * Updates the timer used for playback.
   */
  private void updatePlaybackTimer() {
    final List<Camera> cameraPath = this.controller.getCameraPath();
    final ActionMap actionMap = getActionMap();
    boolean playable = cameraPath.size() > 1;
    if (playable) {
      Camera [] videoFramesPath = getVideoFramesPath(this.controller.getSpeed(), 12);
      // Find current camera location
      Camera homeCamera = home.getCamera();
      int index = videoFramesPath.length;
      while (--index > 0
          && !compareCameraLocation(videoFramesPath [index], homeCamera)) {
      }
      // Prefer last location
      if (index < 0 || index == videoFramesPath.length - 1) {
        index = videoFramesPath.length;
      }
      this.cameraPathIterator = Arrays.asList(videoFramesPath).listIterator(index);
      ActionListener playbackAction = new ActionListener() {
          public void actionPerformed(ActionEvent ev) {
            if ("backward".equals(ev.getActionCommand())) {
              if (cameraPathIterator.hasPrevious()) {
                Camera camera = cameraPathIterator.previous();
                home.getCamera().setCamera(camera);
                controller.setTime(camera.getTime());
              } else {
                pausePlayback();
              }
            } else {
              if (cameraPathIterator.hasNext()) {
                Camera camera = cameraPathIterator.next();
                home.getCamera().setCamera(camera);
                controller.setTime(camera.getTime());
              } else {
                pausePlayback();
              }
            }
            boolean pathEditable = videoCreationExecutor == null && !((Timer)ev.getSource()).isRunning();
            actionMap.get(ActionType.RECORD).setEnabled(pathEditable);
            actionMap.get(ActionType.DELETE_CAMERA_PATH).setEnabled(pathEditable && cameraPath.size() > 0);
            actionMap.get(ActionType.DELETE_LAST_RECORD).setEnabled(pathEditable && cameraPath.size() > 0);
            actionMap.get(ActionType.SEEK_BACKWARD).setEnabled(cameraPathIterator.hasPrevious());
            actionMap.get(ActionType.SKIP_BACKWARD).setEnabled(cameraPathIterator.hasPrevious());
            actionMap.get(ActionType.SEEK_FORWARD).setEnabled(cameraPathIterator.hasNext());
            actionMap.get(ActionType.SKIP_FORWARD).setEnabled(cameraPathIterator.hasNext());
          }
        };
      if (this.playbackTimer != null) {
        this.playbackTimer.stop();
      }
      this.playbackTimer = new Timer(1000 / 12, playbackAction);
      this.playbackTimer.setInitialDelay(0);
      this.playbackTimer.setCoalesce(false);
    }
    actionMap.get(ActionType.PLAYBACK).setEnabled(playable);
    actionMap.get(ActionType.RECORD).setEnabled(this.videoCreationExecutor == null);
    boolean emptyCameraPath = cameraPath.isEmpty();
    actionMap.get(ActionType.DELETE_CAMERA_PATH).setEnabled(this.videoCreationExecutor == null && !emptyCameraPath);
    actionMap.get(ActionType.DELETE_LAST_RECORD).setEnabled(this.videoCreationExecutor == null && !emptyCameraPath);
    actionMap.get(ActionType.SEEK_BACKWARD).setEnabled(playable && this.cameraPathIterator.hasPrevious());
    actionMap.get(ActionType.SKIP_BACKWARD).setEnabled(playable && this.cameraPathIterator.hasPrevious());
    actionMap.get(ActionType.SEEK_FORWARD).setEnabled(playable && this.cameraPathIterator.hasNext());
    actionMap.get(ActionType.SKIP_FORWARD).setEnabled(playable && this.cameraPathIterator.hasNext());
    actionMap.get(ActionType.START_VIDEO_CREATION).setEnabled(playable);
  }

  /**
   * Deletes the last recorded camera location.
   */
  private void deleteLastRecordedCameraLocation() {
    List<Camera> cameraPath = new ArrayList<Camera>(this.controller.getCameraPath());
    cameraPath.remove(cameraPath.size() - 1);
    this.controller.setCameraPath(cameraPath);
  }

  /**
   * Deletes the recorded camera path.
   */
  private void deleteCameraPath() {
    List<Camera> cameraPath = Collections.emptyList();
    this.controller.setCameraPath(cameraPath);
  }

  /**
   * Plays back the camera locations.
   */
  private void playback() {
    if (!this.cameraPathIterator.hasNext()) {
      skipBackward();
    }
    this.playbackTimer.start();
    this.playbackPauseButton.setAction(getActionMap().get(ActionType.PAUSE));
  }

  /**
   * Pauses play back.
   */
  private void pausePlayback() {
    this.playbackTimer.stop();
    this.playbackPauseButton.setAction(getActionMap().get(ActionType.PLAYBACK));
    getActionMap().get(ActionType.RECORD).setEnabled(this.videoCreationExecutor == null);
    boolean emptyCameraPath = this.controller.getCameraPath().isEmpty();
    getActionMap().get(ActionType.DELETE_CAMERA_PATH).setEnabled(this.videoCreationExecutor == null && !emptyCameraPath);
    getActionMap().get(ActionType.DELETE_LAST_RECORD).setEnabled(this.videoCreationExecutor == null && !emptyCameraPath);
  }

  /**
   * Moves quickly camera 10 steps backward.
   */
  private void seekBackward() {
    for (int i = 0; i < 10 && this.cameraPathIterator.hasPrevious(); i++) {
      this.playbackTimer.getActionListeners() [0].actionPerformed(
          new ActionEvent(this.playbackTimer, 0, "backward", System.currentTimeMillis(), 0));
    }
  }

  /**
   * Moves quickly camera 10 steps forward.
   */
  private void seekForward() {
    for (int i = 0; i < 10 && this.cameraPathIterator.hasNext(); i++) {
      this.playbackTimer.getActionListeners() [0].actionPerformed(
          new ActionEvent(this.playbackTimer, 0, "forward", System.currentTimeMillis(), 0));
    }
  }

  /**
   * Moves camera to animation start and restarts animation if it was running.
   */
  private void skipBackward() {
    while (this.cameraPathIterator.hasPrevious()) {
      seekBackward();
    }
  }

  /**
   * Moves camera to animation end and stops animation.
   */
  private void skipForward() {
    while (this.cameraPathIterator.hasNext()) {
      seekForward();
    }
  }

  /**
   * Returns the camera path that should be used to create each frame of an animation.
   */
  private Camera [] getVideoFramesPath(final float speed, int frameRate) {
    List<Camera> videoFramesPath = new ArrayList<Camera>();
    final float moveDistancePerFrame = speed * 100f / frameRate;  // speed is in m/s
    final float moveAnglePerFrame = (float)(Math.PI / 120 * 30 * speed / frameRate);
    final float elapsedTimePerFrame = 345600 / frameRate * 25; // 250 frame/day at 25 frame/second

    List<Camera> cameraPath = this.controller.getCameraPath();
    Camera camera = cameraPath.get(0);
    float x = camera.getX();
    float y = camera.getY();
    float z = camera.getZ();
    float yaw = camera.getYaw();
    float pitch = camera.getPitch();
    float fieldOfView = camera.getFieldOfView();
    long  time = camera.getTime();
    videoFramesPath.add(camera.clone());

    for (int i = 1; i < cameraPath.size(); i++) {
      camera = cameraPath.get(i);
      float newX = camera.getX();
      float newY = camera.getY();
      float newZ = camera.getZ();
      float newYaw = camera.getYaw();
      float newPitch = camera.getPitch();
      float newFieldOfView = camera.getFieldOfView();
      long  newTime = camera.getTime();

      float distance = new Point3f(x, y, z).distance(new Point3f(newX, newY, newZ));
      float moveCount = distance / moveDistancePerFrame;
      float yawAngleCount = Math.abs(newYaw - yaw) / moveAnglePerFrame;
      float pitchAngleCount = Math.abs(newPitch - pitch) / moveAnglePerFrame;
      float fieldOfViewAngleCount = Math.abs(newFieldOfView - fieldOfView) / moveAnglePerFrame;
      float timeCount = Math.abs(newTime - time) / elapsedTimePerFrame;

      int frameCount = (int)Math.max(moveCount, Math.max(yawAngleCount,
          Math.max(pitchAngleCount, Math.max(fieldOfViewAngleCount, timeCount))));

      float deltaX = (newX - x) / frameCount;
      float deltaY = (newY - y) / frameCount;
      float deltaZ = (newZ - z) / frameCount;
      float deltaYawAngle = (newYaw - yaw) / frameCount;
      float deltaPitchAngle = (newPitch - pitch) / frameCount;
      float deltaFieldOfViewAngle = (newFieldOfView - fieldOfView) / frameCount;
      long deltaTime = Math.round(((double)newTime - time) / frameCount);

      for (int j = 1; j <= frameCount; j++) {
        videoFramesPath.add(new Camera(
            x + deltaX * j, y + deltaY * j, z + deltaZ * j,
            yaw + deltaYawAngle * j, pitch + deltaPitchAngle * j,
            fieldOfView + deltaFieldOfViewAngle * j,
            time + deltaTime * j,
            Camera.Lens.PINHOLE));
      }

      x = newX;
      y = newY;
      z = newZ;
      yaw = newYaw;
      pitch = newPitch;
      fieldOfView = newFieldOfView;
      time = newTime;
    }

    return videoFramesPath.toArray(new Camera [videoFramesPath.size()]);
  }

  /**
   * Creates the video image depending on the quality requested by the user.
   */
  private void startVideoCreation() {
    ActionMap actionMap = getActionMap();
    actionMap.get(ActionType.SAVE_VIDEO).setEnabled(false);
    this.createButton.setAction(getActionMap().get(ActionType.STOP_VIDEO_CREATION));
    actionMap.get(ActionType.RECORD).setEnabled(false);
    actionMap.get(ActionType.DELETE_CAMERA_PATH).setEnabled(false);
    actionMap.get(ActionType.DELETE_LAST_RECORD).setEnabled(false);
    getRootPane().setDefaultButton(this.createButton);
    this.videoFormatComboBox.setEnabled(false);
    this.qualitySlider.setEnabled(false);
    this.dateSpinner.setEnabled(false);
    this.timeSpinner.setEnabled(false);
    this.ceilingLightEnabledCheckBox.setEnabled(false);
    this.statusLayout.show(this.statusPanel, PROGRESS_CARD);
    this.progressBar.setIndeterminate(true);
    this.progressLabel.setText("");

    // Compute video in an other executor thread
    // Use a clone of home because the user can modify home during video computation
    final Home home = this.home.clone();
    this.videoCreationExecutor = Executors.newSingleThreadExecutor();
    this.videoCreationExecutor.execute(new Runnable() {
        public void run() {
          computeVideo(home);
        }
      });
  }

  /**
   * Computes the video of the given home.
   * Caution : this method must be thread safe because it's called from an executor.
   */
  private void computeVideo(Home home) {
    this.videoCreationStartTime = System.currentTimeMillis();
    int frameRate = this.controller.getFrameRate();
    int quality = this.controller.getQuality();
    int width = this.controller.getWidth();
    int height = this.controller.getHeight();
    float speed = this.controller.getSpeed();
    final Camera [] videoFramesPath = getVideoFramesPath(speed, frameRate);
    // Set initial camera location because its type may change rendering setting
    home.setCamera(videoFramesPath [0]);
    final BoundedRangeModel progressModel = this.progressBar.getModel();
    EventQueue.invokeLater(new Runnable() {
        public void run() {
          progressModel.setMinimum(0);
          progressModel.setMaximum(videoFramesPath.length);
          progressModel.setValue(0);
        }
      });
    FrameGenerator frameGenerator = null;
    // Delete previous file if it exists
    if (this.videoFile != null) {
      this.videoFile.delete();
      this.videoFile = null;
    }
    File file = null;
    try {
      file = OperatingSystem.createTemporaryFile("video", ".mov");
      if (quality >= 2) {
        frameGenerator = new PhotoImageGenerator(home, width, height, this.object3dFactory,
            quality == 2
              ? PhotoRenderer.Quality.LOW
              : PhotoRenderer.Quality.HIGH);
      } else {
        frameGenerator = new Image3DGenerator(home, this.preferences, width, height, this.object3dFactory,
            quality == 1
            && (!this.preferences.isDrawingModeEnabled()
                || home.getEnvironment().getDrawingMode() != HomeEnvironment.DrawingMode.OUTLINE));
      }
      if (!Thread.currentThread().isInterrupted()) {
        ImageDataSource sourceStream = new ImageDataSource((VideoFormat)this.videoFormatComboBox.getSelectedItem(),
            frameGenerator, videoFramesPath, progressModel);
        new JPEGImagesToVideo().createVideoFile(width, height, frameRate, sourceStream, file);
      }
    } catch (InterruptedIOException ex) {
      if (file != null) {
        file.delete();
        file = null;
      }
    } catch (IOException ex) {
      showError("createVideoError.message", ex.getMessage());
      file = null;
    } catch (OutOfMemoryError ex) {
      showError("createVideoError.message",
          this.preferences.getLocalizedString(VideoPanel.class, "outOfMemory.message"));
      file = null;
    } finally {
      this.videoFile = file;
      EventQueue.invokeLater(new Runnable() {
          public void run() {
            ActionMap actionMap = getActionMap();
            actionMap.get(ActionType.SAVE_VIDEO).setEnabled(videoFile != null);
            createButton.setAction(actionMap.get(ActionType.START_VIDEO_CREATION));
            actionMap.get(ActionType.RECORD).setEnabled(true);
            actionMap.get(ActionType.DELETE_CAMERA_PATH).setEnabled(true);
            actionMap.get(ActionType.DELETE_LAST_RECORD).setEnabled(true);
            if (videoFile != null) {
              getRootPane().setDefaultButton(saveButton);
            }
            videoFormatComboBox.setEnabled(true);
            qualitySlider.setEnabled(true);
            dateSpinner.setEnabled(true);
            timeSpinner.setEnabled(true);
            ceilingLightEnabledCheckBox.setEnabled(true);
            statusLayout.show(statusPanel, TIP_CARD);
            videoCreationExecutor = null;
          }
        });
    }
  }

  /**
   * Shows a message error dialog.
   */
  private void showError(final String messageKey,
                         final String messageDetail) {
    EventQueue.invokeLater(new Runnable() {
        public void run() {
          String messageFormat = preferences.getLocalizedString(VideoPanel.class, messageKey);
          JOptionPane.showMessageDialog(SwingUtilities.getRootPane(VideoPanel.this), String.format(messageFormat, messageDetail),
              preferences.getLocalizedString(VideoPanel.class, "videoError.title"), JOptionPane.ERROR_MESSAGE);
        }
      });
  }

  /**
   * Stops video creation.
   */
  private void stopVideoCreation(boolean confirmStop) {
    if (this.videoCreationExecutor != null
        // Confirm the stop if a rendering has been running for more than 30 s
        && (!confirmStop
            || System.currentTimeMillis() - this.videoCreationStartTime < MINIMUM_DELAY_BEFORE_DISCARDING_WITHOUT_WARNING
            || JOptionPane.showConfirmDialog(this,
                  this.preferences.getLocalizedString(VideoPanel.class, "confirmStopCreation.message"),
                  this.preferences.getLocalizedString(VideoPanel.class, "confirmStopCreation.title"),
                  JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE) == JOptionPane.YES_OPTION)) {
      if (this.videoCreationExecutor != null) { // Check a second time in case rendering stopped meanwhile
        // Interrupt executor thread
        this.videoCreationExecutor.shutdownNow();
        this.videoCreationExecutor = null;
        this.createButton.setAction(getActionMap().get(ActionType.START_VIDEO_CREATION));
      }
    }
  }

  /**
   * Saves the created video.
   */
  private void saveVideo() {
    final String movFileName = this.controller.getContentManager().showSaveDialog(this,
        this.preferences.getLocalizedString(VideoPanel.class, "saveVideoDialog.title"),
        ContentManager.ContentType.MOV, this.home.getName());
    if (movFileName != null) {
      final Component rootPane = SwingUtilities.getRoot(this);
      final Cursor defaultCursor = rootPane.getCursor();
      rootPane.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));

      // Disable panel actions
      ActionMap actionMap = getActionMap();
      final boolean [] actionEnabledStates = new boolean [ActionType.values().length];
      for (ActionType action : ActionType.values()) {
        actionEnabledStates [action.ordinal()] = actionMap.get(action).isEnabled();
        actionMap.get(action).setEnabled(false);
      }
      Executors.newSingleThreadExecutor().execute(new Runnable() {
          public void run() {
            OutputStream out = null;
            InputStream in = null;
            IOException exception = null;
            try {
              // Copy temporary file to home file
              // Overwriting home file will ensure that its rights are kept
              out = new FileOutputStream(movFileName);
              byte [] buffer = new byte [8192];
              in = new FileInputStream(videoFile);
              int size;
              while ((size = in.read(buffer)) != -1 && isDisplayable()) {
                out.write(buffer, 0, size);
              }
            } catch (IOException ex) {
              exception = ex;
            } finally {
              try {
                if (out != null) {
                  out.close();
                }
              } catch (IOException ex) {
                if (exception == null) {
                  exception = ex;
                }
              }
              try {
                if (in != null) {
                  in.close();
                }
              } catch (IOException ex) {
                // Ignore close exception
              }
              // Delete saved file in case of error or if panel was closed meanwhile
              if (exception != null || !isDisplayable()) {
                new File(movFileName).delete();
                if (!isDisplayable()) {
                  exception = null;
                }
              }

              final IOException caughtException = exception;
              EventQueue.invokeLater(new Runnable() {
                  public void run() {
                    // Restore action state
                    ActionMap actionMap = getActionMap();
                    for (ActionType action : ActionType.values()) {
                      actionMap.get(action).setEnabled(actionEnabledStates [action.ordinal()]);
                    }

                    rootPane.setCursor(defaultCursor);
                    if (caughtException != null) {
                      showError("saveVideoError.message", caughtException.getMessage());
                    }
                  }
                });
            }
          }
        });
    }
  }

  /**
   * Manages closing of this pane.
   */
  private void close() {
    Window window = SwingUtilities.getWindowAncestor(this);
    if (window.isDisplayable()) {
      ToolTipManager.sharedInstance().unregisterComponent(this.qualitySlider);
      window.dispose();
    }
  }

  /**
   * Returns the video aspect ration of the given size.
   */
  private AspectRatio getAspectRatio(int width, int height) {
    return Math.abs((float)width / height - 4f / 3) < 0.001f
       ? AspectRatio.RATIO_4_3
       : (Math.abs((float)width / height - 24f / 10) < 0.03f
             ? AspectRatio.RATIO_24_10
             : AspectRatio.RATIO_16_9);
  }

  /**
   * A data source able to create JPEG buffers on the fly from camera path
   * and turn that into a stream of JMF buffers.
   */
  private static class ImageDataSource extends PullBufferDataSource {
    private ImageSourceStream stream;

    public ImageDataSource(VideoFormat format,
                           FrameGenerator frameGenerator,
                           Camera []      framesPath,
                           BoundedRangeModel progressModel) {
      this.stream = new ImageSourceStream(format, frameGenerator, framesPath, progressModel);
    }

    @Override
    public void setLocator(MediaLocator source) {
    }

    @Override
    public MediaLocator getLocator() {
      return null;
    }

    /**
     * Returns RAW since buffers of video frames are sent without a container format.
     */
    @Override
    public String getContentType() {
      return ContentDescriptor.RAW;
    }

    @Override
    public void connect() {
    }

    @Override
    public void disconnect() {
    }

    @Override
    public void start() {
    }

    @Override
    public void stop() {
    }

    public PullBufferStream [] getStreams() {
      return new PullBufferStream [] {this.stream};
    }

    /**
     * Not necessary to compute the duration.
     */
    @Override
    public Time getDuration() {
      return DURATION_UNKNOWN;
    }

    @Override
    public Object [] getControls() {
      return new Object [0];
    }

    @Override
    public Object getControl(String type) {
      return null;
    }
  }

  /**
   * A source of video images.
   */
  private static class ImageSourceStream implements PullBufferStream {
    private final FrameGenerator                 frameGenerator;
    private final Camera []                      framesPath;
    private final BoundedRangeModel              progressModel;
    private final javax.media.format.VideoFormat format;
    private int                                  imageIndex;
    private boolean                              stopped;
    private Object                               mainThreadContext;

    public ImageSourceStream(VideoFormat format,
                             FrameGenerator frameGenerator,
                             Camera [] framesPath,
                             final BoundedRangeModel progressModel) {
      this.frameGenerator = frameGenerator;
      this.framesPath = framesPath;
      this.progressModel = progressModel;
      this.format = format;
      try {
        // Retrieve main thread AppContext instance by reflection
        this.mainThreadContext = Class.forName("sun.awt.AppContext").getMethod("getAppContext").invoke(null);
      } catch (Throwable ex) {
        // Let's consider app context is not necessary for the program
      }
    }

    /**
     * Return <code>false</code> because source stream doesn't
     * need to block assuming data can be created on demand.
     */
    public boolean willReadBlock() {
      return false;
    }

    /**
     * This is called from the Processor to read a frame worth of video data.
     */
    public void read(Buffer buffer) throws IOException {
      buffer.setOffset(0);
      // Check if we've finished all the frames
      if (endOfStream()) {
        buffer.setEOM(true);
        buffer.setLength(0);
      } else {
        checkAppContext();
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        BufferedImage frame = this.frameGenerator.renderImageAt(this.framesPath [this.imageIndex],
            this.imageIndex == this.framesPath.length - 1);
        ImageIO.write(frame, "JPEG", outputStream);
        byte [] data = outputStream.toByteArray();
        buffer.setData(data);
        buffer.setLength(data.length);
        buffer.setFormat(this.format);
        buffer.setFlags(buffer.getFlags() | Buffer.FLAG_KEY_FRAME);

        final int progressionValue = this.imageIndex++;
        EventQueue.invokeLater(new Runnable() {
            public void run() {
              progressModel.setValue(progressionValue);
            }
          });
      }
    }

    /**
     * Checks that app context is correct.
     */
    private void checkAppContext() {
      if (this.mainThreadContext != null) {
        try {
          // Check by reflection that sun.awt.AppContext.getAppContext() doesn't return null
          // (required by ImageIO.write() and other JMF internal calls) to apply workaround proposed at
          // http://stackoverflow.com/questions/17223304/appcontext-is-null-from-rmi-thread-with-java-7-update-25
          Class<?> appContextClass = Class.forName("sun.awt.AppContext");
          if (appContextClass.getMethod("getAppContext").invoke(null) == null) {
            Field field = appContextClass.getDeclaredField("threadGroup2appContext");
            field.setAccessible(true);
            Map threadGroup2appContext = (Map)field.get(null);
            ThreadGroup currentThreadGroup = Thread.currentThread().getThreadGroup();
            threadGroup2appContext.put(currentThreadGroup, this.mainThreadContext);
          }
        } catch (Throwable ex) {
          // Let's consider app context is not necessary for the program
        }
        // Don't need mainThreadContext anymore
        this.mainThreadContext = null;
      }
    }

    /**
     * Return the format of each video frame. That will be JPEG.
     */
    public Format getFormat() {
      return this.format;
    }

    public ContentDescriptor getContentDescriptor() {
      return new ContentDescriptor(ContentDescriptor.RAW);
    }

    public long getContentLength() {
      return 0;
    }

    public boolean endOfStream() {
      return this.stopped || this.imageIndex == this.framesPath.length;
    }

    public Object [] getControls() {
      return new Object [0];
    }

    public Object getControl(String type) {
      return null;
    }
  }


  /**
   * An object able to generate a frame of a video at a camera location.
   */
  private static abstract class FrameGenerator {
    private Thread launchingThread;

    protected FrameGenerator() {
      this.launchingThread = Thread.currentThread();
    }

    public abstract BufferedImage renderImageAt(Camera frameCamera, boolean last) throws IOException;

    protected void checkLaunchingThreadIsntInterrupted() throws InterruptedIOException {
      if (this.launchingThread.isInterrupted()) {
        throw new InterruptedIOException("Lauching thread interrupted");
      }
    }
  }

  /**
   * A frame generator using photo renderer.
   */
  private static class PhotoImageGenerator extends FrameGenerator {
    private PhotoRenderer renderer;
    private BufferedImage image;

    public PhotoImageGenerator(Home home, int width, int height,
                               Object3DFactory object3dFactory,
                               PhotoRenderer.Quality quality) throws IOException {
      this.renderer = new PhotoRenderer(home, object3dFactory, quality);
      this.image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
    }

    public BufferedImage renderImageAt(Camera frameCamera, boolean last) throws IOException {
      try {
        checkLaunchingThreadIsntInterrupted();
        this.renderer.render(this.image, frameCamera, null);
        checkLaunchingThreadIsntInterrupted();
        return image;
      } catch(InterruptedIOException ex) {
        this.renderer = null;
        throw ex;
      } finally {
        if (last) {
          this.renderer.dispose();
          this.renderer = null;
        }
      }
    }
  }

  /**
   * A frame generator using 3D offscreen images.
   */
  private static class Image3DGenerator extends FrameGenerator {
    private final Home      home;
    private HomeComponent3D homeComponent3D;
    private BufferedImage   image;

    public Image3DGenerator(Home home, UserPreferences preferences, int width, int height,
                            Object3DFactory object3dFactory,
                            boolean displayShadowOnFloor) {
      this.home = home;
      this.homeComponent3D = new HomeComponent3D(home, preferences, object3dFactory, displayShadowOnFloor, null);
      this.homeComponent3D.startOffscreenImagesCreation();
      this.image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
    }

    public BufferedImage renderImageAt(Camera frameCamera, boolean last) throws IOException {
      try {
        checkLaunchingThreadIsntInterrupted();
        // Replace home camera with frameCamera to avoid animation interpolator in 3D component
        this.home.setCamera(frameCamera);
        // Get a twice bigger offscreen image for better quality
        // (antialiasing isn't always available for offscreen canvas)
        BufferedImage offScreenImage = this.homeComponent3D.getOffScreenImage(
            2 * this.image.getWidth(), 2 * this.image.getHeight());

        checkLaunchingThreadIsntInterrupted();
        Graphics graphics = this.image.getGraphics();
        graphics.drawImage(offScreenImage.getScaledInstance(
            this.image.getWidth(), this.image.getHeight(), Image.SCALE_SMOOTH), 0, 0, null);
        graphics.dispose();
        checkLaunchingThreadIsntInterrupted();
        return this.image;
      } catch(InterruptedIOException ex) {
        this.homeComponent3D.endOffscreenImagesCreation();
        throw ex;
      } finally {
        if (last) {
          this.homeComponent3D.endOffscreenImagesCreation();
        }
      }
    }
  }
}
