trueなら可逆。framesがあればアニメーション。 */ class Webp implements ImageInterface { public function parse(string $file): \stdClass { $d = (string)file_get_contents($file); $chunks = self::chunks($d); $info = (object)['width' => 0, 'height' => 0, 'compression' => null, 'hasAlpha' => false, 'animated' => false, 'frames' => 1, 'loops' => 0, 'icc' => false, 'exif' => false, 'xmp' => false]; $image = null; foreach ($chunks as [$id, $body]) { if ($id === 'VP8X') { if (strlen($body) < 10) throw new \RuntimeException('VP8Xチャンクが短過ぎます。'); $f = ord($body[0]); $info->width = 1 + self::u24($body, 4); $info->height = 1 + self::u24($body, 7); $info->hasAlpha = (bool)($f & 0x10); $info->animated = (bool)($f & 0x02); $info->icc = (bool)($f & 0x20); $info->exif = (bool)($f & 0x08); $info->xmp = (bool)($f & 0x04); } else if ($id === 'ANIM' && strlen($body) >= 6) { $info->loops = unpack('v', substr($body, 4, 2))[1]; } else if ($id === 'ANMF') { $image ??= self::chunks($body, 16)[0] ?? null; } else if ($id === 'VP8 ' || $id === 'VP8L') { $image ??= [$id, $body]; } } if ($info->animated) $info->frames = count(array_filter($chunks, fn($c) => $c[0] === 'ANMF')); if ($image !== null) { [$id, $body] = $image; $info->compression = $id === 'VP8L' ? 'VP8L' : 'VP8'; if ($chunks[0][0] !== 'VP8X') { if ($id === 'VP8L' && strlen($body) >= 5) { $b = unpack('V', substr($body, 1, 4))[1]; $info->width = ($b & 0x3fff) + 1; $info->height = (($b >> 14) & 0x3fff) + 1; $info->hasAlpha = (bool)(($b >> 28) & 1); } else if ($id === 'VP8 ' && strlen($body) >= 10) { $info->width = unpack('v', substr($body, 6, 2))[1] & 0x3fff; $info->height = unpack('v', substr($body, 8, 2))[1] & 0x3fff; } } } return $info; } /** * RIFFのチャンク * * @param int $pos 最初のチャンクの位置(ファイルなら12、ANMFの中なら16) * @return array [[ID, 中身], ...] */ private static function chunks(string $d, int $pos = 12): array { $end = strlen($d); if ($pos === 12) { if (strlen($d) < 12 || !str_starts_with($d, 'RIFF') || substr($d, 8, 4) !== 'WEBP') throw new \RuntimeException('WebPではありません。'); $end = min($end, 8 + unpack('V', substr($d, 4, 4))[1]); } $out = []; while ($pos + 8 <= $end) { $id = substr($d, $pos, 4); $size = unpack('V', substr($d, $pos + 4, 4))[1]; if ($pos + 8 + $size > $end) throw new \RuntimeException("WebPの{$id}チャンクが途中で切れています。"); $out[] = [$id, substr($d, $pos + 8, $size)]; $pos += 8 + $size + ($size & 1); } return $out; } private static function u24(string $s, int $o): int { return ord($s[$o]) | (ord($s[$o + 1]) << 8) | (ord($s[$o + 2]) << 16); } public function decode(string $data): Bitmap { $chunks = self::chunks($data); $first = $chunks[0][0] ?? ''; if ($first === 'VP8 ' || $first === 'VP8L') return self::still($chunks); if ($first !== 'VP8X' || strlen($chunks[0][1]) < 10) throw new \RuntimeException('WebPの画像がありません。'); $body = $chunks[0][1]; $cw = 1 + self::u24($body, 4); $ch = 1 + self::u24($body, 7); Bitmap::check($cw, $ch); if (!(ord($body[0]) & 0x02)) return self::still(array_slice($chunks, 1), $cw, $ch); // アニメーション:透明な画面にフレームを順に重ねる(背景色はブラウザと同じく使わない) $loops = 0; $canvas = new Bitmap($cw, $ch); $img = null; $dispose = null; foreach ($chunks as [$id, $body]) { if ($id === 'ANIM' && strlen($body) >= 6) $loops = unpack('v', substr($body, 4, 2))[1]; if ($id !== 'ANMF') continue; if (strlen($body) < 16) throw new \RuntimeException('ANMFチャンクが短過ぎます。'); $x = 2 * self::u24($body, 0); $y = 2 * self::u24($body, 3); $fw = 1 + self::u24($body, 6); $fh = 1 + self::u24($body, 9); $flags = ord($body[15]); if ($x + $fw > $cw || $y + $fh > $ch) throw new \RuntimeException('WebPのフレームが画面からはみ出しています。'); if ($dispose !== null) $canvas->clear(...$dispose); $canvas->draw(self::still(self::chunks($body, 16), $fw, $fh), $x, $y, !($flags & 0x02)); $img ??= $canvas->copy(); $img->frames[] = ['bitmap' => $canvas->copy(), 'delay' => self::u24($body, 12)]; $dispose = $flags & 0x01 ? [$x, $y, $fw, $fh] : null; } if ($img === null) throw new \RuntimeException('WebPのアニメーションにフレームがありません。'); if (count($img->frames) < 2) $img->frames = []; else $img->loops = $loops; return $img; } /** * 1枚の画像(VP8L、又はALPHとVP8) * * @param int|null $w 期待する幅と丈(VP8XやANMFの大きさ) */ private static function still(array $chunks, ?int $w = null, ?int $h = null): Bitmap { $alpha = null; foreach ($chunks as [$id, $body]) { if ($id === 'ALPH') { $alpha ??= $body; } else if ($id === 'VP8L') { [$iw, $ih, $argb] = (new Vp8l())->decode($body); $rgba = self::rgba($argb); break; } else if ($id === 'VP8 ') { [$iw, $ih, $rgba] = (new Vp8())->decode($body); if ($alpha !== null) $rgba = self::applyAlpha($rgba, $alpha, $iw, $ih); break; } } if (!isset($rgba)) throw new \RuntimeException('WebPの画像データがありません。'); if ($w !== null && ($iw !== $w || $ih !== $h)) throw new \RuntimeException('WebPの画像の大きさが合いません。'); return new Bitmap($iw, $ih, $rgba); } /** ARGB(ビッグエンディアン)の列をRGBAにする */ private static function rgba(string $argb): string { $out = ''; for ($o = 0, $n = strlen($argb); $o < $n; $o += 16384) { $px = unpack('N*', substr($argb, $o, 16384)); foreach ($px as $i => $v) $px[$i] = (($v << 8) & 0xffffffff) | ($v >> 24); $out .= pack('N*', ...$px); } return $out; } /** * ALPHチャンクのアルファを画素に入れる */ private static function applyAlpha(string $rgba, string $alph, int $w, int $h): string { if ($alph === '') throw new \RuntimeException('ALPHチャンクが空です。'); $hdr = ord($alph[0]); $n = $w * $h; if (($hdr & 3) === 0) { $a = substr($alph, 1, $n); if (strlen($a) < $n) throw new \RuntimeException('ALPHチャンクが途中で切れています。'); } else if (($hdr & 3) === 1) { // 可逆圧縮:緑がアルファ $argb = (new Vp8l())->decodeImage(substr($alph, 1), $w, $h); $a = ''; for ($i = 2, $len = strlen($argb); $i < $len; $i += 4) $a .= $argb[$i]; } else { throw new \RuntimeException('未対応のALPHの圧縮方法です。'); } $filter = ($hdr >> 2) & 3; if ($filter) $a = self::unfilter($a, $w, $h, $filter); for ($i = 0; $i < $n; $i++) $rgba[$i * 4 + 3] = $a[$i]; return $rgba; } /** * アルファのフィルターを戻す(1:左、2:上、3:勾配。1行目は左、左端は上から) */ private static function unfilter(string $a, int $w, int $h, int $filter): string { $out = ''; $prev = null; for ($y = 0; $y < $h; $y++) { $row = array_values(unpack('C*', substr($a, $y * $w, $w))); if ($prev === null) { $left = 0; for ($x = 0; $x < $w; $x++) $row[$x] = $left = ($row[$x] + $left) & 255; } else if ($filter === 1) { $left = $prev[0]; for ($x = 0; $x < $w; $x++) $row[$x] = $left = ($row[$x] + $left) & 255; } else if ($filter === 2) { for ($x = 0; $x < $w; $x++) $row[$x] = ($row[$x] + $prev[$x]) & 255; } else { $left = $topLeft = $prev[0]; for ($x = 0; $x < $w; $x++) { $g = $left + $prev[$x] - $topLeft; $left = $row[$x] = ($row[$x] + ($g < 0 ? 0 : ($g > 255 ? 255 : $g))) & 255; $topLeft = $prev[$x]; } } $out .= pack('C*', ...$row); $prev = $row; } return $out; } /** * @param array $opt lossless(既定false)、quality(非可逆、0~100、既定80)、filter・sharpness・partitions(非可逆の細かい設定) */ public function encode(Bitmap $img, array $opt = []): string { $lossless = (bool)($opt['lossless'] ?? false); if (!$lossless && ($img->width > 16383 || $img->height > 16383)) throw new \RuntimeException('非可逆のWebPは16383x16383までです。'); if ($img->width > 16384 || $img->height > 16384) throw new \RuntimeException('WebPは16384x16384までです。'); if (!$img->frames) { $alpha = !$img->isOpaque(); $body = self::image($img, $lossless, $alpha, $opt); if ($lossless || !$alpha) return self::riff($body); return self::riff(self::vp8x($img, 0x10).$body); } $alpha = false; foreach ($img->frames as $f) $alpha = $alpha || !$f['bitmap']->isOpaque(); $out = self::vp8x($img, 0x02 | ($alpha ? 0x10 : 0)); // 背景色(白、BGRA)と繰り返し回数 $out .= self::chunk('ANIM', "\xff\xff\xff\xff".pack('v', min(65535, $img->loops))); foreach ($img->frames as $f) { $b = $f['bitmap']; if ($b->width !== $img->width || $b->height !== $img->height) throw new \InvalidArgumentException('フレームの大きさが画像と違います。'); // 毎回画面全体を置き換える(合成しない、廃棄しない) $hdr = "\0\0\0\0\0\0".substr(pack('V', $b->width - 1), 0, 3).substr(pack('V', $b->height - 1), 0, 3).substr(pack('V', max(0, min(0xffffff, (int)$f['delay']))), 0, 3)."\x02"; $out .= self::chunk('ANMF', $hdr.self::image($b, $lossless, !$b->isOpaque(), $opt)); } return self::riff($out); } /** 1枚の画像のチャンク(VP8L、VP8、又はALPHとVP8) */ private static function image(Bitmap $b, bool $lossless, bool $alpha, array $opt): string { if ($lossless) { $px = []; foreach (unpack('N*', $b->data) as $v) $px[] = (($v & 255) << 24) | ($v >> 8); return self::chunk('VP8L', (new Vp8l())->encode($px, $b->width, $b->height, $alpha)); } $out = ''; if ($alpha) { $a = []; foreach (unpack('N*', $b->data) as $v) $a[] = ($v & 255) << 8; // 可逆圧縮、フィルター無し $out .= self::chunk('ALPH', "\x01".(new Vp8l())->encodeImage($a, $b->width, $b->height)); } return $out.self::chunk('VP8 ', (new Vp8())->encode($b->data, $b->width, $b->height, $opt)); } private static function vp8x(Bitmap $img, int $flags): string { return self::chunk('VP8X', chr($flags)."\0\0\0".substr(pack('V', $img->width - 1), 0, 3).substr(pack('V', $img->height - 1), 0, 3)); } private static function chunk(string $id, string $body): string { return $id.pack('V', strlen($body)).$body.(strlen($body) & 1 ? "\0" : ''); } private static function riff(string $body): string { return 'RIFF'.pack('V', 4 + strlen($body)).'WEBP'.$body; } }