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}