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            docModel.setPropertyValue(VideoConstants.DURATION_PROPERTY, 0);
108            return;
109        }
110
111        VideoService videoService = Framework.getService(VideoService.class);
112        Configuration configuration = videoService.getConfiguration();
113
114        VideoDocument videoDocument = docModel.getAdapter(VideoDocument.class);
115        double duration = videoDocument.getVideo().getDuration();
116        double storyboardMinDuration = DEFAULT_MIN_DURATION_FOR_STORYBOARD;
117        if (configuration != null) {
118            storyboardMinDuration = configuration.getStoryboardMinDuration();
119        }
120
121        BlobHolder result = null;
122        if (storyboardMinDuration >= 0 && duration >= storyboardMinDuration) {
123            try {
124                Map<String, Serializable> parameters = new HashMap<>();
125                parameters.put("duration", duration);
126                int numberOfThumbnails = DEFAULT_NUMBER_OF_THUMBNAILS;
127                if (configuration != null) {
128                    numberOfThumbnails = configuration.getStoryboardThumbnailCount();
129                }
130                parameters.put(StoryboardConverter.THUMBNAIL_NUMBER_PARAM, numberOfThumbnails);
131
132                result = Framework.getService(ConversionService.class).convert(Constants.STORYBOARD_CONVERTER,
133                        new SimpleBlobHolder(video), parameters);
134            } catch (ConversionException e) {
135                // this can happen when if the codec is not supported or not
136                // readable by ffmpeg and is recoverable by using a dummy preview
137                log.warn(String.format("could not extract story board for document '%s' with video file '%s': %s",
138                        docModel.getTitle(), video.getFilename(), e.getMessage()));
139                log.debug(e, e);
140                return;
141            }
142        }
143
144        if (result != null) {
145            List<Blob> blobs = result.getBlobs();
146            List<String> comments = (List<String>) result.getProperty("comments");
147            List<Double> timecodes = (List<Double>) result.getProperty("timecodes");
148            List<Map<String, Serializable>> storyboard = new ArrayList<>();
149            for (int i = 0; i < blobs.size(); i++) {
150                Map<String, Serializable> item = new HashMap<>();
151                item.put("comment", comments.get(i));
152                item.put("timecode", timecodes.get(i));
153                item.put("content", (Serializable) blobs.get(i));
154                storyboard.add(item);
155            }
156            docModel.setPropertyValue(VideoConstants.STORYBOARD_PROPERTY, (Serializable) storyboard);
157        }
158    }
159
160    /**
161     * Update the JPEG previews of a Video document from the video blob content by taking a screen-shot of the movie at
162     * timecode offset given in seconds.
163     */
164    public static void updatePreviews(DocumentModel docModel, Blob video, Double position,
165            List<Map<String, Object>> templates) throws IOException {
166        if (video == null) {
167            docModel.setPropertyValue("picture:views", null);
168            return;
169        }
170        Map<String, Serializable> parameters = new HashMap<>();
171        parameters.put(Constants.POSITION_PARAMETER, position);
172        BlobHolder result;
173        try {
174            result = Framework.getService(ConversionService.class).convert(Constants.SCREENSHOT_CONVERTER,
175                    new SimpleBlobHolder(video), parameters);
176        } catch (ConversionException e) {
177            // this can happen when if the codec is not supported or not
178            // readable by ffmpeg and is recoverable by using a dummy preview
179            log.warn(String.format("could not extract screenshot from document '%s' with video file '%s': %s",
180                    docModel.getTitle(), video.getFilename(), e.getMessage()));
181            log.debug(e, e);
182            return;
183        }
184
185        // compute the thumbnail preview
186        if (result != null && result.getBlob() != null && result.getBlob().getLength() > 0) {
187            PictureResourceAdapter picture = docModel.getAdapter(PictureResourceAdapter.class);
188            try {
189                picture.fillPictureViews(result.getBlob(), result.getBlob().getFilename(), docModel.getTitle(),
190                        new ArrayList<>(templates));
191            } catch (IOException e) {
192                log.warn("failed to video compute previews for " + docModel.getTitle() + ": " + e.getMessage());
193            }
194        }
195
196        // put a black screen if the video or its screen-shot is unreadable
197        if (docModel.getProperty("picture:views").getValue(List.class).isEmpty()) {
198            try (InputStream is = VideoHelper.class.getResourceAsStream("/" + MISSING_PREVIEW_PICTURE)) {
199                Blob blob = Blobs.createBlob(is, "image/jpeg");
200                blob.setFilename(MISSING_PREVIEW_PICTURE.replace('/', '-'));
201                PictureResourceAdapter picture = docModel.getAdapter(PictureResourceAdapter.class);
202                picture.fillPictureViews(blob, blob.getFilename(), docModel.getTitle(), new ArrayList<>(templates));
203            }
204        }
205    }
206
207    /**
208     * Update the JPEG previews of a Video document from the video blob content by taking a screen-shot of the movie.
209     */
210    public static void updatePreviews(DocumentModel docModel, Blob video) throws IOException {
211        Double duration = (Double) docModel.getPropertyValue(VideoConstants.DURATION_PROPERTY);
212        Double position = 0.0;
213        if (duration != null) {
214            VideoService videoService = Framework.getService(VideoService.class);
215            Configuration configuration = videoService.getConfiguration();
216            if (configuration != null) {
217                position = duration * configuration.getPreviewScreenshotInDurationPercent() / 100;
218            } else {
219                position = duration * 0.1;
220            }
221        }
222        updatePreviews(docModel, video, position, THUMBNAILS_VIEWS);
223    }
224
225    public static void updateVideoInfo(DocumentModel docModel, Blob video) {
226        VideoInfo videoInfo = getVideoInfo(video);
227        if (videoInfo == null || video.getLength() == 0) {
228            docModel.setPropertyValue("vid:info", (Serializable) VideoInfo.EMPTY_INFO.toMap());
229            return;
230        }
231        docModel.setPropertyValue("vid:info", (Serializable) videoInfo.toMap());
232    }
233
234    public static VideoInfo getVideoInfo(Blob video) {
235        if (video == null || video.getLength() == 0) {
236            return null;
237        }
238        try {
239            ExecResult result;
240            try (CloseableFile cf = video.getCloseableFile("." + FilenameUtils.getExtension(video.getFilename()))) {
241                CommandLineExecutorService cles = Framework.getService(CommandLineExecutorService.class);
242                CmdParameters params = cles.getDefaultCmdParameters();
243                params.addNamedParameter("inFilePath", cf.getFile().getAbsolutePath());
244
245                // read the duration with a first command to adjust the best rate:
246                result = cles.execCommand(FFMPEG_INFO_COMMAND_LINE, params);
247            }
248            if (!result.isSuccessful()) {
249                throw result.getError();
250            }
251            return VideoInfo.fromFFmpegOutput(result.getOutput());
252        } catch (CommandNotAvailable | CommandException | IOException e) {
253            throw new NuxeoException(e);
254        }
255    }
256
257}