View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.animate.impl;
23  
24  import org.opencastproject.animate.api.AnimateService;
25  import org.opencastproject.animate.api.AnimateServiceException;
26  import org.opencastproject.job.api.AbstractJobProducer;
27  import org.opencastproject.job.api.Job;
28  import org.opencastproject.security.api.OrganizationDirectoryService;
29  import org.opencastproject.security.api.SecurityService;
30  import org.opencastproject.security.api.UserDirectoryService;
31  import org.opencastproject.serviceregistry.api.ServiceRegistry;
32  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
33  import org.opencastproject.util.ConfigurationException;
34  import org.opencastproject.util.IoSupport;
35  import org.opencastproject.util.LoadUtil;
36  import org.opencastproject.util.NotFoundException;
37  import org.opencastproject.workspace.api.Workspace;
38  
39  import com.google.gson.Gson;
40  import com.google.gson.reflect.TypeToken;
41  
42  import org.apache.commons.io.FileUtils;
43  import org.apache.commons.io.FilenameUtils;
44  import org.apache.commons.io.IOUtils;
45  import org.apache.commons.lang3.StringUtils;
46  import org.apache.commons.text.StringEscapeUtils;
47  import org.osgi.service.cm.ManagedService;
48  import org.osgi.service.component.ComponentContext;
49  import org.osgi.service.component.annotations.Activate;
50  import org.osgi.service.component.annotations.Component;
51  import org.osgi.service.component.annotations.Reference;
52  import org.slf4j.Logger;
53  import org.slf4j.LoggerFactory;
54  
55  import java.io.BufferedReader;
56  import java.io.File;
57  import java.io.FileInputStream;
58  import java.io.IOException;
59  import java.io.InputStream;
60  import java.io.InputStreamReader;
61  import java.lang.reflect.Type;
62  import java.net.URI;
63  import java.util.ArrayList;
64  import java.util.Arrays;
65  import java.util.Dictionary;
66  import java.util.List;
67  import java.util.Map;
68  
69  /** Create video animations using Synfig */
70  @Component(
71      immediate = true,
72      service = {
73          AnimateService.class,
74          ManagedService.class
75      },
76      property = {
77          "service.description=Animation Service",
78          "service.pid=org.opencastproject.animate.impl.AnimateServiceImpl"
79      }
80  )
81  public class AnimateServiceImpl extends AbstractJobProducer implements AnimateService, ManagedService {
82  
83    /** Configuration key for setting a custom synfig path */
84    private static final String SYNFIG_BINARY_CONFIG = "synfig.path";
85  
86    /** Default path to the synfig binary */
87    public static final String SYNFIG_BINARY_DEFAULT = "synfig";
88  
89    /** Path to the synfig binary */
90    private String synfigBinary = SYNFIG_BINARY_DEFAULT;
91  
92    /** Configuration key for this operation's job load */
93    private static final String JOB_LOAD_CONFIG = "job.load.animate";
94  
95    /** The load introduced on the system by creating an inspect job */
96    private static final float JOB_LOAD_DEFAULT = 0.8f;
97  
98    /** The load introduced on the system by creating an inspect job */
99    private float jobLoad = JOB_LOAD_DEFAULT;
100 
101   private static final Logger logger = LoggerFactory.getLogger(AnimateServiceImpl.class);
102 
103   /** List of available operations on jobs */
104   private static final String OPERATION = "animate";
105 
106   private Workspace workspace;
107   private ServiceRegistry serviceRegistry;
108   private SecurityService securityService;
109   private UserDirectoryService userDirectoryService;
110   private OrganizationDirectoryService organizationDirectoryService;
111 
112   private static final Type stringMapType = new TypeToken<Map<String, String>>() { }.getType();
113   private static final Type stringListType = new TypeToken<List<String>>() { }.getType();
114 
115   /** Creates a new animate service instance. */
116   public AnimateServiceImpl() {
117     super(JOB_TYPE);
118   }
119 
120   @Override
121   @Activate
122   public void activate(ComponentContext cc) {
123     super.activate(cc);
124     logger.debug("Activated animate service");
125   }
126 
127   @Override
128   public void updated(Dictionary properties) throws ConfigurationException {
129     if (properties == null) {
130       return;
131     }
132     logger.debug("Start updating animate service");
133 
134     synfigBinary = StringUtils.defaultIfBlank((String) properties.get(SYNFIG_BINARY_CONFIG), SYNFIG_BINARY_DEFAULT);
135     logger.debug("Set synfig binary path to {}", synfigBinary);
136 
137     jobLoad = LoadUtil.getConfiguredLoadValue(properties, JOB_LOAD_CONFIG, JOB_LOAD_DEFAULT, serviceRegistry);
138     logger.debug("Set animate job load to {}", jobLoad);
139 
140     logger.debug("Finished updating animate service");
141   }
142 
143   /**
144    * {@inheritDoc}
145    *
146    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
147    */
148   @Override
149   protected String process(Job job) throws Exception {
150     logger.debug("Started processing job {}", job.getId());
151     if (!OPERATION.equals(job.getOperation())) {
152       throw new ServiceRegistryException(String.format("This service can't handle operations of type '%s'",
153               job.getOperation()));
154     }
155 
156     List<String> arguments = job.getArguments();
157     URI animation = new URI(arguments.get(0));
158     Gson gson = new Gson();
159     Map<String, String> metadata = gson.fromJson(arguments.get(1), stringMapType);
160     List<String> options = gson.fromJson(arguments.get(2), stringListType);
161 
162     // filter animation and get new, custom input file
163     File input = customAnimation(job, animation, metadata);
164 
165     // prepare output file
166     File output = new File(workspace.rootDirectory(), String.format("animate/%d/%s.%s", job.getId(),
167             FilenameUtils.getBaseName(animation.getPath()), "mkv"));
168     FileUtils.forceMkdirParent(output);
169 
170     // create animation process.
171     final List<String> command = new ArrayList<>();
172     command.add(synfigBinary);
173     command.add("-i");
174     command.add(input.getAbsolutePath());
175     command.add("-o");
176     command.add(output.getAbsolutePath());
177     command.addAll(options);
178     logger.info("Executing animation command: {}", command);
179 
180     Process process = null;
181     try {
182       ProcessBuilder processBuilder = new ProcessBuilder(command);
183       processBuilder.redirectErrorStream(true);
184       process = processBuilder.start();
185 
186       // print synfig (+ffmpeg) output
187       try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
188         String line;
189         while ((line = in.readLine()) != null) {
190           logger.debug("Synfig: {}", line);
191         }
192       }
193 
194       // wait until the task is finished
195       int exitCode = process.waitFor();
196       if (exitCode != 0) {
197         throw new AnimateServiceException(String.format("Synfig exited abnormally with status %d (command: %s)",
198                 exitCode, command));
199       }
200       if (!output.isFile()) {
201         throw new AnimateServiceException("Synfig produced no output");
202       }
203       logger.info("Animation generated successfully: {}", output);
204     } catch (Exception e) {
205       // Ensure temporary data are removed
206       FileUtils.deleteQuietly(output.getParentFile());
207       logger.debug("Removed output directory of failed animation process: {}", output.getParentFile());
208       throw new AnimateServiceException(e);
209     } finally {
210       IoSupport.closeQuietly(process);
211       FileUtils.deleteQuietly(input);
212     }
213 
214     URI uri = workspace.putInCollection("animate-" + job.getId(), output.getName(),
215             new FileInputStream(output));
216     FileUtils.deleteQuietly(new File(workspace.rootDirectory(), String.format("animate/%d", job.getId())));
217 
218     return uri.toString();
219   }
220 
221 
222   private File customAnimation(final Job job, final URI input, final Map<String, String> metadata)
223           throws IOException, NotFoundException {
224     logger.debug("Start customizing the animation");
225     File output = new File(workspace.rootDirectory(), String.format("animate/%d/%s.%s", job.getId(),
226             FilenameUtils.getBaseName(input.getPath()), FilenameUtils.getExtension(input.getPath())));
227     FileUtils.forceMkdirParent(output);
228     String animation;
229     try {
230       animation = FileUtils.readFileToString(new File(input), "UTF-8");
231     } catch (IOException e) {
232       // Maybe no local file?
233       logger.debug("Falling back to workspace to read {}", input);
234       try (InputStream in = workspace.read(input)) {
235         animation = IOUtils.toString(in, "UTF-8");
236       }
237     }
238 
239     // replace all metadata
240     for (Map.Entry<String, String> entry: metadata.entrySet()) {
241       String value = StringEscapeUtils.escapeXml11(entry.getValue());
242       animation = animation.replaceAll("\\{\\{" + entry.getKey() + "\\}\\}", value);
243     }
244 
245     // write new animation file
246     FileUtils.write(output, animation, "utf-8");
247 
248     return output;
249   }
250 
251 
252   @Override
253   public Job animate(URI animation, Map<String, String> metadata, List<String> arguments)
254           throws AnimateServiceException {
255     Gson gson = new Gson();
256     List<String> jobArguments = Arrays.asList(animation.toString(), gson.toJson(metadata), gson.toJson(arguments));
257     try {
258       logger.debug("Create animate service job");
259       return serviceRegistry.createJob(JOB_TYPE, OPERATION, jobArguments, jobLoad);
260     } catch (ServiceRegistryException e) {
261       throw new AnimateServiceException(e);
262     }
263   }
264 
265   @Override
266   protected ServiceRegistry getServiceRegistry() {
267     return serviceRegistry;
268   }
269 
270   @Override
271   protected SecurityService getSecurityService() {
272     return securityService;
273   }
274 
275   @Override
276   protected UserDirectoryService getUserDirectoryService() {
277     return userDirectoryService;
278   }
279 
280   @Override
281   protected OrganizationDirectoryService getOrganizationDirectoryService() {
282     return organizationDirectoryService;
283   }
284 
285   @Reference
286   public void setWorkspace(Workspace workspace) {
287     this.workspace = workspace;
288   }
289 
290   @Reference
291   public void setServiceRegistry(ServiceRegistry jobManager) {
292     this.serviceRegistry = jobManager;
293   }
294 
295   @Reference
296   public void setSecurityService(SecurityService securityService) {
297     this.securityService = securityService;
298   }
299 
300   @Reference
301   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
302     this.userDirectoryService = userDirectoryService;
303   }
304 
305   @Reference
306   public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {
307     this.organizationDirectoryService = organizationDirectoryService;
308   }
309 }