AnimateServiceImpl.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.animate.impl;
import org.opencastproject.animate.api.AnimateService;
import org.opencastproject.animate.api.AnimateServiceException;
import org.opencastproject.job.api.AbstractJobProducer;
import org.opencastproject.job.api.Job;
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.util.ConfigurationException;
import org.opencastproject.util.IoSupport;
import org.opencastproject.util.LoadUtil;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.workspace.api.Workspace;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.osgi.service.cm.ManagedService;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.List;
import java.util.Map;
/** Create video animations using Synfig */
@Component(
immediate = true,
service = {
AnimateService.class,
ManagedService.class
},
property = {
"service.description=Animation Service",
"service.pid=org.opencastproject.animate.impl.AnimateServiceImpl"
}
)
public class AnimateServiceImpl extends AbstractJobProducer implements AnimateService, ManagedService {
/** Configuration key for setting a custom synfig path */
private static final String SYNFIG_BINARY_CONFIG = "synfig.path";
/** Default path to the synfig binary */
public static final String SYNFIG_BINARY_DEFAULT = "synfig";
/** Path to the synfig binary */
private String synfigBinary = SYNFIG_BINARY_DEFAULT;
/** Configuration key for this operation's job load */
private static final String JOB_LOAD_CONFIG = "job.load.animate";
/** The load introduced on the system by creating an inspect job */
private static final float JOB_LOAD_DEFAULT = 0.8f;
/** The load introduced on the system by creating an inspect job */
private float jobLoad = JOB_LOAD_DEFAULT;
private static final Logger logger = LoggerFactory.getLogger(AnimateServiceImpl.class);
/** List of available operations on jobs */
private static final String OPERATION = "animate";
private Workspace workspace;
private ServiceRegistry serviceRegistry;
private SecurityService securityService;
private UserDirectoryService userDirectoryService;
private OrganizationDirectoryService organizationDirectoryService;
private static final Type stringMapType = new TypeToken<Map<String, String>>() { }.getType();
private static final Type stringListType = new TypeToken<List<String>>() { }.getType();
/** Creates a new animate service instance. */
public AnimateServiceImpl() {
super(JOB_TYPE);
}
@Override
@Activate
public void activate(ComponentContext cc) {
super.activate(cc);
logger.debug("Activated animate service");
}
@Override
public void updated(Dictionary properties) throws ConfigurationException {
if (properties == null) {
return;
}
logger.debug("Start updating animate service");
synfigBinary = StringUtils.defaultIfBlank((String) properties.get(SYNFIG_BINARY_CONFIG), SYNFIG_BINARY_DEFAULT);
logger.debug("Set synfig binary path to {}", synfigBinary);
jobLoad = LoadUtil.getConfiguredLoadValue(properties, JOB_LOAD_CONFIG, JOB_LOAD_DEFAULT, serviceRegistry);
logger.debug("Set animate job load to {}", jobLoad);
logger.debug("Finished updating animate service");
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
*/
@Override
protected String process(Job job) throws Exception {
logger.debug("Started processing job {}", job.getId());
if (!OPERATION.equals(job.getOperation())) {
throw new ServiceRegistryException(String.format("This service can't handle operations of type '%s'",
job.getOperation()));
}
List<String> arguments = job.getArguments();
URI animation = new URI(arguments.get(0));
Gson gson = new Gson();
Map<String, String> metadata = gson.fromJson(arguments.get(1), stringMapType);
List<String> options = gson.fromJson(arguments.get(2), stringListType);
// filter animation and get new, custom input file
File input = customAnimation(job, animation, metadata);
// prepare output file
File output = new File(workspace.rootDirectory(), String.format("animate/%d/%s.%s", job.getId(),
FilenameUtils.getBaseName(animation.getPath()), "mkv"));
FileUtils.forceMkdirParent(output);
// create animation process.
final List<String> command = new ArrayList<>();
command.add(synfigBinary);
command.add("-i");
command.add(input.getAbsolutePath());
command.add("-o");
command.add(output.getAbsolutePath());
command.addAll(options);
logger.info("Executing animation command: {}", command);
Process process = null;
try {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
process = processBuilder.start();
// print synfig (+ffmpeg) output
try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = in.readLine()) != null) {
logger.debug("Synfig: {}", line);
}
}
// wait until the task is finished
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new AnimateServiceException(String.format("Synfig exited abnormally with status %d (command: %s)",
exitCode, command));
}
if (!output.isFile()) {
throw new AnimateServiceException("Synfig produced no output");
}
logger.info("Animation generated successfully: {}", output);
} catch (Exception e) {
// Ensure temporary data are removed
FileUtils.deleteQuietly(output.getParentFile());
logger.debug("Removed output directory of failed animation process: {}", output.getParentFile());
throw new AnimateServiceException(e);
} finally {
IoSupport.closeQuietly(process);
FileUtils.deleteQuietly(input);
}
URI uri = workspace.putInCollection("animate-" + job.getId(), output.getName(),
new FileInputStream(output));
FileUtils.deleteQuietly(new File(workspace.rootDirectory(), String.format("animate/%d", job.getId())));
return uri.toString();
}
private File customAnimation(final Job job, final URI input, final Map<String, String> metadata)
throws IOException, NotFoundException {
logger.debug("Start customizing the animation");
File output = new File(workspace.rootDirectory(), String.format("animate/%d/%s.%s", job.getId(),
FilenameUtils.getBaseName(input.getPath()), FilenameUtils.getExtension(input.getPath())));
FileUtils.forceMkdirParent(output);
String animation;
try {
animation = FileUtils.readFileToString(new File(input), "UTF-8");
} catch (IOException e) {
// Maybe no local file?
logger.debug("Falling back to workspace to read {}", input);
try (InputStream in = workspace.read(input)) {
animation = IOUtils.toString(in, "UTF-8");
}
}
// replace all metadata
for (Map.Entry<String, String> entry: metadata.entrySet()) {
String value = StringEscapeUtils.escapeXml11(entry.getValue());
animation = animation.replaceAll("\\{\\{" + entry.getKey() + "\\}\\}", value);
}
// write new animation file
FileUtils.write(output, animation, "utf-8");
return output;
}
@Override
public Job animate(URI animation, Map<String, String> metadata, List<String> arguments)
throws AnimateServiceException {
Gson gson = new Gson();
List<String> jobArguments = Arrays.asList(animation.toString(), gson.toJson(metadata), gson.toJson(arguments));
try {
logger.debug("Create animate service job");
return serviceRegistry.createJob(JOB_TYPE, OPERATION, jobArguments, jobLoad);
} catch (ServiceRegistryException e) {
throw new AnimateServiceException(e);
}
}
@Override
protected ServiceRegistry getServiceRegistry() {
return serviceRegistry;
}
@Override
protected SecurityService getSecurityService() {
return securityService;
}
@Override
protected UserDirectoryService getUserDirectoryService() {
return userDirectoryService;
}
@Override
protected OrganizationDirectoryService getOrganizationDirectoryService() {
return organizationDirectoryService;
}
@Reference
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
@Reference
public void setServiceRegistry(ServiceRegistry jobManager) {
this.serviceRegistry = jobManager;
}
@Reference
public void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
@Reference
public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
this.userDirectoryService = userDirectoryService;
}
@Reference
public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {
this.organizationDirectoryService = organizationDirectoryService;
}
}