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.PropertyException;
039import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
040import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolder;
041import org.nuxeo.ecm.core.convert.api.ConversionException;
042import org.nuxeo.ecm.core.convert.api.ConversionService;
043import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
044import org.nuxeo.ecm.platform.commandline.executor.api.CommandException;
045import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService;
046import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable;
047import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
048import org.nuxeo.ecm.platform.picture.api.adapters.AbstractPictureAdapter;
049import org.nuxeo.ecm.platform.picture.api.adapters.PictureResourceAdapter;
050import org.nuxeo.ecm.platform.video.convert.Constants;
051import org.nuxeo.ecm.platform.video.convert.StoryboardConverter;
052import org.nuxeo.ecm.platform.video.service.Configuration;
053import org.nuxeo.ecm.platform.video.service.VideoService;
054import org.nuxeo.runtime.api.Framework;
055
056/**
057 * Helper class to factorize logic than can be either called from the UI or from core event listener.
058 * <p>
059 * If the need to evolve to make this further configurable (not just using the existing converter / commandline
060 * extensions points), we might want to turn this class into a full blown nuxeo service.
061 *
062 * @author ogrisel
063 */
064public class VideoHelper {
065
066    public static final Log log = LogFactory.getLog(VideoHelper.class);
067
068    public static final String MISSING_PREVIEW_PICTURE = "preview/missing-video-preview.jpeg";
069
070    public static final String FFMPEG_INFO_COMMAND_LINE = "ffmpeg-info";
071
072    /**
073     * @since 7.4
074     */
075    public static final int DEFAULT_MIN_DURATION_FOR_STORYBOARD = 10;
076
077    /**
078     * @since 7.4
079     */
080    public static final int DEFAULT_NUMBER_OF_THUMBNAILS = 9;
081
082    // TODO NXP-4792 OG: make this configurable somehow though an extension point. The imaging package need a similar
083    // refactoring, try to make both consistent
084    public static final ArrayList<Map<String, Object>> THUMBNAILS_VIEWS = new ArrayList<>();
085
086    // Utility class.
087    private VideoHelper() {
088    }
089
090    static {
091        Map<String, Object> thumbnailView = new LinkedHashMap<>();
092        thumbnailView.put("title", "Small");
093        thumbnailView.put("maxsize", Long.valueOf(AbstractPictureAdapter.SMALL_SIZE));
094        THUMBNAILS_VIEWS.add(thumbnailView);
095        Map<String, Object> staticPlayerView = new HashMap<>();
096        staticPlayerView.put("title", "StaticPlayerView");
097        staticPlayerView.put("maxsize", Long.valueOf(AbstractPictureAdapter.MEDIUM_SIZE));
098        THUMBNAILS_VIEWS.add(staticPlayerView);
099    }
100
101    /**
102     * Update the JPEG story board and duration in seconds of a Video document from the video blob content.
103     */
104    @SuppressWarnings("unchecked")
105    public static void updateStoryboard(DocumentModel docModel, Blob video) throws PropertyException {
106        if (video == null) {
107            docModel.setPropertyValue(VideoConstants.STORYBOARD_PROPERTY, null);
108            docModel.setPropertyValue(VideoConstants.DURATION_PROPERTY, null);
109            return;
110        }
111
112        VideoService videoService = Framework.getService(VideoService.class);
113        Configuration configuration = videoService.getConfiguration();
114
115        VideoDocument videoDocument = docModel.getAdapter(VideoDocument.class);
116        double duration = videoDocument.getVideo().getDuration();
117        double storyboardMinDuration = DEFAULT_MIN_DURATION_FOR_STORYBOARD;
118        if (configuration != null) {
119            storyboardMinDuration = configuration.getStoryboardMinDuration();
120        }
121
122        BlobHolder result = null;
123        if (storyboardMinDuration >= 0 && duration >= storyboardMinDuration) {
124            try {
125                Map<String, Serializable> parameters = new HashMap<>();
126                parameters.put("duration", duration);
127                int numberOfThumbnails = DEFAULT_NUMBER_OF_THUMBNAILS;
128                if (configuration != null) {
129                    numberOfThumbnails = configuration.getStoryboardThumbnailCount();
130                }
131                parameters.put(StoryboardConverter.THUMBNAIL_NUMBER_PARAM, numberOfThumbnails);
132
133                result = Framework.getService(ConversionService.class).convert(Constants.STORYBOARD_CONVERTER,
134                        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).convert(Constants.SCREENSHOT_CONVERTER,
176                    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) {
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) {
237            return null;
238        }
239        try {
240            ExecResult result;
241            try (CloseableFile cf = video.getCloseableFile("." + FilenameUtils.getExtension(video.getFilename()))) {
242                CommandLineExecutorService cles = Framework.getLocalService(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}