wip: article AI polish, category SEO fields, cover generator, membership plan seeder
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Attachment;
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Template-rendered OG covers (1200×630) written to the attachments disk.
|
||||
* Uses title + summary; does not call an external text-to-image API.
|
||||
*/
|
||||
class ArticleCoverGenerator
|
||||
{
|
||||
public function generate(Article $article): array
|
||||
{
|
||||
$disk = (string) config('larablog.attachments_disk', 'attachments');
|
||||
$relative = sprintf(
|
||||
'covers/%s/article-%d-%s.jpg',
|
||||
now()->format('Y/m'),
|
||||
$article->id,
|
||||
substr(sha1($article->title.'|'.microtime(true)), 0, 10),
|
||||
);
|
||||
|
||||
$binary = $this->renderJpeg($article);
|
||||
Storage::disk($disk)->put($relative, $binary, ['visibility' => 'public']);
|
||||
|
||||
Attachment::query()->create([
|
||||
'article_id' => $article->id,
|
||||
'disk' => $disk,
|
||||
'path' => $relative,
|
||||
'filename' => basename($relative),
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => strlen($binary),
|
||||
'checksum' => hash('sha256', $binary),
|
||||
'visibility' => Attachment::VISIBILITY_PUBLIC,
|
||||
'synced_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'disk' => $disk,
|
||||
'path' => $relative,
|
||||
'source' => ArticleCoverService::SOURCE_GENERATED,
|
||||
];
|
||||
}
|
||||
|
||||
protected function renderJpeg(Article $article): string
|
||||
{
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->create(1200, 630)->fill('#0f3d4c');
|
||||
|
||||
// Soft bands for atmosphere (avoid a single flat fill).
|
||||
$image->drawRectangle(0, 0, function ($rect): void {
|
||||
$rect->size(1200, 210)->background('#123f4f');
|
||||
});
|
||||
$image->drawRectangle(0, 420, function ($rect): void {
|
||||
$rect->size(1200, 210)->background('#16384a');
|
||||
});
|
||||
$image->drawRectangle(0, 0, function ($rect): void {
|
||||
$rect->size(1200, 12)->background('#2bb0a6');
|
||||
});
|
||||
|
||||
$site = 'LaraBlog';
|
||||
try {
|
||||
$site = (string) (app(GeneralSettings::class)->site_name ?: $site);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
|
||||
$title = $this->wrapText((string) $article->title, 18, 3);
|
||||
$summary = trim((string) ($article->ai_summary ?: $article->description ?: ''));
|
||||
if ($summary !== '') {
|
||||
$summary = $this->wrapText($summary, 32, 2);
|
||||
}
|
||||
|
||||
$font = $this->fontFile();
|
||||
|
||||
if ($font !== null) {
|
||||
$image->text($site, 72, 110, function ($f) use ($font): void {
|
||||
$f->filename($font);
|
||||
$f->size(28);
|
||||
$f->color('#8fd9d2');
|
||||
});
|
||||
|
||||
$image->text($title, 72, 220, function ($f) use ($font): void {
|
||||
$f->filename($font);
|
||||
$f->size(56);
|
||||
$f->color('#f4f7f8');
|
||||
$f->lineHeight(1.25);
|
||||
});
|
||||
|
||||
if ($summary !== '') {
|
||||
$image->text($summary, 72, 430, function ($f) use ($font): void {
|
||||
$f->filename($font);
|
||||
$f->size(26);
|
||||
$f->color('#c5d4db');
|
||||
$f->lineHeight(1.35);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$encoded = $image->toJpeg(85)->toString();
|
||||
|
||||
if (! is_string($encoded) || $encoded === '') {
|
||||
throw new RuntimeException('Failed to encode generated cover JPEG.');
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
protected function wrapText(string $text, int $maxChars, int $maxLines): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
|
||||
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
while ($text !== '' && count($lines) < $maxLines) {
|
||||
if (mb_strlen($text) <= $maxChars) {
|
||||
$lines[] = $text;
|
||||
break;
|
||||
}
|
||||
|
||||
$chunk = mb_substr($text, 0, $maxChars);
|
||||
$lines[] = $chunk;
|
||||
$text = ltrim(mb_substr($text, $maxChars));
|
||||
}
|
||||
|
||||
if ($text !== '' && $lines !== []) {
|
||||
$last = $lines[array_key_last($lines)];
|
||||
$lines[array_key_last($lines)] = mb_substr($last, 0, max(1, $maxChars - 1)).'…';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
protected function fontFile(): ?string
|
||||
{
|
||||
$configured = config('larablog.cover_font');
|
||||
$candidates = array_filter([
|
||||
is_string($configured) && $configured !== '' ? $configured : null,
|
||||
resource_path('fonts/Cover.ttf'),
|
||||
resource_path('fonts/NotoSansSC-Regular.otf'),
|
||||
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
||||
'/Library/Fonts/Arial Unicode.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
]);
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (is_string($path) && is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Attachment;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Resolve / assign article covers from content, attachments, or template generation.
|
||||
*/
|
||||
class ArticleCoverService
|
||||
{
|
||||
public const SOURCE_NONE = 'none';
|
||||
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
|
||||
public const SOURCE_ATTACHMENT = 'attachment';
|
||||
|
||||
public const SOURCE_CONTENT_IMAGE = 'content_image';
|
||||
|
||||
public const SOURCE_GENERATED = 'generated';
|
||||
|
||||
public const STATUS_NONE = 'none';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_READY = 'ready';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public function __construct(
|
||||
protected ArticleCoverGenerator $generator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{disk: ?string, path: string, source: string}|null
|
||||
*/
|
||||
public function resolve(Article $article, string $strategy = 'auto'): ?array
|
||||
{
|
||||
return match ($strategy) {
|
||||
'attachment' => $this->fromAttachments($article),
|
||||
'from_content', 'content_image' => $this->fromContent($article),
|
||||
'generate' => $this->generator->generate($article),
|
||||
default => $this->fromContent($article) ?? $this->fromAttachments($article),
|
||||
};
|
||||
}
|
||||
|
||||
public function apply(Article $article, string $strategy = 'auto'): Article
|
||||
{
|
||||
$article->forceFill([
|
||||
'cover_status' => self::STATUS_PENDING,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$resolved = $this->resolve($article, $strategy);
|
||||
} catch (\Throwable $exception) {
|
||||
$article->forceFill([
|
||||
'cover_status' => self::STATUS_FAILED,
|
||||
'cover_source' => $strategy === 'generate' ? self::SOURCE_GENERATED : self::SOURCE_NONE,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($resolved === null) {
|
||||
$article->forceFill([
|
||||
'cover_disk' => null,
|
||||
'cover_path' => null,
|
||||
'cover_source' => self::SOURCE_NONE,
|
||||
'cover_status' => self::STATUS_FAILED,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $article->refresh();
|
||||
}
|
||||
|
||||
$article->forceFill([
|
||||
'cover_disk' => $resolved['disk'],
|
||||
'cover_path' => $resolved['path'],
|
||||
'cover_source' => $resolved['source'],
|
||||
'cover_status' => self::STATUS_READY,
|
||||
'cover_generated_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $article->refresh();
|
||||
}
|
||||
|
||||
public function publicUrl(Article $article): ?string
|
||||
{
|
||||
if (! $article->hasCover()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = (string) $article->cover_path;
|
||||
|
||||
if ($article->cover_disk === 'url' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$disk = (string) ($article->cover_disk ?: config('larablog.attachments_disk', 'attachments'));
|
||||
|
||||
try {
|
||||
return Storage::disk($disk)->url($path);
|
||||
} catch (\Throwable) {
|
||||
return url('/'.$path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{disk: ?string, path: string, source: string}|null
|
||||
*/
|
||||
protected function fromContent(Article $article): ?array
|
||||
{
|
||||
$content = (string) $article->content;
|
||||
|
||||
if (preg_match('/!\[[^\]]*\]\(attach:(\d+)\)/i', $content, $matches) === 1
|
||||
|| preg_match('/\[attach=(\d+)\]/i', $content, $matches) === 1) {
|
||||
$attachment = Attachment::query()->find((int) $matches[1]);
|
||||
if ($attachment !== null && $this->isImage($attachment)) {
|
||||
return [
|
||||
'disk' => $attachment->disk,
|
||||
'path' => $attachment->path,
|
||||
'source' => self::SOURCE_CONTENT_IMAGE,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i', $content, $matches) === 1) {
|
||||
return [
|
||||
'disk' => 'url',
|
||||
'path' => $matches[1],
|
||||
'source' => self::SOURCE_CONTENT_IMAGE,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/<img[^>]+src=["\'](https?:\/\/[^"\']+)["\']/i', $content, $matches) === 1) {
|
||||
return [
|
||||
'disk' => 'url',
|
||||
'path' => $matches[1],
|
||||
'source' => self::SOURCE_CONTENT_IMAGE,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{disk: ?string, path: string, source: string}|null
|
||||
*/
|
||||
protected function fromAttachments(Article $article): ?array
|
||||
{
|
||||
$attachment = $article->attachments()
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->first(fn (Attachment $item): bool => $this->isImage($item));
|
||||
|
||||
if ($attachment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => $attachment->disk,
|
||||
'path' => $attachment->path,
|
||||
'source' => self::SOURCE_ATTACHMENT,
|
||||
];
|
||||
}
|
||||
|
||||
protected function isImage(Attachment $attachment): bool
|
||||
{
|
||||
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo((string) $attachment->filename, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user