Last week, I left you with a working form allowing you to choose a profile and a target for a video export. Now that we know how to retrieve all this information, we can write the export code. As the export will be started from a workflow, we need to use an operation.

The goal of this operation is to retrieve all the parameters associated with the selected profile and target, and then start the video conversion in a work instance. A Work instance gets scheduled and executed by a WorkManager. The WorkManager service allows you to run code later, asynchronously, in a separate thread and transaction. This is exactly what we need for a video conversion.

Here is the code for my operation. The first two @Param annotations are used to retrieve the selected target and profile. We can get them for the node variables of the workflow. You have to use the following variables: @{NodeVariables[“target”]} and @{NodeVariables[“profile”]}. I have added an additional open property - parameters - if we ever need more than just the profile and target.

Export Workflow

From there, the code is fairly straight forward. I first use the DirectoryService to wrap all the information needed in a VideoExportInfo class. It’s a simple POJO I made to make things a bit cleaner. Then I create the work instance and schedule it through the WorkManager.

package org.nuxeo.video;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.nuxeo.ecm.automation.core.Constants;
import org.nuxeo.ecm.automation.core.annotations.Context;
import org.nuxeo.ecm.automation.core.annotations.Operation;
import org.nuxeo.ecm.automation.core.annotations.OperationMethod;
import org.nuxeo.ecm.automation.core.annotations.Param;
import org.nuxeo.ecm.automation.core.collectors.DocumentModelCollector;
import org.nuxeo.ecm.automation.core.util.Properties;
import org.nuxeo.ecm.core.api.ClientException;
import org.nuxeo.ecm.core.api.DocumentModel;
import org.nuxeo.ecm.core.work.api.WorkManager;
import org.nuxeo.ecm.core.work.api.WorkManager.Scheduling;
import org.nuxeo.ecm.directory.Session;
import org.nuxeo.ecm.directory.api.DirectoryService;

/**
 * @author ldoguin
 */
@Operation(id = ExportVideo.ID, category = Constants.CAT_CONVERSION, label = "ExportVideo", description = "Export a Video following given parameters.")
public class ExportVideo {

    public static final String ID = "Conversion.ExportVideo";

    public static final Log log = LogFactory.getLog(ExportVideo.class);

    @Param(name = "target")
    protected String target;

    @Param(name = "profile")
    protected String profile;

    @Param(name = "parameters", required = false)
    protected Properties parameters;

    @Context
    WorkManager workManager;

    @Context
    DirectoryService directoryService;

    @OperationMethod(collector = DocumentModelCollector.class)
    public DocumentModel run(DocumentModel doc) throws ClientException {
        // retrieve informations relative to the target
        Session videoTargetSession = directoryService.open("videoTarget");
        DocumentModel targetEntry = videoTargetSession.getEntry(target);
        String acodec = (String) targetEntry.getPropertyValue("videoTarget:acodec");
        String vcodec = (String) targetEntry.getPropertyValue("videoTarget:vcodec");
        String extension = (String) targetEntry.getPropertyValue("videoTarget:extension");
        String mimetype = (String) targetEntry.getPropertyValue("videoTarget:mimetype");
        String targetLabel = (String) targetEntry.getPropertyValue("videoTarget:label");
        videoTargetSession.close();

        // retrieve informations relative to the profile
        Session videoProfileSession = directoryService.open("videoProfile");
        DocumentModel profiletEntry = videoProfileSession.getEntry(profile);
        Long height = (Long) profiletEntry.getPropertyValue("videoProfile:height");
        Double fps = (Double) profiletEntry.getPropertyValue("videoProfile:fps");
        String profileLabel = (String) profiletEntry.getPropertyValue("videoProfile:label");
        videoProfileSession.close();

        // Wrap every info in the VideoExportInfo class
        VideoExportInfo vei = new VideoExportInfo(height, fps, extension,
                mimetype, acodec, vcodec, profileLabel, targetLabel);

        // Create the work instance
        CustomVideoConversionWork work = new CustomVideoConversionWork(
                doc.getRepositoryName(), doc.getId(), vei, parameters);

        // Schedule the work instance
        workManager.schedule(work, Scheduling.IF_NOT_RUNNING_OR_SCHEDULED);
        return doc;
    }
}

I use this operation in a simple chain that fetches the context document and then runs the Export Video operation. This chain runs when the workflow assignee clicks on the export button.

Node Transitions

Everything else will happen in the CustomConversionWork instance. Every Work instance you write must extend the AbstractWork class. That’s the mandatory start.

