CutMarksToSmilWorkflowOperationHandler.java
/*
* 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.smil;
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.Track;
import org.opencastproject.mediapackage.selector.TrackSelector;
import org.opencastproject.serviceregistry.api.ServiceRegistry;
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.container.api.SmilMediaContainer;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
import org.opencastproject.workflow.api.ConfiguredTagsAndFlavors;
import org.opencastproject.workflow.api.WorkflowInstance;
import org.opencastproject.workflow.api.WorkflowOperationException;
import org.opencastproject.workflow.api.WorkflowOperationHandler;
import org.opencastproject.workflow.api.WorkflowOperationInstance;
import org.opencastproject.workflow.api.WorkflowOperationResult;
import org.opencastproject.workspace.api.Workspace;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.apache.commons.io.IOUtils;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.SAXException;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.ListIterator;
import javax.xml.bind.JAXBException;
/**
* The workflow definition for converting a smil containing cut marks into a legal smil for cutting
*/
@Component(
immediate = true,
service = WorkflowOperationHandler.class,
property = {
"service.description=Cut Marks To Smil Operation Handler",
"workflow.operation=cut-marks-to-smil"
}
)
public class CutMarksToSmilWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
/** Workflow configuration keys */
private static final String SOURCE_MEDIA_FLAVORS = "source-media-flavors";
private static final String SOURCE_JSON_FLAVOR = "source-json-flavor";
private static final String TARGET_SMIL_FLAVOR = "target-smil-flavor";
private static final String TARGET_TAGS = "target-tags";
private static final String CUTTING_SMIL_NAME = "prepared_cutting_smil";
/** The logging facility */
private static final Logger logger = LoggerFactory.getLogger(CutMarksToSmilWorkflowOperationHandler.class);
/** The local workspace */
private Workspace workspace = null;
/**
* Callback for declarative services configuration that will introduce us to the local workspace service.
* Implementation assumes that the reference is configured as being static.
*
* @param workspace
* an instance of the workspace
*/
@Reference
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
/**
* The SMIL service to modify SMIL files.
*/
private SmilService smilService;
@Reference
public void setSmilService(SmilService smilService) {
this.smilService = smilService;
}
/** JSON Parser */
private static final Gson gson = new Gson();
private static final Type timesListType = new TypeToken<List<Times>>() { }.getType();
/** Stores information read from JSON */
static class Times {
private Long begin;
private Long duration;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.WorkflowOperationHandler#start(
* org.opencastproject.workflow.api.WorkflowInstance,
* JobContext)
*/
@Override
public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
throws WorkflowOperationException {
logger.info("Running cut marks to smil workflow operation on workflow {}", workflowInstance.getId());
WorkflowOperationInstance operation = workflowInstance.getCurrentOperation();
final MediaPackage mediaPackage = (MediaPackage) workflowInstance.getMediaPackage().clone();
// Read config options
final MediaPackageElementFlavor jsonFlavor = MediaPackageElementFlavor.parseFlavor(
getConfig(operation, SOURCE_JSON_FLAVOR));
final MediaPackageElementFlavor targetSmilFlavor = MediaPackageElementFlavor.parseFlavor(
getConfig(operation, TARGET_SMIL_FLAVOR));
String flavorNames = operation.getConfiguration(SOURCE_MEDIA_FLAVORS);
final List<MediaPackageElementFlavor> flavors = new ArrayList<>();
for (String flavorName : asList(flavorNames)) {
flavors.add(MediaPackageElementFlavor.parseFlavor(flavorName));
}
// Target tags
ConfiguredTagsAndFlavors tagsAndFlavors = getTagsAndFlavors(workflowInstance,
Configuration.none, Configuration.none, Configuration.many, Configuration.none);
ConfiguredTagsAndFlavors.TargetTags targetTagsOption = tagsAndFlavors.getTargetTags();
// Is there a catalog?
MediaPackageElement[] cutMarksElements = mediaPackage.getAttachments(jsonFlavor);
if (cutMarksElements.length < 1) {
logger.debug("No cut marks found as attachment. Falling back to catalogs…");
cutMarksElements = mediaPackage.getCatalogs(jsonFlavor);
}
if (cutMarksElements.length < 1) {
logger.warn("No cut marks with source flavor {} found. Skipping…", jsonFlavor);
return createResult(mediaPackage, WorkflowOperationResult.Action.SKIP);
} else if (cutMarksElements.length > 1) {
// Remember Highlander? There can be only one!
throw new WorkflowOperationException(String.format(
"More than one cut marks element with source flavor %s found! Make sure there is only one.", jsonFlavor));
}
// Parse JSON
List<Times> cutMarks;
MediaPackageElement jsonWithTimes = cutMarksElements[0];
try (BufferedReader reader = new BufferedReader(new FileReader(getMediaPackageElementPath(jsonWithTimes)))) {
cutMarks = gson.fromJson(reader, timesListType);
} catch (Exception e) {
throw new WorkflowOperationException("Could not read JSON", e);
}
// If the catalog was empty, give up
if (cutMarks.size() < 1) {
logger.warn("Source JSON did not contain any timestamps! Skipping...");
return createResult(mediaPackage, WorkflowOperationResult.Action.SKIP);
}
// Check parsing results
for (Times entry : cutMarks) {
logger.debug("Entry begin={}, duration={}", entry.begin, entry.duration);
if (entry.begin < 0 || entry.duration < 0) {
throw new WorkflowOperationException("Times may not be negative.");
}
}
// Get video tracks
logger.info("Get tracks from media package");
ArrayList<Track> tracksFromFlavors = new ArrayList<>();
for (MediaPackageElementFlavor flavor : flavors) {
logger.debug("Trying to get tracks with flavor {}", flavor);
TrackSelector trackSelector = new TrackSelector();
trackSelector.addFlavor(flavor);
Collection<Track> tracks = trackSelector.select(mediaPackage, false);
logger.debug("Found {} tracks with flavor {}", tracks.size(), flavor);
tracksFromFlavors.addAll(tracks);
}
// Are there actually any tracks?
if (tracksFromFlavors.isEmpty()) {
logger.warn("No track with given flavors. Skipping…");
return createResult(mediaPackage, WorkflowOperationResult.Action.SKIP);
}
// Check for cut marks that would lead to errors with the given tracks and remove them
// Possible TODO: Instead of removing, only apply cut marks to tracks with a long enough duration?
// Get the shortest duration of all tracks
long shortestDuration = Long.MAX_VALUE;
for (Track track : tracksFromFlavors) {
if (track.getDuration() < shortestDuration) {
shortestDuration = track.getDuration();
}
}
// Remove all timestamps that begin after the shortest duration
ListIterator<Times> iter = cutMarks.listIterator();
while (iter.hasNext()) {
long begin = iter.next().begin;
if (begin > shortestDuration) {
logger.info("Skipped mark with begin: {}, ", begin);
iter.remove();
}
}
// If the timestamp list is now empty, give up
if (cutMarks.size() < 1) {
logger.warn("No timestamps are valid for the given tracks! Skipping...");
return createResult(mediaPackage, WorkflowOperationResult.Action.SKIP);
}
// Create the new SMIL document
Smil smil;
try {
SmilResponse smilResponse = smilService.createNewSmil(mediaPackage);
logger.info("Start adding tracks");
for (Times mark : cutMarks) {
smilResponse = smilService.addParallel(smilResponse.getSmil());
SmilMediaContainer par = (SmilMediaContainer) smilResponse.getEntity();
logger.debug("Segment begin: {}; Segment duration: {}", mark.begin, mark.duration);
// Add tracks (as array) to par
smilResponse = smilService
.addClips(smilResponse.getSmil(),
par.getId(),
tracksFromFlavors.toArray(new Track[0]),
mark.begin,
mark.duration);
}
smil = smilResponse.getSmil();
logger.info("Done adding tracks");
} catch (SmilException e) {
throw new WorkflowOperationException("Failed to create SMIL Catalog", e);
}
// Put new SMIL into workspace and add it to media package
try (InputStream is = IOUtils.toInputStream(smil.toXML(), "UTF-8")) {
URI smilURI = workspace.put(mediaPackage.getIdentifier().toString(), smil.getId(), CUTTING_SMIL_NAME, is);
MediaPackageElementBuilder mpeBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
Catalog catalog = (Catalog) mpeBuilder
.elementFromURI(smilURI, MediaPackageElement.Type.Catalog, targetSmilFlavor);
catalog.setIdentifier(smil.getId());
applyTargetTagsToElement(targetTagsOption, catalog);
mediaPackage.add(catalog);
} catch (JAXBException | SAXException | IOException e) {
throw new WorkflowOperationException("Failed to parse crated SMIL Catalog", e);
}
final WorkflowOperationResult result = createResult(mediaPackage, WorkflowOperationResult.Action.CONTINUE);
logger.debug("Cut marks to smil operation completed");
return result;
}
/**
* Returns the absolute path for a given MediaPackageElement
* @param mpe
* The MediaPackageElement we want to know the absolute path for
* @return
* The absolute path
* @throws WorkflowOperationException
*/
private String getMediaPackageElementPath(MediaPackageElement mpe) throws WorkflowOperationException {
File mediaFile;
try {
mediaFile = workspace.get(mpe.getURI());
} catch (NotFoundException | IOException e) {
throw new WorkflowOperationException(
"Error finding the media file in the workspace", e);
}
return mediaFile.getAbsolutePath();
}
@Reference
@Override
public void setServiceRegistry(ServiceRegistry serviceRegistry) {
super.setServiceRegistry(serviceRegistry);
}
}