AdminMakeImmerse.php 16.3 KB
<?php

namespace App\Jobs;

use App\Models\Immerse;
use App\Models\VideoTemp;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\AdminMakeVideo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

class AdminMakeImmerse implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $adminMakeVideo;

    protected $ffmpeg;

    protected $ffprobe;

    protected $media_info;

    protected $output_width;

    protected $output_height;

    /**
     * Create a new job instance.
     * @param  AdminMakeVideo $adminMakeVideo
     * @return void
     */
    public function __construct(AdminMakeVideo $adminMakeVideo)
    {
        $this->adminMakeVideo = $adminMakeVideo;
        $this->ffmpeg = env('FFMPEG_CMD');
        $this->ffprobe = env('FFPROBE_CMD');
        $this->output_width = 720;
        $this->output_height = 1280;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        $file = $this->getAbsolutePath($this->adminMakeVideo->video_url);
        // 分析视频
        $this->media_info = $this->mediaInfo($file);

        // 准备素材
        $watermark =  $this->getAbsolutePath('images/logo.png');

        // 组装文字参数
        $drawtext = $this->getTextContentString();

        // 判断双轨 没有则制作空轨
        $is_bgm = $this->adminMakeVideo->temp->bg_music == 1; //是否手动上传背景音
        $bgm = $this->getAbsolutePath($this->adminMakeVideo->temp->bgm_url);
        if ($this->media_info['format']['nb_streams'] >= 2) {  /** 音频视频轨都有 */
            if ($is_bgm) {
                // 有背景音  融合
                $audio = $this->getAbsolutePath($this->getTempPath('.mp3','audio'));
                $cmd = $this->ffmpeg .
                    ' -y -i ' . escapeshellarg($file) .
                    ' -y -i ' . escapeshellarg($bgm) .
                    ' -filter_complex amix=inputs=2:duration=first:dropout_transition=2 ' .
                    '-ar 48000 -ab 64k  ' . escapeshellarg($audio);
                if (!$this->execCmd($cmd)) return;

                $audio_input = ' -i ' . escapeshellarg($audio);
                $audio_filter = '2:a';
            } else {
                // 没有背景音
                $audio_input = '';
                $audio_filter = '0:a';
            }
        } elseif ($this->media_info['format']['nb_streams'] == 1) { /** 只有视频轨 */
            // 生成一段无声音频
            $audio = $this->getAbsolutePath($this->getTempPath('.mp3','audio'));
            $cmd = $this->ffmpeg .
                ' -y -f lavfi -i aevalsrc=0:duration=' . escapeshellarg($this->media_info['format']['duration']) .
                ' -ar 48000 -ab 64k ' . escapeshellarg($audio);
            if (!$this->execCmd($cmd)) return;

            if ($is_bgm) {
                // 有背景音  融合
                $audio_empty = $audio;
                $audio = $this->getAbsolutePath($this->getTempPath('.mp3','audio'));
                $cmd = $this->ffmpeg .
                    ' -y -i ' . escapeshellarg($audio_empty) .
                    ' -y -i ' . escapeshellarg($bgm) .
                    ' -filter_complex amix=inputs=2:duration=first:dropout_transition=2 ' .
                    '-ar 48000 -ab 64k  ' . escapeshellarg($audio);
                if (!$this->execCmd($cmd)) return;
            }
            $audio_input = ' -i ' . escapeshellarg($audio);
            $audio_filter = '2:a';
        } else {
            /** 音频视频轨都没有 */
            Log::channel('daily')->error('视频没有video track, url:' . $file);
            return;
        }

        // 制作封面图
        $thumbnail = $this->getTempPath('.jpg','thumbnail');
        if ($this->adminMakeVideo->thumbnail == 2){
            // 截取中间帧作为视频封面
            $frame = ceil($this->media_info['streams'][0]['nb_frames'] / 2);
            $cmd = $this->ffmpeg . ' -y ' .
                ' -i ' . escapeshellarg($file) .
                ' -filter_complex "[0:v]select=\'eq(n,' . $frame . ')\'[img]" ' .
                ' -map [img]'.
                ' -frames:v 1 -s ' . $this->output_width . 'x' . $this->output_height . ' -preset superfast ' .
                escapeshellarg($this->getAbsolutePath($thumbnail));
            if (!$this->execCmd($cmd)) return ;
        }else{
            // 手动上传封面
            $origin_thumbnail =  $this->getAbsolutePath($this->adminMakeVideo->thumbnail_url);
            // 将封面分辨率改为指定分辨率
            $cmd = $this->ffmpeg . ' -y ' .
                ' -i ' . escapeshellarg($origin_thumbnail) .
                '-s ' . $this->output_width . 'x' . $this->output_height . ' -preset superfast ' .
                escapeshellarg($this->getAbsolutePath($thumbnail));
            if (!$this->execCmd($cmd)) return ;
        }

        // 合成视频
        $output = $this->getTempPath('.mp4','video');
        $cmd = $this->ffmpeg . ' -y '.
            ' -i ' . escapeshellarg($file).
            ' -i ' . escapeshellarg($watermark).
            $audio_input .
            ' -filter_complex "[0:v]scale=' . $this->output_width . ':' . $this->output_height . ',' . $drawtext .
            ' [text];[text]'.
            ' [1:v]overlay=20:20[v]" ' .
            ' -map [v] -map '. $audio_filter .
            ' -c:v libx264 -bt 256k -r 25' .
            ' -ar 44100 -ac 2 -qmin 30 -qmax 60 -profile:v baseline -preset fast ' .
            escapeshellarg($this->getAbsolutePath($output));

        if (!$this->execCmd($cmd)) return ;

        // 分析视频 入库
        $video_info = $this->mediaInfo($this->getAbsolutePath($output));

        Immerse::query()->create([
            'user_id' => 1,
            'title' => '',
            'weather' => $this->adminMakeVideo->weather,
            'huangli' => $this->adminMakeVideo->huangli,
            'content' => $this->adminMakeVideo->feel,
            'location' => $this->adminMakeVideo->location,
            'longitude' => $this->adminMakeVideo->longitude,
            'latitude' => $this->adminMakeVideo->latitude,
            'url' => $output,
            'type' => $this->adminMakeVideo->type == 1 ? 2 : 1,
            'upload_file' => '',
            'duration' => $video_info['format']['duration'],
            'size' => $video_info['format']['size'],
            'origin_video_url' => $this->adminMakeVideo->video_url,
            'origin_image_url' => '',
            'poem_id' => $this->adminMakeVideo->poem_id,
            'temp_id' => $this->adminMakeVideo->temp_id,
            'thumbnail' => $thumbnail,
            'state' => 1,
            'bgm' => $is_bgm ? $bgm : '',
        ]);
    }

    public function getAbsolutePath($path)
    {
        if ($path == '') return '';

        return Storage::disk('public')->path($path);
    }

    public function mediaInfo($file)
    {
        $cmd = $this->ffprobe . ' -v quiet -print_format json -show_format -show_streams ' . escapeshellarg($file);
        $output = $this->execCmd($cmd);
        $data = json_decode($output, true);
        if (json_last_error() === JSON_ERROR_UTF8) {
            $output = mb_convert_encoding($output, "UTF-8");
            $data = json_decode($output, true);
        }
        $this->media_info = $data;
        return $data;
    }

    public function execCmd($cmd)
    {
        echo $cmd . "\n". "\n";
        return shell_exec("{$cmd} 2>&1");
    }

    public function getTextContentString()
    {
        $components = $this->adminMakeVideo->temp->components;

        $drawtext = '';
        foreach ($components as $component) {
            $text_color = $component->text_color ?? 'white';
            $text_bg_color = $component->text_bg_color ?? '0xd0cdcc';
            $opacity = $component->opacity ? $component->opacity / 100 : 0.5;
            $font_file = $this->getAbsolutePath($component->font_file);
            $text_bg_box = $component->text_bg_box ?? 0;
            $font_size = $this->calcFontSize($component->font_size);

            // 文字淡入淡出模式
            if ($component->draw == 'fade'){
                $contents = []; //
                switch ($component->name){
                    case 'one_poem':
                        $default = $this->adminMakeVideo->poem2->title . "\n" . $this->adminMakeVideo->poem2->author;
                        $contents[] = $this->autoCenter($default,$font_size, $this->output_width);
                        foreach ($this->adminMakeVideo->poem2->verses as $item) {
                            $source = " " . "\n" . $item->source;
                            if ($item->content != '') $contents[] = $this->autoEnter($item->content, $font_size, $this->output_width) . "\n" . $this->autoCenter($source, $font_size, $this->output_width);
                            if ($item->annotate != '') $contents[] = $this->autoEnter($item->annotate, $font_size, $this->output_width);
                            if ($item->spelling != '') $contents[] = $this->autoEnter($item->spelling, $font_size, $this->output_width);
                            if ($item->en != '') $contents[] = $this->autoEnter($item->en, $font_size, $this->output_width);
                        }
                        break;
                    case 'weather':
                        $contents[] = $this->adminMakeVideo->weather;
                        break;
                    case 'date':
                        $contents[] = Carbon::now()->format('Y年m月d日H时');
                        break;
                    case 'feel':
                        $contents[] = $this->adminMakeVideo->feel ?: '读此一言,仿佛身临其境。';
                        break;
                }

                $FID = $FOD = floatval($component->fade_time / 1000);
                $round = round($this->media_info['format']['duration'] / count($contents),1);
                if ($round < 1) $round = 1;
                $sub_text = '';
                foreach ($contents as $key => $content){
                    $DS = $key * $round;
                    $DE = $DS + $round;
                    $text_file = $this->getAbsolutePath($this->getTempPath('.txt','text'));
                    file_put_contents($text_file, $content);
                    $sub_text .= 'drawtext="'.
                        'fontfile=' . escapeshellarg($font_file) . ':' .
                        'textfile=' . escapeshellarg($text_file) . ':' .
                        'fontsize=' . $font_size . ':' .
                        'fontcolor_expr=' . escapeshellarg($text_color . '%{eif\\\\: clip(255*(1*between(t\\, ' . $DS . ' + ' . $FID . '\\, ' . $DE . ' - ' . $FOD . ') + ((t - ' . $DS . ')/' . $FID . ')*between(t\\, ' . $DS . '\\, ' . $DS . ' + ' . $FID . ') + (-(t - ' . $DE . ')/' . $FOD . ')*between(t\\, ' . $DE . ' - ' . $FOD . '\\, ' . $DE . '))\\, 0\\, 255) \\\\: x\\\\: 2 }') . ':' .
                        'x=' . escapeshellarg(VideoTemp::POSITION_FFMPEG[$component->position][0]) . ':' .
                        'y=' . escapeshellarg(VideoTemp::POSITION_FFMPEG[$component->position][1]) . ':' .
                        '", ';
                }

                $drawtext .= $sub_text;
            }

            // 文字固定模式
            if ($component->draw == 'fix'){
                $contents = []; //
                switch ($component->name){
                    case 'one_poem':
                        $stanzas = '';
                        $default = $this->adminMakeVideo->poem2->title . "\n" . $this->adminMakeVideo->poem2->author;
                        $stanzas .= $this->autoCenter($default,$font_size, $this->output_width) .PHP_EOL.PHP_EOL;
                        foreach ($this->adminMakeVideo->poem2->verses as $item) {
                            if ($item->content != '') $stanzas .= $this->autoEnter($item->content, $font_size, $this->output_width) . "\n";
                        }
                        $contents[] = $stanzas;
                        break;
                    case 'weather':
                        $contents[] = $this->adminMakeVideo->weather;
                        break;
                    case 'date':
                        $contents[] = Carbon::now()->format('Y年m月d日H时');
                        break;
                    case 'feel':
                        $contents[] = $this->adminMakeVideo->feel ?: '读此一言,仿佛身临其境。';
                        break;
                }
                $sub_text = '';
                foreach ($contents as $key => $content){
                    $text_file = $this->getAbsolutePath($this->getTempPath('.txt','text'));
                    file_put_contents($text_file, $content);
                    $sub_text .= 'drawtext="'.
                        'fontfile=' . escapeshellarg($font_file) . ':' .
                        'textfile=' . escapeshellarg($text_file) . ':' .
                        'fontsize=' . $font_size . ':' .
                        'fontcolor=' . $text_color . '@' . $opacity . ':' .
                        'x=' . escapeshellarg(VideoTemp::POSITION_FFMPEG[$component->position][0]) . ':' .
                        'y=' . escapeshellarg(VideoTemp::POSITION_FFMPEG[$component->position][1]) . ':' .
                        'box=1:boxborderw='. $text_bg_box . ':' .
                        'boxcolor=' . $text_bg_color . '@' . $opacity . '", ';
                }
                $drawtext .= $sub_text;
            }
        }

        return rtrim($drawtext,', ');
    }

    public function autoEnter($string, $font_width, $video_width)
    {
        $video_width = $video_width - 2 * $font_width; // 两侧留出空隙
        $row_count = floor($video_width / $font_width);
        echo $row_count;
        $str_len = mb_strlen($string);
        if ($str_len > $row_count) {
            $tmp = array_chunk(
                preg_split("//u", $string, -1, PREG_SPLIT_NO_EMPTY), $row_count);
            $new_str = "";
            foreach ($tmp as $t) {
                $new_str .= join("", $t) . "\n";
            }
            return $new_str;
        }else{
            return $string;
        }
    }

    public function autoCenter($string, $font_width, $video_width)
    {
        $video_width = $video_width - 2 * $font_width; // 两侧留出空隙
        $row_count = floor($video_width / $font_width);
        $arr = explode("\n", $string);
        $title = $arr[0] ?? " ";
        $author = $arr[1];

        $title_len = mb_strlen($title);
        if ($title_len > $row_count) {
            $tmp = array_chunk(
                preg_split("//u", $string, -1, PREG_SPLIT_NO_EMPTY), $row_count);
            $tmp[count($tmp - 1)] = $this->autoStrPad(end($tmp), $row_count * 2, ' ', STR_PAD_BOTH);
            $new_title = '';
            foreach ($tmp as $t) {
                $new_title .= join("", $t) . "\n";
            }
        } else {
            $new_title = $this->autoStrPad($title, $row_count * 2, ' ', STR_PAD_BOTH);
        }

        $new_author = $this->autoStrPad($author, $row_count * 2, ' ', STR_PAD_BOTH);

        return $new_title . PHP_EOL . $new_author;
    }


    public function autoStrPad($string, $length, $pad_string = "", $pad_type = STR_PAD_BOTH)
    {
        $mb_disparity_count = (strlen($string) - mb_strlen($string)) / 2;
        return str_pad($string,$length+$mb_disparity_count,$pad_string,$pad_type);
    }

    public function calcFontSize($width)
    {
        return floor($this->output_width / 360 * $width);
    }

    /**
     * 获取输出临时文件名
     * @param  string $ext
     * @param  string $dir
     * @return string
     */
    public function getTempPath($ext = '.mp4',$dir = 'video')
    {
        $filename = "/output_" . time() . rand(0, 10000);

        $hash_hex = md5($filename);
        // 16进制表示的字符串一共32字节,表示16个二进制字节。
        // 前16个字符用来第一级求摸,后16个用做第二级
        $hash_hex_l1 = substr($hash_hex, 0, 8);
        $hash_hex_l2 = substr($hash_hex, 8, 8);
        $dir_l1 = hexdec($hash_hex_l1) % 256;
        $dir_l2 = hexdec($hash_hex_l2) % 512;
        $dir = $dir . '/' . $dir_l1 . '/' . $dir_l2;

        if( !Storage::disk('public')->exists($dir)) Storage::disk('public')->makeDirectory($dir);

        return $dir . $filename . $ext;
    }
}