Summary: Depends on D19200. Fixes T5258. Ref T13101. Attempt to simplify and modernize this code and improve error handling. Test Plan: did real hard dank memes Maniphest Tasks: T13101, T5258 Differential Revision: https://secure.phabricator.com/D19201
382 lines
9.3 KiB
PHP
382 lines
9.3 KiB
PHP
<?php
|
|
|
|
final class PhabricatorMemeEngine extends Phobject {
|
|
|
|
private $viewer;
|
|
private $template;
|
|
private $aboveText;
|
|
private $belowText;
|
|
|
|
private $templateFile;
|
|
private $metrics;
|
|
|
|
public function setViewer(PhabricatorUser $viewer) {
|
|
$this->viewer = $viewer;
|
|
return $this;
|
|
}
|
|
|
|
public function getViewer() {
|
|
return $this->viewer;
|
|
}
|
|
|
|
public function setTemplate($template) {
|
|
$this->template = $template;
|
|
return $this;
|
|
}
|
|
|
|
public function getTemplate() {
|
|
return $this->template;
|
|
}
|
|
|
|
public function setAboveText($above_text) {
|
|
$this->aboveText = $above_text;
|
|
return $this;
|
|
}
|
|
|
|
public function getAboveText() {
|
|
return $this->aboveText;
|
|
}
|
|
|
|
public function setBelowText($below_text) {
|
|
$this->belowText = $below_text;
|
|
return $this;
|
|
}
|
|
|
|
public function getBelowText() {
|
|
return $this->belowText;
|
|
}
|
|
|
|
public function getGenerateURI() {
|
|
return id(new PhutilURI('/macro/meme/'))
|
|
->alter('macro', $this->getTemplate())
|
|
->alter('above', $this->getAboveText())
|
|
->alter('below', $this->getBelowText());
|
|
}
|
|
|
|
public function newAsset() {
|
|
$cache = $this->loadCachedFile();
|
|
if ($cache) {
|
|
return $cache;
|
|
}
|
|
|
|
$template = $this->loadTemplateFile();
|
|
if (!$template) {
|
|
throw new Exception(
|
|
pht(
|
|
'Template "%s" is not a valid template.',
|
|
$template));
|
|
}
|
|
|
|
$hash = $this->newTransformHash();
|
|
|
|
$asset = $this->newAssetFile($template);
|
|
|
|
$xfile = id(new PhabricatorTransformedFile())
|
|
->setOriginalPHID($template->getPHID())
|
|
->setTransformedPHID($asset->getPHID())
|
|
->setTransform($hash);
|
|
|
|
try {
|
|
$caught = null;
|
|
|
|
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
|
|
try {
|
|
$xfile->save();
|
|
} catch (Exception $ex) {
|
|
$caught = $ex;
|
|
}
|
|
unset($unguarded);
|
|
|
|
if ($caught) {
|
|
throw $caught;
|
|
}
|
|
|
|
return $asset;
|
|
} catch (AphrontDuplicateKeyQueryException $ex) {
|
|
$xfile = $this->loadCachedFile();
|
|
if (!$xfile) {
|
|
throw $ex;
|
|
}
|
|
return $xfile;
|
|
}
|
|
}
|
|
|
|
private function newTransformHash() {
|
|
$properties = array(
|
|
'kind' => 'meme',
|
|
'above' => phutil_utf8_strtoupper($this->getAboveText()),
|
|
'below' => phutil_utf8_strtoupper($this->getBelowText()),
|
|
);
|
|
|
|
$properties = phutil_json_encode($properties);
|
|
|
|
return PhabricatorHash::digestForIndex($properties);
|
|
}
|
|
|
|
public function loadCachedFile() {
|
|
$viewer = $this->getViewer();
|
|
|
|
$template_file = $this->loadTemplateFile();
|
|
if (!$template_file) {
|
|
return null;
|
|
}
|
|
|
|
$hash = $this->newTransformHash();
|
|
|
|
$xform = id(new PhabricatorTransformedFile())->loadOneWhere(
|
|
'originalPHID = %s AND transform = %s',
|
|
$template_file->getPHID(),
|
|
$hash);
|
|
if (!$xform) {
|
|
return null;
|
|
}
|
|
|
|
return id(new PhabricatorFileQuery())
|
|
->setViewer($viewer)
|
|
->withPHIDs(array($xform->getTransformedPHID()))
|
|
->executeOne();
|
|
}
|
|
|
|
private function loadTemplateFile() {
|
|
if ($this->templateFile === null) {
|
|
$viewer = $this->getViewer();
|
|
$template = $this->getTemplate();
|
|
|
|
$macro = id(new PhabricatorMacroQuery())
|
|
->setViewer($viewer)
|
|
->withNames(array($template))
|
|
->needFiles(true)
|
|
->executeOne();
|
|
if (!$macro) {
|
|
return null;
|
|
}
|
|
|
|
$this->templateFile = $macro->getFile();
|
|
}
|
|
|
|
return $this->templateFile;
|
|
}
|
|
|
|
private function newAssetFile(PhabricatorFile $template) {
|
|
$data = $this->newAssetData($template);
|
|
return PhabricatorFile::newFromFileData(
|
|
$data,
|
|
array(
|
|
'name' => 'meme-'.$template->getName(),
|
|
'ttl.relative' => phutil_units('24 hours in seconds'),
|
|
'canCDN' => true,
|
|
));
|
|
}
|
|
|
|
private function newAssetData(PhabricatorFile $template) {
|
|
$template_data = $template->loadFileData();
|
|
|
|
$result = $this->newImagemagickAsset($template, $template_data);
|
|
if ($result) {
|
|
return $result;
|
|
}
|
|
|
|
return $this->newGDAsset($template, $template_data);
|
|
}
|
|
|
|
private function newImagemagickAsset(
|
|
PhabricatorFile $template,
|
|
$template_data) {
|
|
|
|
// We're only going to use Imagemagick on GIFs.
|
|
$mime_type = $template->getMimeType();
|
|
if ($mime_type != 'image/gif') {
|
|
return null;
|
|
}
|
|
|
|
// We're only going to use Imagemagick if it is actually available.
|
|
$available = PhabricatorEnv::getEnvConfig('files.enable-imagemagick');
|
|
if (!$available) {
|
|
return null;
|
|
}
|
|
|
|
// Test of the GIF is an animated GIF. If it's a flat GIF, we'll fall
|
|
// back to GD.
|
|
$input = new TempFile();
|
|
Filesystem::writeFile($input, $template_data);
|
|
list($err, $out) = exec_manual('convert %s info:', $input);
|
|
if ($err) {
|
|
return null;
|
|
}
|
|
|
|
$split = phutil_split_lines($out);
|
|
$frames = count($split);
|
|
if ($frames <= 1) {
|
|
return null;
|
|
}
|
|
|
|
// Split the frames apart, transform each frame, then merge them back
|
|
// together.
|
|
$output = new TempFile();
|
|
|
|
$future = new ExecFuture(
|
|
'convert %s -coalesce +adjoin %s_%s',
|
|
$input,
|
|
$input,
|
|
'%09d');
|
|
$future->setTimeout(10)->resolvex();
|
|
|
|
$output_files = array();
|
|
for ($ii = 0; $ii < $frames; $ii++) {
|
|
$frame_name = sprintf('%s_%09d', $input, $ii);
|
|
$output_name = sprintf('%s_%09d', $output, $ii);
|
|
|
|
$output_files[] = $output_name;
|
|
|
|
$frame_data = Filesystem::readFile($frame_name);
|
|
$memed_frame_data = $this->newGDAsset($template, $frame_data);
|
|
Filesystem::writeFile($output_name, $memed_frame_data);
|
|
}
|
|
|
|
$future = new ExecFuture('convert -loop 0 %Ls %s', $output_files, $output);
|
|
$future->setTimeout(10)->resolvex();
|
|
|
|
return Filesystem::readFile($output);
|
|
}
|
|
|
|
private function newGDAsset(PhabricatorFile $template, $data) {
|
|
$img = imagecreatefromstring($data);
|
|
if (!$img) {
|
|
throw new Exception(
|
|
pht('Failed to imagecreatefromstring() image template data.'));
|
|
}
|
|
|
|
$dx = imagesx($img);
|
|
$dy = imagesy($img);
|
|
|
|
$metrics = $this->getMetrics($dx, $dy);
|
|
$font = $this->getFont();
|
|
$size = $metrics['size'];
|
|
|
|
$above = $this->getAboveText();
|
|
if (strlen($above)) {
|
|
$x = (int)floor(($dx - $metrics['text']['above']['width']) / 2);
|
|
$y = $metrics['text']['above']['height'] + 12;
|
|
|
|
$this->drawText($img, $font, $metrics['size'], $x, $y, $above);
|
|
}
|
|
|
|
$below = $this->getBelowText();
|
|
if (strlen($below)) {
|
|
$x = (int)floor(($dx - $metrics['text']['below']['width']) / 2);
|
|
$y = $dy - 12 - $metrics['text']['below']['descend'];
|
|
|
|
$this->drawText($img, $font, $metrics['size'], $x, $y, $below);
|
|
}
|
|
|
|
return PhabricatorImageTransformer::saveImageDataInAnyFormat(
|
|
$img,
|
|
$template->getMimeType());
|
|
}
|
|
|
|
private function getFont() {
|
|
$phabricator_root = dirname(phutil_get_library_root('phabricator'));
|
|
|
|
$font_root = $phabricator_root.'/resources/font/';
|
|
if (Filesystem::pathExists($font_root.'impact.ttf')) {
|
|
$font_path = $font_root.'impact.ttf';
|
|
} else {
|
|
$font_path = $font_root.'tuffy.ttf';
|
|
}
|
|
|
|
return $font_path;
|
|
}
|
|
|
|
private function getMetrics($dim_x, $dim_y) {
|
|
if ($this->metrics === null) {
|
|
$font = $this->getFont();
|
|
|
|
$font_max = 72;
|
|
$font_min = 5;
|
|
|
|
$last = null;
|
|
$cursor = floor(($font_max + $font_min) / 2);
|
|
$min = $font_min;
|
|
$max = $font_max;
|
|
|
|
$texts = array(
|
|
'above' => $this->getAboveText(),
|
|
'below' => $this->getBelowText(),
|
|
);
|
|
|
|
$metrics = null;
|
|
$best = null;
|
|
while (true) {
|
|
$all_fit = true;
|
|
$text_metrics = array();
|
|
foreach ($texts as $key => $text) {
|
|
$box = imagettfbbox($cursor, 0, $font, $text);
|
|
$height = abs($box[3] - $box[5]);
|
|
$width = abs($box[0] - $box[2]);
|
|
|
|
// This is the number of pixels below the baseline that the
|
|
// text extends, for example if it has a "y".
|
|
$descend = $box[3];
|
|
|
|
if ($height > $dim_y) {
|
|
$all_fit = false;
|
|
break;
|
|
}
|
|
|
|
if ($width > $dim_x) {
|
|
$all_fit = false;
|
|
break;
|
|
}
|
|
|
|
$text_metrics[$key]['width'] = $width;
|
|
$text_metrics[$key]['height'] = $height;
|
|
$text_metrics[$key]['descend'] = $descend;
|
|
}
|
|
|
|
if ($all_fit || $best === null) {
|
|
$best = $cursor;
|
|
$metrics = $text_metrics;
|
|
}
|
|
|
|
if ($all_fit) {
|
|
$min = $cursor;
|
|
} else {
|
|
$max = $cursor;
|
|
}
|
|
|
|
$last = $cursor;
|
|
$cursor = floor(($max + $min) / 2);
|
|
if ($cursor === $last) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->metrics = array(
|
|
'size' => $best,
|
|
'text' => $metrics,
|
|
);
|
|
}
|
|
|
|
return $this->metrics;
|
|
}
|
|
|
|
private function drawText($img, $font, $size, $x, $y, $text) {
|
|
$text_color = imagecolorallocate($img, 255, 255, 255);
|
|
$border_color = imagecolorallocate($img, 0, 0, 0);
|
|
|
|
$border = 2;
|
|
for ($xx = ($x - $border); $xx <= ($x + $border); $xx += $border) {
|
|
for ($yy = ($y - $border); $yy <= ($y + $border); $yy += $border) {
|
|
if (($xx === $x) && ($yy === $y)) {
|
|
continue;
|
|
}
|
|
imagettftext($img, $size, 0, $xx, $yy, $border_color, $font, $text);
|
|
}
|
|
}
|
|
|
|
imagettftext($img, $size, 0, $x, $y, $text_color, $font, $text);
|
|
}
|
|
|
|
|
|
}
|