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}