trypost/app/Services/Ai/VideoGenerationService.php
Paulo Castellano 3c3b170b21 feat: @mentions in comments, AI Action layer + MCP tools, settings tabs
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
  marker syntax @[uuid] persisted, display names rendered via CommentBody
  chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
  same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
  notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
  get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
  (NotificationCreated event), scoped channel name avoids client-side
  filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
  template for the email is committed and built into resources/views/mail.

AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
  so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
  return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
  the trypost.ai shim. Default text/image providers flipped to OpenAI.

Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
  with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
  lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
  click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
  pills and post list, with hover tooltip carrying display_name + handle.

Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
  Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
  $invite->workspace and $invite->role; column added to the migration,
  Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
  matches the Echo listener; payload now includes mentioned_users so
  receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
  TikTok/YouTube switched from item.type === 'image' to
  !isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
  posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
  OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
  when creating media rows.

Tests
- MentionParser unit tests, NotifyMentions feature tests including
  online/offline channel selection and preference gating, MCP AI tool
  happy paths, MentionedInComment mailable rendering, AcceptInvite +
  search-members + index mentioned_users path. 1229 passing.
2026-05-01 20:59:03 -03:00

143 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Ai;
use App\Enums\Ai\Orientation;
use App\Enums\Ai\UsageType;
use App\Enums\Media\Type as MediaType;
use App\Models\AiUsageLog;
use App\Models\Media;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class VideoGenerationService
{
private string $baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
private string $model = 'veo-3.1-generate-preview';
private int $maxPollAttempts = 60;
private int $pollIntervalSeconds = 10;
private string $apiKey;
public function __construct()
{
$this->apiKey = config('services.gemini.api_key') ?? '';
}
public function generate(string $prompt, Workspace $workspace, ?string $userId = null, ?string $postId = null, Orientation $orientation = Orientation::Vertical): Media
{
if (empty($this->apiKey)) {
throw new RuntimeException('Gemini API key is not configured. Please set GEMINI_API_KEY in your .env file.');
}
$aspectRatio = $orientation->aspectRatio();
$fullPrompt = view('prompts.assistant.video', [
'prompt' => $prompt,
'brand_name' => $workspace->name ?? '',
'tone' => $workspace->brand_tone ?? 'professional',
'content_language' => $workspace->content_language ?? 'en',
])->render();
$operationName = $this->startGeneration($fullPrompt, $aspectRatio);
$videoData = $this->pollForCompletion($operationName);
$decoded = base64_decode($videoData);
return DB::transaction(function () use ($decoded, $prompt, $workspace, $userId, $postId) {
$filename = Str::uuid().'.mp4';
$path = 'medias/'.$filename;
Storage::put($path, $decoded);
$media = $workspace->media()->create([
'group_id' => Str::uuid()->toString(),
'collection' => 'assets',
'type' => MediaType::Video,
'path' => $path,
'original_filename' => 'ai-generated.mp4',
'mime_type' => 'video/mp4',
'size' => strlen($decoded),
'order' => 0,
'meta' => ['ai_generated' => true, 'prompt' => Str::limit($prompt, 200)],
]);
AiUsageLog::create([
'account_id' => $workspace->account_id,
'workspace_id' => $workspace->id,
'user_id' => $userId,
'post_id' => $postId,
'type' => UsageType::Video,
'provider' => 'veo',
]);
return $media;
});
}
private function startGeneration(string $prompt, string $aspectRatio = '9:16'): string
{
$response = Http::timeout(30)
->withHeaders(['x-goog-api-key' => $this->apiKey])
->post("{$this->baseUrl}/models/{$this->model}:predictLongRunning", [
'instances' => [['prompt' => $prompt]],
'parameters' => [
'aspectRatio' => $aspectRatio,
'durationSeconds' => 8,
'resolution' => '720p',
'personGeneration' => 'allow_all',
],
]);
if ($response->failed()) {
Log::error('VideoGenerationService start failed', ['body' => $response->body()]);
throw new RuntimeException('Failed to start video generation. Please try again.');
}
$operationName = data_get($response->json(), 'name');
if (! $operationName) {
throw new RuntimeException('No operation returned from video generation API.');
}
return $operationName;
}
private function pollForCompletion(string $operationName): string
{
for ($i = 0; $i < $this->maxPollAttempts; $i++) {
sleep($this->pollIntervalSeconds);
$response = Http::timeout(30)
->withHeaders(['x-goog-api-key' => $this->apiKey])
->get("{$this->baseUrl}/{$operationName}");
if ($response->failed()) {
continue;
}
$status = $response->json();
if (data_get($status, 'done')) {
$videoData = data_get($status, 'response.predictions.0.bytesBase64Encoded');
if ($videoData) {
return $videoData;
}
}
}
throw new RuntimeException('Video generation timed out. Please try again.');
}
}