/**
* 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.workflow.handler.videoeditor;
import org.opencastproject.job.api.Job;
import org.opencastproject.job.api.JobContext;
import org.opencastproject.mediapackage.Catalog;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElement;
import org.opencastproject.mediapackage.MediaPackageElementBuilder;
import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.mediapackage.selector.SimpleElementSelector;
import org.opencastproject.mediapackage.selector.TrackSelector;
import org.opencastproject.smil.api.SmilException;
import org.opencastproject.smil.api.SmilResponse;
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.util.NotFoundException;
import org.opencastproject.videoeditor.api.ProcessFailedException;
import org.opencastproject.videoeditor.api.VideoEditorService;
import org.opencastproject.workflow.api.WorkflowInstance;
import org.opencastproject.workflow.api.WorkflowOperationException;
import org.opencastproject.workflow.api.WorkflowOperationInstance;
import org.opencastproject.workflow.api.WorkflowOperationResult;
import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
import org.opencastproject.workflow.handler.workflow.ResumableWorkflowOperationHandlerBase;
import org.opencastproject.workspace.api.Workspace;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
public class VideoEditorWorkflowOperationHandler extends ResumableWorkflowOperationHandlerBase {
private static final Logger logger = LoggerFactory.getLogger(VideoEditorWorkflowOperationHandler.class);
/** Path to the hold ui resources */
private static final String HOLD_UI_PATH = "/ui/operation/editor/index.html";
/** Name of the configuration option that provides the source flavors we use for processing. */
private static final String SOURCE_FLAVORS_PROPERTY = "source-flavors";
/** Name of the configuration option that provides the preview flavors we use as preview. */
private static final String PREVIEW_FLAVORS_PROPERTY = "preview-flavors";
/** Name of the configuration option that provides the source flavors on skipped videoeditor operation. */
private static final String SKIPPED_FLAVORS_PROPERTY = "skipped-flavors";
/** Name of the configuration option that provides the smil flavor as input. */
private static final String SMIL_FLAVORS_PROPERTY = "smil-flavors";
/** Name of the configuration option that provides the smil flavor as input. */
private static final String TARGET_SMIL_FLAVOR_PROPERTY = "target-smil-flavor";
/** Name of the configuration that provides the target flavor subtype for encoded media tracks. */
private static final String TARGET_FLAVOR_SUBTYPE_PROPERTY = "target-flavor-subtype";
/** Name of the configuration that provides the interactive flag */
private static final String INTERACTIVE_PROPERTY = "interactive";
/** Name of the configuration that provides the smil file name */
private static final String SMIL_FILE_NAME = "smil.smil";
/**
* The configuration options for this handler
*/
private static final SortedMap<String, String> CONFIG_OPTIONS;
static {
CONFIG_OPTIONS = new TreeMap<String, String>();
CONFIG_OPTIONS.put(SOURCE_FLAVORS_PROPERTY, "The flavor for working files (tracks to edit).");
CONFIG_OPTIONS.put(PREVIEW_FLAVORS_PROPERTY, "The flavor for preview files (tracks to show in edit UI).");
CONFIG_OPTIONS.put(SKIPPED_FLAVORS_PROPERTY, "The flavor for working files if videoeditor operation is disabled."
+ " This is an optional option." + " Default value is given by \"" + SOURCE_FLAVORS_PROPERTY + "\".");
CONFIG_OPTIONS.put(SMIL_FLAVORS_PROPERTY, "The flavor for input smil files.");
CONFIG_OPTIONS.put(TARGET_SMIL_FLAVOR_PROPERTY, "The flavor for target smil file.");
CONFIG_OPTIONS.put(TARGET_FLAVOR_SUBTYPE_PROPERTY, "The flavor subtype for target media files.");
CONFIG_OPTIONS.put(INTERACTIVE_PROPERTY, "Whether the operation is interactive or not");
}
/**
* The Smil service to modify smil files.
*/
private SmilService smilService;
/**
* The VideoEditor service to edit files.
*/
private VideoEditorService videoEditorService;
/**
* The workspace.
*/
private Workspace workspace;
@Override
public void activate(ComponentContext cc) {
super.activate(cc);
setHoldActionTitle("Review / VideoEdit");
registerHoldStateUserInterface(HOLD_UI_PATH);
logger.info("Registering videoEditor hold state ui from classpath {}", HOLD_UI_PATH);
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.WorkflowOperationHandler#getConfigurationOptions()
*/
@Override
public SortedMap<String, String> getConfigurationOptions() {
return CONFIG_OPTIONS;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.WorkflowOperationHandler#start(org.opencastproject.workflow.api.WorkflowInstance,
* JobContext)
*/
@Override
public WorkflowOperationResult start(WorkflowInstance workflowInstance, JobContext context)
throws WorkflowOperationException {
MediaPackage mp = workflowInstance.getMediaPackage();
logger.info("Start editor workflow for mediapackage {}", mp.getIdentifier().compact());
// get configuration
WorkflowOperationInstance worflowOperationInstance = workflowInstance.getCurrentOperation();
String smilFlavorsProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(SMIL_FLAVORS_PROPERTY));
if (smilFlavorsProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set",
SMIL_FLAVORS_PROPERTY));
}
String targetSmilFlavorProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(TARGET_SMIL_FLAVOR_PROPERTY));
if (targetSmilFlavorProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set",
TARGET_SMIL_FLAVOR_PROPERTY));
}
String previewTrackFlavorsProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(PREVIEW_FLAVORS_PROPERTY));
if (previewTrackFlavorsProperty == null) {
logger.info("Configuration property '{}' not set, use preview tracks from smil catalog", PREVIEW_FLAVORS_PROPERTY);
}
if (StringUtils.trimToNull(worflowOperationInstance.getConfiguration(TARGET_FLAVOR_SUBTYPE_PROPERTY)) == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set",
TARGET_FLAVOR_SUBTYPE_PROPERTY));
}
final boolean interactive = BooleanUtils.toBoolean(worflowOperationInstance.getConfiguration(INTERACTIVE_PROPERTY));
// check at least one smil catalog exists
SimpleElementSelector elementSelector = new SimpleElementSelector();
for (String flavor : asList(smilFlavorsProperty)) {
elementSelector.addFlavor(flavor);
}
Collection<MediaPackageElement> smilCatalogs = elementSelector.select(mp, false);
MediaPackageElementBuilder mpeBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
if (smilCatalogs.isEmpty()) {
// There is nothing to do, skip the operation
if (!interactive) {
logger.info("Skipping cutting opertion since no edit decision list is available");
return skip(workflowInstance, context);
}
// Without SMIL catalogs and without preview tracks, there is nothing we can do
if (previewTrackFlavorsProperty == null) {
throw new WorkflowOperationException(String.format("No smil catalogs with flavor %s nor preview files with flavor %s found in mediapackage %s",
smilFlavorsProperty, previewTrackFlavorsProperty, mp.getIdentifier().compact()));
}
// Basd on the preview trcks, create new and empty SMIL catalog
TrackSelector trackSelector = new TrackSelector();
for (String flavor : asList(previewTrackFlavorsProperty)) {
trackSelector.addFlavor(flavor);
}
Collection<Track> previewTracks = trackSelector.select(mp, false);
if (previewTracks.isEmpty()) {
throw new WorkflowOperationException(String.format("No preview tracks found in mediapackage %s with flavor %s",
mp.getIdentifier().compact(), previewTrackFlavorsProperty));
}
Track[] previewTracksArr = previewTracks.toArray(new Track[previewTracks.size()]);
MediaPackageElementFlavor smilFlavor = MediaPackageElementFlavor.parseFlavor(smilFlavorsProperty);
for (Track previewTrack : previewTracks) {
try {
SmilResponse smilResponse = smilService.createNewSmil(mp);
smilResponse = smilService.addParallel(smilResponse.getSmil());
smilResponse = smilService.addClips(smilResponse.getSmil(), smilResponse.getEntity().getId(),
previewTracksArr, 0L, previewTracksArr[0].getDuration());
Smil smil = smilResponse.getSmil();
InputStream is = null;
try {
// put new smil into workspace
is = IOUtils.toInputStream(smil.toXML(), "UTF-8");
URI smilURI = workspace.put(mp.getIdentifier().compact(), smil.getId(), SMIL_FILE_NAME, is);
MediaPackageElementFlavor trackSmilFlavor = previewTrack.getFlavor();
if (!"*".equals(smilFlavor.getType())) {
trackSmilFlavor = new MediaPackageElementFlavor(smilFlavor.getType(), trackSmilFlavor.getSubtype());
}
if (!"*".equals(smilFlavor.getSubtype())) {
trackSmilFlavor = new MediaPackageElementFlavor(trackSmilFlavor.getType(), smilFlavor.getSubtype());
}
Catalog catalog = (Catalog) mpeBuilder.elementFromURI(smilURI, MediaPackageElement.Type.Catalog,
trackSmilFlavor);
catalog.setIdentifier(smil.getId());
mp.add(catalog);
} finally {
IOUtils.closeQuietly(is);
}
} catch (Exception ex) {
throw new WorkflowOperationException(String.format("Failed to create smil catalog for mediapackage %s", mp
.getIdentifier().compact()), ex);
}
}
}
// check target smil catalog exists
MediaPackageElementFlavor targetSmilFlavor = MediaPackageElementFlavor.parseFlavor(targetSmilFlavorProperty);
Catalog[] targetSmilCatalogs = mp.getCatalogs(targetSmilFlavor);
if (targetSmilCatalogs == null || targetSmilCatalogs.length == 0) {
if (!interactive)
return skip(workflowInstance, context);
// create new empty smil to fill it from editor UI
try {
SmilResponse smilResponse = smilService.createNewSmil(mp);
Smil smil = smilResponse.getSmil();
InputStream is = null;
try {
// put new smil into workspace
is = IOUtils.toInputStream(smil.toXML(), "UTF-8");
URI smilURI = workspace.put(mp.getIdentifier().compact(), smil.getId(), SMIL_FILE_NAME, is);
Catalog catalog = (Catalog) mpeBuilder.elementFromURI(smilURI, MediaPackageElement.Type.Catalog,
targetSmilFlavor);
catalog.setIdentifier(smil.getId());
mp.add(catalog);
} finally {
IOUtils.closeQuietly(is);
}
} catch (Exception ex) {
throw new WorkflowOperationException(String.format(
"Failed to create an initial empty smil catalog for mediapackage %s", mp.getIdentifier().compact()), ex);
}
logger.info("Holding for video edit...");
return createResult(mp, Action.PAUSE);
} else {
logger.debug("Move on, SMIL catalog ({}) already exists for media package '{}'", targetSmilFlavor, mp);
return resume(workflowInstance, context, Collections.<String, String> emptyMap());
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.AbstractWorkflowOperationHandler#skip(org.opencastproject.workflow.api.WorkflowInstance,
* JobContext)
*/
@Override
public WorkflowOperationResult skip(WorkflowInstance workflowInstance, JobContext context)
throws WorkflowOperationException {
// If we do not hold for trim, we still need to put tracks in the mediapackage with the target flavor
MediaPackage mp = workflowInstance.getMediaPackage();
logger.info("Skip video editor operation for mediapackage {}", mp.getIdentifier().compact());
// get configuration
WorkflowOperationInstance worflowOperationInstance = workflowInstance.getCurrentOperation();
String sourceTrackFlavorsProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(SKIPPED_FLAVORS_PROPERTY));
if (sourceTrackFlavorsProperty == null || sourceTrackFlavorsProperty.isEmpty()) {
logger.info("\"{}\" option not set, use value of \"{}\"", SKIPPED_FLAVORS_PROPERTY, SOURCE_FLAVORS_PROPERTY);
sourceTrackFlavorsProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(SOURCE_FLAVORS_PROPERTY));
if (sourceTrackFlavorsProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set.",
SOURCE_FLAVORS_PROPERTY));
}
}
String targetFlavorSubTypeProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(TARGET_FLAVOR_SUBTYPE_PROPERTY));
if (targetFlavorSubTypeProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set.",
TARGET_FLAVOR_SUBTYPE_PROPERTY));
}
// get source tracks
TrackSelector trackSelector = new TrackSelector();
for (String flavor : asList(sourceTrackFlavorsProperty)) {
trackSelector.addFlavor(flavor);
}
Collection<Track> sourceTracks = trackSelector.select(mp, false);
for (Track sourceTrack : sourceTracks) {
// set target track flavor
Track clonedTrack = (Track) sourceTrack.clone();
clonedTrack.setIdentifier(null);
clonedTrack.setURI(sourceTrack.getURI()); // use the same URI as the original
clonedTrack.setFlavor(new MediaPackageElementFlavor(sourceTrack.getFlavor().getType(),
targetFlavorSubTypeProperty));
mp.addDerived(clonedTrack, sourceTrack);
}
return createResult(mp, Action.SKIP);
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.ResumableWorkflowOperationHandler#resume(org.opencastproject.workflow.api.WorkflowInstance,
* JobContext, java.util.Map)
*/
@Override
public WorkflowOperationResult resume(WorkflowInstance workflowInstance, JobContext context,
Map<String, String> properties) throws WorkflowOperationException {
MediaPackage mp = workflowInstance.getMediaPackage();
logger.info("Resume video editor operation for mediapackage {}", mp.getIdentifier().compact());
// get configuration
WorkflowOperationInstance worflowOperationInstance = workflowInstance.getCurrentOperation();
String sourceTrackFlavorsProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(SOURCE_FLAVORS_PROPERTY));
if (sourceTrackFlavorsProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set.",
SOURCE_FLAVORS_PROPERTY));
}
String targetSmilFlavorProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(TARGET_SMIL_FLAVOR_PROPERTY));
if (targetSmilFlavorProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set.",
TARGET_SMIL_FLAVOR_PROPERTY));
}
String targetFlavorSybTypeProperty = StringUtils.trimToNull(worflowOperationInstance
.getConfiguration(TARGET_FLAVOR_SUBTYPE_PROPERTY));
if (targetFlavorSybTypeProperty == null) {
throw new WorkflowOperationException(String.format("Required configuration property %s not set.",
TARGET_FLAVOR_SUBTYPE_PROPERTY));
}
// get source tracks
TrackSelector trackSelector = new TrackSelector();
for (String flavor : asList(sourceTrackFlavorsProperty)) {
trackSelector.addFlavor(flavor);
}
Collection<Track> sourceTracks = trackSelector.select(mp, false);
if (sourceTracks.isEmpty()) {
throw new WorkflowOperationException(String.format("No source tracks found in mediapacksge %s with flavors %s.",
mp.getIdentifier().compact(), sourceTrackFlavorsProperty));
}
// get smil file
MediaPackageElementFlavor smilTargetFlavor = MediaPackageElementFlavor.parseFlavor(targetSmilFlavorProperty);
Catalog[] smilCatalogs = mp.getCatalogs(smilTargetFlavor);
if (smilCatalogs == null || smilCatalogs.length == 0) {
throw new WorkflowOperationException(String.format("No smil catalog found in mediapackage %s with flavor %s.", mp
.getIdentifier().compact(), targetSmilFlavorProperty));
}
File smilFile = null;
Smil smil = null;
try {
smilFile = workspace.get(smilCatalogs[0].getURI());
smil = smilService.fromXml(smilFile).getSmil();
smil = replaceAllTracksWith(smil, sourceTracks.toArray(new Track[sourceTracks.size()]));
InputStream is = null;
try {
is = IOUtils.toInputStream(smil.toXML());
// remove old smil
workspace.delete(mp.getIdentifier().compact(), smilCatalogs[0].getIdentifier());
mp.remove(smilCatalogs[0]);
// put modified smil into workspace
URI newSmilUri = workspace.put(mp.getIdentifier().compact(), smil.getId(), SMIL_FILE_NAME, is);
Catalog catalog = (Catalog) MediaPackageElementBuilderFactory.newInstance().newElementBuilder()
.elementFromURI(newSmilUri, MediaPackageElement.Type.Catalog, smilCatalogs[0].getFlavor());
catalog.setIdentifier(smil.getId());
mp.add(catalog);
} catch (Exception ex) {
throw new WorkflowOperationException(ex);
} finally {
IOUtils.closeQuietly(is);
}
} catch (NotFoundException ex) {
throw new WorkflowOperationException(String.format("Failed to get smil catalog %s from mediapackage %s.",
smilCatalogs[0].getIdentifier(), mp.getIdentifier().compact()), ex);
} catch (IOException ex) {
throw new WorkflowOperationException(String.format("Can't open smil catalog %s from mediapackage %s.",
smilCatalogs[0].getIdentifier(), mp.getIdentifier().compact()), ex);
} catch (SmilException ex) {
throw new WorkflowOperationException(ex);
}
// create video edit jobs and run them
List<Job> jobs = null;
try {
logger.info("Create processing jobs for smil {}.", smilCatalogs[0].getIdentifier());
jobs = videoEditorService.processSmil(smil);
if (!waitForStatus(jobs.toArray(new Job[jobs.size()])).isSuccess()) {
throw new WorkflowOperationException("Smil processing jobs for smil " + smilCatalogs[0].getIdentifier()
+ " are ended unsuccessfull.");
}
logger.info("Smil " + smilCatalogs[0].getIdentifier() + " processing finished.");
} catch (ProcessFailedException ex) {
throw new WorkflowOperationException("Processing smil " + smilCatalogs[0].getIdentifier() + " failed", ex);
}
// move edited tracks to work location and set target flavor
Track editedTrack = null;
boolean mpAdded = false;
for (Job job : jobs) {
try {
editedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
MediaPackageElementFlavor editedTrackFlavor = editedTrack.getFlavor();
editedTrack.setFlavor(new MediaPackageElementFlavor(editedTrackFlavor.getType(), targetFlavorSybTypeProperty));
URI editedTrackNewUri = workspace.moveTo(editedTrack.getURI(), mp.getIdentifier().compact(),
editedTrack.getIdentifier(), FilenameUtils.getName(editedTrack.getURI().toString()));
editedTrack.setURI(editedTrackNewUri);
for (Track track : sourceTracks) {
if (track.getFlavor().getType().equals(editedTrackFlavor.getType())) {
mp.addDerived(editedTrack, track);
mpAdded = true;
break;
}
}
if (!mpAdded) {
mp.add(editedTrack);
}
} catch (MediaPackageException ex) {
throw new WorkflowOperationException("Failed to get edited track information.", ex);
} catch (Exception ex) {
if (ex instanceof NotFoundException || ex instanceof IOException || ex instanceof IllegalArgumentException) {
throw new WorkflowOperationException("Moving edited track to work location failed.", ex);
} else {
throw new WorkflowOperationException(ex);
}
}
}
logger.info("VideoEdit workflow {} finished", workflowInstance.getId());
return createResult(mp, Action.CONTINUE);
}
protected Smil replaceAllTracksWith(Smil smil, Track[] otherTracks) throws SmilException {
SmilResponse smilResponse;
try {
// copy smil to work with
smilResponse = smilService.fromXml(smil.toXML());
} catch (Exception ex) {
throw new SmilException("Can't parse smil.");
}
long start;
long end;
// iterate over all elements inside smil body
for (SmilMediaObject elem : smil.getBody().getMediaElements()) {
start = -1L;
end = -1L;
// body should contain par elements (container)
if (elem.isContainer()) {
// iterate over all elements in container
for (SmilMediaObject child : ((SmilMediaContainer) elem).getElements()) {
// second depth should contain media elements like audio or video
if (!child.isContainer() && child instanceof SmilMediaElement) {
SmilMediaElement media = (SmilMediaElement) child;
start = media.getClipBeginMS();
end = media.getClipEndMS();
// remove it
smilResponse = smilService.removeSmilElement(smilResponse.getSmil(), media.getId());
}
}
if (start != -1L && end != -1L) {
// add the new tracks inside
smilResponse = smilService.addClips(smilResponse.getSmil(), elem.getId(), otherTracks, start, end - start);
}
} else if (elem instanceof SmilMediaElement) {
throw new SmilException("Media elements inside smil body are not supported yet.");
}
}
return smilResponse.getSmil();
}
public void setSmilService(SmilService smilService) {
this.smilService = smilService;
}
public void setVideoEditorService(VideoEditorService editor) {
this.videoEditorService = editor;
}
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
}