Here, we’re writing a work instance for a video conversion and it happens that we already have one. It’s the VideoConversionWork class. We can extend this one instead. It uses the VideoService, which is bound to the video conversions you declare through the appropriate extension point. This limits what we want to do, so instead of relying on the VideoService, we will use a custom converter directly.

If we have a custom converter for video, we will probably need a new commandLine contribution. So we need to contribute to both the converter and the command extension point. It looks like this:

<component name="org.nuxeo.video.converter.genericconverter">
  <extension
    target="org.nuxeo.ecm.platform.commandline.executor.service.CommandLineExecutorComponent"
    point="command">
    <command name="ffmpeg-generic-video" enabled="true">
      <commandLine>ffmpeg</commandLine>
      <parameterString> -i #{inFilePath} -s #{width}x#{height} -acodec #{acodec} -vcodec #{vcodec} #{outFilePath}</parameterString>
      <installationDirective>You need to install ffmpeg from http://ffmpeg.org (apt-get install ffmpeg)</installationDirective>
    </command>
  </extension>

  <extension point="converter"
    target="org.nuxeo.ecm.core.convert.service.ConversionServiceImpl">
    <converter class="org.nuxeo.video.GenericVideoConverter"
      name="genericVideoConverter">
      <parameters>
        <parameter name="CommandLineName">ffmpeg-generic-video</parameter>
      </parameters>
    </converter>
  </extension>
</component>

As you can see from the commandLine, we are using ffmpeg as usual. We specify the height and width of the video, as well as the audio and video codec. The appropriate container is chosen by ffmpeg depending on the file extension we give to the output file. This information is all available on VideoExportInfo POJO.

Now back to our CustomVideoConversionWork. We can override the getTitle method to replace the conversion name used before with the title of our video export. The title is basically the name of the profile followed by the name of the target. Then there is the work method. This is pretty much where everything happens :).

You can see that we can set a status and a progress for our work instance. This way we can change it every time something major is done. This is very useful to give an idea of what’s going on in the activity tab of the Admin Center.

Another thing you can do in a work instance is to manage the transaction manually. This can be useful for batch document creation for instance.

package org.nuxeo.video;

import static org.nuxeo.ecm.core.api.CoreSession.ALLOW_VERSION_WRITE;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.nuxeo.ecm.automation.core.util.Properties;
import org.nuxeo.ecm.core.api.ClientException;
import org.nuxeo.ecm.core.api.ClientRuntimeException;
import org.nuxeo.ecm.core.api.DocumentModel;
import org.nuxeo.ecm.core.api.IdRef;
import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolder;
import org.nuxeo.ecm.core.convert.api.ConversionService;
import org.nuxeo.ecm.platform.video.TranscodedVideo;
import org.nuxeo.ecm.platform.video.Video;
import org.nuxeo.ecm.platform.video.VideoHelper;
import org.nuxeo.ecm.platform.video.VideoInfo;
import org.nuxeo.ecm.platform.video.service.VideoConversionWork;
import org.nuxeo.runtime.api.Framework;

public class CustomVideoConversionWork extends VideoConversionWork {

    private static final long serialVersionUID = 1L;

    private static final Log log = LogFactory.getLog(CustomVideoConversionWork.class);

    public static final String CATEGORY_VIDEO_CONVERSION = "videoConversion";

    public static final String VIDEO_CONVERSIONS_DONE_EVENT = "videoConversionsDone";

    protected final Properties properties;

    protected final VideoExportInfo vei;

    public CustomVideoConversionWork(String repositoryName, String docId,
            VideoExportInfo vei, Properties properties) {
        super(repositoryName, docId, vei.getTitle());
        setDocument(repositoryName, docId);
        this.properties = properties;
        this.vei = vei;
    }

    @Override
    public String getTitle() {
        return "Video Conversion " + vei.getTitle();
    }

    @Override
    public void work() throws Exception {
        setStatus("Extracting");
        setProgress(Progress.PROGRESS_INDETERMINATE);

        Video originalVideo = null;
        try {
            initSession();
            originalVideo = getVideoToConvert();
            commitOrRollbackTransaction();
        } finally {
            cleanUp(true, null);
        }

        if (originalVideo == null) {
            setStatus("Nothing to process");
            return;
        }

        // Perform the actual conversion
        setStatus("Transcoding");
        TranscodedVideo transcodedVideo = convert(originalVideo);

        // Saving it to the document
        startTransaction();
        setStatus("Saving");
        initSession();
        DocumentModel doc = session.getDocument(new IdRef(docId));
        saveNewTranscodedVideo(doc, transcodedVideo);
        fireVideoConversionsDoneEvent(doc);
        setStatus("Done");
    }

