127, 'stderr' => 'PHP proc_open is unavailable.']; } $process = @proc_open($command, [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $pipes, null, null, ['bypass_shell' => true]); if (!is_resource($process)) { return ['exit_code' => 127, 'stderr' => 'FFmpeg could not be started.']; } fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $stdout = ''; $stderr = ''; $started = microtime(true); $exitCode = -1; while (true) { $stdout .= (string)stream_get_contents($pipes[1]); $stderr .= (string)stream_get_contents($pipes[2]); $status = proc_get_status($process); if (!$status['running']) { $exitCode = (int)$status['exitcode']; break; } if ((microtime(true) - $started) >= $timeoutSeconds) { proc_terminate($process); usleep(100000); $status = proc_get_status($process); if ($status['running']) proc_terminate($process, 9); $exitCode = 124; $stderr .= "\nFFmpeg timed out."; break; } usleep(50000); } $stdout .= (string)stream_get_contents($pipes[1]); $stderr .= (string)stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $closeCode = proc_close($process); if ($exitCode < 0) $exitCode = $closeCode; return [ 'exit_code' => $exitCode, 'stdout' => substr($stdout, -4000), 'stderr' => substr($stderr, -4000), ]; } function transcodeVoiceRecording( string $sourcePath, string $sourceFilename, string $sourceMime, ?int $bitrateOverride = null ): array { if (!getConfigVal('voice.transcoding.enabled', false)) { return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false]; } $profile = voiceTranscodingProfile(); $bitrate = max(48, min(320, $bitrateOverride ?? (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128))); $timeout = max(5, min(300, (int)getConfigVal('voice.transcoding.timeout_seconds', 45))); $fallback = (bool)getConfigVal('voice.transcoding.fallback_to_source', true); $token = pathinfo($sourceFilename, PATHINFO_FILENAME); if (!preg_match('/^[a-f0-9]{40}$/', $token)) { throw new RuntimeException('Voice recording filename is invalid.'); } $extension = $profile === 'mp4_h264_aac' ? 'mp4' : 'm4a'; $outputMime = $profile === 'mp4_h264_aac' ? 'video/mp4' : 'audio/mp4'; $outputFilename = $token . '.' . $extension; $outputPath = dirname($sourcePath) . '/' . $outputFilename; $temporaryPath = dirname($sourcePath) . '/.' . $token . '.tmp.' . $extension; $binary = voiceFfmpegBinary(); if ($profile === 'mp4_h264_aac') { $command = [ $binary, '-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'color=c=black:s=16x16:r=1', '-i', $sourcePath, '-map', '0:v:0', '-map', '1:a:0', '-shortest', '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'stillimage', '-profile:v', 'baseline', '-level', '3.0', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-profile:a', 'aac_low', '-b:a', $bitrate . 'k', '-map_metadata', '-1', '-movflags', '+faststart', $temporaryPath, ]; } else { $command = [ $binary, '-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', $sourcePath, '-vn', '-c:a', 'aac', '-profile:a', 'aac_low', '-b:a', $bitrate . 'k', '-map_metadata', '-1', '-movflags', '+faststart', $temporaryPath, ]; } $result = runVoiceTranscodeCommand($command, $timeout); $validOutput = $result['exit_code'] === 0 && is_file($temporaryPath) && filesize($temporaryPath) > 0; if (!$validOutput) { if (is_file($temporaryPath)) @unlink($temporaryPath); $detail = trim((string)($result['stderr'] ?? '')); error_log('Voice transcoding failed: ' . ($detail !== '' ? $detail : 'unknown FFmpeg error')); if ($fallback) { return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false]; } throw new RuntimeException('Voice transcoding failed. Check the FFmpeg dashboard settings.'); } $sourceBackupPath = null; if ($outputPath === $sourcePath) { $sourceBackupPath = dirname($sourcePath) . '/.' . $token . '.source.' . $extension; if (!@rename($sourcePath, $sourceBackupPath)) { @unlink($temporaryPath); if ($fallback) { return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false]; } throw new RuntimeException('The original voice clip could not be prepared for replacement.'); } } if (!@rename($temporaryPath, $outputPath)) { @unlink($temporaryPath); if ($sourceBackupPath !== null && is_file($sourceBackupPath)) { @rename($sourceBackupPath, $sourcePath); } if ($fallback && is_file($sourcePath)) { return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false]; } throw new RuntimeException('The transcoded voice clip could not be stored.'); } if ($sourceBackupPath !== null) { @unlink($sourceBackupPath); } else { @unlink($sourcePath); } return ['filename' => $outputFilename, 'mime' => $outputMime, 'transcoded' => true]; }