/** * 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.composer; import static com.entwinemedia.fn.Equality.hash; import static com.entwinemedia.fn.Prelude.chuck; import static com.entwinemedia.fn.Prelude.unexhaustiveMatchError; import static com.entwinemedia.fn.Stream.$; import static com.entwinemedia.fn.parser.Parsers.character; import static com.entwinemedia.fn.parser.Parsers.many; import static com.entwinemedia.fn.parser.Parsers.opt; import static com.entwinemedia.fn.parser.Parsers.space; import static com.entwinemedia.fn.parser.Parsers.symbol; import static com.entwinemedia.fn.parser.Parsers.token; import static com.entwinemedia.fn.parser.Parsers.yield; import static java.lang.String.format; import static org.opencastproject.util.EqualsUtil.eq; import static org.opencastproject.util.data.Tuple.tuple; import org.opencastproject.composer.api.ComposerService; import org.opencastproject.composer.api.EncodingProfile; import org.opencastproject.job.api.Job; import org.opencastproject.job.api.JobBarrier; import org.opencastproject.job.api.JobContext; import org.opencastproject.mediapackage.Attachment; import org.opencastproject.mediapackage.MediaPackage; import org.opencastproject.mediapackage.MediaPackageElement; import org.opencastproject.mediapackage.MediaPackageElementFlavor; import org.opencastproject.mediapackage.MediaPackageElementParser; import org.opencastproject.mediapackage.MediaPackageException; import org.opencastproject.mediapackage.MediaPackageSupport; import org.opencastproject.mediapackage.MediaPackageSupport.Filters; import org.opencastproject.mediapackage.Track; import org.opencastproject.mediapackage.selector.AbstractMediaPackageElementSelector; import org.opencastproject.mediapackage.selector.TrackSelector; import org.opencastproject.util.JobUtil; import org.opencastproject.util.MimeTypes; import org.opencastproject.util.PathSupport; import org.opencastproject.util.data.Collections; import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler; 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.workspace.api.Workspace; import com.entwinemedia.fn.Fn; import com.entwinemedia.fn.Fn2; import com.entwinemedia.fn.Fx; import com.entwinemedia.fn.P2; import com.entwinemedia.fn.Prelude; import com.entwinemedia.fn.Stream; import com.entwinemedia.fn.StreamFold; import com.entwinemedia.fn.data.Opt; import com.entwinemedia.fn.fns.Strings; import com.entwinemedia.fn.parser.Parser; import com.entwinemedia.fn.parser.Parsers; import com.entwinemedia.fn.parser.Result; import org.apache.commons.io.FilenameUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; import java.util.IllegalFormatException; import java.util.List; import java.util.SortedMap; /** * The workflow definition for handling "image" operations */ public class ImageWorkflowOperationHandler extends AbstractWorkflowOperationHandler { /** The logging facility */ private static final Logger logger = LoggerFactory.getLogger(ImageWorkflowOperationHandler.class); // legacy option public static final String OPT_SOURCE_FLAVOR = "source-flavor"; public static final String OPT_SOURCE_FLAVORS = "source-flavors"; public static final String OPT_SOURCE_TAGS = "source-tags"; public static final String OPT_PROFILES = "encoding-profile"; public static final String OPT_POSITIONS = "time"; public static final String OPT_TARGET_FLAVOR = "target-flavor"; public static final String OPT_TARGET_TAGS = "target-tags"; public static final String OPT_TARGET_BASE_NAME_FORMAT_SECOND = "target-base-name-format-second"; public static final String OPT_TARGET_BASE_NAME_FORMAT_PERCENT = "target-base-name-format-percent"; public static final String OPT_END_MARGIN = "end-margin"; private static final long END_MARGIN_DEFAULT = 100; /** The configuration options for this handler */ @SuppressWarnings("unchecked") private static final SortedMap<String, String> CONFIG_OPTIONS = Collections.smap( tuple(OPT_SOURCE_FLAVOR, "The \"flavor\" of the track to use as a video source input"), tuple(OPT_SOURCE_FLAVORS, "The \"flavors\" of the track to use as a video source input"), tuple(OPT_SOURCE_TAGS, "The required tags that must exist on the track for the track to be used as a video source"), tuple(OPT_PROFILES, "The encoding profile to use"), tuple(OPT_POSITIONS, "The number of seconds into the video file to extract the image"), tuple(OPT_TARGET_FLAVOR, "The flavor to apply to the extracted image"), tuple(OPT_TARGET_TAGS, "The tags to apply to the extracted image"), tuple(OPT_TARGET_BASE_NAME_FORMAT_SECOND, "TODO"), // todo description tuple(OPT_TARGET_BASE_NAME_FORMAT_PERCENT, "The target base name pattern for seconds 'thumbnail' or 'extracted'."), //todo description tuple(OPT_END_MARGIN, "A margin in milliseconds at the end of the video. Each position is " + "limited to not exceed this bound. Defaults to " + END_MARGIN_DEFAULT + "ms.")); /** The composer service */ private ComposerService composerService = null; /** The local workspace */ private Workspace workspace = null; /** * Callback for the OSGi declarative services configuration. * * @param composerService * the composer service */ protected void setComposerService(ComposerService composerService) { this.composerService = composerService; } /** * 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 */ public void setWorkspace(Workspace workspace) { this.workspace = workspace; } @Override public SortedMap<String, String> getConfigurationOptions() { return CONFIG_OPTIONS; } @Override public WorkflowOperationResult start(final WorkflowInstance wi, JobContext ctx) throws WorkflowOperationException { logger.debug("Running image workflow operation on {}", wi); try { final Extractor e = new Extractor(this, configure(wi.getMediaPackage(), wi.getCurrentOperation())); return e.main(MediaPackageSupport.copy(wi.getMediaPackage())); } catch (Exception e) { throw new WorkflowOperationException(e); } } /** * Computation within the context of a {@link Cfg}. */ static final class Extractor { private final ImageWorkflowOperationHandler handler; private final Cfg cfg; Extractor(ImageWorkflowOperationHandler handler, Cfg cfg) { this.handler = handler; this.cfg = cfg; } /** Run the extraction. */ WorkflowOperationResult main(final MediaPackage mp) throws WorkflowOperationException { if (cfg.sourceTracks.size() == 0) { logger.info("No source tracks found in media package {}, skipping operation", mp.getIdentifier()); return handler.createResult(mp, Action.SKIP); } // start image extraction jobs final List<Extraction> extractions = $(cfg.sourceTracks).bind(new Fn<Track, Stream<Extraction>>() { @Override public Stream<Extraction> apply(final Track t) { final List<MediaPosition> p = limit(t, cfg.positions); if (p.size() != cfg.positions.size()) { logger.warn("Could not apply all configured positions to track " + t); } else { logger.info(format("Extracting images from %s at position %s", t, $(p).mkString(", "))); } // create one extraction per encoding profile return $(cfg.profiles).map(new Fn<EncodingProfile, Extraction>() { @Override public Extraction apply(EncodingProfile profile) { return new Extraction(extractImages(t, profile, p), t, profile, p); } }); } }).toList(); final List<Job> extractionJobs = concatJobs(extractions); final JobBarrier.Result extractionResult = JobUtil.waitForJobs(handler.serviceRegistry, extractionJobs); if (extractionResult.isSuccess()) { // all extractions were successful; iterate them for (final Extraction extraction : extractions) { final List<Attachment> images = getImages(extraction.job); final int expectedNrOfImages = extraction.positions.size(); if (images.size() == expectedNrOfImages) { // post process images for (final P2<Attachment, MediaPosition> image : $(images).zip(extraction.positions)) { adjustMetadata(extraction, image.get1()); mp.addDerived(image.get1(), extraction.track); final String fileName = createFileName( extraction.profile.getSuffix(), extraction.track.getURI(), image.get2()); moveToWorkspace(mp, image.get1(), fileName); } } else { // less images than expected have been extracted throw new WorkflowOperationException( format("Only %s of %s images have been extracted from track %s", images.size(), expectedNrOfImages, extraction.track)); } } return handler.createResult(mp, Action.CONTINUE, JobUtil.sumQueueTime(extractionJobs)); } else { throw new WorkflowOperationException("Image extraction failed"); } } /** * Adjust flavor, tags, mime type of <code>image</code> according to the * configuration and the extraction. */ void adjustMetadata(Extraction extraction, Attachment image) { // Adjust the target flavor. Make sure to account for partial updates for (final MediaPackageElementFlavor flavor : cfg.targetImageFlavor) { final String flavorType = eq("*", flavor.getType()) ? extraction.track.getFlavor().getType() : flavor.getType(); final String flavorSubtype = eq("*", flavor.getSubtype()) ? extraction.track.getFlavor().getSubtype() : flavor.getSubtype(); image.setFlavor(new MediaPackageElementFlavor(flavorType, flavorSubtype)); logger.debug("Resulting image has flavor '{}'", image.getFlavor()); } // Set the mime type for (final String mimeType : Opt.nul(extraction.profile.getMimeType())) { image.setMimeType(MimeTypes.parseMimeType(mimeType)); } // Add tags for (final String tag : cfg.targetImageTags) { logger.trace("Tagging image with '{}'", tag); image.addTag(tag); } } /** Create a file name for the extracted image. */ String createFileName(final String suffix, final URI trackUri, final MediaPosition pos) { final String trackBaseName = FilenameUtils.getBaseName(trackUri.getPath()); final String format; switch (pos.type) { case Seconds: format = cfg.targetBaseNameFormatSecond.getOr(trackBaseName + "_%.3fs%s"); break; case Percentage: format = cfg.targetBaseNameFormatPercent.getOr(trackBaseName + "_%.1fp%s"); break; default: throw unexhaustiveMatchError(); } return formatFileName(format, pos.position, suffix); } /** Move the extracted <code>image</code> to its final location in the workspace and rename it to <code>fileName</code>. */ private void moveToWorkspace(final MediaPackage mp, final Attachment image, final String fileName) { try { image.setURI(handler.workspace.moveTo( image.getURI(), mp.getIdentifier().toString(), image.getIdentifier(), fileName)); } catch (Exception e) { chuck(new WorkflowOperationException(e)); } } /** Start a composer job to extract images from a track at the given positions. */ private Job extractImages(final Track track, final EncodingProfile profile, final List<MediaPosition> positions) { final List<Double> p = $(positions).map(new Fn<MediaPosition, Double>() { @Override public Double apply(MediaPosition mediaPosition) { return toSeconds(track, mediaPosition, cfg.endMargin); } }).toList(); try { return handler.composerService.image(track, profile.getIdentifier(), Collections.toDoubleArray(p)); } catch (Exception e) { return chuck(new WorkflowOperationException("Error starting image extraction job", e)); } } } // ** ** ** /** * Format a filename and make it "safe". * * @see org.opencastproject.util.PathSupport#toSafeName(String) */ static String formatFileName(String format, double position, String suffix) { // #toSafeName will be applied to the file name anyway when moving to the working file repository // but doing it here make the tests more readable and useful for documentation return PathSupport.toSafeName(format(format, position, suffix)); } /** Concat the jobs of a list of extraction objects. */ private static List<Job> concatJobs(List<Extraction> extractions) { return $(extractions).map(new Fn<Extraction, Job>() { @Override public Job apply(Extraction extraction) { return extraction.job; } }).toList(); } /** Get the images (payload) from a job. */ @SuppressWarnings("unchecked") private static List<Attachment> getImages(Job job) { final List<Attachment> images; try { images = (List<Attachment>) MediaPackageElementParser.getArrayFromXml(job.getPayload()); } catch (MediaPackageException e) { return chuck(e); } if (!images.isEmpty()) { return images; } else { return chuck(new WorkflowOperationException("Job did not extract any images")); } } /** Limit the list of media positions to those that fit into the length of the track. */ static List<MediaPosition> limit(Track track, List<MediaPosition> positions) { final long duration = track.getDuration(); return $(positions).filter(new Fn<MediaPosition, Boolean>() { @Override public Boolean apply(MediaPosition p) { return !( (eq(p.type, PositionType.Seconds) && (p.position >= duration || p.position < 0.0)) || (eq(p.type, PositionType.Percentage) && (p.position < 0.0 || p.position > 100.0))); } }).toList(); } /** * Convert a <code>position</code> into seconds in relation to the given track. * <em>Attention:</em> The function does not check if the calculated absolute position is within * the bounds of the tracks length. */ static double toSeconds(Track track, MediaPosition position, double endMarginMs) { final long durationMs = track.getDuration(); final double posMs; switch (position.type) { case Percentage: posMs = durationMs * position.position / 100.0; break; case Seconds: posMs = position.position * 1000.0; break; default: throw unexhaustiveMatchError(); } // limit maximum position to Xms before the end of the video return Math.abs(durationMs - posMs) >= endMarginMs ? posMs / 1000.0 : Math.max(0, ((double) durationMs - endMarginMs)) / 1000.0; } // ** ** ** /** Create a fold that folds flavors into a media package element selector. */ public static <E extends MediaPackageElement, S extends AbstractMediaPackageElementSelector<E>> StreamFold<MediaPackageElementFlavor, S> flavorFold(S selector) { return StreamFold.foldl(selector, new Fn2<S, MediaPackageElementFlavor, S>() { @Override public S apply(S sum, MediaPackageElementFlavor flavor) { sum.addFlavor(flavor); return sum; } }); } /** Create a fold that folds tags into a media package element selector. */ public static <E extends MediaPackageElement, S extends AbstractMediaPackageElementSelector<E>> StreamFold<String, S> tagFold(S selector) { return StreamFold.foldl(selector, new Fn2<S, String, S>() { @Override public S apply(S sum, String tag) { sum.addTag(tag); return sum; } }); } /** * Fetch a profile from the composer service. Throw a WorkflowOperationException in case the profile * does not exist. */ public static Fn<String, EncodingProfile> fetchProfile(final ComposerService composerService) { return new Fn<String, EncodingProfile>() { @Override public EncodingProfile apply(String profileName) { final EncodingProfile profile = composerService.getProfile(profileName); return profile != null ? profile : Prelude.<EncodingProfile>chuck(new WorkflowOperationException("Encoding profile '" + profileName + "' was not found")); } }; } /** * Describes the extraction of a list of images from a track, extracted after a certain encoding profile. * Track -> (profile, positions) */ static final class Extraction { /** The extraction job. */ private final Job job; /** The track to extract from. */ private final Track track; /** The encoding profile to use for extraction. */ private final EncodingProfile profile; /** Media positions. */ private final List<MediaPosition> positions; private Extraction(Job job, Track track, EncodingProfile profile, List<MediaPosition> positions) { this.job = job; this.track = track; this.profile = profile; this.positions = positions; } } // ** ** ** /** * The WOH's configuration options. */ static final class Cfg { /** List of source tracks, with duration. */ private final List<Track> sourceTracks; private final List<MediaPosition> positions; private final List<EncodingProfile> profiles; private final Opt<MediaPackageElementFlavor> targetImageFlavor; private final List<String> targetImageTags; private final Opt<String> targetBaseNameFormatSecond; private final Opt<String> targetBaseNameFormatPercent; private final long endMargin; Cfg(List<Track> sourceTracks, List<MediaPosition> positions, List<EncodingProfile> profiles, Opt<MediaPackageElementFlavor> targetImageFlavor, List<String> targetImageTags, Opt<String> targetBaseNameFormatSecond, Opt<String> targetBaseNameFormatPercent, long endMargin) { this.sourceTracks = sourceTracks; this.positions = positions; this.profiles = profiles; this.targetImageFlavor = targetImageFlavor; this.targetImageTags = targetImageTags; this.endMargin = endMargin; this.targetBaseNameFormatSecond = targetBaseNameFormatSecond; this.targetBaseNameFormatPercent = targetBaseNameFormatPercent; } } /** Get and parse the configuration options. */ private Cfg configure(MediaPackage mp, WorkflowOperationInstance woi) throws WorkflowOperationException { final List<EncodingProfile> profiles = getOptConfig(woi, OPT_PROFILES).toStream().bind(asList.toFn()) .map(fetchProfile(composerService)).toList(); final List<String> targetImageTags = getOptConfig(woi, OPT_TARGET_TAGS).toStream().bind(asList.toFn()).toList(); final Opt<MediaPackageElementFlavor> targetImageFlavor = getOptConfig(woi, OPT_TARGET_FLAVOR).map(MediaPackageElementFlavor.parseFlavor.toFn()); final List<Track> sourceTracks; { // get the source flavors final Stream<MediaPackageElementFlavor> sourceFlavors = getOptConfig(woi, OPT_SOURCE_FLAVORS).toStream() .bind(Strings.splitCsv) .append(getOptConfig(woi, OPT_SOURCE_FLAVOR)) .map(MediaPackageElementFlavor.parseFlavor.toFn()); // get the source tags final Stream<String> sourceTags = getOptConfig(woi, OPT_SOURCE_TAGS).toStream().bind(Strings.splitCsv); // fold both into a selector final TrackSelector trackSelector = sourceTags.apply(tagFold(sourceFlavors.apply(flavorFold(new TrackSelector())))); // select the tracks based on source flavors and tags and skip those that don't have video sourceTracks = $(trackSelector.select(mp, true)) .filter(Filters.hasVideo.toFn()) .each(new Fx<Track>() { @Override public void apply(Track track) { if (track.getDuration() == null) { chuck(new WorkflowOperationException(format("Track %s cannot tell its duration", track))); } } }).toList(); } final List<MediaPosition> positions = parsePositions(getConfig(woi, OPT_POSITIONS)); final long endMargin = getOptConfig(woi, OPT_END_MARGIN).bind(Strings.toLong).getOr(END_MARGIN_DEFAULT); // return new Cfg(sourceTracks, positions, profiles, targetImageFlavor, targetImageTags, getTargetBaseNameFormat(woi, OPT_TARGET_BASE_NAME_FORMAT_SECOND), getTargetBaseNameFormat(woi, OPT_TARGET_BASE_NAME_FORMAT_PERCENT), endMargin); } /** Validate a target base name format. */ private Opt<String> getTargetBaseNameFormat(WorkflowOperationInstance woi, final String formatName) { return getOptConfig(woi, formatName).each(validateTargetBaseNameFormat(formatName)); } static Fx<String> validateTargetBaseNameFormat(final String formatName) { return new Fx<String>() { @Override public void apply(String format) { boolean valid; try { final String name = formatFileName(format, 15.11, ".png"); valid = name.contains(".") && name.contains(".png"); } catch (IllegalFormatException e) { valid = false; } if (!valid) { chuck(new WorkflowOperationException(format( "%s is not a valid format string for config option %s", format, formatName))); } } }; } // ** ** ** /** * Parse media position parameter strings. */ static final class MediaPositionParser { private MediaPositionParser() { } static final Parser<Double> number = token(Parsers.dbl); static final Parser<MediaPosition> seconds = number.bind(new Fn<Double, Parser<MediaPosition>>() { @Override public Parser<MediaPosition> apply(Double p) { return yield(new MediaPosition(PositionType.Seconds, p)); } }); static final Parser<MediaPosition> percentage = number.bind(Parsers.<Double, String>ignore(symbol("%"))).bind(new Fn<Double, Parser<MediaPosition>>() { @Override public Parser<MediaPosition> apply(Double p) { return yield(new MediaPosition(PositionType.Percentage, p)); } }); static final Parser<Character> comma = token(character(',')); static final Parser<Character> ws = token(space); static final Parser<MediaPosition> position = percentage.or(seconds); /** Main parser. */ static final Parser<List<MediaPosition>> positions = position.bind(new Fn<MediaPosition, Parser<List<MediaPosition>>>() { // first position @Override public Parser<List<MediaPosition>> apply(final MediaPosition first) { // following return many(opt(comma).bind(Parsers.ignorePrevious(position))) .bind(new Fn<List<MediaPosition>, Parser<List<MediaPosition>>>() { @Override public Parser<List<MediaPosition>> apply(List<MediaPosition> rest) { return yield($(first).append(rest).toList()); } }); } }); } private List<MediaPosition> parsePositions(String time) throws WorkflowOperationException { final Result<List<MediaPosition>> r = MediaPositionParser.positions.parse(time); if (r.isDefined() && r.getRest().isEmpty()) { return r.getResult(); } else { throw new WorkflowOperationException(format("Cannot parse time string %s. Rest is %s", time, r.getRest())); } } enum PositionType { Percentage, Seconds } /** * A position in time in a media file. */ static final class MediaPosition { private final double position; private final PositionType type; MediaPosition(PositionType type, double position) { this.position = position; this.type = type; } @Override public int hashCode() { return hash(position, type); } @Override public boolean equals(Object that) { return (this == that) || (that instanceof MediaPosition && eqFields((MediaPosition) that)); } private boolean eqFields(MediaPosition that) { return position == that.position && eq(type, that.type); } @Override public String toString() { return format("MediaPosition(%s, %s)", type, position); } } }