    public TranscodedVideo convert(Video originalVideo) {
        try {
            BlobHolder blobHolder = new SimpleBlobHolder(
                    originalVideo.getBlob());
            Map<String, Serializable> parameters = new HashMap<String, Serializable>();
            parameters.put("videoInfo", originalVideo.getVideoInfo());
            parameters.put("videoExportInfo", vei);
            ConversionService conversionService = Framework.getLocalService(ConversionService.class);
            BlobHolder result = conversionService.convert(
                    "genericVideoConverter", blobHolder, parameters);
            VideoInfo videoInfo = VideoHelper.getVideoInfo(result.getBlob());
            return TranscodedVideo.fromBlobAndInfo(vei.getTitle(),
                    result.getBlob(), videoInfo);
        } catch (ClientException e) {
            throw new ClientRuntimeException(e);
        }
    }

    @Override
    protected void saveNewTranscodedVideo(DocumentModel doc,
            TranscodedVideo transcodedVideo) throws ClientException {
        if (!doc.hasFacet("VideoExport")) {
            doc.addFacet("VideoExport");
        }
        List<Map<String, Serializable>> transcodedVideos =
                (List<Map<String, Serializable>>) doc.getPropertyValue("vex:exportedVideos");
        if (transcodedVideos == null) {
            transcodedVideos = new ArrayList<>();
        }
        transcodedVideos.add(transcodedVideo.toMap());
        doc.setPropertyValue("vex:exportedVideos",
                (Serializable) transcodedVideos);
        if (doc.isVersion()) {
            doc.putContextData(ALLOW_VERSION_WRITE, Boolean.TRUE);
        }
        session.saveDocument(doc);
    }

}

You then have the convert and saveNewTranscodedVideo methods. This is where most of the core works take place. The convert method calls the custom converter and provides the necessary parameters from the VideoExportInfo object.

The saveNewTranscodedVideo method takes the result of the conversion and saves it on the document. But now you should ask yourself where are we going to save that video? I’ve added an exportVideo schema that looks like this:

<?xml version="1.0"?>
<xs:schema targetNamespace="http://www.nuxeo.org/ecm/schemas/videoExport/"
  xmlns:xs="http://www.w3.org/2001/XMLSchema"
  xmlns:video="http://www.nuxeo.org/ecm/schemas/video">

  <xs:import namespace="http://www.nuxeo.org/ecm/schemas/video" schemaLocation="video.xsd"/>
  <xs:element name="exportedVideos" type="video:transcodedVideoItems" />

</xs:schema>

It includes the default video schema. But instead of putting these videos with the other handled by the VideoService, I will put them on the exportedVideos property of my videoExport schema. And, as I don’t want to add this schema to the default Video document type, I’ve added a VideoExport facet to hold it.

<component name="org.nuxeo.video.converter.schemas">

<extension point="schema" target="org.nuxeo.ecm.core.schema.TypeService">
    <schema name="videoProfile" src="schemas/video_profile.xsd"/>
    <schema name="videoTarget" src="schemas/video_target.xsd"/>
    <schema name="videoExport" src="schemas/video_export.xsd" prefix="vex"/>
  </extension>

  <extension point="doctype" target="org.nuxeo.ecm.core.schema.TypeService">
    <facet name="VideoExport">
      <schema name="videoExport"/>
    </facet>
  </extension>

</component>

Now back to the converter. It extends the CommandLineBasedConverter. What it does is basically call the commandLine we’ve declared before and give it the right parameters. It’s pretty much the BaseVideoConverter found in the nuxeo-platform-video-convert bundle but tweaked a bit to use the VideoExportInfo object.

package org.nuxeo.video;

import static org.nuxeo.ecm.platform.video.convert.Constants.INPUT_FILE_PATH_PARAMETER;
import static org.nuxeo.ecm.platform.video.convert.Constants.OUTPUT_FILE_NAME_PARAMETER;
import static org.nuxeo.ecm.platform.video.convert.Constants.OUTPUT_FILE_PATH_PARAMETER;

import java.io.File;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.apache.commons.io.FilenameUtils;
import org.nuxeo.common.utils.Path;
import org.nuxeo.ecm.core.api.Blob;
import org.nuxeo.ecm.core.api.ClientException;
import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolderWithProperties;
import org.nuxeo.ecm.core.api.impl.blob.FileBlob;
import org.nuxeo.ecm.core.convert.api.ConversionException;
import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
import org.nuxeo.ecm.platform.convert.plugins.CommandLineBasedConverter;
import org.nuxeo.ecm.platform.video.VideoInfo;
import org.nuxeo.runtime.api.Framework;

