Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b93a1bd15 | ||
|
|
cc9fdb2fe8 | ||
|
|
b464398843 | ||
|
|
0c65e736c6 |
BIN
backgrounds/nature.mp4
Normal file
BIN
backgrounds/nature.mp4
Normal file
Binary file not shown.
BIN
backgrounds/space.mp4
Normal file
BIN
backgrounds/space.mp4
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -155,6 +155,9 @@ STATICFILES_DIRS = [
|
|||||||
BASE_DIR / 'node_modules',
|
BASE_DIR / 'node_modules',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
MEDIA_URL = '/outputs/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'outputs'
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_BACKEND = os.getenv(
|
EMAIL_BACKEND = os.getenv(
|
||||||
"EMAIL_BACKEND",
|
"EMAIL_BACKEND",
|
||||||
|
|||||||
@ -27,3 +27,4 @@ urlpatterns = [
|
|||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/video_engine.cpython-311.pyc
Normal file
BIN
core/__pycache__/video_engine.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -1,3 +1,8 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from .models import VideoTask
|
||||||
|
|
||||||
# Register your models here.
|
@admin.register(VideoTask)
|
||||||
|
class VideoTaskAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('surah_name', 'verse_start', 'verse_end', 'reciter_name', 'status', 'created_at')
|
||||||
|
list_filter = ('status', 'created_at')
|
||||||
|
search_fields = ('surah_name', 'reciter_name')
|
||||||
33
core/migrations/0001_initial.py
Normal file
33
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-02-23 08:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VideoTask',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('surah_number', models.IntegerField()),
|
||||||
|
('surah_name', models.CharField(max_length=255)),
|
||||||
|
('reciter_identifier', models.CharField(max_length=255)),
|
||||||
|
('reciter_name', models.CharField(max_length=255)),
|
||||||
|
('verse_start', models.IntegerField()),
|
||||||
|
('verse_end', models.IntegerField()),
|
||||||
|
('background_video', models.CharField(max_length=255)),
|
||||||
|
('text_color', models.CharField(default='#FFFFFF', max_length=20)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)),
|
||||||
|
('output_path', models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
('error_message', models.TextField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
@ -1,3 +1,26 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
# Create your models here.
|
class VideoTask(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('processing', 'Processing'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
surah_number = models.IntegerField()
|
||||||
|
surah_name = models.CharField(max_length=255)
|
||||||
|
reciter_identifier = models.CharField(max_length=255)
|
||||||
|
reciter_name = models.CharField(max_length=255)
|
||||||
|
verse_start = models.IntegerField()
|
||||||
|
verse_end = models.IntegerField()
|
||||||
|
background_video = models.CharField(max_length=255)
|
||||||
|
text_color = models.CharField(max_length=20, default='#FFFFFF')
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
|
output_path = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
error_message = models.TextField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.surah_name} ({self.verse_start}-{self.verse_end}) - {self.status}"
|
||||||
@ -1,145 +1,285 @@
|
|||||||
{% extends "base.html" %}
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ project_name }} - Quran Reels Generator</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Amiri&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-emerald: #064E3B;
|
||||||
|
--accent-gold: #D4AF37;
|
||||||
|
--soft-cream: #FCF8F1;
|
||||||
|
--text-dark: #1F2937;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--soft-cream);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
h1, h2, h3, .arabic {
|
||||||
|
font-family: 'Amiri', serif;
|
||||||
|
}
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--primary-emerald);
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 3px solid var(--accent-gold);
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--accent-gold) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, var(--primary-emerald) 0%, #065F46 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 4rem 0;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
background: white;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
background-color: white;
|
||||||
|
border-bottom: 1px solid #F3F4F6;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: var(--primary-emerald);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-emerald);
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #065F46;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-pending { background: #FEF3C7; color: #92400E; }
|
||||||
|
.status-processing { background: #DBEAFE; color: #1E40AF; }
|
||||||
|
.status-completed { background: #D1FAE5; color: #065F46; }
|
||||||
|
.status-failed { background: #FEE2E2; color: #991B1B; }
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% endblock %}
|
.form-label { font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.form-select, .form-control {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #D1D5DB;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
.video-preview-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.video-preview-btn:hover {
|
||||||
|
color: var(--primary-emerald);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
{% block head %}
|
<nav class="navbar navbar-dark">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<div class="container">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<a class="navbar-brand" href="#">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
{{ project_name }}
|
||||||
<style>
|
</a>
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main>
|
|
||||||
<div class="card">
|
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
|
||||||
<span class="sr-only">Loading…</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
</nav>
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
|
||||||
<p class="runtime">
|
<div class="hero">
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
<div class="container">
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
<h1>Create Beautiful Quran Reels</h1>
|
||||||
</p>
|
<p class="lead">Select verses, pick a reciter, and generate professional videos in seconds.</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer>
|
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<div class="container">
|
||||||
</footer>
|
<div class="row">
|
||||||
{% endblock %}
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Video Settings</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{% url 'generate_video' %}" method="POST" id="genForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Select Surah</label>
|
||||||
|
<select name="surah" id="surahSelect" class="form-select" required>
|
||||||
|
<option value="">Choose...</option>
|
||||||
|
{% for s in surahs %}
|
||||||
|
<option value="{{ s.number }}">{{ s.number }}. {{ s.englishName }} ({{ s.name }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Reciter</label>
|
||||||
|
<select name="reciter" class="form-select" required>
|
||||||
|
{% for r in reciters %}
|
||||||
|
<option value="{{ r.identifier }}" {% if r.identifier == "ar.alafasy" %}selected{% endif %}>{{ r.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Start Verse</label>
|
||||||
|
<input type="number" name="verse_start" id="verseStart" class="form-control" value="1" min="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">End Verse</label>
|
||||||
|
<input type="number" name="verse_end" id="verseEnd" class="form-control" value="5" min="1" required>
|
||||||
|
<small class="text-muted" id="maxAyahs">Max: -</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Background Video</label>
|
||||||
|
<select name="background" class="form-select" required>
|
||||||
|
{% for b in backgrounds %}
|
||||||
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Text Color</label>
|
||||||
|
<input type="color" name="text_color" class="form-control form-control-color w-100" value="#FFFFFF">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Generate Video</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Recent Generations</span>
|
||||||
|
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="location.reload()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for task in tasks %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<strong class="d-block">{{ task.surah_name }}</strong>
|
||||||
|
<small class="text-muted">Ayahs {{ task.verse_start }}-{{ task.verse_end }}</small>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-{{ task.status }}">
|
||||||
|
{{ task.status|title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if task.status == 'completed' %}
|
||||||
|
<div class="btn-group w-100 mt-2" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
onclick="previewVideo('{{ task.output_path }}', '{{ task.surah_name }} ({{ task.verse_start }}-{{ task.verse_end }})')">
|
||||||
|
<i class="bi bi-play-fill"></i> Preview
|
||||||
|
</button>
|
||||||
|
<a href="{{ task.output_path }}" class="btn btn-sm btn-outline-success" download>
|
||||||
|
<i class="bi bi-download"></i> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% elif task.status == 'failed' %}
|
||||||
|
<p class="text-danger small mt-1 mb-0"><i class="bi bi-exclamation-triangle"></i> {{ task.error_message|truncatechars:100 }}</p>
|
||||||
|
{% elif task.status == 'processing' or task.status == 'pending' %}
|
||||||
|
<div class="progress mt-2" style="height: 5px;">
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 100%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mt-1 mb-0">Processing... Please refresh in a moment.</p>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item text-center text-muted py-4">No tasks yet</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Preview Modal -->
|
||||||
|
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="videoModalLabel">Video Preview</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 bg-black">
|
||||||
|
<div class="ratio ratio-9x16">
|
||||||
|
<video id="previewPlayer" controls>
|
||||||
|
<source src="" type="video/mp4">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<a id="downloadBtn" href="" class="btn btn-success" download>Download Video</a>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.getElementById('surahSelect').addEventListener('change', function() {
|
||||||
|
const surahNum = this.value;
|
||||||
|
if (!surahNum) return;
|
||||||
|
|
||||||
|
fetch(`/surah-details/${surahNum}/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const max = data.numberOfAyahs;
|
||||||
|
document.getElementById('maxAyahs').textContent = `Max Ayahs: ${max}`;
|
||||||
|
document.getElementById('verseStart').max = max;
|
||||||
|
document.getElementById('verseEnd').max = max;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoModal = new bootstrap.Modal(document.getElementById('videoModal'));
|
||||||
|
const previewPlayer = document.getElementById('previewPlayer');
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
const modalTitle = document.getElementById('videoModalLabel');
|
||||||
|
|
||||||
|
function previewVideo(url, title) {
|
||||||
|
previewPlayer.src = url;
|
||||||
|
downloadBtn.href = url;
|
||||||
|
modalTitle.textContent = "Preview: " + title;
|
||||||
|
videoModal.show();
|
||||||
|
previewPlayer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop video when modal is closed
|
||||||
|
document.getElementById('videoModal').addEventListener('hidden.bs.modal', function () {
|
||||||
|
previewPlayer.pause();
|
||||||
|
previewPlayer.src = "";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,7 +1,8 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from .views import home, generate_video_view, get_surah_details
|
||||||
from .views import home
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
|
path("generate/", generate_video_view, name="generate_video"),
|
||||||
|
path("surah-details/<int:surah_number>/", get_surah_details, name="surah_details"),
|
||||||
]
|
]
|
||||||
164
core/video_engine.py
Normal file
164
core/video_engine.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUTPUTS_DIR = WORKSPACE_ROOT / "outputs"
|
||||||
|
FINAL_VIDEO_DIR = OUTPUTS_DIR / "final_video"
|
||||||
|
TEMP_AUDIO_DIR = OUTPUTS_DIR / "temp_audio"
|
||||||
|
AUDIO_DIR = OUTPUTS_DIR / "audio"
|
||||||
|
BACKGROUNDS_DIR = WORKSPACE_ROOT / "backgrounds"
|
||||||
|
|
||||||
|
# Droid Sans Fallback often supports Arabic/CJK
|
||||||
|
FONT_ARABIC = "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"
|
||||||
|
# Fallback for English text
|
||||||
|
FONT_SANS = "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"
|
||||||
|
|
||||||
|
def escape_ffmpeg_text(text):
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Use ord() to check for backslash to avoid syntax errors with literals
|
||||||
|
result = ""
|
||||||
|
for char in text:
|
||||||
|
if ord(char) == 92: # Backslash
|
||||||
|
result += "\\"
|
||||||
|
elif char == "'":
|
||||||
|
result += "'\\''"
|
||||||
|
elif char == ":":
|
||||||
|
result += ":"
|
||||||
|
else:
|
||||||
|
result += char
|
||||||
|
return result
|
||||||
|
|
||||||
|
def ensure_bg_video(bg_path):
|
||||||
|
"""Ensures a background video exists and is not empty. Creates a placeholder if needed."""
|
||||||
|
if not bg_path.exists() or bg_path.stat().st_size == 0:
|
||||||
|
bg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Create a 5-second 720x1280 (vertical) placeholder video
|
||||||
|
color = "black"
|
||||||
|
if "nature" in bg_path.name:
|
||||||
|
color = "#064E3B" # Deep Emerald
|
||||||
|
elif "space" in bg_path.name:
|
||||||
|
color = "#1E1B4B" # Deep Indigo
|
||||||
|
|
||||||
|
subprocess.run([
|
||||||
|
'ffmpeg', '-y', '-f', 'lavfi', '-i', f'color=c={color}:s=720x1280:d=5',
|
||||||
|
'-pix_fmt', 'yuv420p', str(bg_path)
|
||||||
|
], check=True)
|
||||||
|
|
||||||
|
def get_audio_duration(file_path):
|
||||||
|
result = subprocess.run([
|
||||||
|
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1', str(file_path)
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
return float(result.stdout.strip())
|
||||||
|
|
||||||
|
def generate_video(task_id):
|
||||||
|
from core.models import VideoTask
|
||||||
|
task = VideoTask.objects.get(id=task_id)
|
||||||
|
task.status = 'processing'
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure directories exist
|
||||||
|
for d in [FINAL_VIDEO_DIR, TEMP_AUDIO_DIR, AUDIO_DIR, BACKGROUNDS_DIR]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Fetch Verses Text and Audio
|
||||||
|
verses_data = []
|
||||||
|
audio_files = []
|
||||||
|
|
||||||
|
api_url = f"https://api.alquran.cloud/v1/surah/{task.surah_number}/{task.reciter_identifier}"
|
||||||
|
resp = requests.get(api_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()['data']
|
||||||
|
|
||||||
|
all_ayahs = data['ayahs']
|
||||||
|
selected_ayahs = [a for a in all_ayahs if task.verse_start <= a['numberInSurah'] <= task.verse_end]
|
||||||
|
|
||||||
|
# Download audio files and collect text
|
||||||
|
for i, ayah in enumerate(selected_ayahs):
|
||||||
|
audio_url = ayah['audio']
|
||||||
|
audio_path = TEMP_AUDIO_DIR / f"task_{task.id}_ayah_{i}.mp3"
|
||||||
|
with requests.get(audio_url, stream=True) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(audio_path, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
audio_files.append(str(audio_path))
|
||||||
|
verses_data.append({
|
||||||
|
'text': ayah['text'],
|
||||||
|
'duration': get_audio_duration(audio_path)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Combine Audio
|
||||||
|
combined_audio = AUDIO_DIR / f"task_{task.id}_full.mp3"
|
||||||
|
filter_complex_audio = "".join([f"[{i}:a]" for i in range(len(audio_files))]) + f"concat=n={len(audio_files)}:v=0:a=1[a]"
|
||||||
|
cmd = ['ffmpeg', '-y']
|
||||||
|
for f in audio_files:
|
||||||
|
cmd.extend(['-i', f])
|
||||||
|
cmd.extend(['-filter_complex', filter_complex_audio, '-map', '[a]', str(combined_audio)])
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
total_duration = get_audio_duration(combined_audio)
|
||||||
|
|
||||||
|
# 3. Generate Video with FFmpeg
|
||||||
|
bg_video = BACKGROUNDS_DIR / (task.background_video or "nature.mp4")
|
||||||
|
ensure_bg_video(bg_video)
|
||||||
|
|
||||||
|
output_video = FINAL_VIDEO_DIR / f"reels_{task.id}.mp4"
|
||||||
|
|
||||||
|
# Build drawtext filters
|
||||||
|
drawtext_filters = []
|
||||||
|
|
||||||
|
# 1. Surah Name at top
|
||||||
|
escaped_surah = escape_ffmpeg_text(task.surah_name)
|
||||||
|
drawtext_filters.append(
|
||||||
|
f"drawtext=text='{escaped_surah}':fontfile={FONT_SANS}:fontcolor={task.text_color}:fontsize=48:x=(w-text_w)/2:y=100:shadowcolor=black:shadowx=2:shadowy=2"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Verse text in middle (sequential)
|
||||||
|
current_time = 0
|
||||||
|
for verse in verses_data:
|
||||||
|
escaped_text = escape_ffmpeg_text(verse['text'])
|
||||||
|
start = current_time
|
||||||
|
end = current_time + verse['duration']
|
||||||
|
|
||||||
|
font_to_use = FONT_ARABIC if os.path.exists(FONT_ARABIC) else FONT_SANS
|
||||||
|
|
||||||
|
drawtext_filters.append(
|
||||||
|
f"drawtext=text='{escaped_text}':fontfile={font_to_use}:fontcolor={task.text_color}:fontsize=42:x=(w-text_w)/2:y=(h-text_h)/2:shadowcolor=black:shadowx=2:shadowy=2:enable='between(t,{start},{end})'"
|
||||||
|
)
|
||||||
|
current_time += verse['duration']
|
||||||
|
|
||||||
|
vf_chain = [
|
||||||
|
"scale=720:1280:force_original_aspect_ratio=increase",
|
||||||
|
"crop=720:1280",
|
||||||
|
*drawtext_filters
|
||||||
|
]
|
||||||
|
vf_string = ",".join(vf_chain)
|
||||||
|
|
||||||
|
subprocess.run([
|
||||||
|
'ffmpeg', '-y',
|
||||||
|
'-stream_loop', '-1', '-i', str(bg_video),
|
||||||
|
'-i', str(combined_audio),
|
||||||
|
'-t', str(total_duration),
|
||||||
|
'-map', '0:v:0', '-map', '1:a:0',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-vf', vf_string,
|
||||||
|
str(output_video)
|
||||||
|
], check=True)
|
||||||
|
|
||||||
|
task.status = 'completed'
|
||||||
|
task.output_path = f"/outputs/final_video/{output_video.name}"
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
task.status = 'failed'
|
||||||
|
task.error_message = str(e)
|
||||||
|
task.save()
|
||||||
|
import traceback
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise e
|
||||||
@ -1,25 +1,86 @@
|
|||||||
import os
|
import os
|
||||||
import platform
|
import requests
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
from django import get_version as django_version
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import render
|
from .models import VideoTask
|
||||||
from django.utils import timezone
|
from .video_engine import generate_video
|
||||||
|
import threading
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
"""Render the landing screen with loader and environment details."""
|
# Fetch surahs and reciters for the form
|
||||||
host_name = request.get_host().lower()
|
surahs = []
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
reciters = []
|
||||||
now = timezone.now()
|
try:
|
||||||
|
surah_resp = requests.get("https://api.alquran.cloud/v1/surah", timeout=5)
|
||||||
|
if surah_resp.status_code == 200:
|
||||||
|
surahs = surah_resp.json()['data']
|
||||||
|
|
||||||
|
reciter_resp = requests.get("https://api.alquran.cloud/v1/edition?format=audio&language=ar&type=versebyverse", timeout=5)
|
||||||
|
if reciter_resp.status_code == 200:
|
||||||
|
reciters = reciter_resp.json()['data']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
backgrounds = []
|
||||||
|
if os.path.exists('backgrounds'):
|
||||||
|
backgrounds = [f for f in os.listdir('backgrounds') if f.endswith(('.mp4', '.mov'))]
|
||||||
|
|
||||||
|
if not backgrounds:
|
||||||
|
backgrounds = ["nature.mp4"]
|
||||||
|
|
||||||
|
tasks = VideoTask.objects.all().order_by('-created_at')[:10]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
"surahs": surahs,
|
||||||
"agent_brand": agent_brand,
|
"reciters": reciters,
|
||||||
"django_version": django_version(),
|
"backgrounds": backgrounds,
|
||||||
"python_version": platform.python_version(),
|
"tasks": tasks,
|
||||||
"current_time": now,
|
"project_name": "Quran Reels Gen",
|
||||||
"host_name": host_name,
|
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, "core/index.html", context)
|
||||||
|
|
||||||
|
def generate_video_view(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
surah_number = request.POST.get('surah')
|
||||||
|
reciter = request.POST.get('reciter')
|
||||||
|
verse_start = request.POST.get('verse_start')
|
||||||
|
verse_end = request.POST.get('verse_end')
|
||||||
|
background = request.POST.get('background')
|
||||||
|
text_color = request.POST.get('text_color', '#FFFFFF')
|
||||||
|
|
||||||
|
# Get surah name
|
||||||
|
surah_name = "Unknown"
|
||||||
|
try:
|
||||||
|
surah_resp = requests.get(f"https://api.alquran.cloud/v1/surah/{surah_number}", timeout=5)
|
||||||
|
if surah_resp.status_code == 200:
|
||||||
|
surah_name = surah_resp.json()['data']['englishName']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
task = VideoTask.objects.create(
|
||||||
|
surah_number=surah_number,
|
||||||
|
surah_name=surah_name,
|
||||||
|
reciter_identifier=reciter,
|
||||||
|
reciter_name=reciter,
|
||||||
|
verse_start=verse_start,
|
||||||
|
verse_end=verse_end,
|
||||||
|
background_video=background,
|
||||||
|
text_color=text_color,
|
||||||
|
status='pending'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run generation in background thread
|
||||||
|
thread = threading.Thread(target=generate_video, args=(task.id,))
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return redirect('home')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
def get_surah_details(request, surah_number):
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"https://api.alquran.cloud/v1/surah/{surah_number}", timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return JsonResponse(resp.json()['data'])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return JsonResponse({'error': 'Failed to fetch'}, status=400)
|
||||||
|
|||||||
BIN
outputs/audio/task_4_full.mp3
Normal file
BIN
outputs/audio/task_4_full.mp3
Normal file
Binary file not shown.
BIN
outputs/audio/task_5_full.mp3
Normal file
BIN
outputs/audio/task_5_full.mp3
Normal file
Binary file not shown.
BIN
outputs/audio/task_6_full.mp3
Normal file
BIN
outputs/audio/task_6_full.mp3
Normal file
Binary file not shown.
BIN
outputs/audio/task_7_full.mp3
Normal file
BIN
outputs/audio/task_7_full.mp3
Normal file
Binary file not shown.
BIN
outputs/final_video/reels_5.mp4
Normal file
BIN
outputs/final_video/reels_5.mp4
Normal file
Binary file not shown.
BIN
outputs/final_video/reels_6.mp4
Normal file
BIN
outputs/final_video/reels_6.mp4
Normal file
Binary file not shown.
BIN
outputs/final_video/reels_7.mp4
Normal file
BIN
outputs/final_video/reels_7.mp4
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_1_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_1_ayah_0.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_1_ayah_1.mp3
Normal file
BIN
outputs/temp_audio/task_1_ayah_1.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_1_ayah_2.mp3
Normal file
BIN
outputs/temp_audio/task_1_ayah_2.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_1_ayah_3.mp3
Normal file
BIN
outputs/temp_audio/task_1_ayah_3.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_1_ayah_4.mp3
Normal file
BIN
outputs/temp_audio/task_1_ayah_4.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_2_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_2_ayah_0.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_3_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_3_ayah_0.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_4_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_4_ayah_0.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_5_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_5_ayah_0.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_0.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_1.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_1.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_2.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_2.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_3.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_3.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_4.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_4.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_5.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_5.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_6_ayah_6.mp3
Normal file
BIN
outputs/temp_audio/task_6_ayah_6.mp3
Normal file
Binary file not shown.
BIN
outputs/temp_audio/task_7_ayah_0.mp3
Normal file
BIN
outputs/temp_audio/task_7_ayah_0.mp3
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user