Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 565 |
|
0.00% |
0 / 24 |
CRAP | |
0.00% |
0 / 1 |
Score | |
0.00% |
0 / 565 |
|
0.00% |
0 / 24 |
23256 | |
0.00% |
0 / 1 |
throwCallException | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getLilypondVersion | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
fetchLilypondVersion | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
boxedCommand | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
createDirectory | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getBaseUrl | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getBackend | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
render | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
renderScore | |
0.00% |
0 / 92 |
|
0.00% |
0 / 1 |
756 | |||
generateHTML | |
0.00% |
0 / 137 |
|
0.00% |
0 / 1 |
1560 | |||
generatePngAndMidi | |
0.00% |
0 / 132 |
|
0.00% |
0 / 1 |
992 | |||
addScript | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
throwCompileException | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
throwSynthException | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
extractMessage | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
imageSize | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getPaperCode | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
embedLilypondCode | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
generateAudio | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
42 | |||
getDurationFromScriptOutput | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
recordShellout | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
recordError | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
eraseDirectory | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
debug | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /* |
3 | Score, a MediaWiki extension for rendering musical scores with LilyPond. |
4 | Copyright © 2011 Alexander Klauer |
5 | |
6 | This program is free software: you can redistribute it and/or modify |
7 | it under the terms of the GNU General Public License as published by |
8 | the Free Software Foundation, either version 3 of the License, or |
9 | (at your option) any later version. |
10 | |
11 | This program is distributed in the hope that it will be useful, |
12 | but WITHOUT ANY WARRANTY; without even the implied warranty of |
13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 | GNU General Public License for more details. |
15 | |
16 | You should have received a copy of the GNU General Public License |
17 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
18 | |
19 | To contact the author: |
20 | <Graf.Zahl@gmx.net> |
21 | http://en.wikisource.org/wiki/User_talk:GrafZahl |
22 | https://github.com/TheCount/score |
23 | |
24 | */ |
25 | |
26 | namespace MediaWiki\Extension\Score; |
27 | |
28 | use Exception; |
29 | use FileBackend; |
30 | use FormatJson; |
31 | use FSFileBackend; |
32 | use MediaWiki\Html\Html; |
33 | use MediaWiki\Logger\LoggerFactory; |
34 | use MediaWiki\MediaWikiServices; |
35 | use MediaWiki\Message\Message; |
36 | use MediaWiki\Parser\Parser; |
37 | use MediaWiki\Title\Title; |
38 | use MediaWiki\WikiMap\WikiMap; |
39 | use NullLockManager; |
40 | use PPFrame; |
41 | use Shellbox\Command\BoxedCommand; |
42 | use Wikimedia\ScopedCallback; |
43 | |
44 | /** |
45 | * Score class. |
46 | */ |
47 | class Score { |
48 | /** |
49 | * Version for cache invalidation. |
50 | */ |
51 | private const CACHE_VERSION = 1; |
52 | |
53 | /** |
54 | * Cache expiry time for the LilyPond version |
55 | */ |
56 | private const VERSION_TTL = 3600; |
57 | |
58 | /** |
59 | * Supported score languages. |
60 | */ |
61 | private const SUPPORTED_LANGS = [ 'lilypond', 'ABC' ]; |
62 | |
63 | /** |
64 | * Supported note languages. |
65 | * Key is LilyPond filename. Value is language code |
66 | */ |
67 | public const SUPPORTED_NOTE_LANGUAGES = [ |
68 | 'arabic' => 'ar', |
69 | 'catalan' => 'ca', |
70 | 'deutsch' => 'de', |
71 | 'english' => 'en', |
72 | 'espanol' => 'es', |
73 | 'italiano' => 'it', |
74 | 'nederlands' => 'nl', |
75 | 'norsk' => 'no', |
76 | 'portugues' => 'pt', |
77 | 'suomi' => 'fi', |
78 | 'svenska' => 'sv', |
79 | 'vlaams' => 'vls', |
80 | ]; |
81 | |
82 | /** |
83 | * Default language used for notes. |
84 | */ |
85 | private const DEFAULT_NOTE_LANGUAGE = 'nederlands'; |
86 | |
87 | /** |
88 | * LilyPond version string. |
89 | * It defaults to null and is set the first time it is required. |
90 | * @var string|null |
91 | */ |
92 | private static $lilypondVersion = null; |
93 | |
94 | /** |
95 | * FileBackend instance cache |
96 | * @var FileBackend|null |
97 | */ |
98 | private static $backend; |
99 | |
100 | /** |
101 | * Throws proper ScoreException in case of failed shell executions. |
102 | * |
103 | * @param string $message Message key to display |
104 | * @param array $params Message parameters |
105 | * @param string $output collected output from stderr. |
106 | * @param string|bool $factoryDir The factory directory to replace with "..." |
107 | * |
108 | * @throws ScoreException always. |
109 | * @return never |
110 | */ |
111 | private static function throwCallException( $message, array $params, $output, $factoryDir = false ) { |
112 | /* clean up the output a bit */ |
113 | if ( $factoryDir ) { |
114 | $output = str_replace( $factoryDir, '...', $output ); |
115 | } |
116 | $params[] = Html::rawElement( 'pre', |
117 | // Error messages from LilyPond & abc2ly are always English |
118 | [ 'lang' => 'en', 'dir' => 'ltr' ], |
119 | htmlspecialchars( $output ) |
120 | ); |
121 | throw new ScoreException( $message, $params ); |
122 | } |
123 | |
124 | /** |
125 | * @return string |
126 | * @throws ScoreException if LilyPond could not be executed properly. |
127 | */ |
128 | public static function getLilypondVersion() { |
129 | global $wgScoreLilyPondFakeVersion; |
130 | |
131 | if ( strlen( $wgScoreLilyPondFakeVersion ) ) { |
132 | return $wgScoreLilyPondFakeVersion; |
133 | } |
134 | if ( self::$lilypondVersion === null ) { |
135 | // In case fetchLilypondVersion() throws an exception |
136 | self::$lilypondVersion = 'disabled'; |
137 | |
138 | $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache(); |
139 | self::$lilypondVersion = $cache->getWithSetCallback( |
140 | $cache->makeGlobalKey( __CLASS__, 'lilypond-version' ), |
141 | self::VERSION_TTL, |
142 | function () { |
143 | return self::fetchLilypondVersion(); |
144 | } |
145 | ); |
146 | } |
147 | |
148 | return self::$lilypondVersion; |
149 | } |
150 | |
151 | /** |
152 | * Determines the version of LilyPond in use without caching |
153 | * |
154 | * @throws ScoreException if LilyPond could not be executed properly. |
155 | * @return string |
156 | */ |
157 | private static function fetchLilypondVersion() { |
158 | global $wgScoreLilyPond, $wgScoreEnvironment; |
159 | |
160 | $result = self::boxedCommand() |
161 | ->routeName( 'score-lilypond' ) |
162 | ->params( $wgScoreLilyPond, '--version' ) |
163 | ->environment( $wgScoreEnvironment ) |
164 | ->includeStderr() |
165 | ->execute(); |
166 | self::recordShellout( 'lilypond_version' ); |
167 | |
168 | $output = $result->getStdout(); |
169 | if ( $result->getExitCode() != 0 ) { |
170 | self::throwCallException( 'score-versionerr', [], $output ); |
171 | } |
172 | |
173 | if ( !preg_match( '/^GNU LilyPond (\S+)/', $output, $m ) ) { |
174 | self::throwCallException( 'score-versionerr', [], $output ); |
175 | } |
176 | return $m[1]; |
177 | } |
178 | |
179 | /** |
180 | * Return a BoxedCommand object, or throw an exception if shell execution is |
181 | * disabled. |
182 | * |
183 | * The check for $wgScoreDisableExec should be redundant with checks in the |
184 | * callers, since the callers generally need to avoid writing input files. |
185 | * We check twice to be safe. |
186 | * |
187 | * @return BoxedCommand |
188 | * @throws ScoreDisabledException |
189 | */ |
190 | private static function boxedCommand() { |
191 | global $wgScoreDisableExec; |
192 | |
193 | if ( $wgScoreDisableExec ) { |
194 | throw new ScoreDisabledException(); |
195 | } |
196 | |
197 | return MediaWikiServices::getInstance()->getShellCommandFactory() |
198 | ->createBoxed( 'score' ) |
199 | ->disableNetwork() |
200 | ->firejailDefaultSeccomp(); |
201 | } |
202 | |
203 | /** |
204 | * Creates the specified local directory if it does not exist yet. |
205 | * Otherwise does nothing. |
206 | * |
207 | * @param string $path Local path to directory to be created. |
208 | * @param int|null $mode Chmod value of the new directory. |
209 | * |
210 | * @throws ScoreException if the directory does not exist and could not |
211 | * be created. |
212 | */ |
213 | private static function createDirectory( $path, $mode = null ) { |
214 | if ( !is_dir( $path ) ) { |
215 | $rc = wfMkdirParents( $path, $mode, __METHOD__ ); |
216 | if ( !$rc ) { |
217 | throw new ScoreException( 'score-nooutput', [ $path ] ); |
218 | } |
219 | } |
220 | } |
221 | |
222 | /** |
223 | * @return bool|string |
224 | */ |
225 | private static function getBaseUrl() { |
226 | global $wgScorePath, $wgUploadPath; |
227 | if ( $wgScorePath === false ) { |
228 | return "{$wgUploadPath}/lilypond"; |
229 | } |
230 | |
231 | return $wgScorePath; |
232 | } |
233 | |
234 | /** |
235 | * @return FileBackend |
236 | */ |
237 | public static function getBackend() { |
238 | global $wgScoreFileBackend; |
239 | |
240 | if ( $wgScoreFileBackend ) { |
241 | return MediaWikiServices::getInstance()->getFileBackendGroup() |
242 | ->get( $wgScoreFileBackend ); |
243 | } |
244 | |
245 | if ( !self::$backend ) { |
246 | global $wgScoreDirectory, $wgUploadDirectory; |
247 | if ( $wgScoreDirectory === false ) { |
248 | $dir = "{$wgUploadDirectory}/lilypond"; |
249 | } else { |
250 | $dir = $wgScoreDirectory; |
251 | } |
252 | self::$backend = new FSFileBackend( [ |
253 | 'name' => 'score-backend', |
254 | 'wikiId' => WikiMap::getCurrentWikiId(), |
255 | 'lockManager' => new NullLockManager( [] ), |
256 | 'containerPaths' => [ 'score-render' => $dir ], |
257 | 'fileMode' => 0777, |
258 | 'obResetFunc' => 'wfResetOutputBuffers', |
259 | 'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ], |
260 | 'statusWrapper' => [ 'Status', 'wrap' ], |
261 | 'logger' => LoggerFactory::getInstance( 'score' ), |
262 | ] ); |
263 | } |
264 | |
265 | return self::$backend; |
266 | } |
267 | |
268 | /** |
269 | * Callback for Parser's hook on 'score' tags. Renders the score code. |
270 | * |
271 | * @param string $code Score code. |
272 | * @param array $args Array of score tag attributes. |
273 | * @param Parser $parser |
274 | * @param PPFrame $frame Expansion frame, not used by this extension. |
275 | * |
276 | * @throws ScoreException |
277 | * @return string Image link HTML, and possibly anchor to MIDI file. |
278 | */ |
279 | public static function render( $code, array $args, Parser $parser, PPFrame $frame ) { |
280 | return self::renderScore( $code, $args, $parser ); |
281 | } |
282 | |
283 | /** |
284 | * Renders the score code (LilyPond, ABC, etc.) in a <score>…</score> tag. |
285 | * |
286 | * @param string $code Score code. |
287 | * @param array $args Array of score tag attributes. |
288 | * @param Parser|null $parser Parser must be set when called during a wiki page parse. |
289 | * |
290 | * @throws ScoreException |
291 | * @return string Image link HTML, and possibly anchor to MIDI file. |
292 | */ |
293 | public static function renderScore( $code, array $args, ?Parser $parser = null ) { |
294 | global $wgTmpDirectory; |
295 | |
296 | try { |
297 | $baseUrl = self::getBaseUrl(); |
298 | $baseStoragePath = self::getBackend()->getRootStoragePath() . '/score-render'; |
299 | |
300 | // options to self::generateHTML() |
301 | $options = []; |
302 | |
303 | if ( isset( $args['line_width_inches'] ) ) { |
304 | $lineWidthInches = abs( (float)$args[ 'line_width_inches' ] ); |
305 | if ( $lineWidthInches > 0 ) { |
306 | $options['line_width_inches'] = $lineWidthInches; |
307 | } |
308 | } |
309 | |
310 | /* temporary working directory to use */ |
311 | $fuzz = md5( (string)mt_rand() ); |
312 | $options['factory_directory'] = $wgTmpDirectory . "/MWLP.$fuzz"; |
313 | |
314 | /* Score language selection */ |
315 | if ( array_key_exists( 'lang', $args ) ) { |
316 | $options['lang'] = $args['lang']; |
317 | } else { |
318 | $options['lang'] = 'lilypond'; |
319 | } |
320 | if ( !in_array( $options['lang'], self::SUPPORTED_LANGS, true ) ) { |
321 | throw new ScoreException( 'score-invalidlang', |
322 | [ htmlspecialchars( $options['lang'] ) ] ); |
323 | } |
324 | |
325 | /* Override MIDI file? */ |
326 | if ( array_key_exists( 'override_midi', $args ) ) { |
327 | $file = MediaWikiServices::getInstance()->getRepoGroup() |
328 | ->findFile( $args['override_midi'] ); |
329 | if ( $file === false ) { |
330 | throw new ScoreException( 'score-midioverridenotfound', |
331 | [ htmlspecialchars( $args['override_midi'] ) ] ); |
332 | } |
333 | if ( $parser && $parser->getOutput() !== null ) { |
334 | $parser->getOutput()->addImage( $file->getName() ); |
335 | } |
336 | |
337 | $options['override_midi'] = true; |
338 | $options['midi_file'] = $file; |
339 | /* Set output stuff in case audio rendering is requested */ |
340 | $sha1 = $file->getSha1(); |
341 | $audioRelDir = "override-midi/{$sha1[0]}/{$sha1[1]}"; |
342 | $audioRel = "$audioRelDir/$sha1.mp3"; |
343 | $options['audio_storage_dir'] = "$baseStoragePath/$audioRelDir"; |
344 | $options['audio_storage_path'] = "$baseStoragePath/$audioRel"; |
345 | $options['audio_url'] = "$baseUrl/$audioRel"; |
346 | $options['audio_sha_name'] = "$sha1.mp3"; |
347 | if ( $parser ) { |
348 | $parser->addTrackingCategory( 'score-deprecated-category' ); |
349 | } |
350 | } else { |
351 | $options['override_midi'] = false; |
352 | } |
353 | |
354 | // Raw rendering? |
355 | $options['raw'] = array_key_exists( 'raw', $args ); |
356 | |
357 | /* Note language selection */ |
358 | if ( array_key_exists( 'note-language', $args ) ) { |
359 | if ( !$options['raw'] ) { |
360 | $options['note-language'] = $args['note-language']; |
361 | } else { |
362 | throw new ScoreException( 'score-notelanguagewithraw' ); |
363 | } |
364 | } else { |
365 | $options['note-language'] = self::DEFAULT_NOTE_LANGUAGE; |
366 | } |
367 | if ( !isset( self::SUPPORTED_NOTE_LANGUAGES[$options['note-language']] ) ) { |
368 | throw new ScoreException( |
369 | 'score-invalidnotelanguage', [ |
370 | Message::plaintextParam( $options['note-language'] ), |
371 | Message::plaintextParam( implode( ', ', array_keys( self::SUPPORTED_NOTE_LANGUAGES ) ) ) |
372 | ] |
373 | ); |
374 | } |
375 | |
376 | /* Override audio file? */ |
377 | if ( array_key_exists( 'override_audio', $args ) |
378 | || array_key_exists( 'override_ogg', $args ) ) { |
379 | $overrideAudio = $args['override_ogg'] ?? $args['override_audio']; |
380 | $t = Title::newFromText( $overrideAudio, NS_FILE ); |
381 | if ( $t === null ) { |
382 | throw new ScoreException( 'score-invalidaudiooverride', |
383 | [ htmlspecialchars( $overrideAudio ) ] ); |
384 | } |
385 | if ( !$t->isKnown() ) { |
386 | throw new ScoreException( 'score-audiooverridenotfound', |
387 | [ htmlspecialchars( $overrideAudio ) ] ); |
388 | } |
389 | $options['override_audio'] = true; |
390 | $options['audio_name'] = $overrideAudio; |
391 | if ( $parser ) { |
392 | $parser->addTrackingCategory( 'score-deprecated-category' ); |
393 | } |
394 | } else { |
395 | $options['override_audio'] = false; |
396 | } |
397 | |
398 | /* Audio rendering? */ |
399 | $options['generate_audio'] = array_key_exists( 'sound', $args ) |
400 | || array_key_exists( 'vorbis', $args ); |
401 | |
402 | if ( $options['generate_audio'] && $options['override_audio'] ) { |
403 | throw new ScoreException( 'score-convertoverrideaudio' ); |
404 | } |
405 | |
406 | // Input for cache key |
407 | $cacheOptions = [ |
408 | 'code' => $code, |
409 | 'lang' => $options['lang'], |
410 | 'note-language' => $options['note-language'], |
411 | 'raw' => $options['raw'], |
412 | 'ExtVersion' => self::CACHE_VERSION, |
413 | 'LyVersion' => self::getLilypondVersion(), |
414 | ]; |
415 | |
416 | /* image file path and URL prefixes */ |
417 | $imageCacheName = \Wikimedia\base_convert( sha1( serialize( $cacheOptions ) ), 16, 36, 31 ); |
418 | $imagePrefixEnd = "{$imageCacheName[0]}/" . |
419 | "{$imageCacheName[1]}/$imageCacheName"; |
420 | $options['dest_storage_path'] = "$baseStoragePath/$imagePrefixEnd"; |
421 | $options['dest_url'] = "$baseUrl/$imagePrefixEnd"; |
422 | $options['file_name_prefix'] = substr( $imageCacheName, 0, 8 ); |
423 | |
424 | $html = self::generateHTML( $parser, $code, $options ); |
425 | } catch ( ScoreException $e ) { |
426 | if ( $parser && $parser->getOutput() !== null ) { |
427 | if ( $e->isTracked() ) { |
428 | $parser->addTrackingCategory( 'score-error-category' ); |
429 | } |
430 | self::recordError( $e ); |
431 | } |
432 | $html = $e->getHtml(); |
433 | } |
434 | |
435 | // Mark the page as using the score extension, it makes easier |
436 | // to track all those pages. |
437 | if ( $parser && $parser->getOutput() !== null ) { |
438 | $parser->addTrackingCategory( 'score-use-category' ); |
439 | } |
440 | |
441 | return $html; |
442 | } |
443 | |
444 | /** |
445 | * Generates the HTML code for a score tag. |
446 | * |
447 | * @param Parser|null $parser MediaWiki parser, provide when inside parse of wiki page |
448 | * @param string $code Score code. |
449 | * @param array $options array of rendering options. |
450 | * The options keys are: |
451 | * - factory_directory: string Path to directory in which files |
452 | * may be generated without stepping on someone else's |
453 | * toes. The directory may not exist yet. Required. |
454 | * - generate_audio: bool Whether to create an audio file in |
455 | * TimedMediaHandler. If set to true, the override_audio option |
456 | * must be set to false. Required. |
457 | * - dest_storage_path: The path of the destination directory relative to |
458 | * the current backend. Required. |
459 | * - dest_url: The default destination URL. Required. |
460 | * - file_name_prefix: The filename prefix used for all files |
461 | * in the default destination directory. Required. |
462 | * - lang: string Score language. Required. |
463 | * - override_midi: bool Whether to use a user-provided MIDI file. |
464 | * Required. |
465 | * - midi_file: If override_midi is true, MIDI file object. |
466 | * - audio_storage_dir: If override_midi and generate_audio are true, the |
467 | * backend directory in which the audio file is to be stored. |
468 | * - audio_storage_path: string If override_midi and generate_audio are true, |
469 | * the backend path at which the generated audio file is to be |
470 | * stored. |
471 | * - audio_url: string If override_midi and generate_audio is true, |
472 | * the URL corresponding to audio_storage_path |
473 | * - audio_sha_name: string If override_midi, generated audio file name. |
474 | * - override_audio: bool Whether to generate a wikilink to a |
475 | * user-provided audio file. If set to true, the sound |
476 | * option must be set to false. Required. |
477 | * - audio_name: string If override_audio is true, the audio file name |
478 | * - raw: bool Whether to assume raw LilyPond code. Ignored if the |
479 | * language is not lilypond, required otherwise. |
480 | * - note-language: language to use for notes (one of supported by LilyPond) |
481 | * |
482 | * @return string HTML. |
483 | * |
484 | * @throws Exception |
485 | * @throws ScoreException if an error occurs. |
486 | */ |
487 | private static function generateHTML( ?Parser $parser, $code, $options ) { |
488 | global $wgScoreOfferSourceDownload, $wgScoreUseSvg; |
489 | |
490 | $cleanup = new ScopedCallback( function () use ( $options ) { |
491 | self::eraseDirectory( $options['factory_directory'] ); |
492 | } ); |
493 | if ( $parser && $parser->getOutput() !== null ) { |
494 | $parser->getOutput()->addModuleStyles( [ 'ext.score.styles' ] ); |
495 | $parser->getOutput()->addModules( [ 'ext.score.popup' ] ); |
496 | } |
497 | |
498 | $backend = self::getBackend(); |
499 | $fileIter = $backend->getFileList( |
500 | [ 'dir' => $options['dest_storage_path'], 'topOnly' => true ] ); |
501 | if ( $fileIter === null ) { |
502 | throw new ScoreException( 'score-file-list-error' ); |
503 | } |
504 | $existingFiles = []; |
505 | foreach ( $fileIter as $file ) { |
506 | $existingFiles[$file] = true; |
507 | } |
508 | |
509 | /* Generate SVG, PNG and MIDI files if necessary */ |
510 | $imageFileName = "{$options['file_name_prefix']}.png"; |
511 | $imageSvgFileName = "{$options['file_name_prefix']}.svg"; |
512 | $multi1FileName = "{$options['file_name_prefix']}-page1.png"; |
513 | $multi1SvgFileName = "{$options['file_name_prefix']}-1.svg"; |
514 | $midiFileName = "{$options['file_name_prefix']}.midi"; |
515 | $metaDataFileName = "{$options['file_name_prefix']}.json"; |
516 | $audioUrl = ''; |
517 | |
518 | if ( isset( $existingFiles[$metaDataFileName] ) ) { |
519 | $metaDataFile = $backend->getFileContents( |
520 | [ 'src' => "{$options['dest_storage_path']}/$metaDataFileName" ] ); |
521 | if ( $metaDataFile === false ) { |
522 | throw new ScoreException( 'score-nocontent', [ $metaDataFileName ] ); |
523 | } |
524 | $metaData = FormatJson::decode( $metaDataFile, true ); |
525 | } else { |
526 | $metaData = []; |
527 | } |
528 | |
529 | if ( |
530 | !isset( $existingFiles[$metaDataFileName] ) |
531 | || ( |
532 | !isset( $existingFiles[$imageFileName] ) |
533 | && !isset( $existingFiles[$multi1FileName] ) |
534 | ) |
535 | || ( |
536 | $wgScoreUseSvg |
537 | && !isset( $existingFiles[$multi1SvgFileName] ) |
538 | && !isset( $existingFiles[$imageSvgFileName] ) |
539 | ) |
540 | || ( |
541 | !isset( $metaData[$imageFileName]['size'] ) |
542 | && !isset( $metaData[$multi1FileName]['size'] ) |
543 | ) |
544 | || !isset( $existingFiles[$midiFileName] ) ) { |
545 | $existingFiles += self::generatePngAndMidi( $code, $options, $metaData ); |
546 | } |
547 | |
548 | /* Generate audio file if necessary */ |
549 | if ( $options['generate_audio'] ) { |
550 | $audioFileName = "{$options['file_name_prefix']}.mp3"; |
551 | if ( $options['override_midi'] ) { |
552 | $audioUrl = $options['audio_url']; |
553 | $audioPath = $options['audio_storage_path']; |
554 | $exists = $backend->fileExists( [ 'src' => $options['audio_storage_path'] ] ); |
555 | if ( |
556 | !$exists || |
557 | !isset( $metaData[ $options['audio_sha_name'] ]['length'] ) || |
558 | !$metaData[ $options['audio_sha_name'] ]['length'] |
559 | ) { |
560 | $backend->prepare( [ 'dir' => $options['audio_storage_dir'] ] ); |
561 | $sourcePath = $options['midi_file']->getLocalRefPath(); |
562 | self::generateAudio( $sourcePath, $options, $audioPath, $metaData ); |
563 | } |
564 | } else { |
565 | $audioUrl = "{$options['dest_url']}/$audioFileName"; |
566 | $audioPath = "{$options['dest_storage_path']}/$audioFileName"; |
567 | if ( |
568 | !isset( $existingFiles[$audioFileName] ) || |
569 | !isset( $metaData[$audioFileName]['length'] ) || |
570 | !$metaData[$audioFileName]['length'] |
571 | ) { |
572 | // Maybe we just generated it |
573 | $sourcePath = "{$options['factory_directory']}/file.midi"; |
574 | if ( !file_exists( $sourcePath ) ) { |
575 | // No, need to fetch it from the backend |
576 | $sourceFileRef = $backend->getLocalReference( |
577 | [ 'src' => "{$options['dest_storage_path']}/$midiFileName" ] ); |
578 | $sourcePath = $sourceFileRef->getPath(); |
579 | } |
580 | self::generateAudio( $sourcePath, $options, $audioPath, $metaData ); |
581 | } |
582 | } |
583 | } |
584 | |
585 | /* return output link(s) */ |
586 | if ( isset( $existingFiles[$imageFileName] ) ) { |
587 | [ $width, $height ] = $metaData[$imageFileName]['size']; |
588 | $attribs = [ |
589 | 'src' => "{$options['dest_url']}/$imageFileName", |
590 | 'width' => $width, |
591 | 'height' => $height, |
592 | 'alt' => $code, |
593 | ]; |
594 | if ( $wgScoreUseSvg ) { |
595 | $attribs['srcset'] = "{$options['dest_url']}/$imageSvgFileName 1x"; |
596 | } |
597 | $link = Html::rawElement( 'img', $attribs ); |
598 | } elseif ( isset( $existingFiles[$multi1FileName] ) ) { |
599 | $link = ''; |
600 | for ( $i = 1; ; ++$i ) { |
601 | $fileName = "{$options['file_name_prefix']}-page$i.png"; |
602 | if ( !isset( $existingFiles[$fileName] ) ) { |
603 | break; |
604 | } |
605 | $pageNumb = wfMessage( 'score-page' ) |
606 | ->inContentLanguage() |
607 | ->numParams( $i ) |
608 | ->plain(); |
609 | [ $width, $height ] = $metaData[$fileName]['size']; |
610 | $attribs = [ |
611 | 'src' => "{$options['dest_url']}/$fileName", |
612 | 'width' => $width, |
613 | 'height' => $height, |
614 | 'alt' => $pageNumb, |
615 | 'title' => $pageNumb, |
616 | 'style' => "margin-bottom:1em" |
617 | ]; |
618 | if ( $wgScoreUseSvg ) { |
619 | $svgFileName = "{$options['file_name_prefix']}-$i.svg"; |
620 | $attribs['srcset'] = "{$options['dest_url']}/$svgFileName 1x"; |
621 | } |
622 | $link .= Html::rawElement( 'img', $attribs ); |
623 | } |
624 | } else { |
625 | $link = ''; |
626 | } |
627 | if ( $options['generate_audio'] ) { |
628 | $link .= '<div style="margin-top: 3px;">' . |
629 | Html::rawElement( |
630 | 'audio', |
631 | [ |
632 | 'controls' => true |
633 | ], |
634 | Html::openElement( |
635 | 'source', |
636 | [ |
637 | 'src' => $audioUrl, |
638 | 'type' => 'audio/mpeg', |
639 | ] |
640 | ) . |
641 | "<div>" . |
642 | wfMessage( 'score-audio-alt' ) |
643 | ->rawParams( |
644 | Html::element( 'a', [ 'href' => $audioUrl ], |
645 | wfMessage( 'score-audio-alt-link' )->text() |
646 | ) |
647 | ) |
648 | ->escaped() . |
649 | '</div>' |
650 | ) . |
651 | '</div>'; |
652 | } |
653 | if ( $parser && $options['override_audio'] !== false ) { |
654 | $link .= $parser->recursiveTagParse( "[[File:{$options['audio_name']}]]" ); |
655 | } |
656 | |
657 | // Clean up the factory directory now |
658 | ScopedCallback::consume( $cleanup ); |
659 | |
660 | $attributes = [ |
661 | 'class' => 'mw-ext-score noresize' |
662 | ]; |
663 | |
664 | if ( $options['override_midi'] |
665 | || isset( $existingFiles["{$options['file_name_prefix']}.midi"] ) ) { |
666 | $attributes['data-midi'] = $options['override_midi'] ? |
667 | $options['midi_file']->getUrl() |
668 | : "{$options['dest_url']}/{$options['file_name_prefix']}.midi"; |
669 | } |
670 | |
671 | if ( $wgScoreOfferSourceDownload |
672 | && isset( $existingFiles["{$options['file_name_prefix']}.ly"] ) |
673 | ) { |
674 | $attributes['data-source'] = "{$options['dest_url']}/{$options['file_name_prefix']}.ly"; |
675 | } |
676 | |
677 | // Wrap score in div container. |
678 | return Html::rawElement( 'div', $attributes, $link ); |
679 | } |
680 | |
681 | /** |
682 | * Generates score PNG file(s) and a MIDI file. Stores lilypond file. |
683 | * |
684 | * @param string $code Score code. |
685 | * @param array $options Rendering options. They are the same as for |
686 | * Score::generateHTML(). |
687 | * @param array &$metaData array to hold information about images |
688 | * |
689 | * @return array of file names placed in the remote dest dir, with the |
690 | * file names in each key. |
691 | * |
692 | * @throws ScoreException on error. |
693 | */ |
694 | private static function generatePngAndMidi( $code, $options, &$metaData ) { |
695 | global $wgScoreLilyPond, $wgScoreTrim, $wgScoreSafeMode, $wgScoreDisableExec, |
696 | $wgScoreGhostscript, $wgScoreAbc2Ly, $wgImageMagickConvertCommand, $wgScoreUseSvg, |
697 | $wgShellboxShell, $wgPhpCli, $wgScoreEnvironment, $wgScoreImageMagickConvert; |
698 | |
699 | if ( $wgScoreDisableExec ) { |
700 | throw new ScoreDisabledException(); |
701 | } |
702 | |
703 | if ( $wgScoreSafeMode |
704 | && version_compare( self::getLilypondVersion(), '2.23.12', '>=' ) |
705 | ) { |
706 | throw new ScoreException( 'score-safe-mode' ); |
707 | } |
708 | |
709 | /* Create the working environment */ |
710 | $factoryDirectory = $options['factory_directory']; |
711 | self::createDirectory( $factoryDirectory, 0700 ); |
712 | $factoryMidi = "$factoryDirectory/file.midi"; |
713 | |
714 | $command = self::boxedCommand() |
715 | ->routeName( 'score-lilypond' ) |
716 | ->params( |
717 | $wgShellboxShell, |
718 | 'scripts/generatePngAndMidi.sh' ) |
719 | ->outputFileToFile( 'file.midi', $factoryMidi ) |
720 | ->outputGlobToFile( 'file', 'png', $factoryDirectory ) |
721 | ->outputGlobToFile( 'file', 'svg', $factoryDirectory ) |
722 | ->includeStderr() |
723 | ->environment( [ |
724 | 'SCORE_ABC2LY' => $wgScoreAbc2Ly, |
725 | 'SCORE_LILYPOND' => $wgScoreLilyPond, |
726 | 'SCORE_USESVG' => $wgScoreUseSvg ? 'yes' : 'no', |
727 | 'SCORE_SAFE' => $wgScoreSafeMode ? 'yes' : 'no', |
728 | 'SCORE_GHOSTSCRIPT' => $wgScoreGhostscript, |
729 | 'SCORE_CONVERT' => $wgScoreImageMagickConvert ?: $wgImageMagickConvertCommand, |
730 | 'SCORE_TRIM' => $wgScoreTrim ? 'yes' : 'no', |
731 | 'SCORE_PHP' => $wgPhpCli |
732 | ] + $wgScoreEnvironment ); |
733 | self::addScript( $command, 'generatePngAndMidi.sh' ); |
734 | if ( !$wgScoreUseSvg ) { |
735 | self::addScript( $command, 'extractPostScriptPageSize.php' ); |
736 | } |
737 | if ( $options['lang'] === 'lilypond' ) { |
738 | if ( $options['raw'] ) { |
739 | $lilypondCode = $code; |
740 | } else { |
741 | $paperConfig = []; |
742 | if ( isset( $options['line_width_inches'] ) ) { |
743 | $paperConfig['line-width'] = $options['line_width_inches'] . "\in"; |
744 | } |
745 | $paperCode = self::getPaperCode( $paperConfig ); |
746 | |
747 | $lilypondCode = self::embedLilypondCode( $code, $options['note-language'], $paperCode ); |
748 | } |
749 | $command->inputFileFromString( 'file.ly', $lilypondCode ); |
750 | } else { |
751 | self::addScript( $command, 'removeTagline.php' ); |
752 | $command->inputFileFromString( 'file.abc', $code ); |
753 | $command->outputFileToString( 'file.ly' ); |
754 | $lilypondCode = ''; |
755 | } |
756 | $result = $command->execute(); |
757 | self::recordShellout( 'generate_png_and_midi' ); |
758 | |
759 | if ( $result->getExitCode() != 0 ) { |
760 | self::throwCompileException( $result->getStdout(), $options ); |
761 | } |
762 | |
763 | if ( $result->wasReceived( 'file.ly' ) ) { |
764 | $lilypondCode = $result->getFileContents( 'file.ly' ); |
765 | } |
766 | |
767 | $numPages = 0; |
768 | for ( $i = 1; ; $i++ ) { |
769 | if ( !$result->wasReceived( "file-page$i.png" ) ) { |
770 | $numPages = $i - 1; |
771 | break; |
772 | } |
773 | } |
774 | |
775 | # LilyPond 2.24+ generates file.png and file.svg if there is only one page |
776 | if ( $wgScoreUseSvg && $result->wasReceived( 'file.svg' ) ) { |
777 | $numPages = 1; |
778 | } |
779 | |
780 | if ( $numPages === 0 ) { |
781 | throw new ScoreException( 'score-noimages' ); |
782 | } |
783 | |
784 | $needMidi = false; |
785 | $haveMidi = $result->wasReceived( 'file.midi' ); |
786 | if ( !$options['raw'] || ( $options['generate_audio'] && !$options['override_midi'] ) ) { |
787 | $needMidi = true; |
788 | if ( !$haveMidi ) { |
789 | throw new ScoreException( 'score-nomidi' ); |
790 | } |
791 | } |
792 | |
793 | // Create the destination directory if it doesn't exist |
794 | $backend = self::getBackend(); |
795 | $status = $backend->prepare( [ 'dir' => $options['dest_storage_path'] ] ); |
796 | if ( !$status->isOK() ) { |
797 | throw new ScoreBackendException( $status ); |
798 | } |
799 | |
800 | // File names of generated files |
801 | $newFiles = []; |
802 | // Backend operation batch |
803 | $ops = []; |
804 | |
805 | // Add LY source to its file |
806 | $ops[] = [ |
807 | 'op' => 'create', |
808 | 'content' => $lilypondCode, |
809 | 'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.ly", |
810 | 'headers' => [ |
811 | 'Content-Type' => 'text/x-lilypond; charset=utf-8' |
812 | ] |
813 | ]; |
814 | $newFiles["{$options['file_name_prefix']}.ly"] = true; |
815 | |
816 | if ( $needMidi ) { |
817 | // Add the MIDI file to the batch |
818 | $ops[] = [ |
819 | 'op' => 'store', |
820 | 'src' => $factoryMidi, |
821 | 'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.midi" ]; |
822 | $newFiles["{$options['file_name_prefix']}.midi"] = true; |
823 | if ( !$status->isOK() ) { |
824 | throw new ScoreBackendException( $status ); |
825 | } |
826 | } |
827 | |
828 | // Add the PNG and SVG image files |
829 | for ( $i = 1; $i <= $numPages; ++$i ) { |
830 | $srcPng = "$factoryDirectory/file-page$i.png"; |
831 | $srcSvg = "$factoryDirectory/file-$i.svg"; |
832 | $dstPngFileName = "{$options['file_name_prefix']}-page$i.png"; |
833 | $dstSvgFileName = "{$options['file_name_prefix']}-$i.svg"; |
834 | if ( $numPages === 1 ) { |
835 | $dstPngFileName = "{$options['file_name_prefix']}.png"; |
836 | if ( $wgScoreUseSvg ) { |
837 | $srcPng = "$factoryDirectory/file.png"; |
838 | $srcSvg = "$factoryDirectory/file.svg"; |
839 | $dstSvgFileName = "{$options['file_name_prefix']}.svg"; |
840 | } |
841 | } |
842 | $destPng = "{$options['dest_storage_path']}/$dstPngFileName"; |
843 | $ops[] = [ |
844 | 'op' => 'store', |
845 | 'src' => $srcPng, |
846 | 'dst' => $destPng |
847 | ]; |
848 | [ $width, $height ] = self::imageSize( $srcPng ); |
849 | $metaData[$dstPngFileName]['size'] = [ $width, $height ]; |
850 | $newFiles[$dstPngFileName] = true; |
851 | |
852 | if ( $wgScoreUseSvg ) { |
853 | $destSvg = "{$options['dest_storage_path']}/$dstSvgFileName"; |
854 | $ops[] = [ |
855 | 'op' => 'store', |
856 | 'src' => $srcSvg, |
857 | 'dst' => $destSvg, |
858 | 'headers' => [ |
859 | 'Content-Type' => 'image/svg+xml' |
860 | ] |
861 | ]; |
862 | $newFiles[$dstSvgFileName] = true; |
863 | } |
864 | } |
865 | |
866 | $dstFileName = "{$options['file_name_prefix']}.json"; |
867 | $dest = "{$options['dest_storage_path']}/$dstFileName"; |
868 | $ops[] = [ |
869 | 'op' => 'create', |
870 | 'content' => FormatJson::encode( $metaData ), |
871 | 'dst' => $dest ]; |
872 | |
873 | $newFiles[$dstFileName] = true; |
874 | |
875 | // Execute the batch |
876 | $status = $backend->doQuickOperations( $ops ); |
877 | if ( !$status->isOK() ) { |
878 | throw new ScoreBackendException( $status ); |
879 | } |
880 | return $newFiles; |
881 | } |
882 | |
883 | /** |
884 | * Add an input file from the scripts directory |
885 | * |
886 | * @param BoxedCommand $command |
887 | * @param string $script |
888 | */ |
889 | private static function addScript( BoxedCommand $command, string $script ) { |
890 | $command->inputFileFromFile( "scripts/$script", |
891 | __DIR__ . "/../scripts/$script" ); |
892 | } |
893 | |
894 | /** |
895 | * Get error information from the output returned by scripts/generatePngAndMidi.sh |
896 | * and throw a relevant error. |
897 | * |
898 | * @param string $stdout |
899 | * @param array $options |
900 | * @throws ScoreException |
901 | */ |
902 | private static function throwCompileException( $stdout, $options ) { |
903 | global $wgScoreDebugOutput; |
904 | |
905 | $message = self::extractMessage( $stdout ); |
906 | if ( !$message ) { |
907 | $message = [ 'score-compilererr', [] ]; |
908 | } elseif ( !$wgScoreDebugOutput && $message[0] === 'score-compilererr' ) { |
909 | // when input is not raw, we build the final lilypond file content |
910 | // in self::embedLilypondCode. The user input then is not inserted |
911 | // on the first line in the file we pass to lilypond and so we need |
912 | // to offset error messages back. |
913 | $scoreFirstLineOffset = $options['raw'] ? 0 : 7; |
914 | $errMsgBeautifier = new LilypondErrorMessageBeautifier( $scoreFirstLineOffset ); |
915 | |
916 | $stdout = $errMsgBeautifier->beautifyMessage( $stdout ); |
917 | } |
918 | self::throwCallException( |
919 | $message[0], |
920 | $message[1], |
921 | $stdout |
922 | ); |
923 | } |
924 | |
925 | /** |
926 | * Get error information from the output returned by scripts/synth.sh |
927 | * and throw a relevant error. |
928 | * |
929 | * @param string $stdout |
930 | * @throws ScoreException |
931 | */ |
932 | private static function throwSynthException( $stdout ) { |
933 | $message = self::extractMessage( $stdout ); |
934 | if ( !$message ) { |
935 | $message = [ 'score-audioconversionerr', [] ]; |
936 | } |
937 | self::throwCallException( |
938 | $message[0], |
939 | $message[1], |
940 | $stdout |
941 | ); |
942 | } |
943 | |
944 | /** |
945 | * Parse the script return value and extract any mw-msg lines. Modify the |
946 | * text to remove the lines. Return the first mw-msg line as a message |
947 | * key and parameters. If there was no mw-msg line, return null. |
948 | * |
949 | * @param string &$stdout |
950 | * @return array|null |
951 | */ |
952 | private static function extractMessage( &$stdout ) { |
953 | $filteredStdout = ''; |
954 | $messageParams = []; |
955 | foreach ( explode( "\n", $stdout ) as $line ) { |
956 | if ( preg_match( '/^mw-msg:\t/', $line ) ) { |
957 | if ( !$messageParams ) { |
958 | $messageParams = array_slice( explode( "\t", $line ), 1 ); |
959 | } |
960 | } else { |
961 | if ( $filteredStdout !== '' ) { |
962 | $filteredStdout .= "\n"; |
963 | } |
964 | $filteredStdout .= $line; |
965 | } |
966 | } |
967 | $stdout = $filteredStdout; |
968 | if ( $messageParams ) { |
969 | $messageName = array_shift( $messageParams ); |
970 | // Used messages: |
971 | // - score-abc2lynotexecutable |
972 | // - score-abcconversionerr |
973 | // - score-notexecutable |
974 | // - score-compilererr |
975 | // - score-nops |
976 | // - score-scripterr |
977 | // - score-gs-error |
978 | // - score-trimerr |
979 | // - score-readerr |
980 | // - score-pregreplaceerr |
981 | // - score-audioconversionerr |
982 | // - score-soundfontnotexists |
983 | // - score-fallbacknotexecutable |
984 | // - score-lamenotexecutable |
985 | return [ $messageName, $messageParams ]; |
986 | } else { |
987 | return null; |
988 | } |
989 | } |
990 | |
991 | /** |
992 | * Extract the size of one of our generated PNG images |
993 | * |
994 | * @param string $filename |
995 | * @return array of ints (width, height) |
996 | */ |
997 | private static function imageSize( $filename ) { |
998 | [ $width, $height ] = getimagesize( $filename ); |
999 | return [ $width, $height ]; |
1000 | } |
1001 | |
1002 | /** |
1003 | * @param array $paperConfig |
1004 | * @return string |
1005 | */ |
1006 | private static function getPaperCode( $paperConfig = [] ) { |
1007 | $config = array_merge( [ |
1008 | "indent" => "0\\mm", |
1009 | ], $paperConfig ); |
1010 | |
1011 | $paperCode = "\\paper {\n"; |
1012 | foreach ( $config as $key => $value ) { |
1013 | $paperCode .= "\t$key = $value\n"; |
1014 | } |
1015 | $paperCode .= "}"; |
1016 | |
1017 | return $paperCode; |
1018 | } |
1019 | |
1020 | /** |
1021 | * Embeds simple LilyPond code in a score block. |
1022 | * |
1023 | * @param string $lilypondCode Simple LilyPond code. |
1024 | * @param string $noteLanguage Language of notes. |
1025 | * @param string $paperCode |
1026 | * |
1027 | * @return string Raw lilypond code. |
1028 | * |
1029 | * @throws ScoreException if determining the LilyPond version fails. |
1030 | */ |
1031 | private static function embedLilypondCode( $lilypondCode, $noteLanguage, $paperCode ) { |
1032 | $version = self::getLilypondVersion(); |
1033 | |
1034 | // Check if parameters have already been supplied (hybrid-raw mode) |
1035 | $options = ""; |
1036 | if ( strpos( $lilypondCode, "\\layout" ) === false ) { |
1037 | $options .= "\\layout { }\n"; |
1038 | } |
1039 | if ( strpos( $lilypondCode, "\\midi" ) === false ) { |
1040 | $options .= <<<LY |
1041 | \\midi { |
1042 | \\context { \Score tempoWholesPerMinute = #(ly:make-moment 100 4) } |
1043 | } |
1044 | LY; |
1045 | } |
1046 | |
1047 | /* Raw code. In Scheme, ##f is false and ##t is true. */ |
1048 | /* Set the default MIDI tempo to 100, 60 is a bit too slow */ |
1049 | $raw = <<<LILYPOND |
1050 | \\header { |
1051 | tagline = ##f |
1052 | } |
1053 | \\version "$version" |
1054 | \\language "$noteLanguage" |
1055 | \\score { |
1056 | |
1057 | $lilypondCode |
1058 | $options |
1059 | |
1060 | } |
1061 | $paperCode |
1062 | LILYPOND; |
1063 | |
1064 | return $raw; |
1065 | } |
1066 | |
1067 | /** |
1068 | * Generates an audio file from a MIDI file using fluidsynth with TiMidity as fallback. |
1069 | * |
1070 | * @param string $sourceFile The local filename of the MIDI file |
1071 | * @param array $options array of rendering options. |
1072 | * @param string $remoteDest The backend storage path to upload the audio file to |
1073 | * @param array &$metaData Array with metadata information |
1074 | * |
1075 | * @throws ScoreException if an error occurs. |
1076 | */ |
1077 | private static function generateAudio( $sourceFile, $options, $remoteDest, &$metaData ) { |
1078 | global $wgScoreFluidsynth, $wgScoreSoundfont, $wgScoreLame, $wgScoreDisableExec, |
1079 | $wgScoreEnvironment, $wgShellboxShell, $wgPhpCli; |
1080 | |
1081 | if ( $wgScoreDisableExec ) { |
1082 | throw new ScoreDisabledException(); |
1083 | } |
1084 | |
1085 | // Working environment |
1086 | $factoryDir = $options['factory_directory']; |
1087 | self::createDirectory( $factoryDir, 0700 ); |
1088 | $factoryFile = "$factoryDir/file.mp3"; |
1089 | |
1090 | // Run FluidSynth and LAME |
1091 | $command = self::boxedCommand() |
1092 | ->routeName( 'score-fluidsynth' ) |
1093 | ->params( |
1094 | $wgShellboxShell, |
1095 | 'scripts/synth.sh' |
1096 | ) |
1097 | ->environment( [ |
1098 | 'SCORE_FLUIDSYNTH' => $wgScoreFluidsynth, |
1099 | 'SCORE_SOUNDFONT' => $wgScoreSoundfont, |
1100 | 'SCORE_LAME' => $wgScoreLame, |
1101 | 'SCORE_PHP' => $wgPhpCli |
1102 | ] + $wgScoreEnvironment ) |
1103 | ->inputFileFromFile( 'file.midi', $sourceFile ) |
1104 | ->outputFileToFile( 'file.mp3', $factoryFile ) |
1105 | ->includeStderr() |
1106 | // 150 MB max. filesize (for large MIDIs) |
1107 | ->fileSizeLimit( 150 * 1024 * 1024 ); |
1108 | |
1109 | self::addScript( $command, 'synth.sh' ); |
1110 | self::addScript( $command, 'getWavDuration.php' ); |
1111 | |
1112 | $result = $command->execute(); |
1113 | self::recordShellout( 'generate_audio' ); |
1114 | |
1115 | if ( ( $result->getExitCode() != 0 ) || !$result->wasReceived( 'file.mp3' ) ) { |
1116 | self::throwSynthException( $result->getStdout() ); |
1117 | } |
1118 | |
1119 | // Move file to the final destination |
1120 | $backend = self::getBackend(); |
1121 | $status = $backend->doQuickOperation( [ |
1122 | 'op' => 'store', |
1123 | 'src' => $factoryFile, |
1124 | 'dst' => $remoteDest |
1125 | ] ); |
1126 | |
1127 | if ( !$status->isOK() ) { |
1128 | throw new ScoreBackendException( $status ); |
1129 | } |
1130 | |
1131 | // Create metadata json |
1132 | $metaData[basename( $remoteDest )]['length'] = self::getDurationFromScriptOutput( |
1133 | $result->getStdout() ); |
1134 | $dstFileName = "{$options['file_name_prefix']}.json"; |
1135 | $dest = "{$options['dest_storage_path']}/$dstFileName"; |
1136 | |
1137 | // Store metadata in backend |
1138 | $backend = self::getBackend(); |
1139 | $status = $backend->doQuickOperation( [ |
1140 | 'op' => 'create', |
1141 | 'content' => FormatJson::encode( $metaData ), |
1142 | 'dst' => $dest |
1143 | ] ); |
1144 | |
1145 | if ( !$status->isOK() ) { |
1146 | throw new ScoreBackendException( $status ); |
1147 | } |
1148 | } |
1149 | |
1150 | /** |
1151 | * Get the duration of the audio file from the script stdout |
1152 | * |
1153 | * @param string $stdout The script output |
1154 | * @return float duration in seconds |
1155 | */ |
1156 | private static function getDurationFromScriptOutput( $stdout ) { |
1157 | if ( preg_match( '/^wavDuration: ([0-9.]+)$/m', $stdout, $m ) ) { |
1158 | return (float)$m[1]; |
1159 | } else { |
1160 | return 0.0; |
1161 | } |
1162 | } |
1163 | |
1164 | /** |
1165 | * Track how often we do each type of shellout in statsd |
1166 | * |
1167 | * @param string $type Type of shellout |
1168 | */ |
1169 | private static function recordShellout( $type ) { |
1170 | $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); |
1171 | $statsd->increment( "score.$type" ); |
1172 | } |
1173 | |
1174 | /** |
1175 | * Track how often each error occurs in statsd |
1176 | * |
1177 | * @param ScoreException $ex |
1178 | */ |
1179 | private static function recordError( ScoreException $ex ) { |
1180 | $key = $ex->getStatsdKey(); |
1181 | if ( $key === false ) { |
1182 | return; |
1183 | } |
1184 | $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); |
1185 | $statsd->increment( "score_error.$key" ); |
1186 | } |
1187 | |
1188 | /** |
1189 | * Deletes a local directory with no subdirectories with all files in it. |
1190 | * |
1191 | * @param string $dir Local path to the directory that is to be deleted. |
1192 | * |
1193 | * @return bool true on success, false on error |
1194 | */ |
1195 | private static function eraseDirectory( $dir ) { |
1196 | if ( file_exists( $dir ) ) { |
1197 | // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown |
1198 | array_map( 'unlink', glob( "$dir/*", GLOB_NOSORT ) ); |
1199 | $rc = rmdir( $dir ); |
1200 | if ( !$rc ) { |
1201 | self::debug( "Unable to remove directory $dir\n." ); |
1202 | } |
1203 | return $rc; |
1204 | } |
1205 | |
1206 | /* Nothing to do */ |
1207 | return true; |
1208 | } |
1209 | |
1210 | /** |
1211 | * Writes the specified message to the Score debug log. |
1212 | * |
1213 | * @param string $msg message to log. |
1214 | */ |
1215 | private static function debug( $msg ) { |
1216 | wfDebugLog( 'Score', $msg ); |
1217 | } |
1218 | |
1219 | } |