Update
This commit is contained in:
@@ -53,7 +53,7 @@ public static function getVectorEmbeddingBgeSmall($embedding_query)
|
|||||||
|
|
||||||
KeywordEmbedding::create([
|
KeywordEmbedding::create([
|
||||||
'keyword' => $embedding_query,
|
'keyword' => $embedding_query,
|
||||||
'embedding' => $embedding
|
'embedding' => $embedding,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public static function getMemeKeywords(string $name, string $description)
|
|||||||
|
|
||||||
$response = Http::withHeaders([
|
$response = Http::withHeaders([
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Authorization' => 'Bearer ' . $apiKey,
|
'Authorization' => 'Bearer '.$apiKey,
|
||||||
])->post('https://api.openai.com/v1/responses', [
|
])->post('https://api.openai.com/v1/responses', [
|
||||||
'model' => 'gpt-4.1-nano',
|
'model' => 'gpt-4.1-nano',
|
||||||
'input' => [
|
'input' => [
|
||||||
@@ -64,7 +64,7 @@ public static function getSingleMemeGenerator($user_prompt)
|
|||||||
|
|
||||||
$response = Http::withHeaders([
|
$response = Http::withHeaders([
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Authorization' => 'Bearer ' . env('OPENAI_API_KEY'),
|
'Authorization' => 'Bearer '.env('OPENAI_API_KEY'),
|
||||||
])
|
])
|
||||||
->post('https://api.openai.com/v1/responses', [
|
->post('https://api.openai.com/v1/responses', [
|
||||||
'model' => 'gpt-4.1-nano',
|
'model' => 'gpt-4.1-nano',
|
||||||
@@ -102,7 +102,7 @@ public static function getSingleMemeGenerator($user_prompt)
|
|||||||
],
|
],
|
||||||
'primary_keyword_type' => [
|
'primary_keyword_type' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'description' => "Primary keyword type, choose only between: (action|emotion|misc)",
|
'description' => 'Primary keyword type, choose only between: (action|emotion|misc)',
|
||||||
],
|
],
|
||||||
'action_keywords' => [
|
'action_keywords' => [
|
||||||
'type' => 'array',
|
'type' => 'array',
|
||||||
@@ -168,7 +168,7 @@ public static function getSingleMemeGenerator($user_prompt)
|
|||||||
return $data;
|
return $data;
|
||||||
} else {
|
} else {
|
||||||
// Handle error
|
// Handle error
|
||||||
throw new \Exception('API request failed: ' . $response->body());
|
throw new \Exception('API request failed: '.$response->body());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
use App\Models\MemeMedia;
|
use App\Models\MemeMedia;
|
||||||
use App\Models\MemeMediaEmbedding;
|
use App\Models\MemeMediaEmbedding;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Filesystem\Cloud;
|
|
||||||
use PhpParser\Lexer\TokenEmulator\KeywordEmulator;
|
|
||||||
|
|
||||||
class KeywordEmbeddingMaintenance
|
class KeywordEmbeddingMaintenance
|
||||||
{
|
{
|
||||||
@@ -24,7 +22,7 @@ public static function populateCategoryEmbeddings()
|
|||||||
|
|
||||||
foreach ($categories as $category) {
|
foreach ($categories as $category) {
|
||||||
|
|
||||||
$embedding_query = $category->name . " " . $category->description;
|
$embedding_query = $category->name.' '.$category->description;
|
||||||
|
|
||||||
$keyword_embedding = KeywordEmbedding::where('keyword', $embedding_query)->first();
|
$keyword_embedding = KeywordEmbedding::where('keyword', $embedding_query)->first();
|
||||||
|
|
||||||
@@ -75,7 +73,6 @@ public static function populateMemeMediasKeywordsEmbeddings()
|
|||||||
|
|
||||||
dump("{Processing: {$count}/{$max}: {$meme_media->name}");
|
dump("{Processing: {$count}/{$max}: {$meme_media->name}");
|
||||||
|
|
||||||
|
|
||||||
// keywords:
|
// keywords:
|
||||||
foreach ($meme_media->keywords as $keyword) {
|
foreach ($meme_media->keywords as $keyword) {
|
||||||
|
|
||||||
@@ -113,7 +110,6 @@ public static function populateMemeMediasKeywordsEmbeddings()
|
|||||||
dump("Populating emotion keyword embedding for {$keyword}");
|
dump("Populating emotion keyword embedding for {$keyword}");
|
||||||
$embedding = self::fetchAndCacheEmbedding($keyword);
|
$embedding = self::fetchAndCacheEmbedding($keyword);
|
||||||
|
|
||||||
|
|
||||||
if ($embedding) {
|
if ($embedding) {
|
||||||
MemeMediaEmbedding::create([
|
MemeMediaEmbedding::create([
|
||||||
'meme_media_id' => $meme_media->id,
|
'meme_media_id' => $meme_media->id,
|
||||||
@@ -140,8 +136,8 @@ public static function populateMemeMediasKeywordsEmbeddings()
|
|||||||
}
|
}
|
||||||
|
|
||||||
dump("✓ Successfully processed meme media {$meme_media->id}");
|
dump("✓ Successfully processed meme media {$meme_media->id}");
|
||||||
dump("");
|
dump('');
|
||||||
dump("");
|
dump('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +146,7 @@ public static function populateMemeMediaEmbeddings()
|
|||||||
$meme_medias = MemeMedia::whereNotNull('embedding')->get();
|
$meme_medias = MemeMedia::whereNotNull('embedding')->get();
|
||||||
|
|
||||||
foreach ($meme_medias as $meme_media) {
|
foreach ($meme_medias as $meme_media) {
|
||||||
$embedding_query = $meme_media->name . " " . $meme_media->description;
|
$embedding_query = $meme_media->name.' '.$meme_media->description;
|
||||||
|
|
||||||
$keyword_embedding = KeywordEmbedding::where('keyword', $embedding_query)->first();
|
$keyword_embedding = KeywordEmbedding::where('keyword', $embedding_query)->first();
|
||||||
|
|
||||||
@@ -170,7 +166,6 @@ private static function fetchAndCacheEmbedding($keyword)
|
|||||||
$max_retries = 3;
|
$max_retries = 3;
|
||||||
$current_attempt = 0;
|
$current_attempt = 0;
|
||||||
|
|
||||||
|
|
||||||
while ($embedding === null && $current_attempt < $max_retries) {
|
while ($embedding === null && $current_attempt < $max_retries) {
|
||||||
$current_attempt++;
|
$current_attempt++;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ public static function generateMemesByCategories()
|
|||||||
Category::where('system_memes_generated_count', '<', 1)
|
Category::where('system_memes_generated_count', '<', 1)
|
||||||
->chunk(10, function ($categories) {
|
->chunk(10, function ($categories) {
|
||||||
foreach ($categories as $category) {
|
foreach ($categories as $category) {
|
||||||
dump('Processing ' . $category->name);
|
dump('Processing '.$category->name);
|
||||||
|
|
||||||
$meme = MemeGenerator::generateMemeByCategory($category);
|
$meme = MemeGenerator::generateMemeByCategory($category);
|
||||||
|
|
||||||
if (!is_null($meme)) {
|
if (! is_null($meme)) {
|
||||||
$category->system_memes_generated_count++;
|
$category->system_memes_generated_count++;
|
||||||
$category->save();
|
$category->save();
|
||||||
}
|
}
|
||||||
@@ -27,15 +27,13 @@ public static function generateMemesByCategories()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static function patchMemeKeywords()
|
public static function patchMemeKeywords()
|
||||||
{
|
{
|
||||||
$meme_medias = MemeMedia::whereNull('action_keywords')->get();
|
$meme_medias = MemeMedia::whereNull('action_keywords')->get();
|
||||||
|
|
||||||
foreach ($meme_medias as $key => $meme_media) {
|
foreach ($meme_medias as $key => $meme_media) {
|
||||||
|
|
||||||
dump('Processing ' . $key + 1 . '/' . $meme_medias->count() . ': ' . $meme_media->name);
|
dump('Processing '.$key + 1 .'/'.$meme_medias->count().': '.$meme_media->name);
|
||||||
|
|
||||||
$meme_keywords_response = OpenAI::getMemeKeywords($meme_media->name, $meme_media->description);
|
$meme_keywords_response = OpenAI::getMemeKeywords($meme_media->name, $meme_media->description);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public static function getSuitableMemeMedia(Meme $meme, $tolerance = 5)
|
|||||||
{
|
{
|
||||||
$meme_media = null;
|
$meme_media = null;
|
||||||
|
|
||||||
|
|
||||||
$primary_keyword_type = $meme->primary_keyword_type;
|
$primary_keyword_type = $meme->primary_keyword_type;
|
||||||
|
|
||||||
if ($primary_keyword_type == 'action') {
|
if ($primary_keyword_type == 'action') {
|
||||||
@@ -40,7 +39,7 @@ public static function getSuitableMemeMedia(Meme $meme, $tolerance = 5)
|
|||||||
|
|
||||||
$meme_media = self::getMemeMediaByKeywords($keywords, $tolerance);
|
$meme_media = self::getMemeMediaByKeywords($keywords, $tolerance);
|
||||||
}
|
}
|
||||||
} else if ($primary_keyword_type == 'emotion') {
|
} elseif ($primary_keyword_type == 'emotion') {
|
||||||
$meme_media = self::getMemeMediaByKeywords($meme->emotion_keywords, $tolerance, 'emotion_keywords');
|
$meme_media = self::getMemeMediaByKeywords($meme->emotion_keywords, $tolerance, 'emotion_keywords');
|
||||||
|
|
||||||
if (is_null($meme_media)) {
|
if (is_null($meme_media)) {
|
||||||
@@ -48,8 +47,8 @@ public static function getSuitableMemeMedia(Meme $meme, $tolerance = 5)
|
|||||||
|
|
||||||
$meme_media = self::getMemeMediaByKeywords($keywords, $tolerance);
|
$meme_media = self::getMemeMediaByKeywords($keywords, $tolerance);
|
||||||
}
|
}
|
||||||
} else if ($primary_keyword_type == 'misc') {
|
} elseif ($primary_keyword_type == 'misc') {
|
||||||
$meme_media = self::getMemeMediaByKeywords($meme->misc_keywords, $tolerance, 'misc_keywords');
|
$meme_media = self::getMemeMediaByKeywords($meme->misc_keywords, $tolerance, 'misc_keywords');
|
||||||
|
|
||||||
if (is_null($meme_media)) {
|
if (is_null($meme_media)) {
|
||||||
$keywords = array_merge($meme->action_keywords, $meme->emotion_keywords, $meme->keywords);
|
$keywords = array_merge($meme->action_keywords, $meme->emotion_keywords, $meme->keywords);
|
||||||
@@ -97,7 +96,7 @@ public static function generateMemeByCategory(Category $category)
|
|||||||
$meme->background_media_id = self::generateBackgroundMediaWithRunware($meme_output->background)->id;
|
$meme->background_media_id = self::generateBackgroundMediaWithRunware($meme_output->background)->id;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
//!is_null($meme->meme_media_id) &&
|
// !is_null($meme->meme_media_id) &&
|
||||||
! is_null($meme->background_media_id)
|
! is_null($meme->background_media_id)
|
||||||
) {
|
) {
|
||||||
$meme->status = self::STATUS_COMPLETED;
|
$meme->status = self::STATUS_COMPLETED;
|
||||||
@@ -117,13 +116,13 @@ public static function generateMemeOutputByCategory(Category $category)
|
|||||||
$random_keyword = Str::lower($category->name);
|
$random_keyword = Str::lower($category->name);
|
||||||
|
|
||||||
if (! is_null($category->parent_id)) {
|
if (! is_null($category->parent_id)) {
|
||||||
$random_keyword = $category->parent->name . ' - ' . $random_keyword;
|
$random_keyword = $category->parent->name.' - '.$random_keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_null($category->meme_angles)) {
|
if (! is_null($category->meme_angles)) {
|
||||||
$random_keyword .= ' - ' . collect($category->meme_angles)->random();
|
$random_keyword .= ' - '.collect($category->meme_angles)->random();
|
||||||
} elseif (! is_null($category->keywords)) {
|
} elseif (! is_null($category->keywords)) {
|
||||||
$random_keyword .= ' - ' . collect($category->keywords)->random();
|
$random_keyword .= ' - '.collect($category->keywords)->random();
|
||||||
}
|
}
|
||||||
|
|
||||||
$prompt = "Write me 1 meme about {$random_keyword}";
|
$prompt = "Write me 1 meme about {$random_keyword}";
|
||||||
@@ -170,7 +169,7 @@ public static function generateMemeOutputByCategory(Category $category)
|
|||||||
$meme_output = (object) [
|
$meme_output = (object) [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'attempts' => $attempt, // Optional: track how many attempts were made
|
'attempts' => $attempt, // Optional: track how many attempts were made
|
||||||
'error' => 'Failed to generate valid meme after ' . $retries . ' attempts',
|
'error' => 'Failed to generate valid meme after '.$retries.' attempts',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,16 +213,14 @@ public static function getMemeMediaByKeywords(array $keywords, int $tolerance =
|
|||||||
|
|
||||||
$meme_embedding = CloudflareAI::getVectorEmbeddingBgeSmall(implode(' ', $keywords));
|
$meme_embedding = CloudflareAI::getVectorEmbeddingBgeSmall(implode(' ', $keywords));
|
||||||
|
|
||||||
|
|
||||||
$meme_medias = MemeMediaEmbedding::query()
|
$meme_medias = MemeMediaEmbedding::query()
|
||||||
->when(!is_empty($tag), function ($query) use ($tag) {
|
->when(! is_empty($tag), function ($query) use ($tag) {
|
||||||
return $query->where('tag', $tag);
|
return $query->where('tag', $tag);
|
||||||
})
|
})
|
||||||
->nearestNeighbors('embedding', $meme_embedding, Distance::L2)
|
->nearestNeighbors('embedding', $meme_embedding, Distance::L2)
|
||||||
->take($tolerance)
|
->take($tolerance)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|
||||||
if ($meme_medias->count() > 0) {
|
if ($meme_medias->count() > 0) {
|
||||||
$meme_media = $meme_medias->random()->meme_media;
|
$meme_media = $meme_medias->random()->meme_media;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
* @property string|null $tag
|
* @property string|null $tag
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
*
|
|
||||||
* @package App\Models
|
|
||||||
*/
|
*/
|
||||||
class KeywordEmbedding extends Model
|
class KeywordEmbedding extends Model
|
||||||
{
|
{
|
||||||
@@ -33,6 +31,6 @@ class KeywordEmbedding extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'keyword',
|
'keyword',
|
||||||
'embedding',
|
'embedding',
|
||||||
'tag'
|
'tag',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,7 @@
|
|||||||
* @property string|null $tag
|
* @property string|null $tag
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
*
|
|
||||||
* @property MemeMedia $meme_media
|
* @property MemeMedia $meme_media
|
||||||
*
|
|
||||||
* @package App\Models
|
|
||||||
*/
|
*/
|
||||||
class MemeMediaEmbedding extends Model
|
class MemeMediaEmbedding extends Model
|
||||||
{
|
{
|
||||||
@@ -41,7 +38,7 @@ class MemeMediaEmbedding extends Model
|
|||||||
'meme_media_id',
|
'meme_media_id',
|
||||||
'keyword',
|
'keyword',
|
||||||
'embedding',
|
'embedding',
|
||||||
'tag'
|
'tag',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function meme_media()
|
public function meme_media()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
|
|||||||
BIN
public/fonts/Bungee/Bungee-Regular.ttf
Normal file
BIN
public/fonts/Bungee/Bungee-Regular.ttf
Normal file
Binary file not shown.
93
public/fonts/Bungee/OFL.txt
Normal file
93
public/fonts/Bungee/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2023 The Bungee Project Authors (https://github.com/djrrb/Bungee)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
public/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Montserrat/Montserrat-VariableFont_wght.ttf
Normal file
Binary file not shown.
93
public/fonts/Montserrat/OFL.txt
Normal file
93
public/fonts/Montserrat/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
81
public/fonts/Montserrat/README.txt
Normal file
81
public/fonts/Montserrat/README.txt
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
Montserrat Variable Font
|
||||||
|
========================
|
||||||
|
|
||||||
|
This download contains Montserrat as both variable fonts and static fonts.
|
||||||
|
|
||||||
|
Montserrat is a variable font with this axis:
|
||||||
|
wght
|
||||||
|
|
||||||
|
This means all the styles are contained in these files:
|
||||||
|
Montserrat-VariableFont_wght.ttf
|
||||||
|
Montserrat-Italic-VariableFont_wght.ttf
|
||||||
|
|
||||||
|
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||||
|
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||||
|
in those cases you can use the static font files for Montserrat:
|
||||||
|
static/Montserrat-Thin.ttf
|
||||||
|
static/Montserrat-ExtraLight.ttf
|
||||||
|
static/Montserrat-Light.ttf
|
||||||
|
static/Montserrat-Regular.ttf
|
||||||
|
static/Montserrat-Medium.ttf
|
||||||
|
static/Montserrat-SemiBold.ttf
|
||||||
|
static/Montserrat-Bold.ttf
|
||||||
|
static/Montserrat-ExtraBold.ttf
|
||||||
|
static/Montserrat-Black.ttf
|
||||||
|
static/Montserrat-ThinItalic.ttf
|
||||||
|
static/Montserrat-ExtraLightItalic.ttf
|
||||||
|
static/Montserrat-LightItalic.ttf
|
||||||
|
static/Montserrat-Italic.ttf
|
||||||
|
static/Montserrat-MediumItalic.ttf
|
||||||
|
static/Montserrat-SemiBoldItalic.ttf
|
||||||
|
static/Montserrat-BoldItalic.ttf
|
||||||
|
static/Montserrat-ExtraBoldItalic.ttf
|
||||||
|
static/Montserrat-BlackItalic.ttf
|
||||||
|
|
||||||
|
Get started
|
||||||
|
-----------
|
||||||
|
|
||||||
|
1. Install the font files you want to use
|
||||||
|
|
||||||
|
2. Use your app's font picker to view the font family and all the
|
||||||
|
available styles
|
||||||
|
|
||||||
|
Learn more about variable fonts
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||||
|
https://variablefonts.typenetwork.com
|
||||||
|
https://medium.com/variable-fonts
|
||||||
|
|
||||||
|
In desktop apps
|
||||||
|
|
||||||
|
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||||
|
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||||
|
|
||||||
|
Online
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/getting_started
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||||
|
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||||
|
|
||||||
|
Installing fonts
|
||||||
|
|
||||||
|
MacOS: https://support.apple.com/en-us/HT201749
|
||||||
|
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||||
|
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||||
|
|
||||||
|
Android Apps
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/android
|
||||||
|
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
Please read the full license text (OFL.txt) to understand the permissions,
|
||||||
|
restrictions and requirements for usage, redistribution, and modification.
|
||||||
|
|
||||||
|
You can use them in your products & projects – print or digital,
|
||||||
|
commercial or otherwise.
|
||||||
|
|
||||||
|
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||||
|
license for all details.
|
||||||
BIN
public/fonts/Montserrat/static/Montserrat-Black.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Black.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-BlackItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-Bold.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-BoldItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-ExtraBold.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-ExtraLight.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-Italic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Italic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-Light.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-LightItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-LightItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-Medium.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-MediumItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-Regular.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-SemiBold.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-SemiBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-Thin.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-Thin.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Montserrat/static/Montserrat-ThinItalic.ttf
Normal file
BIN
public/fonts/Montserrat/static/Montserrat-ThinItalic.ttf
Normal file
Binary file not shown.
@@ -167,50 +167,52 @@ const Editor = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto flex min-h-screen flex-col space-y-2 py-4" style={{ width: `${responsiveWidth}px` }}>
|
<>
|
||||||
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
|
<div className="relative mx-auto flex min-h-screen flex-col space-y-2 py-4" style={{ width: `${responsiveWidth}px` }}>
|
||||||
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
|
<EditSidebar isOpen={isEditSidebarOpen} onClose={handleEditClose} />
|
||||||
<TextSidebar isOpen={isTextSidebarOpen} onClose={handleTextSidebarClose} />
|
<EditNavSidebar isOpen={isEditNavSidebarOpen} onClose={handleEditNavClose} />
|
||||||
|
<TextSidebar isOpen={isTextSidebarOpen} onClose={handleTextSidebarClose} />
|
||||||
|
|
||||||
<EditorHeader
|
<EditorHeader
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
style={{ width: `${responsiveWidth}px` }}
|
style={{ width: `${responsiveWidth}px` }}
|
||||||
onNavClick={handleEditNavClick}
|
onNavClick={handleEditNavClick}
|
||||||
isNavActive={isEditNavSidebarOpen}
|
isNavActive={isEditNavSidebarOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isBelowMinWidth ? (
|
{isBelowMinWidth ? (
|
||||||
<div className="aspect-[9/16]">
|
<div className="aspect-[9/16]">
|
||||||
<div className="flex h-full flex-1 items-center justify-center rounded-lg border bg-white p-6 shadow-lg dark:bg-neutral-900">
|
<div className="flex h-full flex-1 items-center justify-center rounded-lg border bg-white p-6 shadow-lg dark:bg-neutral-900">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="relative mb-3 flex justify-center">
|
<div className="relative mb-3 flex justify-center">
|
||||||
<img width="180" height="180" src="https://cdn.memeaigen.com/landing/dancing-potato.gif"></img>
|
<img width="180" height="180" src="https://cdn.memeaigen.com/landing/dancing-potato.gif"></img>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full space-y-2 text-center">
|
<div className="w-full space-y-2 text-center">
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
{getSetting('genAlphaSlang')
|
{getSetting('genAlphaSlang')
|
||||||
? 'Not gonna lie, using on a potato screen is giving L vibes. Desktop hits different - that experience is straight fire, bet!'
|
? 'Not gonna lie, using on a potato screen is giving L vibes. Desktop hits different - that experience is straight fire, bet!'
|
||||||
: 'You seem to be using a potato-sized screen. Please continue with desktop for a more refined experience!'}
|
: 'You seem to be using a potato-sized screen. Please continue with desktop for a more refined experience!'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<EditorCanvas maxWidth={maxWidth} onOpenTextSidebar={handleTextSidebarOpen} />
|
||||||
<EditorCanvas maxWidth={maxWidth} onOpenTextSidebar={handleTextSidebarOpen} />
|
<EditorControls
|
||||||
<EditorControls
|
className="mx-auto"
|
||||||
className="mx-auto"
|
style={{ width: `${responsiveWidth}px` }}
|
||||||
style={{ width: `${responsiveWidth}px` }}
|
onEditClick={handleEditClick}
|
||||||
onEditClick={handleEditClick}
|
isEditActive={isEditSidebarOpen}
|
||||||
isEditActive={isEditSidebarOpen}
|
/>
|
||||||
/>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
const VideoDownloadModal = ({ isOpen, onClose, ffmpegCommand, handleDownloadButton, isExporting, exportProgress, exportStatus }) => {
|
||||||
|
const debug = true;
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Download Video</DialogTitle>
|
||||||
|
{exportStatus ||
|
||||||
|
(exportProgress > 0 && (
|
||||||
|
<DialogDescription>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-medium">{exportStatus}</span>
|
||||||
|
<span className="text-xs font-medium">{exportProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Spinner className={'h-5 w-5'} />
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
))}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{debug && <Textarea value={ffmpegCommand} readOnly />}
|
||||||
|
|
||||||
|
<Button onClick={handleDownloadButton}>{isExporting ? <Spinner className="text-secondary h-4 w-4" /> : 'Download'}</Button>
|
||||||
|
|
||||||
|
{/* Add your content here */}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoDownloadModal;
|
||||||
@@ -4,10 +4,13 @@ import useVideoEditorStore from '@/stores/VideoEditorStore';
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import SINGLE_CAPTION_TEMPLATE from '../../templates/single_caption_meme_background.json';
|
import SINGLE_CAPTION_TEMPLATE from '../../templates/single_caption_meme_background.json';
|
||||||
import { generateTimelineFromTemplate } from '../../utils/timeline-template-processor';
|
import { generateTimelineFromTemplate } from '../../utils/timeline-template-processor';
|
||||||
|
import VideoDownloadModal from './video-download/video-download-modal';
|
||||||
import useVideoExport from './video-export';
|
import useVideoExport from './video-export';
|
||||||
import VideoPreview from './video-preview';
|
import VideoPreview from './video-preview';
|
||||||
|
|
||||||
const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
||||||
|
const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false);
|
||||||
|
|
||||||
const [showConsoleLogs] = useState(true);
|
const [showConsoleLogs] = useState(true);
|
||||||
|
|
||||||
const [dimensions] = useState({
|
const [dimensions] = useState({
|
||||||
@@ -34,7 +37,7 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
|||||||
const pausedTimeRef = useRef(0);
|
const pausedTimeRef = useRef(0);
|
||||||
|
|
||||||
const { setVideoIsPlaying } = useVideoEditorStore();
|
const { setVideoIsPlaying } = useVideoEditorStore();
|
||||||
const { selectedMeme, selectedBackground, currentCaption } = useMediaStore();
|
const { selectedMeme, selectedBackground, currentCaption, watermarked } = useMediaStore();
|
||||||
|
|
||||||
const FPS_INTERVAL = 1000 / 30; // 30 FPS
|
const FPS_INTERVAL = 1000 / 30; // 30 FPS
|
||||||
|
|
||||||
@@ -544,9 +547,19 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
|||||||
});
|
});
|
||||||
}, [handlePause, handleSeek, videoElements]);
|
}, [handlePause, handleSeek, videoElements]);
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
exportVideo();
|
||||||
|
}, [exportVideo]);
|
||||||
|
|
||||||
|
const handleOpenDownloadModal = () => {
|
||||||
|
setIsDownloadModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const activeElements = getActiveElements(currentTime);
|
const activeElements = getActiveElements(currentTime);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
emitter.on('video-open-download-modal', handleOpenDownloadModal);
|
||||||
|
emitter.on('video-export', handleExport);
|
||||||
emitter.on('video-play', handlePlay);
|
emitter.on('video-play', handlePlay);
|
||||||
emitter.on('video-reset', handleReset);
|
emitter.on('video-reset', handleReset);
|
||||||
emitter.on('video-seek', handleSeek);
|
emitter.on('video-seek', handleSeek);
|
||||||
@@ -555,6 +568,8 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
emitter.off('video-open-download-modal', handleOpenDownloadModal);
|
||||||
|
emitter.off('video-export', handleExport);
|
||||||
emitter.off('video-play', handlePlay);
|
emitter.off('video-play', handlePlay);
|
||||||
emitter.off('video-reset', handleReset);
|
emitter.off('video-reset', handleReset);
|
||||||
emitter.off('video-seek', handleSeek);
|
emitter.off('video-seek', handleSeek);
|
||||||
@@ -563,33 +578,45 @@ const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
|||||||
}, [emitter, handlePlay, handleReset, handleSeek, handleElementUpdate]);
|
}, [emitter, handlePlay, handleReset, handleSeek, handleElementUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
<>
|
||||||
<VideoPreview
|
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
||||||
dimensions={dimensions}
|
<VideoPreview
|
||||||
currentTime={currentTime}
|
watermarked={watermarked}
|
||||||
totalDuration={totalDuration}
|
dimensions={dimensions}
|
||||||
isPlaying={isPlaying}
|
currentTime={currentTime}
|
||||||
status={status}
|
totalDuration={totalDuration}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
status={status}
|
||||||
|
isExporting={isExporting}
|
||||||
|
exportProgress={exportProgress}
|
||||||
|
exportStatus={exportStatus}
|
||||||
|
timelineElements={timelineElements}
|
||||||
|
activeElements={activeElements}
|
||||||
|
videoElements={videoElements}
|
||||||
|
loadedVideos={loadedVideos}
|
||||||
|
videoStates={videoStates}
|
||||||
|
ffmpegCommand={ffmpegCommand}
|
||||||
|
handlePlay={handlePlay}
|
||||||
|
handlePause={handlePause}
|
||||||
|
handleReset={handleReset}
|
||||||
|
handleSeek={handleSeek}
|
||||||
|
copyFFmpegCommand={copyFFmpegCommand}
|
||||||
|
exportVideo={exportVideo}
|
||||||
|
onElementUpdate={handleElementUpdate}
|
||||||
|
onOpenTextSidebar={onOpenTextSidebar}
|
||||||
|
layerRef={layerRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<VideoDownloadModal
|
||||||
|
isOpen={isDownloadModalOpen}
|
||||||
|
onClose={() => setIsDownloadModalOpen(false)}
|
||||||
|
ffmpegCommand={ffmpegCommand}
|
||||||
|
handleDownloadButton={handleExport}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
exportProgress={exportProgress}
|
exportProgress={exportProgress}
|
||||||
exportStatus={exportStatus}
|
exportStatus={exportStatus}
|
||||||
timelineElements={timelineElements}
|
|
||||||
activeElements={activeElements}
|
|
||||||
videoElements={videoElements}
|
|
||||||
loadedVideos={loadedVideos}
|
|
||||||
videoStates={videoStates}
|
|
||||||
ffmpegCommand={ffmpegCommand}
|
|
||||||
handlePlay={handlePlay}
|
|
||||||
handlePause={handlePause}
|
|
||||||
handleReset={handleReset}
|
|
||||||
handleSeek={handleSeek}
|
|
||||||
copyFFmpegCommand={copyFFmpegCommand}
|
|
||||||
exportVideo={exportVideo}
|
|
||||||
onElementUpdate={handleElementUpdate}
|
|
||||||
onOpenTextSidebar={onOpenTextSidebar}
|
|
||||||
layerRef={layerRef}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||||
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
// Font configuration mapping
|
// Font configuration mapping
|
||||||
const FONT_CONFIG = {
|
const FONT_CONFIG = {
|
||||||
@@ -25,7 +25,7 @@ const FONT_CONFIG = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
||||||
const [showConsoleLogs] = useState(false);
|
const [showConsoleLogs] = useState(true);
|
||||||
|
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [exportProgress, setExportProgress] = useState(0);
|
const [exportProgress, setExportProgress] = useState(0);
|
||||||
@@ -50,6 +50,10 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(JSON.stringify(timelineElements));
|
||||||
|
}, [timelineElements]);
|
||||||
|
|
||||||
// Helper function to convert color format for FFmpeg
|
// Helper function to convert color format for FFmpeg
|
||||||
const formatColorForFFmpeg = (color) => {
|
const formatColorForFFmpeg = (color) => {
|
||||||
// Handle hex colors (e.g., #ffffff or #fff)
|
// Handle hex colors (e.g., #ffffff or #fff)
|
||||||
@@ -72,6 +76,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
const generateFFmpegCommand = useCallback(
|
const generateFFmpegCommand = useCallback(
|
||||||
(is_string = true, useLocalFiles = false) => {
|
(is_string = true, useLocalFiles = false) => {
|
||||||
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
|
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
|
||||||
|
showConsoleLogs && console.log(`📐 Canvas size: ${dimensions.width}x${dimensions.height}, Duration: ${totalDuration}s`);
|
||||||
|
|
||||||
const videos = timelineElements.filter((el) => el.type === 'video');
|
const videos = timelineElements.filter((el) => el.type === 'video');
|
||||||
const images = timelineElements.filter((el) => el.type === 'image');
|
const images = timelineElements.filter((el) => el.type === 'image');
|
||||||
@@ -81,6 +86,20 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
showConsoleLogs && console.log('Images found:', images.length);
|
showConsoleLogs && console.log('Images found:', images.length);
|
||||||
showConsoleLogs && console.log('Texts found:', texts.length);
|
showConsoleLogs && console.log('Texts found:', texts.length);
|
||||||
|
|
||||||
|
// Check for WebM videos with potential transparency
|
||||||
|
const webmVideos = videos.filter((v) => v.source_webm && (v.source_webm.includes('.webm') || v.source_webm.includes('webm')));
|
||||||
|
if (webmVideos.length > 0) {
|
||||||
|
showConsoleLogs && console.log(`🌟 Found ${webmVideos.length} WebM video(s) - will preserve transparency`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary of all elements for debugging
|
||||||
|
showConsoleLogs && console.log('📋 Element Summary:');
|
||||||
|
videos.forEach((v, i) => showConsoleLogs && console.log(` Video ${i}: Layer ${v.layer} (${v.x},${v.y}) ${v.width}x${v.height}`));
|
||||||
|
images.forEach(
|
||||||
|
(img, i) => showConsoleLogs && console.log(` Image ${i}: Layer ${img.layer} (${img.x},${img.y}) ${img.width}x${img.height}`),
|
||||||
|
);
|
||||||
|
texts.forEach((t, i) => showConsoleLogs && console.log(` Text ${i}: Layer ${t.layer} (${t.x},${t.y}) "${t.text.substring(0, 30)}..."`));
|
||||||
|
|
||||||
if (videos.length === 0 && images.length === 0) {
|
if (videos.length === 0 && images.length === 0) {
|
||||||
if (is_string) {
|
if (is_string) {
|
||||||
return 'ffmpeg -f lavfi -i color=black:size=450x800:duration=1 -c:v libx264 -t 1 output.mp4';
|
return 'ffmpeg -f lavfi -i color=black:size=450x800:duration=1 -c:v libx264 -t 1 output.mp4';
|
||||||
@@ -110,32 +129,139 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
||||||
|
|
||||||
let videoLayer = 'base';
|
let videoLayer = 'base';
|
||||||
let currentInputIndex = 0;
|
|
||||||
|
|
||||||
// Process video elements
|
// FIXED: Sort all visual elements by layer, then process in layer order
|
||||||
videos.forEach((v, i) => {
|
const allVisualElements = [
|
||||||
filters.push(`[${currentInputIndex}:v]trim=start=${v.inPoint}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`);
|
...videos.map((v, i) => ({ ...v, elementType: 'video', originalIndex: i })),
|
||||||
filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}[v${i}_scale]`);
|
...images.map((img, i) => ({ ...img, elementType: 'image', originalIndex: i })),
|
||||||
filters.push(
|
...texts.map((t, i) => ({ ...t, elementType: 'text', originalIndex: i })),
|
||||||
`[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${
|
].sort((a, b) => (a.layer || 0) - (b.layer || 0)); // Sort by layer (lowest first)
|
||||||
v.startTime + v.duration
|
|
||||||
})'[v${i}_out]`,
|
|
||||||
);
|
|
||||||
videoLayer = `v${i}_out`;
|
|
||||||
currentInputIndex++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process image elements
|
showConsoleLogs &&
|
||||||
images.forEach((img, i) => {
|
console.log(
|
||||||
const imgInputIndex = currentInputIndex;
|
'🎭 Processing order by layer:',
|
||||||
filters.push(`[${imgInputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`);
|
allVisualElements.map((el) => `${el.elementType}${el.originalIndex}(L${el.layer || 0})`).join(' → '),
|
||||||
filters.push(
|
|
||||||
`[${videoLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${
|
|
||||||
img.startTime + img.duration
|
|
||||||
})'[img${i}_out]`,
|
|
||||||
);
|
);
|
||||||
videoLayer = `img${i}_out`;
|
|
||||||
currentInputIndex++;
|
// Track input indices for videos and images
|
||||||
|
let videoInputIndex = 0;
|
||||||
|
let imageInputIndex = videos.length; // Images start after videos
|
||||||
|
|
||||||
|
// Process elements in layer order
|
||||||
|
allVisualElements.forEach((element, processingIndex) => {
|
||||||
|
if (element.elementType === 'video') {
|
||||||
|
const v = element;
|
||||||
|
const i = element.originalIndex;
|
||||||
|
|
||||||
|
showConsoleLogs &&
|
||||||
|
console.log(
|
||||||
|
`🎬 Video ${i} (Layer ${v.layer || 0}) - Position: (${v.x}, ${v.y}), Size: ${v.width}x${v.height}, Time: ${v.startTime}-${v.startTime + v.duration}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if video extends outside canvas
|
||||||
|
if (v.x < 0 || v.y < 0 || v.x + v.width > dimensions.width || v.y + v.height > dimensions.height) {
|
||||||
|
console.warn(`⚠️ Video ${i} extends outside canvas boundaries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a WebM video (likely has transparency)
|
||||||
|
const isWebM = v.source_webm && (v.source_webm.includes('.webm') || v.source_webm.includes('webm'));
|
||||||
|
|
||||||
|
filters.push(`[${videoInputIndex}:v]trim=start=${v.inPoint}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`);
|
||||||
|
|
||||||
|
// For WebM videos, preserve alpha channel during scaling
|
||||||
|
if (isWebM) {
|
||||||
|
showConsoleLogs && console.log(`🌟 Video ${i} is WebM - preserving alpha channel`);
|
||||||
|
filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}:flags=bicubic[v${i}_scale]`);
|
||||||
|
} else {
|
||||||
|
filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}[v${i}_scale]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For overlay, ensure alpha blending is enabled for WebM
|
||||||
|
if (isWebM) {
|
||||||
|
filters.push(
|
||||||
|
`[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${
|
||||||
|
v.startTime + v.duration
|
||||||
|
})':format=auto:eof_action=pass[v${i}_out]`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
filters.push(
|
||||||
|
`[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${
|
||||||
|
v.startTime + v.duration
|
||||||
|
})'[v${i}_out]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
videoLayer = `v${i}_out`;
|
||||||
|
videoInputIndex++;
|
||||||
|
} else if (element.elementType === 'image') {
|
||||||
|
const img = element;
|
||||||
|
const i = element.originalIndex;
|
||||||
|
|
||||||
|
showConsoleLogs &&
|
||||||
|
console.log(
|
||||||
|
`🖼️ Image ${i} (Layer ${img.layer || 0}) - Position: (${img.x}, ${img.y}), Size: ${img.width}x${img.height}, Time: ${img.startTime}-${img.startTime + img.duration}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if image is larger than canvas or positioned outside
|
||||||
|
if (img.width > dimensions.width || img.height > dimensions.height) {
|
||||||
|
console.warn(`⚠️ Image ${i} (${img.width}x${img.height}) is larger than canvas (${dimensions.width}x${dimensions.height})`);
|
||||||
|
}
|
||||||
|
if (img.x < 0 || img.y < 0 || img.x + img.width > dimensions.width || img.y + img.height > dimensions.height) {
|
||||||
|
console.warn(`⚠️ Image ${i} extends outside canvas boundaries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push(`[${imageInputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`);
|
||||||
|
filters.push(
|
||||||
|
`[${videoLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${
|
||||||
|
img.startTime + img.duration
|
||||||
|
})'[img${i}_out]`,
|
||||||
|
);
|
||||||
|
videoLayer = `img${i}_out`;
|
||||||
|
imageInputIndex++;
|
||||||
|
} else if (element.elementType === 'text') {
|
||||||
|
const t = element;
|
||||||
|
const i = element.originalIndex;
|
||||||
|
|
||||||
|
showConsoleLogs &&
|
||||||
|
console.log(`📝 Text ${i} (Layer ${t.layer || 0}) - Position: (${t.x}, ${t.y}) Text: "${t.text.substring(0, 30)}..."`);
|
||||||
|
|
||||||
|
// Better text escaping for FFmpeg
|
||||||
|
const escapedText = t.text
|
||||||
|
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||||
|
.replace(/'/g, is_string ? "\\'" : "'") // Escape quotes
|
||||||
|
.replace(/:/g, '\\:') // Escape colons
|
||||||
|
.replace(/\[/g, '\\[') // Escape square brackets
|
||||||
|
.replace(/\]/g, '\\]')
|
||||||
|
.replace(/,/g, '\\,') // Escape commas
|
||||||
|
.replace(/;/g, '\\;'); // Escape semicolons
|
||||||
|
|
||||||
|
// Get the appropriate font file path
|
||||||
|
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle);
|
||||||
|
const fontFileName = fontFilePath.split('/').pop();
|
||||||
|
|
||||||
|
// Center the text: x position is the center point, y is adjusted for baseline
|
||||||
|
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
|
||||||
|
|
||||||
|
// Format colors for FFmpeg
|
||||||
|
const fontColor = formatColorForFFmpeg(t.fill);
|
||||||
|
const borderColor = formatColorForFFmpeg(t.stroke);
|
||||||
|
const borderWidth = Math.max(0, t.strokeWidth || 0); // Ensure non-negative
|
||||||
|
|
||||||
|
// Build drawtext filter with proper border handling
|
||||||
|
// For centering: use (w-tw)/2 for x and adjust y as needed
|
||||||
|
let drawTextFilter = `[${videoLayer}]drawtext=fontfile=/${fontFileName}:text='${escapedText}':x=(w-tw)/2:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${fontColor}`;
|
||||||
|
|
||||||
|
// Only add border if strokeWidth > 0
|
||||||
|
if (borderWidth > 0) {
|
||||||
|
drawTextFilter += `:borderw=${borderWidth}:bordercolor=${borderColor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTextFilter += `:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`;
|
||||||
|
|
||||||
|
showConsoleLogs && console.log(`Text filter ${i}:`, drawTextFilter);
|
||||||
|
filters.push(drawTextFilter);
|
||||||
|
videoLayer = `t${i}`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
||||||
@@ -159,39 +285,14 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
|
|
||||||
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
||||||
|
|
||||||
// Process text elements with proper font support and color handling
|
|
||||||
texts.forEach((t, i) => {
|
|
||||||
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
|
|
||||||
|
|
||||||
// Get the appropriate font file path
|
|
||||||
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle);
|
|
||||||
const fontFileName = fontFilePath.split('/').pop();
|
|
||||||
|
|
||||||
// Center the text: x position is the center point, y is adjusted for baseline
|
|
||||||
const centerX = Math.round(t.x);
|
|
||||||
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
|
|
||||||
|
|
||||||
// Format colors for FFmpeg
|
|
||||||
const fontColor = formatColorForFFmpeg(t.fill);
|
|
||||||
const borderColor = formatColorForFFmpeg(t.stroke);
|
|
||||||
const borderWidth = Math.max(0, t.strokeWidth || 0); // Ensure non-negative
|
|
||||||
|
|
||||||
// Build drawtext filter with proper border handling
|
|
||||||
let drawTextFilter = `[${videoLayer}]drawtext=fontfile=/${fontFileName}:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${fontColor}`;
|
|
||||||
|
|
||||||
// Only add border if strokeWidth > 0
|
|
||||||
if (borderWidth > 0) {
|
|
||||||
drawTextFilter += `:borderw=${borderWidth}:bordercolor=${borderColor}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTextFilter += `:text_align=center:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`;
|
|
||||||
|
|
||||||
filters.push(drawTextFilter);
|
|
||||||
videoLayer = `t${i}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filterComplex = filters.join('; ');
|
const filterComplex = filters.join('; ');
|
||||||
showConsoleLogs && console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim'));
|
showConsoleLogs && console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim'));
|
||||||
|
showConsoleLogs && console.log('📝 Complete filter complex:', filterComplex);
|
||||||
|
showConsoleLogs &&
|
||||||
|
console.log(
|
||||||
|
`🎭 Final layer order:`,
|
||||||
|
allVisualElements.map((el) => `${el.elementType}${el.originalIndex}(L${el.layer || 0})`).join(' → '),
|
||||||
|
);
|
||||||
|
|
||||||
const finalArgs = [
|
const finalArgs = [
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
@@ -263,7 +364,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ffmpeg.on('log', ({ message }) => {
|
ffmpeg.on('log', ({ message }) => {
|
||||||
showConsoleLogs && console.log(message);
|
showConsoleLogs && console.log('FFmpeg Log:', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
||||||
@@ -288,44 +389,43 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
|
|
||||||
setExportStatus('Loading fonts...');
|
setExportStatus('Loading fonts...');
|
||||||
|
|
||||||
// Load all required fonts
|
// Collect all fonts that need to be loaded with their correct paths
|
||||||
const fontsToLoad = new Set();
|
const fontsToLoad = new Map(); // Map from filename to full path
|
||||||
|
|
||||||
// Add Arial font (fallback)
|
// Add Arial font (fallback)
|
||||||
fontsToLoad.add('arial.ttf');
|
fontsToLoad.set('arial.ttf', 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf');
|
||||||
|
|
||||||
// Add fonts used by text elements
|
// Add fonts used by text elements - FIXED: use the actual font paths from getFontFilePath
|
||||||
timelineElements
|
timelineElements
|
||||||
.filter((el) => el.type === 'text')
|
.filter((el) => el.type === 'text')
|
||||||
.forEach((text) => {
|
.forEach((text) => {
|
||||||
const fontFilePath = getFontFilePath(text.fontFamily, text.fontWeight, text.fontStyle);
|
const fontFilePath = getFontFilePath(text.fontFamily, text.fontWeight, text.fontStyle);
|
||||||
const fontFileName = fontFilePath.split('/').pop();
|
const fontFileName = fontFilePath.split('/').pop();
|
||||||
fontsToLoad.add(fontFileName);
|
|
||||||
|
// Only add if not already in map and not arial.ttf
|
||||||
|
if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) {
|
||||||
|
fontsToLoad.set(fontFileName, fontFilePath); // Use the actual path, not reconstructed
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
showConsoleLogs && console.log('Fonts to load:', Array.from(fontsToLoad.entries()));
|
||||||
|
|
||||||
// Load each unique font
|
// Load each unique font
|
||||||
let fontProgress = 0;
|
let fontProgress = 0;
|
||||||
for (const fontFile of fontsToLoad) {
|
for (const [fontFileName, fontPath] of fontsToLoad) {
|
||||||
try {
|
try {
|
||||||
if (fontFile === 'arial.ttf') {
|
showConsoleLogs && console.log(`Loading font: ${fontFileName} from ${fontPath}`);
|
||||||
await ffmpeg.writeFile(
|
await ffmpeg.writeFile(fontFileName, await fetchFile(fontPath));
|
||||||
'arial.ttf',
|
showConsoleLogs && console.log(`✓ Font ${fontFileName} loaded successfully`);
|
||||||
await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Load Montserrat fonts from local filesystem
|
|
||||||
const fontPath = `/fonts/Montserrat/static/${fontFile}`;
|
|
||||||
await ffmpeg.writeFile(fontFile, await fetchFile(fontPath));
|
|
||||||
}
|
|
||||||
fontProgress++;
|
fontProgress++;
|
||||||
setExportProgress(10 + Math.round((fontProgress / fontsToLoad.size) * 10));
|
setExportProgress(10 + Math.round((fontProgress / fontsToLoad.size) * 10));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to load font ${fontFile}, falling back to arial.ttf:`, error);
|
console.error(`❌ Failed to load font ${fontFileName} from ${fontPath}:`, error);
|
||||||
// If font loading fails, we'll use arial.ttf as fallback
|
// If font loading fails, we'll use arial.ttf as fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showConsoleLogs && console.log('Fonts loaded!');
|
showConsoleLogs && console.log('All fonts loaded!');
|
||||||
setExportProgress(20);
|
setExportProgress(20);
|
||||||
|
|
||||||
setExportStatus('Downloading media...');
|
setExportStatus('Downloading media...');
|
||||||
@@ -333,27 +433,69 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
const images = timelineElements.filter((el) => el.type === 'image');
|
const images = timelineElements.filter((el) => el.type === 'image');
|
||||||
const totalMedia = videos.length + images.length;
|
const totalMedia = videos.length + images.length;
|
||||||
|
|
||||||
|
showConsoleLogs && console.log(`Total media to download: ${totalMedia} (${videos.length} videos, ${images.length} images)`);
|
||||||
|
|
||||||
let mediaProgress = 0;
|
let mediaProgress = 0;
|
||||||
|
|
||||||
// Download videos
|
// Download videos
|
||||||
for (let i = 0; i < videos.length; i++) {
|
for (let i = 0; i < videos.length; i++) {
|
||||||
await ffmpeg.writeFile(`input_video_${i}.webm`, await fetchFile(videos[i].source_webm));
|
try {
|
||||||
|
showConsoleLogs && console.log(`Downloading video ${i}: ${videos[i].source_webm}`);
|
||||||
|
await ffmpeg.writeFile(`input_video_${i}.webm`, await fetchFile(videos[i].source_webm));
|
||||||
|
showConsoleLogs && console.log(`✓ Video ${i} downloaded`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to download video ${i}:`, error);
|
||||||
|
throw new Error(`Failed to download video ${i}: ${error.message}`);
|
||||||
|
}
|
||||||
mediaProgress++;
|
mediaProgress++;
|
||||||
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download images
|
// Download images
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
await ffmpeg.writeFile(`input_image_${i}.jpg`, await fetchFile(images[i].source));
|
try {
|
||||||
|
showConsoleLogs && console.log(`Downloading image ${i}: ${images[i].source}`);
|
||||||
|
await ffmpeg.writeFile(`input_image_${i}.jpg`, await fetchFile(images[i].source));
|
||||||
|
showConsoleLogs && console.log(`✓ Image ${i} downloaded`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to download image ${i}:`, error);
|
||||||
|
throw new Error(`Failed to download image ${i}: ${error.message}`);
|
||||||
|
}
|
||||||
mediaProgress++;
|
mediaProgress++;
|
||||||
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showConsoleLogs && console.log('All media downloaded successfully!');
|
||||||
|
|
||||||
|
// List all files in FFmpeg filesystem for debugging
|
||||||
|
try {
|
||||||
|
const files = await ffmpeg.listDir('/');
|
||||||
|
showConsoleLogs && console.log('Files in FFmpeg filesystem:', files);
|
||||||
|
} catch (listError) {
|
||||||
|
console.warn('Could not list FFmpeg filesystem:', listError);
|
||||||
|
}
|
||||||
|
|
||||||
setExportStatus('Processing video...');
|
setExportStatus('Processing video...');
|
||||||
let args = generateFFmpegCommand(false, true);
|
let args = generateFFmpegCommand(false, true);
|
||||||
|
|
||||||
|
showConsoleLogs && console.log('Generated FFmpeg arguments:', args);
|
||||||
|
|
||||||
setExportProgress(70);
|
setExportProgress(70);
|
||||||
await ffmpeg.exec(args);
|
|
||||||
|
try {
|
||||||
|
await ffmpeg.exec(args);
|
||||||
|
showConsoleLogs && console.log('FFmpeg execution completed successfully!');
|
||||||
|
} catch (execError) {
|
||||||
|
console.error('FFmpeg execution failed:', execError);
|
||||||
|
console.error('Failed arguments:', args);
|
||||||
|
|
||||||
|
// Log the specific error details
|
||||||
|
if (execError.message) {
|
||||||
|
console.error('Error message:', execError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`FFmpeg execution failed: ${execError.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
setExportStatus('Downloading...');
|
setExportStatus('Downloading...');
|
||||||
setExportProgress(90);
|
setExportProgress(90);
|
||||||
@@ -375,7 +517,15 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|||||||
|
|
||||||
ffmpeg.terminate();
|
ffmpeg.terminate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export error:', error);
|
console.error('Full export error details:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
name: error.name,
|
||||||
|
code: error.code,
|
||||||
|
errno: error.errno,
|
||||||
|
path: error.path,
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
setExportStatus(`Failed: ${error.message}`);
|
setExportStatus(`Failed: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useElementTransform } from './video-preview/video-preview-element-trans
|
|||||||
import { getImageSource, getTextFontStyle } from './video-preview/video-preview-utils';
|
import { getImageSource, getTextFontStyle } from './video-preview/video-preview-utils';
|
||||||
|
|
||||||
const VideoPreview = ({
|
const VideoPreview = ({
|
||||||
|
watermarked,
|
||||||
// Dimensions
|
// Dimensions
|
||||||
dimensions,
|
dimensions,
|
||||||
|
|
||||||
@@ -242,6 +243,27 @@ const VideoPreview = ({
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Watermark - only show when watermarked is true */}
|
||||||
|
{watermarked && (
|
||||||
|
<Text
|
||||||
|
text="MEMEAIGEN.COM"
|
||||||
|
x={dimensions.width / 2}
|
||||||
|
y={dimensions.height / 2 + dimensions.height * 0.2}
|
||||||
|
fontSize={20}
|
||||||
|
fontFamily="Bungee"
|
||||||
|
fill="white"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth={2}
|
||||||
|
opacity={0.5}
|
||||||
|
align="center"
|
||||||
|
verticalAlign="middle"
|
||||||
|
offsetX={90} // Approximate half-width to center the text
|
||||||
|
offsetY={5} // Approximate half-height to center the text
|
||||||
|
draggable={false}
|
||||||
|
listening={false} // Prevents any mouse interactions
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Guide Lines Layer */}
|
{/* Guide Lines Layer */}
|
||||||
{guideLines.showVertical && (
|
{guideLines.showVertical && (
|
||||||
<Line
|
<Line
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
|
|||||||
emitter.emit('video-reset');
|
emitter.emit('video-reset');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadButton = () => {
|
||||||
|
emitter.emit('video-open-download-modal');
|
||||||
|
};
|
||||||
|
|
||||||
const togglePlayPause = () => {
|
const togglePlayPause = () => {
|
||||||
if (videoIsPlaying) {
|
if (videoIsPlaying) {
|
||||||
handleReset();
|
handleReset();
|
||||||
@@ -50,7 +54,7 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
|
|||||||
<Edit3 className={`h-8 w-8 ${isEditActive ? 'text-white' : ''}`} />
|
<Edit3 className={`h-8 w-8 ${isEditActive ? 'text-white' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="outline" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
<Button onClick={handleDownloadButton} variant="outline" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||||
<Download className="h-8 w-8" />
|
<Download className="h-8 w-8" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,15 @@ export default defineConfig({
|
|||||||
'@': resolve(__dirname, 'resources/js'),
|
'@': resolve(__dirname, 'resources/js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
|
||||||
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
headers: {
|
||||||
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
|
'Cross-Origin-Embedder-Policy': 'require-corp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user