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