001/*
002 * (C) Copyright 2010-2015 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Olivier Grisel
018 */
019package org.nuxeo.ecm.platform.video;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.Serializable;
024import java.util.ArrayList;
025import java.util.HashMap;
026import java.util.LinkedHashMap;
027import java.util.List;
028import java.util.Map;
029
030import org.apache.commons.io.FilenameUtils;
031import org.apache.commons.logging.Log;
032import org.apache.commons.logging.LogFactory;
033import org.nuxeo.ecm.core.api.Blob;
034import org.nuxeo.ecm.core.api.Blobs;
035import org.nuxeo.ecm.core.api.CloseableFile;
036import org.nuxeo.ecm.core.api.DocumentModel;
037import org.nuxeo.ecm.core.api.NuxeoException;
038import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
039import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolder;
040import org.nuxeo.ecm.core.convert.api.ConversionException;
041import org.nuxeo.ecm.core.convert.api.ConversionService;
042import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
043import org.nuxeo.ecm.platform.commandline.executor.api.CommandException;
044import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService;
045import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable;
046import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
047import org.nuxeo.ecm.platform.picture.api.adapters.AbstractPictureAdapter;
048import org.nuxeo.ecm.platform.picture.api.adapters.PictureResourceAdapter;
049import org.nuxeo.ecm.platform.video.convert.Constants;
050import org.nuxeo.ecm.platform.video.convert.StoryboardConverter;
051import org.nuxeo.ecm.platform.video.service.Configuration;
052import org.nuxeo.ecm.platform.video.service.VideoService;
053import org.nuxeo.runtime.api.Framework;
054
055/**
056 * Helper class to factorize logic than can be either called from the UI or from core event listener.
057 * <p>
058 * If the need to evolve to make this further configurable (not just using the existing converter / commandline
059 * extensions points), we might want to turn this class into a full blown nuxeo service.
060 *
061 * @author ogrisel
062 */
063public class VideoHelper {
064
065    public static final Log log = LogFactory.getLog(VideoHelper.class);
066
067    public static final String MISSING_PREVIEW_PICTURE = "preview/missing-video-preview.jpeg";
068
069    public static final String FFMPEG_INFO_COMMAND_LINE = "ffmpeg-info";
070
071    /**
072     * @since 7.4
073     */
074    public static final int DEFAULT_MIN_DURATION_FOR_STORYBOARD = 10;
075
076    /**
077     * @since 7.4
078     */
079    public static final int DEFAULT_NUMBER_OF_THUMBNAILS = 9;
080
081    // TODO NXP-4792 OG: make this configurable somehow though an extension point. The imaging package need a similar
082    // refactoring, try to make both consistent
083    protected static final List<Map<String, Object>> THUMBNAILS_VIEWS = new ArrayList<>();
084
085    // Utility class.
086    private VideoHelper() {
087    }
088
089    static {
090        Map<String, Object> thumbnailView = new LinkedHashMap<>();
091        thumbnailView.put("title", "Small");
092        thumbnailView.put("maxsize", Long.valueOf(AbstractPictureAdapter.SMALL_SIZE));
093        THUMBNAILS_VIEWS.add(thumbnailView);
094        Map<String, Object> staticPlayerView = new HashMap<>();
095        staticPlayerView.put("title", "StaticPlayerView");
096        staticPlayerView.put("maxsize", Long.valueOf(AbstractPictureAdapter.MEDIUM_SIZE));
097        THUMBNAILS_VIEWS.add(staticPlayerView);
098    }
099
100    /**
101     * Update the JPEG story board and duration in seconds of a Video document from the video blob content.
102     */
103    @SuppressWarnings("unchecked")
104    public static void updateStoryboard(DocumentModel docModel, Blob video) {
105        if (video == null) {
106            docModel.setPropertyValue(VideoConstants.STORYBOARD_PROPERTY, null);
107            return;
108        }
109
110        VideoService videoService = Framework.getService(VideoService.class);
111        Configuration configuration = videoService.getConfiguration();
112
113        VideoDocument videoDocument = docModel.getAdapter(VideoDocument.class);
114        double duration = videoDocument.getVideo().getDuration();
115        double storyboardMinDuration = DEFAULT_MIN_DURATION_FOR_STORYBOARD;
116        if (configuration != null) {
117            storyboardMinDuration = configuration.getStoryboardMinDuration();
118        }
119
120        BlobHolder result = null;
121        if (storyboardMinDuration >= 0 && duration >= storyboardMinDuration) {
122            try {
123                Map<String, Serializable> parameters = new HashMap<>();
124                parameters.put("duration", duration);
125                int numberOfThumbnails = DEFAULT_NUMBER_OF_THUMBNAILS;
126                if (configuration != null) {
127                    numberOfThumbnails = configuration.getStoryboardThumbnailCount();
128                }
129                parameters.put(StoryboardConverter.THUMBNAIL_NUMBER_PARAM, numberOfThumbnails);
130                parameters.put(StoryboardConverter.ORIGINAL_WIDTH_PARAM, videoDocument.getVideo().getWidth());
131                parameters.put(StoryboardConverter.ORIGINAL_HEIGHT_PARAM, videoDocument.getVideo().getHeight());
132
133                result = Framework.getService(ConversionService.class)
134                                  .convert(Constants.STORYBOARD_CONVERTER, new SimpleBlobHolder(video), parameters);
135            } catch (ConversionException e) {
136                // this can happen when if the codec is not supported or not
137                // readable by ffmpeg and is recoverable by using a dummy preview
138                log.warn(String.format("could not extract story board for document '%s' with video file '%s': %s",
139                        docModel.getTitle(), video.getFilename(), e.getMessage()));
140                log.debug(e, e);
141                return;
142            }
143        }
144
145        if (result != null) {
146            List<Blob> blobs = result.getBlobs();
147            List<String> comments = (List<String>) result.getProperty("comments");
148            List<Double> timecodes = (List<Double>) result.getProperty("timecodes");
149            List<Map<String, Serializable>> storyboard = new ArrayList<>();
150            for (int i = 0; i < blobs.size(); i++) {
151                Map<String, Serializable> item = new HashMap<>();
152                item.put("comment", comments.get(i));
153                item.put("timecode", timecodes.get(i));
154                item.put("content", (Serializable) blobs.get(i));
155                storyboard.add(item);
156            }
157            docModel.setPropertyValue(VideoConstants.STORYBOARD_PROPERTY, (Serializable) storyboard);
158        }
159    }
160
161    /**
162     * Update the JPEG previews of a Video document from the video blob content by taking a screen-shot of the movie at
163     * timecode offset given in seconds.
164     */
165    public static void updatePreviews(DocumentModel docModel, Blob video, Double position,
166            List<Map<String, Object>> templates) throws IOException {
167        if (video == null) {
168            docModel.setPropertyValue("picture:views", null);
169            return;
170        }
171        Map<String, Serializable> parameters = new HashMap<>();
172        parameters.put(Constants.POSITION_PARAMETER, position);
173        BlobHolder result;
174        try {
175            result = Framework.getService(ConversionService.class)
176                              .convert(Constants.SCREENSHOT_CONVERTER, new SimpleBlobHolder(video), parameters);
177        } catch (ConversionException e) {
178            // this can happen when if the codec is not supported or not
179            // readable by ffmpeg and is recoverable by using a dummy preview
180            log.warn(String.format("could not extract screenshot from document '%s' with video file '%s': %s",
181                    docModel.getTitle(), video.getFilename(), e.getMessage()));
182            log.debug(e, e);
183            return;
184        }
185
186        // compute the thumbnail preview
187        if (result != null && result.getBlob() != null && result.getBlob().getLength() > 0) {
188            PictureResourceAdapter picture = docModel.getAdapter(PictureResourceAdapter.class);
189            try {
190                picture.fillPictureViews(result.getBlob(), result.getBlob().getFilename(), docModel.getTitle(),
191                        new ArrayList<>(templates));
192            } catch (IOException e) {
193                log.warn("failed to video compute previews for " + docModel.getTitle() + ": " + e.getMessage());
194            }
195        }
196
197        // put a black screen if the video or its screen-shot is unreadable
198        if (docModel.getProperty("picture:views").getValue(List.class).isEmpty()) {
199            try (InputStream is = VideoHelper.class.getResourceAsStream("/" + MISSING_PREVIEW_PICTURE)) {
200                Blob blob = Blobs.createBlob(is, "image/jpeg");
201                blob.setFilename(MISSING_PREVIEW_PICTURE.replace('/', '-'));
202                PictureResourceAdapter picture = docModel.getAdapter(PictureResourceAdapter.class);
203                picture.fillPictureViews(blob, blob.getFilename(), docModel.getTitle(), new ArrayList<>(templates));
204            }
205        }
206    }
207
208    /**
209     * Update the JPEG previews of a Video document from the video blob content by taking a screen-shot of the movie.
210     */
211    public static void updatePreviews(DocumentModel docModel, Blob video) throws IOException {
212        Double duration = (Double) docModel.getPropertyValue(VideoConstants.DURATION_PROPERTY);
213        double position = 0.0;
214        if (duration != null) {
215            VideoService videoService = Framework.getService(VideoService.class);
216            Configuration configuration = videoService.getConfiguration();
217            if (configuration != null) {
218                position = duration * configuration.getPreviewScreenshotInDurationPercent() / 100;
219            } else {
220                position = duration * 0.1;
221            }
222        }
223        updatePreviews(docModel, video, position, THUMBNAILS_VIEWS);
224    }
225
226    public static void updateVideoInfo(DocumentModel docModel, Blob video) {
227        VideoInfo videoInfo = getVideoInfo(video);
228        if (videoInfo == null || video.getLength() == 0) {
229            docModel.setPropertyValue("vid:info", (Serializable) VideoInfo.EMPTY_INFO.toMap());
230            return;
231        }
232        docModel.setPropertyValue("vid:info", (Serializable) videoInfo.toMap());
233    }
234
235    public static VideoInfo getVideoInfo(Blob video) {
236        if (video == null || video.getLength() == 0) {
237            return null;
238        }
239        try {
240            ExecResult result;
241            try (CloseableFile cf = video.getCloseableFile("." + FilenameUtils.getExtension(video.getFilename()))) {
242                CommandLineExecutorService cles = Framework.getService(CommandLineExecutorService.class);
243                CmdParameters params = cles.getDefaultCmdParameters();
244                params.addNamedParameter("inFilePath", cf.getFile().getAbsolutePath());
245
246                // read the duration with a first command to adjust the best rate:
247                result = cles.execCommand(FFMPEG_INFO_COMMAND_LINE, params);
248            }
249            if (!result.isSuccessful()) {
250                throw result.getError();
251            }
252            return VideoInfo.fromFFmpegOutput(result.getOutput());
253        } catch (CommandNotAvailable | CommandException | IOException e) {
254            throw new NuxeoException(e);
255        }
256    }
257
258}