public class GenericVideoConverter extends CommandLineBasedConverter {

    @Override
    protected Map<String, Blob> getCmdBlobParameters(BlobHolder blobHolder,
            Map<String, Serializable> stringSerializableMap)
            throws ConversionException {
        Map<String, Blob> cmdBlobParams = new HashMap<String, Blob>();
        try {
            cmdBlobParams.put(INPUT_FILE_PATH_PARAMETER, blobHolder.getBlob());
        } catch (ClientException e) {
            throw new ConversionException("Unable to get Blob for holder", e);
        }
        return cmdBlobParams;
    }

    @Override
    protected Map<String, String> getCmdStringParameters(BlobHolder blobHolder,
            Map<String, Serializable> parameters) throws ConversionException {
        Map<String, String> cmdStringParams = new HashMap<String, String>();
        VideoExportInfo vie = (VideoExportInfo) parameters.get("videoExportInfo");
        cmdStringParams.put("acodec", vie.acodec);
        cmdStringParams.put("vcodec", vie.vcodec);
        cmdStringParams.put("mimetype", vie.mimeType);

        String baseDir = getTmpDirectory(parameters);
        Path tmpPath = new Path(baseDir).append("convertTo" + vie.extension
                + "_" + UUID.randomUUID());

        File outDir = new File(tmpPath.toString());
        boolean dirCreated = outDir.mkdir();
        if (!dirCreated) {
            throw new ConversionException(
                    "Unable to create tmp dir for transformer output: "
                            + outDir);
        }

        try {
            File outFile = File.createTempFile("videoConversion", "."
                    + vie.extension, outDir);
            // delete the file as we need only the path for ffmpeg
            outFile.delete();
            Framework.trackFile(outFile, this);
            cmdStringParams.put(OUTPUT_FILE_PATH_PARAMETER,
                    outFile.getAbsolutePath());
            String baseName = FilenameUtils.getBaseName(blobHolder.getBlob().getFilename());
            cmdStringParams.put(OUTPUT_FILE_NAME_PARAMETER, baseName + "."
                    + vie.extension);

            VideoInfo videoInfo = (VideoInfo) parameters.get("videoInfo");
            if (videoInfo == null) {
                return cmdStringParams;
            }

            long width = videoInfo.getWidth();
            long height = videoInfo.getHeight();
            long newHeight = vie.height;

            long newWidth = width * newHeight / height;
            if (newWidth % 2 != 0) {
                newWidth += 1;
            }

            cmdStringParams.put("width", String.valueOf(newWidth));
            cmdStringParams.put("height", String.valueOf(newHeight));
            return cmdStringParams;
        } catch (Exception e) {
            throw new ConversionException("Unable to get Blob for holder", e);
        }
    }

    @Override
    protected BlobHolder buildResult(List<String> cmdOutput,
            CmdParameters cmdParameters) throws ConversionException {
        String outputPath = cmdParameters.getParameters().get(
                OUTPUT_FILE_PATH_PARAMETER);
        String mimeType = cmdParameters.getParameters().get("mimetype");
        File outputFile = new File(outputPath);
        List<Blob> blobs = new ArrayList<Blob>();
        String outFileName = cmdParameters.getParameters().get(
                OUTPUT_FILE_NAME_PARAMETER);
        if (outFileName == null) {
            outFileName = outputFile.getName();
        } else {
            outFileName = unquoteValue(outFileName);
        }

        Blob blob = new FileBlob(outputFile);
        blob.setFilename(outFileName);
        blob.setMimeType(mimeType);
        blobs.add(blob);

        Map<String, Serializable> properties = new HashMap<String, Serializable>();
        properties.put("cmdOutput", (Serializable) cmdOutput);
        return new SimpleBlobHolderWithProperties(blobs, properties);
    }

    /**
     * @since 5.6
     */
    protected String unquoteValue(String value) {
        if (value.startsWith(""") && value.endsWith(""")) {
            return value.substring(1, value.length() - 1);
        }
        return value;
    }

}

With all of this, you should be ready to build your project and deploy it on your server. Then you should be able to test it. We currently have no UI to see the exported videos, so if you want to know if that works:

First, take a look at your server log. You should not have any errors, but you never know :) Then, take a look at the Admin Center, in the Activity tab, in the Background Work tab, you should see the conversion in the videoConversion queue.

Last step, look at the XML export of the Video document you launch the workflow in, you should see the video.

The next part of this series will be on how to see those exports easily.