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}