/**
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.videoeditor.impl;
import org.opencastproject.inspection.api.MediaInspectionException;
import org.opencastproject.inspection.api.MediaInspectionService;
import org.opencastproject.job.api.AbstractJobProducer;
import org.opencastproject.job.api.Job;
import org.opencastproject.job.api.JobBarrier;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.mediapackage.identifier.IdBuilder;
import org.opencastproject.mediapackage.identifier.IdBuilderFactory;
import org.opencastproject.security.api.OrganizationDirectoryService;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UserDirectoryService;
import org.opencastproject.serviceregistry.api.ServiceRegistry;
import org.opencastproject.serviceregistry.api.ServiceRegistryException;
import org.opencastproject.smil.api.SmilException;
import org.opencastproject.smil.api.SmilService;
import org.opencastproject.smil.entity.api.Smil;
import org.opencastproject.smil.entity.media.api.SmilMediaObject;
import org.opencastproject.smil.entity.media.container.api.SmilMediaContainer;
import org.opencastproject.smil.entity.media.element.api.SmilMediaElement;
import org.opencastproject.smil.entity.media.param.api.SmilMediaParam;
import org.opencastproject.smil.entity.media.param.api.SmilMediaParamGroup;
import org.opencastproject.util.FileSupport;
import org.opencastproject.util.LoadUtil;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.videoeditor.api.ProcessFailedException;
import org.opencastproject.videoeditor.api.VideoEditorService;
import org.opencastproject.videoeditor.ffmpeg.FFmpegEdit;
import org.opencastproject.workspace.api.Workspace;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import javax.xml.bind.JAXBException;
/**
* Implementation of VideoeditorService using FFMPEG
*/
public class VideoEditorServiceImpl extends AbstractJobProducer implements VideoEditorService, ManagedService {
public static final String JOB_LOAD_KEY = "job.load.videoeditor";
private static final float DEFAULT_JOB_LOAD = 2.0f;
private float jobload = DEFAULT_JOB_LOAD;
/**
* The logging instance
*/
private static final Logger logger = LoggerFactory.getLogger(VideoEditorServiceImpl.class);
private static final String JOB_TYPE = "org.opencastproject.videoeditor";
private static final String COLLECTION_ID = "videoeditor";
private static final String SINK_FLAVOR_SUBTYPE = "trimmed";
private enum Operation {
PROCESS_SMIL
}
/**
* Reference to the media inspection service
*/
private MediaInspectionService inspectionService = null;
/**
* Reference to the workspace service
*/
private Workspace workspace = null;
/**
* Id builder used to create ids for encoded tracks
*/
private final IdBuilder idBuilder = IdBuilderFactory.newInstance().newIdBuilder();
/**
* Reference to the receipt service
*/
private ServiceRegistry serviceRegistry;
/**
* The organization directory service
*/
protected OrganizationDirectoryService organizationDirectoryService = null;
/**
* The security service
*/
protected SecurityService securityService = null;
/**
* The user directory service
*/
protected UserDirectoryService userDirectoryService = null;
/**
* The smil service.
*/
protected SmilService smilService = null;
/**
* Bundle properties
*/
private Properties properties = new Properties();
public VideoEditorServiceImpl() {
super(JOB_TYPE);
}
/**
* Splice segments given by smil document for the given track to the new one.
*
* @param job
* processing job
* @param smil
* smil document with media segments description
* @param trackParamGroupId
* @return processed track
* @throws ProcessFailedException
* if an error occured
*/
protected Track processSmil(Job job, Smil smil, String trackParamGroupId) throws ProcessFailedException {
SmilMediaParamGroup trackParamGroup;
ArrayList<String> inputfile = new ArrayList<String>();
ArrayList<VideoClip> videoclips = new ArrayList<VideoClip>();
try {
trackParamGroup = (SmilMediaParamGroup) smil.get(trackParamGroupId);
} catch (SmilException ex) {
// can't be thrown, because we found the Id in processSmil(Smil)
throw new ProcessFailedException("Smil does not contain a paramGroup element with Id " + trackParamGroupId);
}
String sourceTrackId = null;
MediaPackageElementFlavor sourceTrackFlavor = null;
String sourceTrackUri = null;
// get source track metadata
for (SmilMediaParam param : trackParamGroup.getParams()) {
if (SmilMediaParam.PARAM_NAME_TRACK_ID.equals(param.getName())) {
sourceTrackId = param.getValue();
} else if (SmilMediaParam.PARAM_NAME_TRACK_SRC.equals(param.getName())) {
sourceTrackUri = param.getValue();
} else if (SmilMediaParam.PARAM_NAME_TRACK_FLAVOR.equals(param.getName())) {
sourceTrackFlavor = MediaPackageElementFlavor.parseFlavor(param.getValue());
}
}
File sourceFile = null;
try {
sourceFile = workspace.get(new URI(sourceTrackUri));
} catch (IOException ex) {
throw new ProcessFailedException("Can't read " + sourceTrackUri);
} catch (NotFoundException ex) {
throw new ProcessFailedException("Workspace does not contain a track " + sourceTrackUri);
} catch (URISyntaxException ex) {
throw new ProcessFailedException("Source URI " + sourceTrackUri + " is not valid.");
}
// inspect input file to retrieve media information
Job inspectionJob = null;
Track sourceTrack = null;
try {
inspectionJob = inspect(job, new URI(sourceTrackUri));
sourceTrack = (Track) MediaPackageElementParser.getFromXml(inspectionJob.getPayload());
} catch (URISyntaxException e) {
throw new ProcessFailedException("Source URI " + sourceTrackUri + " is not valid.");
} catch (MediaInspectionException e) {
throw new ProcessFailedException("Media inspection of " + sourceTrackUri + " failed", e);
} catch (MediaPackageException e) {
throw new ProcessFailedException("Deserialization of source track " + sourceTrackUri + " failed", e);
}
// get output file extension
String outputFileExtension = properties.getProperty(VideoEditorProperties.DEFAULT_EXTENSION, ".mp4");
outputFileExtension = properties.getProperty(VideoEditorProperties.OUTPUT_FILE_EXTENSION, outputFileExtension);
if (!outputFileExtension.startsWith(".")) {
outputFileExtension = '.' + outputFileExtension;
}
// create working directory
File tempDirectory = FileSupport.getTempDirectory(Long.toString(job.getId()));
File outputPath = new File(tempDirectory, sourceTrackFlavor + "_" + sourceFile.getName() + outputFileExtension);
if (!outputPath.getParentFile().exists()) {
outputPath.getParentFile().mkdirs();
}
URI newTrackURI = null;
inputfile.add(sourceFile.getAbsolutePath()); // default source - add to source table as 0
int srcIndex = inputfile.indexOf(sourceFile.getAbsolutePath()); // index = 0
logger.info("Start processing srcfile {}", sourceFile.getAbsolutePath());
try {
// parse body elements
for (SmilMediaObject element : smil.getBody().getMediaElements()) {
// body should contain par elements
if (element.isContainer()) {
SmilMediaContainer container = (SmilMediaContainer) element;
if (SmilMediaContainer.ContainerType.PAR == container.getContainerType()) {
// par element should contain media elements
for (SmilMediaObject elementChild : container.getElements()) {
if (!elementChild.isContainer()) {
SmilMediaElement media = (SmilMediaElement) elementChild;
if (trackParamGroupId.equals(media.getParamGroup())) {
long begin = media.getClipBeginMS();
long end = media.getClipEndMS();
URI clipTrackURI = media.getSrc();
File clipSourceFile = null;
if (clipTrackURI != null) {
try {
clipSourceFile = workspace.get(clipTrackURI);
} catch (IOException ex) {
throw new ProcessFailedException("Can't read " + clipTrackURI);
} catch (NotFoundException ex) {
throw new ProcessFailedException("Workspace does not contain a track " + clipTrackURI);
}
}
int index = -1;
if (clipSourceFile != null) { // clip has different source
index = inputfile.indexOf(clipSourceFile.getAbsolutePath()); // Look for known tracks
if (index == -1) {
inputfile.add(clipSourceFile.getAbsolutePath()); // add new track
//TODO: inspect each new video file, bad input will throw exc
}
index = inputfile.indexOf(clipSourceFile.getAbsolutePath());
} else {
index = srcIndex; // default src
}
videoclips.add(new VideoClip(index, begin / 1000.0, end / 1000.0));
}
} else {
throw new ProcessFailedException("Smil container '"
+ ((SmilMediaContainer) elementChild).getContainerType().toString()
+ "'is not supportet yet");
}
}
} else {
throw new ProcessFailedException("Smil container '"
+ container.getContainerType().toString() + "'is not supportet yet");
}
}
}
List<VideoClip> cleanclips = sortSegments(videoclips); // remove very short cuts that will look bad
String error = null;
String outputResolution = ""; //TODO: fetch the largest output resolution from SMIL.head.layout.root-layout
// When outputResolution is set to WxH, all clips are scaled to that size in the output video.
// TODO: Each clips could have a region id, relative to the root-layout
// Then each clip is zoomed/panned/padded to WxH befor concatenation
FFmpegEdit ffmpeg = new FFmpegEdit(properties);
error = ffmpeg.processEdits(inputfile, outputPath.getAbsolutePath(), outputResolution, cleanclips,
sourceTrack.hasAudio(), sourceTrack.hasVideo());
if (error != null) {
FileUtils.deleteQuietly(tempDirectory);
throw new ProcessFailedException("Editing pipeline exited abnormaly! Error: " + error);
}
// create Track for edited file
String newTrackId = idBuilder.createNew().toString();
InputStream in = new FileInputStream(outputPath);
try {
newTrackURI = workspace.putInCollection(COLLECTION_ID,
String.format("%s-%s%s", sourceTrackFlavor.getType(), newTrackId, outputFileExtension), in);
} catch (IllegalArgumentException ex) {
throw new ProcessFailedException("Copy track into workspace failed! " + ex.getMessage());
} finally {
IOUtils.closeQuietly(in);
FileUtils.deleteQuietly(tempDirectory);
}
// inspect new Track
try {
inspectionJob = inspect(job,newTrackURI);
} catch (MediaInspectionException e) {
throw new ProcessFailedException("Media inspection of " + newTrackURI + " failed", e);
}
Track editedTrack = (Track) MediaPackageElementParser.getFromXml(inspectionJob.getPayload());
logger.info("Finished editing track {}", editedTrack);
editedTrack.setIdentifier(newTrackId);
editedTrack.setFlavor(new MediaPackageElementFlavor(sourceTrackFlavor.getType(), SINK_FLAVOR_SUBTYPE));
return editedTrack;
} catch (MediaInspectionException ex) {
throw new ProcessFailedException("Inspecting encoded Track failed with: " + ex.getMessage());
} catch (MediaPackageException ex) {
throw new ProcessFailedException("Unable to serialize edited Track! " + ex.getMessage());
} catch (Exception ex) {
throw new ProcessFailedException(ex.getMessage());
} finally {
FileUtils.deleteQuietly(tempDirectory);
}
}
/*
* Inspect the output file
*/
protected Job inspect(Job job, URI workspaceURI) throws MediaInspectionException, ProcessFailedException {
Job inspectionJob;
try {
inspectionJob = inspectionService.inspect(workspaceURI);
} catch (MediaInspectionException e) {
incident().recordJobCreationIncident(job, e);
throw new MediaInspectionException("Media inspection of " + workspaceURI + " failed", e);
}
JobBarrier barrier = new JobBarrier(job, serviceRegistry, inspectionJob);
if (!barrier.waitForJobs().isSuccess()) {
throw new ProcessFailedException("Media inspection of " + workspaceURI + " failed");
}
return inspectionJob;
}
/* Clean up the edit points, make sure they are at least 2 seconds apart (default fade duration)
* Otherwise it can be very slow to run and output will be ugly because of the cross fades
*/
private static List<VideoClip> sortSegments(List<VideoClip> edits) {
LinkedList<VideoClip> ll = new LinkedList<VideoClip>();
List<VideoClip> clips = new ArrayList<VideoClip>();
Iterator<VideoClip> it = edits.iterator();
VideoClip clip;
VideoClip nextclip;
while (it.hasNext()) { // Check for legal durations
clip = it.next();
if (clip.getDuration() > 2) { // Keep segments at least 2 seconds long
ll.add(clip);
}
}
clip = ll.pop(); // initialize
while (!ll.isEmpty()) { // Check that 2 consecutive segments from same src are at least 2 secs apart
if (ll.peek() != null) {
nextclip = ll.pop(); // check next consecutive segment
if ((nextclip.getSrc() == clip.getSrc()) && (nextclip.getStart() - clip.getEnd()) < 2) { // collapse two segments into one
clip.setEnd(nextclip.getEnd()); // by using inpt of seg 1 and outpoint of seg 2
} else {
clips.add(clip); // keep last segment
clip = nextclip; // check next segment
}
}
}
clips.add(clip); // add last segment
return clips;
}
/**
* {@inheritDoc}
*
* @see
* org.opencastproject.videoeditor.api.VideoEditorService#processSmil(org.opencastproject.smil.entity.Smil)
*/
@Override
public List<Job> processSmil(Smil smil) throws ProcessFailedException {
if (smil == null) {
throw new ProcessFailedException("Smil document is null!");
}
List<Job> jobs = new LinkedList<Job>();
try {
for (SmilMediaParamGroup paramGroup : smil.getHead().getParamGroups()) {
for (SmilMediaParam param : paramGroup.getParams()) {
if (SmilMediaParam.PARAM_NAME_TRACK_ID.equals(param.getName())) {
jobs.add(serviceRegistry.createJob(getJobType(), Operation.PROCESS_SMIL.toString(),
Arrays.asList(smil.toXML(), paramGroup.getId()), jobload));
}
}
}
return jobs;
} catch (JAXBException ex) {
throw new ProcessFailedException("Failed to serialize smil " + smil.getId());
} catch (ServiceRegistryException ex) {
throw new ProcessFailedException("Failed to create job: " + ex.getMessage());
} catch (Exception ex) {
throw new ProcessFailedException(ex.getMessage());
}
}
@Override
protected String process(Job job) throws Exception {
if (Operation.PROCESS_SMIL.toString().equals(job.getOperation())) {
Smil smil = smilService.fromXml(job.getArguments().get(0)).getSmil();
if (smil == null) {
throw new ProcessFailedException("Smil document is null!");
}
Track editedTrack = processSmil(job, smil, job.getArguments().get(1));
return MediaPackageElementParser.getAsXml(editedTrack);
}
throw new ProcessFailedException("Can't handle this operation: " + job.getOperation());
}
@Override
protected ServiceRegistry getServiceRegistry() {
return serviceRegistry;
}
@Override
protected SecurityService getSecurityService() {
return securityService;
}
@Override
protected UserDirectoryService getUserDirectoryService() {
return userDirectoryService;
}
@Override
protected OrganizationDirectoryService getOrganizationDirectoryService() {
return organizationDirectoryService;
}
@Override
public void activate(ComponentContext context) {
logger.debug("activating...");
super.activate(context);
FFmpegEdit.init(context.getBundleContext());
}
protected void deactivate(ComponentContext context) {
logger.debug("deactivating...");
}
@Override
public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
this.properties = new Properties();
if (properties == null) {
logger.info("No configuration available, using defaults");
return;
}
Enumeration<String> keys = properties.keys();
while (keys.hasMoreElements()) {
String key = keys.nextElement();
this.properties.put(key, properties.get(key));
}
logger.debug("Properties updated!");
jobload = LoadUtil.getConfiguredLoadValue(properties, JOB_LOAD_KEY, DEFAULT_JOB_LOAD, serviceRegistry);
}
public void setMediaInspectionService(MediaInspectionService inspectionService) {
this.inspectionService = inspectionService;
}
public void setServiceRegistry(ServiceRegistry serviceRegistry) {
this.serviceRegistry = serviceRegistry;
}
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
public void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
this.userDirectoryService = userDirectoryService;
}
public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {
this.organizationDirectoryService = organizationDirectoryService;
}
public void setSmilService(SmilService smilService) {
this.smilService = smilService;
}
}