001/* 002 * (C) Copyright 2010-2015 Nuxeo SA (http://nuxeo.com/) and contributors. 003 * 004 * All rights reserved. This program and the accompanying materials 005 * are made available under the terms of the GNU Lesser General Public License 006 * (LGPL) version 2.1 which accompanies this distribution, and is available at 007 * http://www.gnu.org/licenses/lgpl-2.1.html 008 * 009 * This library is distributed in the hope that it will be useful, 010 * but WITHOUT ANY WARRANTY; without even the implied warranty of 011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 012 * Lesser General Public License for more details. 013 * 014 * Contributors: 015 * Olivier Grisel 016 */ 017package org.nuxeo.ecm.platform.video; 018 019import java.io.IOException; 020import java.io.InputStream; 021import java.io.Serializable; 022import java.util.ArrayList; 023import java.util.HashMap; 024import java.util.LinkedHashMap; 025import java.util.List; 026import java.util.Map; 027 028import org.apache.commons.io.FilenameUtils; 029import org.apache.commons.logging.Log; 030import org.apache.commons.logging.LogFactory; 031import org.nuxeo.ecm.core.api.Blob; 032import org.nuxeo.ecm.core.api.Blobs; 033import org.nuxeo.ecm.core.api.CloseableFile; 034import org.nuxeo.ecm.core.api.DocumentModel; 035import org.nuxeo.ecm.core.api.NuxeoException; 036import org.nuxeo.ecm.core.api.PropertyException; 037import org.nuxeo.ecm.core.api.blobholder.BlobHolder; 038import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolder; 039import org.nuxeo.ecm.core.convert.api.ConversionException; 040import org.nuxeo.ecm.core.convert.api.ConversionService; 041import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters; 042import org.nuxeo.ecm.platform.commandline.executor.api.CommandException; 043import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService; 044import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable; 045import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult; 046import org.nuxeo.ecm.platform.picture.api.adapters.AbstractPictureAdapter; 047import org.nuxeo.ecm.platform.picture.api.adapters.PictureResourceAdapter; 048import org.nuxeo.ecm.platform.video.convert.Constants; 049import org.nuxeo.ecm.platform.video.convert.StoryboardConverter; 050import org.nuxeo.ecm.platform.video.service.Configuration; 051import org.nuxeo.ecm.platform.video.service.VideoService; 052import org.nuxeo.runtime.api.Framework; 053 054/** 055 * Helper class to factorize logic than can be either called from the UI or from core event listener. 056 * <p> 057 * If the need to evolve to make this further configurable (not just using the existing converter / commandline 058 * extensions points), we might want to turn this class into a full blown nuxeo service. 059 * 060 * @author ogrisel 061 */ 062public class VideoHelper { 063 064 public static final Log log = LogFactory.getLog(VideoHelper.class); 065 066 public static final String MISSING_PREVIEW_PICTURE = "preview/missing-video-preview.jpeg"; 067 068 public static final String FFMPEG_INFO_COMMAND_LINE = "ffmpeg-info"; 069 070 /** 071 * @since 7.4 072 */ 073 public static final int DEFAULT_MIN_DURATION_FOR_STORYBOARD = 10; 074 075 /** 076 * @since 7.4 077 */ 078 public static final int DEFAULT_NUMBER_OF_THUMBNAILS = 9; 079 080 // TODO NXP-4792 OG: make this configurable somehow though an extension point. The imaging package need a similar 081 // refactoring, try to make both consistent 082 public static final ArrayList<Map<String, Object>> THUMBNAILS_VIEWS = new ArrayList<>(); 083 084 // Utility class. 085 private VideoHelper() { 086 } 087 088 static { 089 Map<String, Object> thumbnailView = new LinkedHashMap<>(); 090 thumbnailView.put("title", "Small"); 091 thumbnailView.put("maxsize", Long.valueOf(AbstractPictureAdapter.SMALL_SIZE)); 092 THUMBNAILS_VIEWS.add(thumbnailView); 093 Map<String, Object> staticPlayerView = new HashMap<>(); 094 staticPlayerView.put("title", "StaticPlayerView"); 095 staticPlayerView.put("maxsize", Long.valueOf(AbstractPictureAdapter.MEDIUM_SIZE)); 096 THUMBNAILS_VIEWS.add(staticPlayerView); 097 } 098 099 /** 100 * Update the JPEG story board and duration in seconds of a Video document from the video blob content. 101 */ 102 @SuppressWarnings("unchecked") 103 public static void updateStoryboard(DocumentModel docModel, Blob video) throws PropertyException { 104 if (video == null) { 105 docModel.setPropertyValue(VideoConstants.STORYBOARD_PROPERTY, null); 106 docModel.setPropertyValue(VideoConstants.DURATION_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 131 result = Framework.getService(ConversionService.class).convert(Constants.STORYBOARD_CONVERTER, 132 new SimpleBlobHolder(video), parameters); 133 } catch (ConversionException e) { 134 // this can happen when if the codec is not supported or not 135 // readable by ffmpeg and is recoverable by using a dummy preview 136 log.warn(String.format("could not extract story board for document '%s' with video file '%s': %s", 137 docModel.getTitle(), video.getFilename(), e.getMessage())); 138 log.debug(e, e); 139 return; 140 } 141 } 142 143 if (result != null) { 144 List<Blob> blobs = result.getBlobs(); 145 List<String> comments = (List<String>) result.getProperty("comments"); 146 List<Double> timecodes = (List<Double>) result.getProperty("timecodes"); 147 List<Map<String, Serializable>> storyboard = new ArrayList<>(); 148 for (int i = 0; i < blobs.size(); i++) { 149 Map<String, Serializable> item = new HashMap<>(); 150 item.put("comment", comments.get(i)); 151 item.put("timecode", timecodes.get(i)); 152 item.put("content", (Serializable) blobs.get(i)); 153 storyboard.add(item); 154 } 155 docModel.setPropertyValue(VideoConstants.STORYBOARD_PROPERTY, (Serializable) storyboard); 156 } 157 } 158 159 /** 160 * Update the JPEG previews of a Video document from the video blob content by taking a screen-shot of the movie at 161 * timecode offset given in seconds. 162 */ 163 public static void updatePreviews(DocumentModel docModel, Blob video, Double position, 164 List<Map<String, Object>> templates) throws IOException { 165 if (video == null) { 166 docModel.setPropertyValue("picture:views", null); 167 return; 168 } 169 Map<String, Serializable> parameters = new HashMap<>(); 170 parameters.put(Constants.POSITION_PARAMETER, position); 171 BlobHolder result; 172 try { 173 result = Framework.getService(ConversionService.class).convert(Constants.SCREENSHOT_CONVERTER, 174 new SimpleBlobHolder(video), parameters); 175 } catch (ConversionException e) { 176 // this can happen when if the codec is not supported or not 177 // readable by ffmpeg and is recoverable by using a dummy preview 178 log.warn(String.format("could not extract screenshot from document '%s' with video file '%s': %s", 179 docModel.getTitle(), video.getFilename(), e.getMessage())); 180 log.debug(e, e); 181 return; 182 } 183 184 // compute the thumbnail preview 185 if (result != null && result.getBlob() != null && result.getBlob().getLength() > 0) { 186 PictureResourceAdapter picture = docModel.getAdapter(PictureResourceAdapter.class); 187 try { 188 picture.fillPictureViews(result.getBlob(), result.getBlob().getFilename(), docModel.getTitle(), 189 new ArrayList<>(templates)); 190 } catch (IOException e) { 191 log.warn("failed to video compute previews for " + docModel.getTitle() + ": " + e.getMessage()); 192 } 193 } 194 195 // put a black screen if the video or its screen-shot is unreadable 196 if (docModel.getProperty("picture:views").getValue(List.class).isEmpty()) { 197 try (InputStream is = VideoHelper.class.getResourceAsStream("/" + MISSING_PREVIEW_PICTURE)) { 198 Blob blob = Blobs.createBlob(is, "image/jpeg"); 199 blob.setFilename(MISSING_PREVIEW_PICTURE.replace('/', '-')); 200 PictureResourceAdapter picture = docModel.getAdapter(PictureResourceAdapter.class); 201 picture.fillPictureViews(blob, blob.getFilename(), docModel.getTitle(), new ArrayList<>(templates)); 202 } 203 } 204 } 205 206 /** 207 * Update the JPEG previews of a Video document from the video blob content by taking a screen-shot of the movie. 208 */ 209 public static void updatePreviews(DocumentModel docModel, Blob video) throws IOException { 210 Double duration = (Double) docModel.getPropertyValue(VideoConstants.DURATION_PROPERTY); 211 Double position = 0.0; 212 if (duration != null) { 213 VideoService videoService = Framework.getService(VideoService.class); 214 Configuration configuration = videoService.getConfiguration(); 215 if (configuration != null) { 216 position = duration * configuration.getPreviewScreenshotInDurationPercent() / 100; 217 } else { 218 position = duration * 0.1; 219 } 220 } 221 updatePreviews(docModel, video, position, THUMBNAILS_VIEWS); 222 } 223 224 public static void updateVideoInfo(DocumentModel docModel, Blob video) { 225 VideoInfo videoInfo = getVideoInfo(video); 226 if (videoInfo == null) { 227 docModel.setPropertyValue("vid:info", (Serializable) VideoInfo.EMPTY_INFO.toMap()); 228 return; 229 } 230 docModel.setPropertyValue("vid:info", (Serializable) videoInfo.toMap()); 231 } 232 233 public static VideoInfo getVideoInfo(Blob video) { 234 if (video == null) { 235 return null; 236 } 237 try { 238 ExecResult result; 239 try (CloseableFile cf = video.getCloseableFile("." + FilenameUtils.getExtension(video.getFilename()))) { 240 CommandLineExecutorService cles = Framework.getLocalService(CommandLineExecutorService.class); 241 CmdParameters params = cles.getDefaultCmdParameters(); 242 params.addNamedParameter("inFilePath", cf.getFile().getAbsolutePath()); 243 244 // read the duration with a first command to adjust the best rate: 245 result = cles.execCommand(FFMPEG_INFO_COMMAND_LINE, params); 246 } 247 if (!result.isSuccessful()) { 248 throw result.getError(); 249 } 250 return VideoInfo.fromFFmpegOutput(result.getOutput()); 251 } catch (CommandNotAvailable | CommandException | IOException e) { 252 throw new NuxeoException(e); 253 } 254 } 255 256}