Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +1249 -265
- index.html +1278 -300
app.py
CHANGED
|
@@ -1,287 +1,1271 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
try:
|
| 4 |
-
import
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
import
|
| 10 |
-
import
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
try:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
if success:
|
| 58 |
-
filename = os.path.basename(output_p)
|
| 59 |
-
download_url = f"/stream-and-delete/{filename}"
|
| 60 |
-
jobs[job_id] = {'status': 'success', 'url': download_url}
|
| 61 |
-
print(f"✅ Job {job_id} Complete!")
|
| 62 |
-
else:
|
| 63 |
-
jobs[job_id] = {'status': 'failed', 'message': f'Rendering Failed: {err_msg}'}
|
| 64 |
-
print(f"❌ Job {job_id} Failed! Reason: {err_msg}")
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
try:
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
if
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
except Exception as e:
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
#
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
session['user_id'] = str(uuid.uuid4())[:6]
|
| 110 |
-
return session['user_id']
|
| 111 |
|
| 112 |
-
#
|
|
|
|
|
|
|
| 113 |
|
| 114 |
@app.route('/')
|
| 115 |
-
def
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
|
|
|
| 121 |
try:
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
except Exception as e:
|
| 137 |
-
|
|
|
|
| 138 |
|
| 139 |
-
@app.route('/
|
| 140 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
try:
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
try:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
if
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
return jsonify(
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
try:
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
txt = analyze_script_with_ai(path)
|
| 213 |
-
return jsonify({'status':'success', 'translated_text': txt})
|
| 214 |
-
except Exception as e: return jsonify({'status':'error', 'message':str(e)})
|
| 215 |
-
|
| 216 |
-
@app.route('/process', methods=['POST'])
|
| 217 |
-
def start_process():
|
| 218 |
try:
|
| 219 |
-
d = request.
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
if
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
if d.get('ai_text'):
|
| 265 |
-
ap = os.path.join(UPLOAD_FOLDER, f"audio_{job_id}.mp3")
|
| 266 |
-
gender = d.get('voice_gender','male')
|
| 267 |
-
# utils.py သို့ srt_path ပါ ပို့ပေးခြင်း
|
| 268 |
-
if create_ai_audio(d.get('ai_text'), ap, gender, srt_path):
|
| 269 |
-
opts['ai_audio_path'] = ap
|
| 270 |
-
opts['ai_text'] = d.get('ai_text')
|
| 271 |
-
|
| 272 |
-
jobs[job_id] = {'status': 'queued'}
|
| 273 |
-
task_queue.put((job_id, ip, op, opts))
|
| 274 |
-
|
| 275 |
-
return jsonify({'status':'queued', 'job_id': job_id, 'message': 'Added to Queue'})
|
| 276 |
-
|
| 277 |
-
except Exception as e: return jsonify({'status':'error', 'message':str(e)})
|
| 278 |
-
|
| 279 |
-
@app.route('/status/<job_id>')
|
| 280 |
-
def check_status(job_id):
|
| 281 |
-
job = jobs.get(job_id)
|
| 282 |
-
if not job: return jsonify({'status': 'not_found'})
|
| 283 |
-
return jsonify(job)
|
| 284 |
|
| 285 |
if __name__ == '__main__':
|
| 286 |
-
|
| 287 |
-
app.run(debug=False, port=7860, host='0.0.0.0')
|
|
|
|
| 1 |
+
import os, json, hashlib, uuid, random, re, glob, shutil, subprocess, threading, time, struct, wave
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
|
| 4 |
+
# ══════════════════════════════════════════════
|
| 5 |
+
# STAGE-BASED PIPELINE SYSTEM
|
| 6 |
+
# ══════════════════════════════════════════════
|
| 7 |
+
|
| 8 |
+
# Per-stage locks — only 1 job per stage at a time for CPU-heavy stages
|
| 9 |
+
_stage_locks = {
|
| 10 |
+
'whisper': threading.Lock(),
|
| 11 |
+
'ai': threading.Semaphore(3), # 3 concurrent AI API calls ok
|
| 12 |
+
'tts': threading.Semaphore(2), # 2 concurrent TTS ok
|
| 13 |
+
'ffmpeg': threading.Lock(), # 1 ffmpeg at a time (CPU)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
# Last finish time per stage — enforce gap between jobs
|
| 17 |
+
_stage_last = {
|
| 18 |
+
'whisper': 0.0,
|
| 19 |
+
'ai': 0.0,
|
| 20 |
+
'tts': 0.0,
|
| 21 |
+
'ffmpeg': 0.0,
|
| 22 |
+
}
|
| 23 |
+
_stage_time_lock = threading.Lock()
|
| 24 |
+
|
| 25 |
+
# Minimum gap (seconds) between consecutive jobs per stage
|
| 26 |
+
STAGE_GAPS = {
|
| 27 |
+
'whisper': 3,
|
| 28 |
+
'ai': 2,
|
| 29 |
+
'tts': 2,
|
| 30 |
+
'ffmpeg': 4,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
def run_stage(name, fn, *args, **kwargs):
|
| 34 |
+
"""
|
| 35 |
+
Acquire the stage lock, wait out the gap since last job,
|
| 36 |
+
run fn(*args, **kwargs), record finish time, release lock.
|
| 37 |
+
Use this wrapper for every Whisper / AI / TTS / FFmpeg call.
|
| 38 |
+
"""
|
| 39 |
+
lock = _stage_locks[name]
|
| 40 |
+
lock.acquire()
|
| 41 |
+
try:
|
| 42 |
+
# Wait remaining gap since last job finished
|
| 43 |
+
gap = STAGE_GAPS.get(name, 0)
|
| 44 |
+
with _stage_time_lock:
|
| 45 |
+
elapsed = time.time() - _stage_last[name]
|
| 46 |
+
wait = max(0.0, gap - elapsed)
|
| 47 |
+
if wait > 0:
|
| 48 |
+
time.sleep(wait)
|
| 49 |
+
# Run the actual work
|
| 50 |
+
result = fn(*args, **kwargs)
|
| 51 |
+
# Record finish time
|
| 52 |
+
with _stage_time_lock:
|
| 53 |
+
_stage_last[name] = time.time()
|
| 54 |
+
return result
|
| 55 |
+
finally:
|
| 56 |
+
lock.release()
|
| 57 |
+
from datetime import datetime
|
| 58 |
+
from pathlib import Path
|
| 59 |
+
from flask import Flask, request, jsonify, send_from_directory, Response
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
from openai import OpenAI
|
| 63 |
+
except ImportError:
|
| 64 |
+
OpenAI = None
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
import whisper
|
| 68 |
+
except ImportError:
|
| 69 |
+
whisper = None
|
| 70 |
+
|
| 71 |
try:
|
| 72 |
+
import edge_tts, asyncio
|
| 73 |
+
except ImportError:
|
| 74 |
+
edge_tts = None
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
from google import genai as ggenai
|
| 78 |
+
from google.genai import types as gtypes
|
| 79 |
+
except ImportError:
|
| 80 |
+
ggenai = None
|
| 81 |
+
|
| 82 |
+
# ── APP SETUP ──
|
| 83 |
+
BASE_DIR = Path(__file__).parent
|
| 84 |
+
COOKIES_FILE = str(BASE_DIR / 'm_youtube_com_cookies.txt')
|
| 85 |
+
app = Flask(__name__)
|
| 86 |
+
|
| 87 |
+
# #5: YouTube/TikTok/Facebook/Instagram download — hard cap 720p
|
| 88 |
+
def ytdlp_download(out_tmpl, video_url, timeout=600):
|
| 89 |
+
"""yt-dlp download — hard cap 720p max, platform-aware, cookies, robust fallback."""
|
| 90 |
+
url_lower = video_url.lower()
|
| 91 |
+
is_tiktok = 'tiktok.com' in url_lower
|
| 92 |
+
is_facebook = 'facebook.com' in url_lower or 'fb.watch' in url_lower
|
| 93 |
+
is_instagram = 'instagram.com' in url_lower
|
| 94 |
+
|
| 95 |
+
if is_tiktok or is_facebook or is_instagram:
|
| 96 |
+
# These platforms don't always have mp4+m4a splits — use best available ≤720p
|
| 97 |
+
fmt = (
|
| 98 |
+
'bestvideo[height<=720]+bestaudio'
|
| 99 |
+
'/best[height<=720]'
|
| 100 |
+
'/best'
|
| 101 |
+
)
|
| 102 |
+
else:
|
| 103 |
+
# YouTube and others — prefer mp4+m4a for clean merge
|
| 104 |
+
fmt = (
|
| 105 |
+
'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]'
|
| 106 |
+
'/bestvideo[height<=720]+bestaudio'
|
| 107 |
+
'/best[height<=720][ext=mp4]'
|
| 108 |
+
'/best[height<=720]'
|
| 109 |
+
'/best[height<=480]'
|
| 110 |
+
'/best'
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
cmd = [
|
| 114 |
+
'yt-dlp', '--no-playlist',
|
| 115 |
+
'-f', fmt,
|
| 116 |
+
'--merge-output-format', 'mp4',
|
| 117 |
+
'--no-check-certificates',
|
| 118 |
+
]
|
| 119 |
+
if os.path.exists(COOKIES_FILE):
|
| 120 |
+
cmd += ['--cookies', COOKIES_FILE]
|
| 121 |
+
cmd += ['-o', out_tmpl, video_url]
|
| 122 |
+
print(f'[ytdlp] Running: {" ".join(cmd)}')
|
| 123 |
+
subprocess.run(cmd, check=True, timeout=timeout)
|
| 124 |
+
|
| 125 |
+
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
|
| 126 |
+
|
| 127 |
+
OUTPUT_DIR = BASE_DIR / 'outputs'
|
| 128 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 129 |
+
|
| 130 |
+
# ── JOB PROGRESS (for SSE real-time updates) ──
|
| 131 |
+
job_progress = {}
|
| 132 |
+
|
| 133 |
+
# ── CPU QUEUE — 5 second gap between jobs ──
|
| 134 |
+
_cpu_lock = threading.Lock()
|
| 135 |
+
_last_job_time = 0
|
| 136 |
+
|
| 137 |
+
def cpu_queue_wait():
|
| 138 |
+
"""Wait until 5 seconds have passed since last job started."""
|
| 139 |
+
global _last_job_time
|
| 140 |
+
with _cpu_lock:
|
| 141 |
+
now = time.time()
|
| 142 |
+
wait = max(0, 5.0 - (now - _last_job_time))
|
| 143 |
+
if wait > 0:
|
| 144 |
+
print(f'[CPU Queue] Waiting {wait:.1f}s before starting...')
|
| 145 |
+
time.sleep(wait)
|
| 146 |
+
_last_job_time = time.time()
|
| 147 |
+
|
| 148 |
+
# ── DB CONFIG ──
|
| 149 |
+
DB_FILE = str(BASE_DIR / 'users_db.json')
|
| 150 |
+
HF_TOKEN = os.getenv('HF_TOKEN', '')
|
| 151 |
+
HF_REPO = 'Phoe2004/MovieRecapDB'
|
| 152 |
+
ADMIN_U = os.getenv('ADMIN_USERNAME', 'Phoe')
|
| 153 |
+
ADMIN_P = os.getenv('ADMIN_PASSWORD', 'phoe1234')
|
| 154 |
+
|
| 155 |
+
GEMINI_KEYS = [os.getenv(f'GEMINI_API_KEY_{i}') for i in range(1, 11)]
|
| 156 |
+
DEEPSEEK_KEYS = [os.getenv('DEEPSEEK_API_KEY')]
|
| 157 |
+
|
| 158 |
+
_rr_idx = 0
|
| 159 |
+
_rr_lock = threading.Lock()
|
| 160 |
+
|
| 161 |
+
def next_gemini_key():
|
| 162 |
+
global _rr_idx
|
| 163 |
+
valid = [k for k in GEMINI_KEYS if k]
|
| 164 |
+
if not valid: return None, []
|
| 165 |
+
with _rr_lock:
|
| 166 |
+
idx = _rr_idx % len(valid)
|
| 167 |
+
_rr_idx += 1
|
| 168 |
+
primary = valid[idx]
|
| 169 |
+
ordered = valid[idx:] + valid[:idx]
|
| 170 |
+
return primary, ordered
|
| 171 |
+
|
| 172 |
+
# ── DB HELPERS ──
|
| 173 |
+
def pull_db():
|
| 174 |
+
if not HF_TOKEN:
|
| 175 |
+
print('⚠️ pull: HF_TOKEN missing')
|
| 176 |
+
return
|
| 177 |
+
try:
|
| 178 |
+
from huggingface_hub import hf_hub_download
|
| 179 |
+
import traceback
|
| 180 |
+
path = hf_hub_download(
|
| 181 |
+
repo_id=HF_REPO, filename='users_db.json', repo_type='dataset',
|
| 182 |
+
token=HF_TOKEN, local_dir=str(BASE_DIR), force_download=True,
|
| 183 |
+
)
|
| 184 |
+
dest = str(BASE_DIR / 'users_db.json')
|
| 185 |
+
if path != dest:
|
| 186 |
+
import shutil as _shutil
|
| 187 |
+
_shutil.copy2(path, dest)
|
| 188 |
+
print('✅ DB pulled from HuggingFace')
|
| 189 |
+
except Exception as e:
|
| 190 |
+
import traceback
|
| 191 |
+
print(f'⚠️ pull failed: {e}')
|
| 192 |
+
traceback.print_exc()
|
| 193 |
+
|
| 194 |
+
_push_lock = threading.Lock()
|
| 195 |
+
|
| 196 |
+
def push_db():
|
| 197 |
+
if not HF_TOKEN:
|
| 198 |
+
print('⚠️ push: HF_TOKEN missing')
|
| 199 |
+
return
|
| 200 |
+
with _push_lock:
|
| 201 |
+
for attempt in range(4):
|
| 202 |
+
try:
|
| 203 |
+
from huggingface_hub import HfApi
|
| 204 |
+
api = HfApi(token=HF_TOKEN)
|
| 205 |
+
api.upload_file(
|
| 206 |
+
path_or_fileobj=DB_FILE, path_in_repo='users_db.json',
|
| 207 |
+
repo_id=HF_REPO, repo_type='dataset',
|
| 208 |
+
commit_message=f'db {datetime.now().strftime("%Y%m%d_%H%M%S")}',
|
| 209 |
+
)
|
| 210 |
+
print(f'✅ DB pushed (attempt {attempt+1})')
|
| 211 |
+
return
|
| 212 |
+
except Exception as e:
|
| 213 |
+
print(f'⚠️ push attempt {attempt+1} failed: {e}')
|
| 214 |
+
if attempt < 3:
|
| 215 |
+
time.sleep(3 * (attempt + 1))
|
| 216 |
+
print('❌ push_db: all retries failed')
|
| 217 |
+
|
| 218 |
+
def load_db():
|
| 219 |
+
if not os.path.exists(DB_FILE): return {'users': {}}
|
| 220 |
+
try:
|
| 221 |
+
with open(DB_FILE, encoding='utf-8') as f: return json.load(f)
|
| 222 |
+
except: return {'users': {}}
|
| 223 |
+
|
| 224 |
+
def save_db(db):
|
| 225 |
+
with open(DB_FILE, 'w', encoding='utf-8') as f:
|
| 226 |
+
json.dump(db, f, ensure_ascii=False, indent=2)
|
| 227 |
+
threading.Thread(target=push_db, daemon=True).start()
|
| 228 |
+
|
| 229 |
+
def hp(p): return hashlib.sha256(p.encode()).hexdigest()
|
| 230 |
+
|
| 231 |
+
ADJ = ['Red','Blue','Gold','Star','Sky','Fire','Moon','Cool','Ice','Dark','Neon','Wild']
|
| 232 |
+
NOUN = ['Tiger','Dragon','Wolf','Hawk','Lion','Fox','Eagle','Storm','Flash','Ghost']
|
| 233 |
+
|
| 234 |
+
def gen_uname():
|
| 235 |
+
db = load_db()
|
| 236 |
+
for _ in range(60):
|
| 237 |
+
u = random.choice(ADJ)+random.choice(NOUN)+str(random.randint(10,999))
|
| 238 |
+
if u not in db['users']: return u
|
| 239 |
+
return 'User'+str(uuid.uuid4())[:6].upper()
|
| 240 |
+
|
| 241 |
+
def login_user(u, p):
|
| 242 |
+
if u == ADMIN_U and p == ADMIN_P: return True, '✅ Admin', -1
|
| 243 |
+
db = load_db()
|
| 244 |
+
if u not in db['users']: return False, '❌ Username not found', 0
|
| 245 |
+
stored = db['users'][u].get('password', '')
|
| 246 |
+
if stored and stored != hp(p): return False, '❌ Wrong password', 0
|
| 247 |
+
db['users'][u]['last_login'] = datetime.now().isoformat()
|
| 248 |
+
save_db(db)
|
| 249 |
+
return True, '✅ Logged in', db['users'][u]['coins']
|
| 250 |
+
|
| 251 |
+
def get_coins(u): return load_db()['users'].get(u, {}).get('coins', 0)
|
| 252 |
+
|
| 253 |
+
def deduct(u, n):
|
| 254 |
+
db = load_db()
|
| 255 |
+
if u not in db['users']: return False, 0
|
| 256 |
+
if db['users'][u]['coins'] < n: return False, db['users'][u]['coins']
|
| 257 |
+
db['users'][u]['coins'] -= n; save_db(db)
|
| 258 |
+
return True, db['users'][u]['coins']
|
| 259 |
+
|
| 260 |
+
def add_coins_fn(u, n):
|
| 261 |
+
db = load_db()
|
| 262 |
+
if u not in db['users']: return '❌ User not found'
|
| 263 |
+
db['users'][u]['coins'] += int(n); save_db(db)
|
| 264 |
+
return f"✅ +{n} → {db['users'][u]['coins']} 🪙"
|
| 265 |
+
|
| 266 |
+
def set_coins_fn(u, n):
|
| 267 |
+
db = load_db()
|
| 268 |
+
if u not in db['users']: return '❌ User not found'
|
| 269 |
+
db['users'][u]['coins'] = int(n); save_db(db)
|
| 270 |
+
return f'✅ Coin = {n} 🪙'
|
| 271 |
+
|
| 272 |
+
def upd_stat(u, t):
|
| 273 |
+
db = load_db()
|
| 274 |
+
if u not in db['users']: return
|
| 275 |
+
k = 'total_transcripts' if t == 'tr' else 'total_videos'
|
| 276 |
+
db['users'][u][k] = db['users'][u].get(k, 0) + 1; save_db(db)
|
| 277 |
+
|
| 278 |
+
def create_user_fn(uname, coins, caller):
|
| 279 |
+
if caller != ADMIN_U: return '❌ Not admin', ''
|
| 280 |
+
uname = (uname or '').strip() or gen_uname()
|
| 281 |
+
db = load_db()
|
| 282 |
+
if uname in db['users']: return f"❌ '{uname}' already exists", ''
|
| 283 |
+
db['users'][uname] = {'password': '', 'coins': int(coins),
|
| 284 |
+
'created_at': datetime.now().isoformat(), 'last_login': None,
|
| 285 |
+
'total_transcripts': 0, 'total_videos': 0}
|
| 286 |
+
save_db(db); return f"✅ '{uname}' created", uname
|
| 287 |
+
|
| 288 |
+
# ── AI ──
|
| 289 |
+
|
| 290 |
+
# ── Language-aware system prompts ──
|
| 291 |
+
def get_sys_prompt(ct, vo_lang='my'):
|
| 292 |
+
"""
|
| 293 |
+
vo_lang: 'my' = Myanmar (default), 'th' = Thai, 'en' = English
|
| 294 |
+
"""
|
| 295 |
+
if vo_lang == 'th':
|
| 296 |
+
# Thai language prompts
|
| 297 |
+
if ct == 'Medical/Health':
|
| 298 |
+
return (
|
| 299 |
+
"คุณคือผู้แปลด้านการแพทย์ภาษาไทย — ภาษาไทยที่พูดในชีวิตประจำวัน\n"
|
| 300 |
+
"Rules: 100% ภาษาไทย | ไม่ใช้ภาษาทางการมากเกินไป | เนื้อหาต้นฉบับเท่านั้น\n"
|
| 301 |
+
"ใช้ตัวเลขไทย: 1=หนึ่ง, 2=สอง, 10=สิบ, 100=ร้อย, 1000=พัน\n"
|
| 302 |
+
"Format EXACTLY:\n[SCRIPT](full thai script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #สุขภาพ #thailand #health #viral #trending)"
|
| 303 |
+
)
|
| 304 |
+
else:
|
| 305 |
+
return (
|
| 306 |
+
"คุณคือนักเขียนสคริปต์สรุปหนังภาษาไทย — เล่าแบบสนุก ภาษาพูดธรรมชาติ\n"
|
| 307 |
+
"Rules: 100% ภาษาไทย | ไม่ใช้ภาษาทางการ | เนื้อหาต้นฉบับเท่านั้น\n"
|
| 308 |
+
"ใช้ตัวเลขไทย: 1=หนึ่ง, 2=สอง, 10=สิบ, 100=ร้อย, 1000=พัน\n"
|
| 309 |
+
"แปลเนื้อหาต่อไปนี้เป็นภาษาไทย (สไตล์เล่าเรื่อง movie recap ที่สนุก)\n"
|
| 310 |
+
"ตอบเป็นภาษาไทยเท่านั้น ห้ามมีภาษาอังกฤษในสคริปต์\n"
|
| 311 |
+
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #thailand"
|
| 312 |
+
)
|
| 313 |
+
elif vo_lang == 'en':
|
| 314 |
+
# English language prompts
|
| 315 |
+
if ct == 'Medical/Health':
|
| 316 |
+
return (
|
| 317 |
+
"You are an English medical content translator — use clear, conversational English.\n"
|
| 318 |
+
"Rules: 100% English | conversational tone | original content only\n"
|
| 319 |
+
"Write numbers as words: 1=one, 2=two, 10=ten, 100=one hundred\n"
|
| 320 |
+
"Format EXACTLY:\n[SCRIPT](full english script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #health #medical #wellness #viral #trending)"
|
| 321 |
+
)
|
| 322 |
+
else:
|
| 323 |
+
return (
|
| 324 |
+
"You are an English movie recap script writer — engaging storytelling tone, conversational.\n"
|
| 325 |
+
"Rules: 100% English | conversational not formal | original content only\n"
|
| 326 |
+
"Write numbers as words: 1=one, 2=two, 10=ten, 100=one hundred\n"
|
| 327 |
+
"Translate and retell the following content in English (movie recap storytelling style)\n"
|
| 328 |
+
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #english"
|
| 329 |
+
)
|
| 330 |
+
else:
|
| 331 |
+
# Myanmar (default)
|
| 332 |
+
if ct == 'Medical/Health':
|
| 333 |
+
return (
|
| 334 |
+
"မြန်မာ ဆေးဘက် ဘာသာပြန်သူ — spoken Myanmar\n"
|
| 335 |
+
"Rules: 100% မြန်မာ | ကျောင်းသုံးစာပေမသုံးရ | ပုဒ်မတိုင်း ။\n"
|
| 336 |
+
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
|
| 337 |
+
"Format EXACTLY:\n[SCRIPT](full myanmar script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #ကျန်းမာရေး #myanmar #health #viral #trending)"
|
| 338 |
+
)
|
| 339 |
+
else:
|
| 340 |
+
return (
|
| 341 |
+
"မြန်မာ movie recap script ရေးသားသူ — spoken Myanmar (နေ့စဉ်ပြောဆိုမှုဘာသာ)\n"
|
| 342 |
+
"Rules: 100% မြန်မာဘာသာ | ကျောင်းသုံးစာပေမသုံးရ | မူလcontent သာ | ပုဒ်မတိုင်း ။\n"
|
| 343 |
+
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
|
| 344 |
+
"Translate the following content into Burmese (storytelling tone movie recap tone and keep original content)\n"
|
| 345 |
+
"မြန်မာလိုပဲ ဖြေပေးပါ။ အင်္ဂလိပ်လို ဘာမှမပြန်နဲ့။အင်္ဂလိပ်စကားလုံးတွေကိုတွေ့ရင်လည်း မြန်မာလိုပဲ ဘာသာပြန်ပြီး ဖြေပေးပါ)\n"
|
| 346 |
+
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #မြန်မာ"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Keep legacy constants for backward compat
|
| 350 |
+
SYS_MOVIE = get_sys_prompt('Movie Recap', 'my')
|
| 351 |
+
SYS_MED = get_sys_prompt('Medical/Health', 'my')
|
| 352 |
+
|
| 353 |
+
NUM_TO_MM_RULE = (
|
| 354 |
+
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — "
|
| 355 |
+
"ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, "
|
| 356 |
+
"100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ။"
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
def get_num_rule(vo_lang='my'):
|
| 360 |
+
if vo_lang == 'th':
|
| 361 |
+
return "ใช้ตัวเลขไทยเท่านั้น: 1=หนึ่ง, 2=สอง, 10=สิบ, 20=ยี่สิบ, 100=ร้อย, 1000=พัน ห้ามใช้ตัวเลขอารบิก"
|
| 362 |
+
elif vo_lang == 'en':
|
| 363 |
+
return "Write all numbers as English words: 1=one, 2=two, 10=ten, 20=twenty, 100=one hundred, 1000=one thousand — no Arabic digits."
|
| 364 |
+
else:
|
| 365 |
+
return NUM_TO_MM_RULE
|
| 366 |
+
|
| 367 |
+
def call_api(msgs, api='Gemini'):
|
| 368 |
+
if api == 'DeepSeek':
|
| 369 |
+
keys, base, mdl = DEEPSEEK_KEYS, 'https://api.deepseek.com', 'deepseek-chat'
|
| 370 |
+
else:
|
| 371 |
+
keys, base, mdl = GEMINI_KEYS, 'https://generativelanguage.googleapis.com/v1beta/openai/', 'gemini-2.5-flash'
|
| 372 |
+
valid = [(i+1, k) for i, k in enumerate(keys) if k]
|
| 373 |
+
if not valid: raise Exception('No API Key available')
|
| 374 |
+
if api == 'Gemini':
|
| 375 |
+
_, ordered = next_gemini_key()
|
| 376 |
+
valid = sorted(valid, key=lambda x: ordered.index(x[1]) if x[1] in ordered else 99)
|
| 377 |
+
else:
|
| 378 |
+
random.shuffle(valid)
|
| 379 |
+
time.sleep(2)
|
| 380 |
+
for n, k in valid:
|
| 381 |
try:
|
| 382 |
+
r = OpenAI(api_key=k, base_url=base, timeout=600.0).chat.completions.create(
|
| 383 |
+
model=mdl, messages=msgs, max_tokens=8192)
|
| 384 |
+
if r and r.choices and r.choices[0].message.content:
|
| 385 |
+
return r.choices[0].message.content.strip(), f'✅ Key{n}'
|
| 386 |
+
except: continue
|
| 387 |
+
raise Exception('❌ All API keys failed')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
+
def parse_out(text):
|
| 390 |
+
sc, ti, ht = '', '', ''
|
| 391 |
+
m = re.search(r'\[SCRIPT\](.*?)\[TITLE\]', text, re.DOTALL)
|
| 392 |
+
if m: sc = m.group(1).strip()
|
| 393 |
+
m2 = re.search(r'\[TITLE\](.*?)(\[HASHTAGS\]|$)', text, re.DOTALL)
|
| 394 |
+
m3 = re.search(r'\[HASHTAGS\](.*?)$', text, re.DOTALL)
|
| 395 |
+
if m2: ti = m2.group(1).strip()
|
| 396 |
+
if m3: ht = m3.group(1).strip()
|
| 397 |
+
if not sc: sc = re.sub(r'\[SCRIPT\]|\[TITLE\]|\[HASHTAGS\]', '', text.split('[TITLE]')[0]).strip()
|
| 398 |
+
tags = re.findall(r'#\S+', ht)
|
| 399 |
+
if len(tags) < 5:
|
| 400 |
+
defaults = ['#myanmar','#viral','#trending','#foryou','#entertainment']
|
| 401 |
+
tags = tags + [t for t in defaults if t not in tags]
|
| 402 |
+
ht = ' '.join(tags[:5])
|
| 403 |
+
return sc, ti, ht
|
| 404 |
+
|
| 405 |
+
def split_txt(txt, vo_lang='my'):
|
| 406 |
+
if vo_lang == 'th':
|
| 407 |
+
parts = re.split(r'[。\n]', txt)
|
| 408 |
+
return [s.strip() for s in parts if s.strip()] or [txt]
|
| 409 |
+
elif vo_lang == 'en':
|
| 410 |
+
parts = re.split(r'(?<=[.!?])\s+', txt)
|
| 411 |
+
return [s.strip() for s in parts if s.strip()] or [txt]
|
| 412 |
+
else:
|
| 413 |
+
return [s.strip() + '။' for s in re.split(r'[။]', txt) if s.strip()] or [txt]
|
| 414 |
+
|
| 415 |
+
def dur(fp):
|
| 416 |
+
try:
|
| 417 |
+
r = subprocess.run(
|
| 418 |
+
f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{fp}"',
|
| 419 |
+
shell=True, capture_output=True, text=True)
|
| 420 |
+
return float(r.stdout.strip())
|
| 421 |
+
except: return 0
|
| 422 |
+
|
| 423 |
+
# ── ASYNC HELPERS ──
|
| 424 |
+
def run_tts_sync(sentences, voice_id, rate, tmp_dir):
|
| 425 |
+
async def _run():
|
| 426 |
+
sil = f'{tmp_dir}/sil.mp3'
|
| 427 |
+
proc = await asyncio.create_subprocess_shell(
|
| 428 |
+
f'ffmpeg -f lavfi -i anullsrc=r=24000:cl=mono -t 0.4 -c:a libmp3lame -q:a 2 "{sil}" -y',
|
| 429 |
+
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
| 430 |
+
await proc.wait()
|
| 431 |
+
parts = []
|
| 432 |
+
for i, s in enumerate(sentences):
|
| 433 |
+
raw = f'{tmp_dir}/r{i:03d}.mp3'
|
| 434 |
+
await edge_tts.Communicate(s, voice_id, rate=rate).save(raw)
|
| 435 |
+
parts += [raw, sil]
|
| 436 |
+
return parts
|
| 437 |
+
loop = asyncio.new_event_loop()
|
| 438 |
+
try:
|
| 439 |
+
return loop.run_until_complete(_run())
|
| 440 |
+
finally:
|
| 441 |
+
loop.close()
|
| 442 |
+
|
| 443 |
+
def run_edge_preview(voice_id, rate, out_path):
|
| 444 |
+
# Choose preview text based on voice language
|
| 445 |
+
if voice_id.startswith('th-'):
|
| 446 |
+
text = 'สวัสดีครับ ยินดีต้อนรับ'
|
| 447 |
+
elif voice_id.startswith('en-'):
|
| 448 |
+
text = 'Hello, welcome to Recap Studio.'
|
| 449 |
+
else:
|
| 450 |
+
text = 'မင်္ဂလာပါ။ ကြိုဆိုပါတယ်။'
|
| 451 |
+
async def _run():
|
| 452 |
+
await edge_tts.Communicate(text, voice_id, rate=rate).save(out_path)
|
| 453 |
+
loop = asyncio.new_event_loop()
|
| 454 |
+
try:
|
| 455 |
+
loop.run_until_complete(_run())
|
| 456 |
+
finally:
|
| 457 |
+
loop.close()
|
| 458 |
+
|
| 459 |
+
# ── GEMINI TTS ──
|
| 460 |
+
def _get_gemini_client():
|
| 461 |
+
if ggenai is None:
|
| 462 |
+
raise Exception('google-genai package not installed')
|
| 463 |
+
valid_keys = [k for k in GEMINI_KEYS if k]
|
| 464 |
+
if not valid_keys:
|
| 465 |
+
raise Exception('No Gemini API Key')
|
| 466 |
+
random.shuffle(valid_keys)
|
| 467 |
+
return ggenai.Client(api_key=valid_keys[0]), valid_keys
|
| 468 |
+
|
| 469 |
+
def _save_pcm_as_wav(pcm_data, wav_path, sample_rate=24000, channels=1, sample_width=2):
|
| 470 |
+
with wave.open(wav_path, 'wb') as wf:
|
| 471 |
+
wf.setnchannels(channels)
|
| 472 |
+
wf.setsampwidth(sample_width)
|
| 473 |
+
wf.setframerate(sample_rate)
|
| 474 |
+
wf.writeframes(pcm_data)
|
| 475 |
+
|
| 476 |
+
def _wav_to_mp3(wav_path, mp3_path):
|
| 477 |
+
subprocess.run(
|
| 478 |
+
f'ffmpeg -y -i "{wav_path}" -c:a libmp3lame -q:a 2 "{mp3_path}"',
|
| 479 |
+
shell=True, check=True, capture_output=True)
|
| 480 |
+
|
| 481 |
+
def run_gemini_tts_sync(sentences, voice_name, tmp_dir, speed=0):
|
| 482 |
+
if ggenai is None:
|
| 483 |
+
raise Exception('google-genai package not installed')
|
| 484 |
+
_, ordered_keys = next_gemini_key()
|
| 485 |
+
if not ordered_keys:
|
| 486 |
+
raise Exception('No Gemini API Key')
|
| 487 |
+
time.sleep(2)
|
| 488 |
+
full_script = ' '.join(sentences)
|
| 489 |
+
wav_out = f'{tmp_dir}/gemini_raw.wav'
|
| 490 |
+
mp3_raw = f'{tmp_dir}/gemini_raw.mp3'
|
| 491 |
+
mp3_out = f'{tmp_dir}/gemini_final.mp3'
|
| 492 |
+
last_err = None
|
| 493 |
+
for api_key in ordered_keys:
|
| 494 |
try:
|
| 495 |
+
client = ggenai.Client(api_key=api_key)
|
| 496 |
+
response = client.models.generate_content(
|
| 497 |
+
model="gemini-2.5-flash-preview-tts",
|
| 498 |
+
contents=full_script,
|
| 499 |
+
config=gtypes.GenerateContentConfig(
|
| 500 |
+
response_modalities=["AUDIO"],
|
| 501 |
+
speech_config=gtypes.SpeechConfig(
|
| 502 |
+
voice_config=gtypes.VoiceConfig(
|
| 503 |
+
prebuilt_voice_config=gtypes.PrebuiltVoiceConfig(
|
| 504 |
+
voice_name=voice_name or "Kore"
|
| 505 |
+
)
|
| 506 |
+
)
|
| 507 |
+
)
|
| 508 |
+
)
|
| 509 |
+
)
|
| 510 |
+
audio_data = None
|
| 511 |
+
if response.candidates:
|
| 512 |
+
for part in response.candidates[0].content.parts:
|
| 513 |
+
if part.inline_data and part.inline_data.mime_type.startswith('audio/'):
|
| 514 |
+
audio_data = part.inline_data.data
|
| 515 |
+
break
|
| 516 |
+
if not audio_data:
|
| 517 |
+
raise Exception('Gemini TTS: no audio data received')
|
| 518 |
+
_save_pcm_as_wav(audio_data, wav_out)
|
| 519 |
+
_wav_to_mp3(wav_out, mp3_raw)
|
| 520 |
+
try: os.remove(wav_out)
|
| 521 |
+
except: pass
|
| 522 |
+
tempo = max(0.5, min(2.0, 1.0 + speed / 100.0))
|
| 523 |
+
if abs(tempo - 1.0) < 0.02:
|
| 524 |
+
import shutil as _sh; _sh.copy2(mp3_raw, mp3_out)
|
| 525 |
+
else:
|
| 526 |
+
subprocess.run(
|
| 527 |
+
f'ffmpeg -y -i "{mp3_raw}" -af "atempo={tempo:.3f}" -c:a libmp3lame -q:a 2 "{mp3_out}"',
|
| 528 |
+
shell=True, check=True, capture_output=True)
|
| 529 |
+
try: os.remove(mp3_raw)
|
| 530 |
+
except: pass
|
| 531 |
+
print(f'✅ Gemini TTS done, key=...{api_key[-6:]}, tempo={tempo:.2f}x')
|
| 532 |
+
return [mp3_out]
|
| 533 |
except Exception as e:
|
| 534 |
+
last_err = e
|
| 535 |
+
print(f'⚠️ Gemini TTS key failed: {e}')
|
| 536 |
+
continue
|
| 537 |
+
raise Exception(f'❌ Gemini TTS all keys failed: {last_err}')
|
| 538 |
|
| 539 |
+
def run_gemini_preview(voice_name, out_path):
|
| 540 |
+
if ggenai is None:
|
| 541 |
+
raise Exception('google-genai package not installed')
|
| 542 |
+
_, ordered_keys = next_gemini_key()
|
| 543 |
+
if not ordered_keys:
|
| 544 |
+
raise Exception('No Gemini API Key')
|
| 545 |
+
wav_path = out_path.replace('.mp3', '.wav')
|
| 546 |
+
for api_key in ordered_keys:
|
| 547 |
+
try:
|
| 548 |
+
client = ggenai.Client(api_key=api_key)
|
| 549 |
+
response = client.models.generate_content(
|
| 550 |
+
model="gemini-2.5-flash-preview-tts",
|
| 551 |
+
contents="မင်္ဂလာပါ။ ဒီနေ့ ဘာများ လုပ်မလဲ။",
|
| 552 |
+
config=gtypes.GenerateContentConfig(
|
| 553 |
+
response_modalities=["AUDIO"],
|
| 554 |
+
speech_config=gtypes.SpeechConfig(
|
| 555 |
+
voice_config=gtypes.VoiceConfig(
|
| 556 |
+
prebuilt_voice_config=gtypes.PrebuiltVoiceConfig(
|
| 557 |
+
voice_name=voice_name or "Kore"
|
| 558 |
+
)
|
| 559 |
+
)
|
| 560 |
+
)
|
| 561 |
+
)
|
| 562 |
+
)
|
| 563 |
+
audio_data = None
|
| 564 |
+
if response.candidates:
|
| 565 |
+
for part in response.candidates[0].content.parts:
|
| 566 |
+
if part.inline_data and part.inline_data.mime_type.startswith('audio/'):
|
| 567 |
+
audio_data = part.inline_data.data
|
| 568 |
+
break
|
| 569 |
+
if not audio_data:
|
| 570 |
+
raise Exception('Gemini TTS preview: no audio data')
|
| 571 |
+
_save_pcm_as_wav(audio_data, wav_path)
|
| 572 |
+
_wav_to_mp3(wav_path, out_path)
|
| 573 |
+
try: os.remove(wav_path)
|
| 574 |
+
except: pass
|
| 575 |
+
return
|
| 576 |
+
except Exception as e:
|
| 577 |
+
print(f'⚠️ Gemini preview key failed: {e}')
|
| 578 |
+
continue
|
| 579 |
+
raise Exception('❌ Gemini TTS preview: all keys failed')
|
| 580 |
|
| 581 |
+
# ── PULL DB ON START ──
|
| 582 |
+
threading.Thread(target=pull_db, daemon=True).start()
|
| 583 |
+
whisper_model = None
|
|
|
|
|
|
|
| 584 |
|
| 585 |
+
# ════════════════════════════════════════
|
| 586 |
+
# ROUTES
|
| 587 |
+
# ════════════════════════════════════════
|
| 588 |
|
| 589 |
@app.route('/')
|
| 590 |
+
def index():
|
| 591 |
+
return send_from_directory(str(BASE_DIR), 'index.html')
|
| 592 |
+
|
| 593 |
+
@app.route('/outputs/<path:fn>')
|
| 594 |
+
def serve_output(fn):
|
| 595 |
+
return send_from_directory(str(OUTPUT_DIR), fn)
|
| 596 |
|
| 597 |
+
# ── AUTH ──
|
| 598 |
+
@app.route('/api/login', methods=['POST'])
|
| 599 |
+
def api_login():
|
| 600 |
try:
|
| 601 |
+
d = request.get_json(force=True) or {}
|
| 602 |
+
ok, msg, coins = login_user(d.get('username',''), d.get('password',''))
|
| 603 |
+
return jsonify(ok=ok, msg=msg, coins=coins, is_admin=(d.get('username','')==ADMIN_U and ok))
|
| 604 |
+
except Exception as e:
|
| 605 |
+
return jsonify(ok=False, msg=str(e))
|
| 606 |
|
| 607 |
+
@app.route('/api/register', methods=['POST'])
|
| 608 |
+
def api_register():
|
| 609 |
+
try:
|
| 610 |
+
d = request.get_json(force=True) or {}
|
| 611 |
+
uname = (d.get('username') or '').strip() or gen_uname()
|
| 612 |
+
pw = d.get('password', '')
|
| 613 |
+
db = load_db()
|
| 614 |
+
if uname in db['users']: return jsonify(ok=False, msg='❌ Already exists')
|
| 615 |
+
db['users'][uname] = {'password': hp(pw) if pw else '', 'coins': 5,
|
| 616 |
+
'created_at': datetime.now().isoformat(), 'last_login': None,
|
| 617 |
+
'total_transcripts': 0, 'total_videos': 0}
|
| 618 |
+
save_db(db)
|
| 619 |
+
return jsonify(ok=True, msg=f'✅ {uname} created', username=uname, coins=5)
|
| 620 |
+
except Exception as e:
|
| 621 |
+
return jsonify(ok=False, msg=str(e))
|
| 622 |
|
| 623 |
+
@app.route('/api/preview_voice', methods=['POST'])
|
| 624 |
+
def api_preview_voice():
|
| 625 |
+
try:
|
| 626 |
+
d = request.get_json(force=True) or {}
|
| 627 |
+
voice_id = d.get('voice', 'my-MM-ThihaNeural')
|
| 628 |
+
speed = int(d.get('speed', 30))
|
| 629 |
+
engine = d.get('engine', 'ms')
|
| 630 |
+
out = str(OUTPUT_DIR / f'preview_{uuid.uuid4().hex[:8]}.mp3')
|
| 631 |
+
if engine == 'gemini':
|
| 632 |
+
run_gemini_preview(voice_id, out)
|
| 633 |
+
else:
|
| 634 |
+
run_edge_preview(voice_id, f'+{speed}%', out)
|
| 635 |
+
return jsonify(ok=True, url='/outputs/' + Path(out).name)
|
| 636 |
except Exception as e:
|
| 637 |
+
import traceback; traceback.print_exc()
|
| 638 |
+
return jsonify(ok=False, msg=str(e))
|
| 639 |
|
| 640 |
+
@app.route('/api/gemini_voices')
|
| 641 |
+
def api_gemini_voices():
|
| 642 |
+
voices = [
|
| 643 |
+
{"id": "Kore", "name": "Kore (Female, Firm)"},
|
| 644 |
+
{"id": "Charon", "name": "Charon (Male, Informative)"},
|
| 645 |
+
{"id": "Fenrir", "name": "Fenrir (Male, Excitable)"},
|
| 646 |
+
{"id": "Leda", "name": "Leda (Female, Youthful)"},
|
| 647 |
+
{"id": "Orus", "name": "Orus (Male, Firm)"},
|
| 648 |
+
{"id": "Puck", "name": "Puck (Male, Upbeat)"},
|
| 649 |
+
{"id": "Aoede", "name": "Aoede (Female, Breezy)"},
|
| 650 |
+
{"id": "Zephyr", "name": "Zephyr (Female, Bright)"},
|
| 651 |
+
{"id": "Achelois", "name": "Achelois (Female, Soft)"},
|
| 652 |
+
{"id": "Pegasus", "name": "Pegasus (Male, Confident)"},
|
| 653 |
+
{"id": "Perseus", "name": "Perseus (Male, Casual)"},
|
| 654 |
+
{"id": "Schedar", "name": "Schedar (Male, Even-keeled)"},
|
| 655 |
+
]
|
| 656 |
+
return jsonify(ok=True, voices=voices)
|
| 657 |
+
|
| 658 |
+
# ── DRAFT ──
|
| 659 |
+
@app.route('/api/draft', methods=['POST'])
|
| 660 |
+
def api_draft():
|
| 661 |
+
global whisper_model
|
| 662 |
try:
|
| 663 |
+
u = (request.form.get('username') or '').strip()
|
| 664 |
+
video_url = (request.form.get('video_url') or '').strip()
|
| 665 |
+
ct = request.form.get('content_type', 'Movie Recap')
|
| 666 |
+
api = request.form.get('ai_model', 'Gemini')
|
| 667 |
+
vo_lang = request.form.get('vo_lang', 'my') # 'my', 'th', 'en'
|
| 668 |
+
video_file = request.files.get('video_file')
|
| 669 |
+
|
| 670 |
+
if not u: return jsonify(ok=False, msg='❌ Not logged in')
|
| 671 |
+
is_adm = (u == ADMIN_U)
|
| 672 |
+
if not is_adm and get_coins(u) < 1:
|
| 673 |
+
return jsonify(ok=False, msg='❌ Not enough coins')
|
| 674 |
+
|
| 675 |
+
cpu_queue_wait()
|
| 676 |
+
|
| 677 |
+
tid = uuid.uuid4().hex[:8]
|
| 678 |
+
tmp_dir = str(BASE_DIR / f'temp_{tid}')
|
| 679 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 680 |
+
vpath = None
|
| 681 |
+
|
| 682 |
+
try:
|
| 683 |
+
if video_file and video_file.filename:
|
| 684 |
+
vpath = f'{tmp_dir}/input.mp4'
|
| 685 |
+
video_file.save(vpath)
|
| 686 |
+
elif video_url:
|
| 687 |
+
out_tmpl = f'{tmp_dir}/input.%(ext)s'
|
| 688 |
+
ytdlp_download(out_tmpl, video_url)
|
| 689 |
+
found = glob.glob(f'{tmp_dir}/input.*')
|
| 690 |
+
if found: vpath = found[0]
|
| 691 |
+
if not vpath: return jsonify(ok=False, msg='❌ No video selected')
|
| 692 |
+
|
| 693 |
+
if whisper is None: raise Exception('whisper not installed')
|
| 694 |
+
if whisper_model is None:
|
| 695 |
+
whisper_model = whisper.load_model('tiny', device='cpu')
|
| 696 |
+
res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
|
| 697 |
+
tr = res['text']; lang = res.get('language', 'en')
|
| 698 |
+
|
| 699 |
+
if vo_lang == 'en':
|
| 700 |
+
# English — skip AI API, return whisper transcript directly
|
| 701 |
+
sc = tr.strip()
|
| 702 |
+
ti = sc[:60].strip() + ('…' if len(sc) > 60 else '')
|
| 703 |
+
ht = '#english #movierecap #viral #foryou #trending'
|
| 704 |
+
key_n = 'Whisper Direct'
|
| 705 |
+
else:
|
| 706 |
+
sys_p = get_sys_prompt(ct, vo_lang)
|
| 707 |
+
sys_p = sys_p + '\n' + get_num_rule(vo_lang)
|
| 708 |
+
out_txt, key_n = run_stage('ai', call_api,
|
| 709 |
+
[{'role':'system','content':sys_p},
|
| 710 |
+
{'role':'user','content':f'Language:{lang}\n\n{tr}'}], api=api)
|
| 711 |
+
sc, ti, ht = parse_out(out_txt)
|
| 712 |
+
|
| 713 |
+
rem = -1
|
| 714 |
+
if not is_adm: _, rem = deduct(u, 1); upd_stat(u, 'tr')
|
| 715 |
+
return jsonify(ok=True, script=sc, title=ti, hashtags=ht,
|
| 716 |
+
status=f'{key_n} · {lang}', coins=rem)
|
| 717 |
+
finally:
|
| 718 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 719 |
+
|
| 720 |
+
except Exception as e:
|
| 721 |
+
return jsonify(ok=False, msg=f'❌ {e}')
|
| 722 |
+
|
| 723 |
+
# ── #7: Audio filter — louder, cleaner voice (no hiss/air noise) ──
|
| 724 |
+
def _build_audio_filter(mpath, ad):
|
| 725 |
+
"""
|
| 726 |
+
Voice louder and cleaner - works on ALL inputs (URL + local upload).
|
| 727 |
+
- highpass=f=100 : cut low rumble / breath noise
|
| 728 |
+
- lowpass=f=10000 : cut high hiss / sibilance
|
| 729 |
+
- dynaudnorm : dynamic normalization (no two-pass, any input format)
|
| 730 |
+
- volume=3.5 : extra loudness boost
|
| 731 |
+
"""
|
| 732 |
+
voice_chain = 'highpass=f=100,lowpass=f=10000,dynaudnorm=p=0.9:m=100:s=12,volume=3.5'
|
| 733 |
+
if mpath:
|
| 734 |
+
return (f'[1:a]{voice_chain}[nar];'
|
| 735 |
+
f'[2:a]volume=0.10,afade=t=out:st={max(0,ad-2):.3f}:d=2[bgm];'
|
| 736 |
+
f'[nar][bgm]amix=inputs=2:duration=first:dropout_transition=2[outa]')
|
| 737 |
+
else:
|
| 738 |
+
return f'[1:a]{voice_chain}[outa]'
|
| 739 |
+
|
| 740 |
+
# ── Mid-section Audio Sync Correction ──
|
| 741 |
+
def _get_mid_range(duration):
|
| 742 |
+
"""
|
| 743 |
+
Return (start_ratio, end_ratio) for middle section based on total duration.
|
| 744 |
+
"""
|
| 745 |
+
if duration < 180: # < 3 min
|
| 746 |
+
return 0.30, 0.70
|
| 747 |
+
elif duration < 300: # 3–5 min
|
| 748 |
+
return 0.25, 0.75
|
| 749 |
+
elif duration < 600: # 5–10 min
|
| 750 |
+
return 0.20, 0.80
|
| 751 |
+
else: # > 10 min
|
| 752 |
+
return 0.15, 0.85
|
| 753 |
+
|
| 754 |
+
def _fix_mid_sync(audio_path, video_dur, audio_dur, tmp_dir):
|
| 755 |
+
"""
|
| 756 |
+
Split audio into 3 parts: head / middle / tail.
|
| 757 |
+
Apply atempo correction ONLY to middle part if drift > 0.2s.
|
| 758 |
+
Recombine and return new audio path (or original if no fix needed).
|
| 759 |
+
Pitch is preserved (atempo only, no asetrate).
|
| 760 |
+
"""
|
| 761 |
+
drift = audio_dur - video_dur
|
| 762 |
+
if abs(drift) <= 0.2:
|
| 763 |
+
print(f'[sync] drift={drift:.3f}s ≤ 0.2s — skip mid-sync')
|
| 764 |
+
return audio_path
|
| 765 |
+
|
| 766 |
+
s_ratio, e_ratio = _get_mid_range(audio_dur)
|
| 767 |
+
t_start = audio_dur * s_ratio
|
| 768 |
+
t_end = audio_dur * e_ratio
|
| 769 |
+
mid_dur = t_end - t_start
|
| 770 |
+
|
| 771 |
+
# Target mid duration after correction
|
| 772 |
+
# We want total audio ≈ video_dur
|
| 773 |
+
# head + mid_corrected + tail = video_dur
|
| 774 |
+
head_dur = t_start
|
| 775 |
+
tail_dur = audio_dur - t_end
|
| 776 |
+
mid_target = video_dur - head_dur - tail_dur
|
| 777 |
+
|
| 778 |
+
if mid_target <= 0:
|
| 779 |
+
print(f'[sync] mid_target invalid ({mid_target:.3f}s) — skip')
|
| 780 |
+
return audio_path
|
| 781 |
+
|
| 782 |
+
tempo = mid_dur / mid_target
|
| 783 |
+
# atempo range: 0.5 ~ 2.0 (chain if needed)
|
| 784 |
+
tempo = max(0.5, min(2.0, tempo))
|
| 785 |
+
|
| 786 |
+
print(f'[sync] drift={drift:.3f}s | mid {t_start:.2f}s~{t_end:.2f}s | tempo={tempo:.4f}x')
|
| 787 |
+
|
| 788 |
+
head_f = f'{tmp_dir}/sync_head.mp3'
|
| 789 |
+
mid_f = f'{tmp_dir}/sync_mid.mp3'
|
| 790 |
+
tail_f = f'{tmp_dir}/sync_tail.mp3'
|
| 791 |
+
mid_fx = f'{tmp_dir}/sync_mid_fx.mp3'
|
| 792 |
+
out_f = f'{tmp_dir}/sync_fixed.mp3'
|
| 793 |
+
lst_f = f'{tmp_dir}/sync_list.txt'
|
| 794 |
+
|
| 795 |
+
try:
|
| 796 |
+
# Cut head
|
| 797 |
+
subprocess.run(
|
| 798 |
+
f'ffmpeg -y -i "{audio_path}" -ss 0 -t {t_start:.6f} '
|
| 799 |
+
f'-c:a libmp3lame -q:a 2 "{head_f}"',
|
| 800 |
+
shell=True, check=True, capture_output=True)
|
| 801 |
+
|
| 802 |
+
# Cut middle
|
| 803 |
+
subprocess.run(
|
| 804 |
+
f'ffmpeg -y -i "{audio_path}" -ss {t_start:.6f} -t {mid_dur:.6f} '
|
| 805 |
+
f'-c:a libmp3lame -q:a 2 "{mid_f}"',
|
| 806 |
+
shell=True, check=True, capture_output=True)
|
| 807 |
+
|
| 808 |
+
# Cut tail
|
| 809 |
+
subprocess.run(
|
| 810 |
+
f'ffmpeg -y -i "{audio_path}" -ss {t_end:.6f} '
|
| 811 |
+
f'-c:a libmp3lame -q:a 2 "{tail_f}"',
|
| 812 |
+
shell=True, check=True, capture_output=True)
|
| 813 |
+
|
| 814 |
+
# Apply atempo to middle (pitch unchanged)
|
| 815 |
+
subprocess.run(
|
| 816 |
+
f'ffmpeg -y -i "{mid_f}" -af "atempo={tempo:.6f}" '
|
| 817 |
+
f'-c:a libmp3lame -q:a 2 "{mid_fx}"',
|
| 818 |
+
shell=True, check=True, capture_output=True)
|
| 819 |
+
|
| 820 |
+
# Concat head + mid_fixed + tail
|
| 821 |
+
with open(lst_f, 'w') as lf:
|
| 822 |
+
for f in [head_f, mid_fx, tail_f]:
|
| 823 |
+
if os.path.exists(f) and os.path.getsize(f) > 0:
|
| 824 |
+
lf.write(f"file '{os.path.abspath(f)}'\n")
|
| 825 |
+
subprocess.run(
|
| 826 |
+
f'ffmpeg -y -f concat -safe 0 -i "{lst_f}" '
|
| 827 |
+
f'-c:a libmp3lame -q:a 2 "{out_f}"',
|
| 828 |
+
shell=True, check=True, capture_output=True)
|
| 829 |
+
|
| 830 |
+
print(f'[sync] mid-sync done → {out_f}')
|
| 831 |
+
return out_f
|
| 832 |
+
|
| 833 |
+
except Exception as e:
|
| 834 |
+
print(f'[sync] mid-sync failed: {e} — using original audio')
|
| 835 |
+
return audio_path
|
| 836 |
+
|
| 837 |
+
# ── #6: Video render — smaller output file ──
|
| 838 |
+
def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
|
| 839 |
+
logo_path=None, logo_x=10, logo_y=10, logo_w=80,
|
| 840 |
+
blur_enabled=False, blur_x=0, blur_y=0, blur_w=0, blur_h=0):
|
| 841 |
+
|
| 842 |
+
raw_ratio = ad / vd
|
| 843 |
+
|
| 844 |
+
# ── Step 1: Pre-process video — resize + fix even dims ──
|
| 845 |
+
pre_out = vpath + '_pre.mp4'
|
| 846 |
+
pre_cmd = (
|
| 847 |
+
f'ffmpeg -y -hide_banner -loglevel error '
|
| 848 |
+
f'-i "{vpath}" '
|
| 849 |
+
f'-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" '
|
| 850 |
+
f'-c:v libx264 -crf 18 -preset ultrafast -pix_fmt yuv420p '
|
| 851 |
+
f'-an "{pre_out}"'
|
| 852 |
+
)
|
| 853 |
+
subprocess.run(pre_cmd, shell=True, check=True)
|
| 854 |
+
|
| 855 |
+
# ── Step 2: Precise sync calculation ──
|
| 856 |
+
sync_ratio = max(0.8, min(1.5, raw_ratio))
|
| 857 |
+
need_loop = raw_ratio > 1.5
|
| 858 |
+
need_trim = raw_ratio < 0.8
|
| 859 |
+
|
| 860 |
+
# ── Step 3: Build video filters ──
|
| 861 |
+
base = []
|
| 862 |
+
if need_loop:
|
| 863 |
+
loop_times = int(ad / vd) + 2
|
| 864 |
+
base.append(f'loop={loop_times}:size=32767:start=0,trim=duration={ad:.3f},setpts=PTS-STARTPTS')
|
| 865 |
+
elif need_trim:
|
| 866 |
+
base.append(f'trim=duration={ad:.3f},setpts=PTS-STARTPTS')
|
| 867 |
+
else:
|
| 868 |
+
base.append(f'setpts={sync_ratio:.6f}*PTS')
|
| 869 |
+
if flip: base.append('hflip')
|
| 870 |
+
if col: base.append('eq=brightness=0.06:contrast=1.2:saturation=1.4')
|
| 871 |
+
base.append('scale=iw:ih')
|
| 872 |
+
base.append('format=yuv420p')
|
| 873 |
+
base_str = ','.join(base)
|
| 874 |
+
|
| 875 |
+
if crop == '9:16':
|
| 876 |
+
vbase = (
|
| 877 |
+
f'[0:v]{base_str},split[s1][s2];'
|
| 878 |
+
f'[s1]scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,boxblur=20:20[bg];'
|
| 879 |
+
f'[s2]scale=720:1280:force_original_aspect_ratio=decrease[fg];'
|
| 880 |
+
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 881 |
+
)
|
| 882 |
+
elif crop == '16:9':
|
| 883 |
+
vbase = (
|
| 884 |
+
f'[0:v]{base_str},split[s1][s2];'
|
| 885 |
+
f'[s1]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=20:20[bg];'
|
| 886 |
+
f'[s2]scale=1280:720:force_original_aspect_ratio=decrease[fg];'
|
| 887 |
+
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 888 |
+
)
|
| 889 |
+
elif crop == '1:1':
|
| 890 |
+
vbase = (
|
| 891 |
+
f'[0:v]{base_str},split[s1][s2];'
|
| 892 |
+
f'[s1]scale=720:720:force_original_aspect_ratio=increase,crop=720:720,boxblur=20:20[bg];'
|
| 893 |
+
f'[s2]scale=720:720:force_original_aspect_ratio=decrease[fg];'
|
| 894 |
+
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 895 |
+
)
|
| 896 |
+
else:
|
| 897 |
+
vbase = f'[0:v]{base_str}'
|
| 898 |
+
|
| 899 |
+
# ── Blur / Delogo filter (subtitle hide) ──
|
| 900 |
+
if blur_enabled and blur_w > 0 and blur_h > 0:
|
| 901 |
+
vbase = f'{vbase},delogo=x={blur_x}:y={blur_y}:w={blur_w}:h={blur_h}'
|
| 902 |
+
|
| 903 |
+
# ── Logo + Watermark — build as single continuous chain, no label re-use ──
|
| 904 |
+
logo_idx = None
|
| 905 |
+
if logo_path and os.path.exists(logo_path):
|
| 906 |
+
logo_idx = 2 if not mpath else 3
|
| 907 |
+
|
| 908 |
+
if wmk and logo_idx is not None:
|
| 909 |
+
cn = wmk.replace("'","").replace("\\","").replace(":","")
|
| 910 |
+
vff = (
|
| 911 |
+
f'{vbase},'
|
| 912 |
+
f'drawtext=text=\'{cn}\':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2'
|
| 913 |
+
f'[vwmk];'
|
| 914 |
+
f'[{logo_idx}:v]scale={logo_w}:-1[logo];'
|
| 915 |
+
f'[vwmk][logo]overlay={logo_x}:{logo_y}[outv]'
|
| 916 |
+
)
|
| 917 |
+
elif wmk:
|
| 918 |
+
cn = wmk.replace("'","").replace("\\","").replace(":","")
|
| 919 |
+
vff = (
|
| 920 |
+
f'{vbase},'
|
| 921 |
+
f'drawtext=text=\'{cn}\':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2'
|
| 922 |
+
f'[outv]'
|
| 923 |
+
)
|
| 924 |
+
elif logo_idx is not None:
|
| 925 |
+
vff = (
|
| 926 |
+
f'{vbase}[vbase];'
|
| 927 |
+
f'[{logo_idx}:v]scale={logo_w}:-1[logo];'
|
| 928 |
+
f'[vbase][logo]overlay={logo_x}:{logo_y}[outv]'
|
| 929 |
+
)
|
| 930 |
+
else:
|
| 931 |
+
vff = f'{vbase}[outv]'
|
| 932 |
+
|
| 933 |
+
af = _build_audio_filter(mpath, ad)
|
| 934 |
+
|
| 935 |
+
inp = f'-fflags +genpts+igndts -err_detect ignore_err -i "{pre_out}" -i "{cmb}"'
|
| 936 |
+
if mpath:
|
| 937 |
+
inp += f' -stream_loop -1 -i "{mpath}"'
|
| 938 |
+
if logo_idx is not None:
|
| 939 |
+
inp += f' -i "{logo_path}"'
|
| 940 |
+
|
| 941 |
+
cmd = (
|
| 942 |
+
f'nice -n 10 ffmpeg -y -hide_banner -loglevel error {inp} '
|
| 943 |
+
f'-filter_complex "{vff};{af}" '
|
| 944 |
+
f'-map "[outv]" -map "[outa]" '
|
| 945 |
+
f'-c:v libx264 -crf 26 -preset medium -pix_fmt yuv420p '
|
| 946 |
+
f'-c:a aac -ar 44100 -b:a 128k '
|
| 947 |
+
f'-t {ad:.3f} -movflags +faststart "{out_file}"'
|
| 948 |
+
)
|
| 949 |
+
try:
|
| 950 |
+
run_stage('ffmpeg', subprocess.run, cmd, shell=True, check=True)
|
| 951 |
+
finally:
|
| 952 |
+
try: os.remove(pre_out)
|
| 953 |
+
except: pass
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
# ── PROCESS ──
|
| 957 |
+
@app.route('/api/process', methods=['POST'])
|
| 958 |
+
def api_process():
|
| 959 |
+
try:
|
| 960 |
+
u = (request.form.get('username') or '').strip()
|
| 961 |
+
video_url = (request.form.get('video_url') or '').strip()
|
| 962 |
+
sc = (request.form.get('script') or '').strip()
|
| 963 |
+
voice_id = request.form.get('voice', 'my-MM-ThihaNeural')
|
| 964 |
+
engine = request.form.get('engine', 'ms')
|
| 965 |
+
spd = int(request.form.get('speed', 30))
|
| 966 |
+
wmk = request.form.get('watermark', '')
|
| 967 |
+
crop = request.form.get('crop', '9:16')
|
| 968 |
+
flip = request.form.get('flip', '0') == '1'
|
| 969 |
+
col = request.form.get('color', '0') == '1'
|
| 970 |
+
vo_lang = request.form.get('vo_lang', 'my')
|
| 971 |
+
# Speed default per language (can be overridden by slider)
|
| 972 |
+
LANG_SPD = {'th': 20, 'en': 0, 'my': 30}
|
| 973 |
+
if request.form.get('speed') is None:
|
| 974 |
+
spd = LANG_SPD.get(vo_lang, 30)
|
| 975 |
+
is_adm = (u == ADMIN_U)
|
| 976 |
+
if not is_adm and get_coins(u) < 1:
|
| 977 |
+
return jsonify(ok=False, msg='❌ Not enough coins')
|
| 978 |
+
|
| 979 |
+
cpu_queue_wait()
|
| 980 |
+
|
| 981 |
+
tid = uuid.uuid4().hex[:8]
|
| 982 |
+
tmp_dir = str(BASE_DIR / f'temp_{tid}')
|
| 983 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 984 |
+
out_file = str(OUTPUT_DIR / f'final_{tid}.mp4')
|
| 985 |
+
vpath = None; mpath = None
|
| 986 |
+
|
| 987 |
+
try:
|
| 988 |
+
if video_file and video_file.filename:
|
| 989 |
+
vpath = f'{tmp_dir}/input.mp4'
|
| 990 |
+
video_file.save(vpath)
|
| 991 |
+
elif video_url:
|
| 992 |
+
out_tmpl = f'{tmp_dir}/input.%(ext)s'
|
| 993 |
+
ytdlp_download(out_tmpl, video_url)
|
| 994 |
+
found = glob.glob(f'{tmp_dir}/input.*')
|
| 995 |
+
if found: vpath = found[0]
|
| 996 |
+
if not vpath: return jsonify(ok=False, msg='❌ No video selected')
|
| 997 |
+
|
| 998 |
+
if music_file and music_file.filename:
|
| 999 |
+
mpath = f'{tmp_dir}/music.mp3'
|
| 1000 |
+
music_file.save(mpath)
|
| 1001 |
+
|
| 1002 |
+
logo_path = None
|
| 1003 |
+
logo_file = request.files.get('logo_file')
|
| 1004 |
+
logo_x = int(request.form.get('logo_x', 10))
|
| 1005 |
+
logo_y = int(request.form.get('logo_y', 10))
|
| 1006 |
+
logo_w = int(request.form.get('logo_w', 80))
|
| 1007 |
+
if logo_file and logo_file.filename:
|
| 1008 |
+
ext = Path(logo_file.filename).suffix or '.png'
|
| 1009 |
+
logo_path = f'{tmp_dir}/logo{ext}'
|
| 1010 |
+
logo_file.save(logo_path)
|
| 1011 |
+
blur_enabled = request.form.get('blur_enabled') == '1'
|
| 1012 |
+
blur_x = int(request.form.get('blur_x', 0))
|
| 1013 |
+
blur_y = int(request.form.get('blur_y', 0))
|
| 1014 |
+
blur_w = int(request.form.get('blur_w', 0))
|
| 1015 |
+
blur_h = int(request.form.get('blur_h', 0))
|
| 1016 |
+
if engine == 'gemini':
|
| 1017 |
+
parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
|
| 1018 |
+
else:
|
| 1019 |
+
parts = run_stage('tts', run_tts_sync, sentences, voice_id, rate, tmp_dir)
|
| 1020 |
+
|
| 1021 |
+
cmb = f'{tmp_dir}/combined.mp3'
|
| 1022 |
+
lst = f'{tmp_dir}/list.txt'
|
| 1023 |
+
with open(lst, 'w') as f:
|
| 1024 |
+
for a in parts: f.write(f"file '{os.path.abspath(a)}'\n")
|
| 1025 |
+
subprocess.run(
|
| 1026 |
+
f'ffmpeg -y -f concat -safe 0 -i "{lst}" '
|
| 1027 |
+
f'-af "silenceremove=start_periods=1:stop_periods=-1:stop_duration=0.1:stop_threshold=-50dB" '
|
| 1028 |
+
f'-c:a libmp3lame -q:a 2 "{cmb}"', shell=True, check=True)
|
| 1029 |
+
|
| 1030 |
+
vd = dur(vpath); ad = dur(cmb)
|
| 1031 |
+
if vd <= 0: raise Exception('Video duration read failed')
|
| 1032 |
+
if ad <= 0: raise Exception('Audio duration read failed')
|
| 1033 |
+
|
| 1034 |
+
_build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
|
| 1035 |
+
logo_path=logo_path, logo_x=logo_x, logo_y=logo_y, logo_w=logo_w,
|
| 1036 |
+
blur_enabled=blur_enabled, blur_x=blur_x, blur_y=blur_y, blur_w=blur_w, blur_h=blur_h)
|
| 1037 |
+
return jsonify(ok=True, output_url=f'/outputs/final_{tid}.mp4', coins=rem)
|
| 1038 |
+
|
| 1039 |
+
finally:
|
| 1040 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 1041 |
+
|
| 1042 |
+
except Exception as e:
|
| 1043 |
+
import traceback; traceback.print_exc()
|
| 1044 |
+
return jsonify(ok=False, msg=f'❌ {e}')
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
# ── PROCESS ALL ──
|
| 1048 |
+
@app.route('/api/progress/<tid>')
|
| 1049 |
+
def api_progress(tid):
|
| 1050 |
+
def generate():
|
| 1051 |
+
sent_done = False
|
| 1052 |
+
for _ in range(1800): # 1800 × 0.4s = 12 minutes max
|
| 1053 |
+
p = job_progress.get(tid)
|
| 1054 |
+
if p is None:
|
| 1055 |
+
yield f"data: {json.dumps({'pct':0,'msg':'Please wait…'})}\n\n"
|
| 1056 |
+
else:
|
| 1057 |
+
yield f"data: {json.dumps(p)}\n\n"
|
| 1058 |
+
if p.get('done') or p.get('error'):
|
| 1059 |
+
sent_done = True
|
| 1060 |
+
break
|
| 1061 |
+
time.sleep(0.4)
|
| 1062 |
+
if not sent_done:
|
| 1063 |
+
yield f"data: {json.dumps({'pct':0,'msg':'Timeout — process took too long','error':True})}\n\n"
|
| 1064 |
+
return Response(generate(), mimetype='text/event-stream',
|
| 1065 |
+
headers={'Cache-Control':'no-cache','X-Accel-Buffering':'no'})
|
| 1066 |
+
|
| 1067 |
+
@app.route('/api/process_all', methods=['POST'])
|
| 1068 |
+
def api_process_all():
|
| 1069 |
+
global whisper_model
|
| 1070 |
try:
|
| 1071 |
+
u = (request.form.get('username') or '').strip()
|
| 1072 |
+
video_url = (request.form.get('video_url') or '').strip()
|
| 1073 |
+
voice_id = request.form.get('voice', 'my-MM-ThihaNeural')
|
| 1074 |
+
engine = request.form.get('engine', 'ms')
|
| 1075 |
+
spd = int(request.form.get('speed', 30))
|
| 1076 |
+
wmk = request.form.get('watermark', '')
|
| 1077 |
+
crop = request.form.get('crop', '9:16')
|
| 1078 |
+
flip = request.form.get('flip', '0') == '1'
|
| 1079 |
+
col = request.form.get('color', '0') == '1'
|
| 1080 |
+
ct = request.form.get('content_type', 'Movie Recap')
|
| 1081 |
+
api = request.form.get('ai_model', 'Gemini')
|
| 1082 |
+
vo_lang = request.form.get('vo_lang', 'my') # 'my', 'th', 'en'
|
| 1083 |
+
# Speed default per language (can be overridden by slider)
|
| 1084 |
+
LANG_SPD = {'th': 20, 'en': 0, 'my': 30}
|
| 1085 |
+
if request.form.get('speed') is None:
|
| 1086 |
+
spd = LANG_SPD.get(vo_lang, 30)
|
| 1087 |
+
video_file = request.files.get('video_file')
|
| 1088 |
+
music_file = request.files.get('music_file')
|
| 1089 |
+
logo_file = request.files.get('logo_file')
|
| 1090 |
+
logo_x = int(request.form.get('logo_x', 10))
|
| 1091 |
+
logo_y = int(request.form.get('logo_y', 10))
|
| 1092 |
+
logo_w = int(request.form.get('logo_w', 80))
|
| 1093 |
+
client_tid = (request.form.get('tid') or '').strip()
|
| 1094 |
+
|
| 1095 |
+
if not u: return jsonify(ok=False, msg='❌ Not logged in')
|
| 1096 |
+
is_adm = (u == ADMIN_U)
|
| 1097 |
+
if not is_adm and get_coins(u) < 2:
|
| 1098 |
+
return jsonify(ok=False, msg=f'❌ Not enough coins (need 2)')
|
| 1099 |
+
|
| 1100 |
+
tid = client_tid if client_tid else uuid.uuid4().hex[:8]
|
| 1101 |
+
tmp_dir = str(BASE_DIR / f'temp_{tid}')
|
| 1102 |
+
os.makedirs(tmp_dir, exist_ok=True)
|
| 1103 |
+
out_file = str(OUTPUT_DIR / f'final_{tid}.mp4')
|
| 1104 |
+
vpath = None; mpath = None
|
| 1105 |
+
|
| 1106 |
+
cur_coins = get_coins(u)
|
| 1107 |
+
coin_msg = 'Admin' if is_adm else f'🪙 {cur_coins} coins'
|
| 1108 |
+
job_progress[tid] = {'pct': 2, 'msg': f'⏳ Starting… {coin_msg}', 'done': False}
|
| 1109 |
+
|
| 1110 |
+
cpu_queue_wait()
|
| 1111 |
+
|
| 1112 |
+
try:
|
| 1113 |
+
job_progress[tid] = {'pct': 8, 'msg': '📥 Downloading video…', 'done': False}
|
| 1114 |
+
if video_file and video_file.filename:
|
| 1115 |
+
vpath = f'{tmp_dir}/input.mp4'
|
| 1116 |
+
video_file.save(vpath)
|
| 1117 |
+
elif video_url:
|
| 1118 |
+
out_tmpl = f'{tmp_dir}/input.%(ext)s'
|
| 1119 |
+
ytdlp_download(out_tmpl, video_url)
|
| 1120 |
+
found = glob.glob(f'{tmp_dir}/input.*')
|
| 1121 |
+
if found: vpath = found[0]
|
| 1122 |
+
if not vpath: return jsonify(ok=False, msg='❌ No video selected')
|
| 1123 |
+
|
| 1124 |
+
job_progress[tid] = {'pct': 20, 'msg': '🎙️ Transcribing with Whisper…', 'done': False}
|
| 1125 |
+
if whisper is None: raise Exception('whisper not installed')
|
| 1126 |
+
if whisper_model is None:
|
| 1127 |
+
whisper_model = whisper.load_model('tiny', device='cpu')
|
| 1128 |
+
res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
|
| 1129 |
+
tr = res['text']; src_lang = res.get('language', 'en')
|
| 1130 |
+
|
| 1131 |
+
if vo_lang == 'en':
|
| 1132 |
+
# English — skip AI API, use whisper transcript directly
|
| 1133 |
+
sc = tr.strip()
|
| 1134 |
+
caption_text = sc[:60].strip() + ('…' if len(sc) > 60 else '')
|
| 1135 |
+
hashtags = '#english #movierecap #viral #foryou #trending'
|
| 1136 |
+
else:
|
| 1137 |
+
job_progress[tid] = {'pct': 45, 'msg': '🤖 Generating AI script…', 'done': False}
|
| 1138 |
+
sys_p = get_sys_prompt(ct, vo_lang)
|
| 1139 |
+
sys_p = sys_p + '\n' + get_num_rule(vo_lang)
|
| 1140 |
+
out_txt, _ = run_stage('ai', call_api,
|
| 1141 |
+
[{'role':'system','content':sys_p},
|
| 1142 |
+
{'role':'user','content':f'Language:{src_lang}\n\n{tr}'}], api=api)
|
| 1143 |
+
sc, caption_text, hashtags = parse_out(out_txt)
|
| 1144 |
+
|
| 1145 |
+
if music_file and music_file.filename:
|
| 1146 |
+
mpath = f'{tmp_dir}/music.mp3'
|
| 1147 |
+
music_file.save(mpath)
|
| 1148 |
+
|
| 1149 |
+
logo_path = None
|
| 1150 |
+
if logo_file and logo_file.filename:
|
| 1151 |
+
ext = Path(logo_file.filename).suffix or '.png'
|
| 1152 |
+
logo_path = f'{tmp_dir}/logo{ext}'
|
| 1153 |
+
logo_file.save(logo_path)
|
| 1154 |
+
blur_enabled = request.form.get('blur_enabled') == '1'
|
| 1155 |
+
blur_x = int(request.form.get('blur_x', 0))
|
| 1156 |
+
blur_y = int(request.form.get('blur_y', 0))
|
| 1157 |
+
blur_w = int(request.form.get('blur_w', 0))
|
| 1158 |
+
blur_h = int(request.form.get('blur_h', 0))
|
| 1159 |
+
rate = f'+{spd}%'
|
| 1160 |
+
sentences = split_txt(sc, vo_lang)
|
| 1161 |
+
if engine == 'gemini':
|
| 1162 |
+
parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
|
| 1163 |
+
else:
|
| 1164 |
+
parts = run_stage('tts', run_tts_sync, sentences, voice_id, rate, tmp_dir)
|
| 1165 |
+
|
| 1166 |
+
cmb = f'{tmp_dir}/combined.mp3'
|
| 1167 |
+
lst = f'{tmp_dir}/list.txt'
|
| 1168 |
+
with open(lst, 'w') as lf:
|
| 1169 |
+
for a in parts: lf.write(f"file '{os.path.abspath(a)}'\n")
|
| 1170 |
+
subprocess.run(
|
| 1171 |
+
f'ffmpeg -y -f concat -safe 0 -i "{lst}" '
|
| 1172 |
+
f'-af "silenceremove=start_periods=1:stop_periods=-1:stop_duration=0.1:stop_threshold=-50dB" '
|
| 1173 |
+
f'-c:a libmp3lame -q:a 2 "{cmb}"', shell=True, check=True)
|
| 1174 |
+
|
| 1175 |
+
job_progress[tid] = {'pct': 78, 'msg': '🎬 Rendering video…', 'done': False}
|
| 1176 |
+
|
| 1177 |
+
vd = dur(vpath); ad = dur(cmb)
|
| 1178 |
+
if vd <= 0: raise Exception('Video duration read failed')
|
| 1179 |
+
if ad <= 0: raise Exception('Audio duration read failed')
|
| 1180 |
+
|
| 1181 |
+
_build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
|
| 1182 |
+
logo_path=logo_path, logo_x=logo_x, logo_y=logo_y, logo_w=logo_w,
|
| 1183 |
+
blur_enabled=blur_enabled, blur_x=blur_x, blur_y=blur_y, blur_w=blur_w, blur_h=blur_h)
|
| 1184 |
+
|
| 1185 |
+
rem = -1
|
| 1186 |
+
if not is_adm:
|
| 1187 |
+
_, rem = deduct(u, 2); upd_stat(u, 'tr'); upd_stat(u, 'vd')
|
| 1188 |
+
|
| 1189 |
+
job_progress[tid] = {'pct': 100, 'msg': '✅ Done!', 'done': True}
|
| 1190 |
+
|
| 1191 |
+
return jsonify(
|
| 1192 |
+
ok=True,
|
| 1193 |
+
output_url=f'/outputs/final_{tid}.mp4',
|
| 1194 |
+
title=caption_text,
|
| 1195 |
+
caption=caption_text,
|
| 1196 |
+
hashtags=hashtags,
|
| 1197 |
+
source_lang=src_lang,
|
| 1198 |
+
coins=rem,
|
| 1199 |
+
tid=tid
|
| 1200 |
+
)
|
| 1201 |
+
|
| 1202 |
+
finally:
|
| 1203 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 1204 |
+
|
| 1205 |
+
except Exception as e:
|
| 1206 |
+
import traceback; traceback.print_exc()
|
| 1207 |
+
try: job_progress[tid] = {'pct': 0, 'msg': f'❌ {e}', 'error': True}
|
| 1208 |
+
except: pass
|
| 1209 |
+
return jsonify(ok=False, msg=f'❌ {e}')
|
| 1210 |
+
|
| 1211 |
+
# ── ADMIN ──
|
| 1212 |
+
@app.route('/api/admin/create_user', methods=['POST'])
|
| 1213 |
+
def api_create_user():
|
| 1214 |
try:
|
| 1215 |
+
d = request.get_json(force=True) or {}
|
| 1216 |
+
msg, uname = create_user_fn(d.get('username',''), d.get('coins',10), d.get('caller',''))
|
| 1217 |
+
return jsonify(ok=bool(uname), msg=msg, username=uname)
|
| 1218 |
+
except Exception as e:
|
| 1219 |
+
return jsonify(ok=False, msg=str(e))
|
| 1220 |
+
|
| 1221 |
+
@app.route('/api/admin/coins', methods=['POST'])
|
| 1222 |
+
def api_coins():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1223 |
try:
|
| 1224 |
+
d = request.get_json(force=True) or {}
|
| 1225 |
+
if d.get('caller') != ADMIN_U: return jsonify(ok=False, msg='❌ Admin only')
|
| 1226 |
+
u = d.get('username',''); n = d.get('amount', 10)
|
| 1227 |
+
msg = set_coins_fn(u, n) if d.get('action') == 'set' else add_coins_fn(u, n)
|
| 1228 |
+
return jsonify(ok=True, msg=msg)
|
| 1229 |
+
except Exception as e:
|
| 1230 |
+
return jsonify(ok=False, msg=str(e))
|
| 1231 |
+
|
| 1232 |
+
@app.route('/api/admin/users')
|
| 1233 |
+
def api_users():
|
| 1234 |
+
try:
|
| 1235 |
+
if request.args.get('caller') != ADMIN_U:
|
| 1236 |
+
return jsonify(ok=False, msg='❌ Admin only')
|
| 1237 |
+
db = load_db()
|
| 1238 |
+
users = [{'username':k,'coins':v.get('coins',0),
|
| 1239 |
+
'transcripts':v.get('total_transcripts',0),
|
| 1240 |
+
'videos':v.get('total_videos',0),
|
| 1241 |
+
'created':v.get('created_at','')[:10]}
|
| 1242 |
+
for k,v in db['users'].items()]
|
| 1243 |
+
return jsonify(ok=True, users=users)
|
| 1244 |
+
except Exception as e:
|
| 1245 |
+
return jsonify(ok=False, msg=str(e))
|
| 1246 |
+
|
| 1247 |
+
@app.route('/api/admin/delete_user', methods=['POST'])
|
| 1248 |
+
def api_delete_user():
|
| 1249 |
+
try:
|
| 1250 |
+
d = request.get_json(force=True) or {}
|
| 1251 |
+
if d.get('caller') != ADMIN_U: return jsonify(ok=False, msg='❌ Admin only')
|
| 1252 |
+
u = d.get('username','').strip()
|
| 1253 |
+
if not u: return jsonify(ok=False, msg='❌ No username')
|
| 1254 |
+
db = load_db()
|
| 1255 |
+
if u not in db['users']: return jsonify(ok=False, msg='❌ User not found')
|
| 1256 |
+
del db['users'][u]; save_db(db)
|
| 1257 |
+
return jsonify(ok=True, msg=f'✅ {u} deleted')
|
| 1258 |
+
except Exception as e:
|
| 1259 |
+
return jsonify(ok=False, msg=str(e))
|
| 1260 |
+
|
| 1261 |
+
@app.route('/api/admin/gen_username')
|
| 1262 |
+
def api_gen_username():
|
| 1263 |
+
try:
|
| 1264 |
+
if request.args.get('caller') != ADMIN_U:
|
| 1265 |
+
return jsonify(ok=False, msg='❌ Admin only')
|
| 1266 |
+
return jsonify(ok=True, username=gen_uname())
|
| 1267 |
+
except Exception as e:
|
| 1268 |
+
return jsonify(ok=False, msg=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1269 |
|
| 1270 |
if __name__ == '__main__':
|
| 1271 |
+
app.run(host='0.0.0.0', port=7860, debug=False, threaded=True)
|
|
|
index.html
CHANGED
|
@@ -1,317 +1,1295 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="
|
| 3 |
<head>
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
handle.addEventListener('mousedown', resizeStart); handle.addEventListener('touchstart', resizeStart, {passive: false});
|
| 220 |
-
window.addEventListener('mousemove', move); window.addEventListener('touchmove', move, {passive: false});
|
| 221 |
-
window.addEventListener('mouseup', end); window.addEventListener('touchend', end);
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
document.addEventListener('DOMContentLoaded', () => {
|
| 225 |
-
makeInteractive('blurBox'); makeInteractive('logoBox');
|
| 226 |
-
window.addEventListener('resize', () => { updateCoords('blurBox'); updateCoords('logoBox'); });
|
| 227 |
-
});
|
| 228 |
-
</script>
|
| 229 |
</head>
|
| 230 |
<body>
|
| 231 |
-
|
| 232 |
-
<
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
<div
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
</div>
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
</div>
|
| 244 |
|
| 245 |
-
<
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
</div>
|
|
|
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
<
|
| 273 |
-
<
|
| 274 |
-
|
| 275 |
-
<
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
</div>
|
| 290 |
-
<
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
| 295 |
-
<span class="card-title" style="margin:0;">AI Dubbing</span>
|
| 296 |
-
<button type="button" id="btnReloadAI" onclick="reloadTranslation()" class="btn btn-outline" style="padding:6px 12px; font-size:12px; width:auto;">↻ Retry AI</button>
|
| 297 |
</div>
|
| 298 |
-
<
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
<span class="card-title">Overlays</span>
|
| 303 |
-
<div style="position:relative; margin-bottom:10px;">
|
| 304 |
-
<label style="color:var(--text-muted); font-size:12px; display:block;">Text Watermark</label>
|
| 305 |
-
<input type="text" name="text_watermark" value="Shine Movie Recap" readonly style="color:var(--accent); background:#1e293b; font-weight:bold;">
|
| 306 |
-
<span style="position:absolute; right:15px; top:35px; font-size:12px; color:var(--accent);">🔒 LOCKED</span>
|
| 307 |
</div>
|
| 308 |
-
|
| 309 |
-
<label style="color:var(--text-muted); font-size:14px;">Logo Overlay:</label>
|
| 310 |
-
<input type="file" name="logo_file" accept="image/*" onchange="loadLogo(event)">
|
| 311 |
</div>
|
| 312 |
-
|
| 313 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
</div>
|
|
|
|
| 315 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
</body>
|
| 317 |
-
</html>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Recap Studio</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
| 10 |
+
<style>
|
| 11 |
+
:root {
|
| 12 |
+
--bg: #ffffff;
|
| 13 |
+
--bg2: #f8f9fb;
|
| 14 |
+
--bg3: #f0f1f4;
|
| 15 |
+
--border: #e2e4ea;
|
| 16 |
+
--border2: #d0d3db;
|
| 17 |
+
--text: #1a1d28;
|
| 18 |
+
--muted: #6b7080;
|
| 19 |
+
--muted2: #8b90a0;
|
| 20 |
+
--amber: #f5a623;
|
| 21 |
+
--amber2: #e69500;
|
| 22 |
+
--violet: #6c5ce7;
|
| 23 |
+
--green: #00b894;
|
| 24 |
+
--red: #e74c3c;
|
| 25 |
+
--cyan: #0984e3;
|
| 26 |
+
--sans: 'Inter', -apple-system, sans-serif;
|
| 27 |
+
}
|
| 28 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 29 |
+
body{background:var(--bg);color:var(--text);font-family:var(--sans);min-height:100vh;overflow-x:hidden}
|
| 30 |
+
|
| 31 |
+
/* ── TOPBAR ── */
|
| 32 |
+
.topbar{display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:54px;background:var(--bg);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100}
|
| 33 |
+
.logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:1.05rem;color:var(--text)}
|
| 34 |
+
.logo-icon{width:32px;height:32px;background:linear-gradient(135deg,var(--amber),var(--violet));border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:.9rem}
|
| 35 |
+
.topbar-right{display:flex;align-items:center;gap:10px}
|
| 36 |
+
.coin-badge{display:flex;align-items:center;gap:6px;padding:5px 12px;background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);border-radius:20px;font-size:.82rem;font-weight:600;color:var(--amber2)}
|
| 37 |
+
.btn-sm{padding:6px 14px;border-radius:6px;border:none;cursor:pointer;font-family:var(--sans);font-size:.8rem;font-weight:600;transition:.2s}
|
| 38 |
+
.btn-logout{background:transparent;border:1px solid var(--border2);color:var(--muted2)}
|
| 39 |
+
.btn-logout:hover{border-color:var(--red);color:var(--red)}
|
| 40 |
+
.btn-admin{background:rgba(108,92,231,.08);border:1px solid rgba(108,92,231,.2);color:var(--violet)}
|
| 41 |
+
.btn-buy{background:linear-gradient(135deg,var(--amber),var(--amber2));color:#fff;font-weight:700}
|
| 42 |
+
|
| 43 |
+
/* ── LOGIN ── */
|
| 44 |
+
#login-screen{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg)}
|
| 45 |
+
.login-box{width:380px;padding:40px;background:var(--bg);border:1px solid var(--border);border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.06)}
|
| 46 |
+
.login-box h2{font-size:1.4rem;font-weight:700;margin-bottom:6px;color:var(--text)}
|
| 47 |
+
.login-box p{color:var(--muted2);font-size:.85rem;margin-bottom:28px}
|
| 48 |
+
.field{margin-bottom:14px}
|
| 49 |
+
.field label{display:block;font-size:.72rem;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
|
| 50 |
+
.input{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:var(--sans);font-size:.9rem;outline:none;transition:.2s}
|
| 51 |
+
.input:focus{border-color:var(--amber)}
|
| 52 |
+
.btn-primary{width:100%;padding:11px;background:linear-gradient(135deg,var(--amber),var(--amber2));border:none;border-radius:8px;color:#fff;font-family:var(--sans);font-size:.95rem;font-weight:700;cursor:pointer;transition:.2s;margin-top:4px}
|
| 53 |
+
.btn-primary:hover{opacity:.9;transform:translateY(-1px)}
|
| 54 |
+
.msg-box{padding:10px 12px;border-radius:7px;font-size:.82rem;margin-bottom:12px;display:none}
|
| 55 |
+
.msg-ok{background:rgba(0,184,148,.08);border:1px solid rgba(0,184,148,.2);color:var(--green)}
|
| 56 |
+
.msg-err{background:rgba(231,76,60,.08);border:1px solid rgba(231,76,60,.2);color:var(--red)}
|
| 57 |
+
|
| 58 |
+
/* ── APP LAYOUT ── */
|
| 59 |
+
#app-screen{display:none}
|
| 60 |
+
.app-wrap{display:grid;grid-template-columns:1fr 340px;gap:16px;padding:16px;max-width:1200px;margin:0 auto}
|
| 61 |
+
@media(max-width:900px){.app-wrap{grid-template-columns:1fr}}
|
| 62 |
+
|
| 63 |
+
/* ── CARDS ── */
|
| 64 |
+
.card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:12px}
|
| 65 |
+
.card-label{font-size:.68rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);margin-bottom:12px;display:flex;align-items:center;gap:6px}
|
| 66 |
+
.card-label i{color:var(--amber)}
|
| 67 |
+
|
| 68 |
+
/* ── UPLOAD ── */
|
| 69 |
+
.upload-area{border:2px dashed var(--border);border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:.2s;position:relative;overflow:hidden}
|
| 70 |
+
.upload-area:hover{border-color:var(--amber);background:rgba(245,166,35,.02)}
|
| 71 |
+
.upload-area.drag{border-color:var(--amber);background:rgba(245,166,35,.04)}
|
| 72 |
+
.upload-area input{position:absolute;inset:0;opacity:0;cursor:pointer}
|
| 73 |
+
.upload-icon{font-size:1.8rem;color:var(--muted);margin-bottom:8px}
|
| 74 |
+
.upload-text{font-size:.85rem;color:var(--muted2)}
|
| 75 |
+
.upload-text span{color:var(--amber)}
|
| 76 |
+
.upload-name{margin-top:8px;font-size:.8rem;color:var(--green);display:none}
|
| 77 |
+
|
| 78 |
+
/* ── URL INPUT ── */
|
| 79 |
+
.input-row{display:flex;gap:8px}
|
| 80 |
+
.paste-btn{padding:0 14px;background:var(--bg3);border:1px solid var(--border);border-radius:7px;color:var(--muted2);cursor:pointer;transition:.2s;flex-shrink:0}
|
| 81 |
+
.paste-btn:hover{color:var(--amber);border-color:var(--amber)}
|
| 82 |
+
.platform-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:5px;font-size:.75rem;font-weight:600;margin-top:8px}
|
| 83 |
+
|
| 84 |
+
/* ── VOICE ── */
|
| 85 |
+
.vcat-tabs{display:flex;gap:6px;margin-bottom:10px}
|
| 86 |
+
.vcat-btn{flex:1;padding:7px;background:var(--bg3);border:1px solid var(--border);color:var(--muted2);font-family:var(--sans);font-size:.78rem;font-weight:600;border-radius:5px;cursor:pointer;transition:.2s}
|
| 87 |
+
.vcat-btn.active{background:rgba(245,166,35,.08);border-color:var(--amber);color:var(--amber2)}
|
| 88 |
+
.voice-search{width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--sans);font-size:.8rem;outline:none;margin-bottom:8px}
|
| 89 |
+
.voice-search:focus{border-color:var(--amber)}
|
| 90 |
+
.voice-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;max-height:220px;overflow-y:auto;padding-right:2px}
|
| 91 |
+
.voice-grid::-webkit-scrollbar{width:3px}
|
| 92 |
+
.voice-grid::-webkit-scrollbar-track{background:transparent}
|
| 93 |
+
.voice-grid::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
|
| 94 |
+
.vcard{background:var(--bg3);border:1px solid var(--border);border-radius:7px;padding:8px 6px;text-align:center;cursor:pointer;transition:.2s}
|
| 95 |
+
.vcard:hover{border-color:var(--border2);background:var(--bg2)}
|
| 96 |
+
.vcard.selected{border-color:var(--amber);background:rgba(245,166,35,.06)}
|
| 97 |
+
.vcard-name{font-size:.72rem;font-weight:600;margin-bottom:2px;line-height:1.2}
|
| 98 |
+
.vcard-sub{font-size:.6rem;color:var(--muted);margin-bottom:4px}
|
| 99 |
+
.vcard-play{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:4px;padding:3px 0;border-radius:4px;background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.15);color:var(--amber2);font-size:.6rem;font-weight:600;cursor:pointer;transition:.2s}
|
| 100 |
+
.vcard-play:hover{background:rgba(245,166,35,.18)}
|
| 101 |
+
.vcard-play.playing{background:rgba(0,184,148,.08);border-color:rgba(0,184,148,.2);color:var(--green)}
|
| 102 |
+
|
| 103 |
+
/* ── SPEED ── */
|
| 104 |
+
.speed-toggle{display:flex;align-items:center;gap:6px;margin-top:10px;cursor:pointer;font-size:.78rem;color:var(--muted2);font-weight:500;user-select:none}
|
| 105 |
+
.speed-toggle:hover{color:var(--amber)}
|
| 106 |
+
.speed-toggle i{transition:transform .2s}
|
| 107 |
+
.speed-toggle.open i{transform:rotate(180deg)}
|
| 108 |
+
.speed-row{display:none;align-items:center;gap:10px;margin-top:8px;padding:8px 10px;background:var(--bg3);border-radius:6px}
|
| 109 |
+
.speed-row.visible{display:flex}
|
| 110 |
+
.speed-label{font-size:.75rem;color:var(--muted2);white-space:nowrap}
|
| 111 |
+
.speed-val{font-size:.75rem;color:var(--amber2);font-weight:600;min-width:32px;text-align:right}
|
| 112 |
+
input[type=range]{flex:1;accent-color:var(--amber);cursor:pointer}
|
| 113 |
+
|
| 114 |
+
/* ── OPTIONS ── */
|
| 115 |
+
.checks-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
| 116 |
+
.check-item{display:flex;align-items:center;gap:8px;padding:9px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;cursor:pointer;font-size:.8rem;color:var(--muted);transition:.2s;user-select:none}
|
| 117 |
+
.check-item input{display:none}
|
| 118 |
+
.check-item:hover{border-color:var(--border2);color:var(--text)}
|
| 119 |
+
.check-item.checked{border-color:var(--amber);color:var(--text);background:rgba(245,166,35,.05)}
|
| 120 |
+
.check-box{width:15px;height:15px;border-radius:3px;border:1.5px solid var(--border2);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:.6rem;transition:.2s}
|
| 121 |
+
.check-item.checked .check-box{background:var(--amber);border-color:var(--amber);color:#fff}
|
| 122 |
+
|
| 123 |
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
| 124 |
+
.field-label{font-size:.72rem;color:var(--muted);margin-bottom:5px;font-weight:600}
|
| 125 |
+
select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7080'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:28px}
|
| 126 |
+
|
| 127 |
+
/* ── SCRIPT ── */
|
| 128 |
+
.script-area{width:100%;min-height:140px;padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:7px;color:var(--text);font-family:var(--sans);font-size:.88rem;resize:vertical;outline:none;line-height:1.6;transition:.2s}
|
| 129 |
+
.script-area:focus{border-color:var(--amber)}
|
| 130 |
+
.script-meta{display:flex;justify-content:space-between;margin-top:6px;font-size:.72rem;color:var(--muted)}
|
| 131 |
+
|
| 132 |
+
/* ── BUTTONS ── */
|
| 133 |
+
.btn-full{width:100%;padding:12px;border:none;border-radius:8px;font-family:var(--sans);font-size:.95rem;font-weight:700;cursor:pointer;transition:.2s;display:flex;align-items:center;justify-content:center;gap:8px}
|
| 134 |
+
.btn-amber{background:linear-gradient(135deg,var(--amber),var(--amber2));color:#fff}
|
| 135 |
+
.btn-amber:hover{opacity:.9;transform:translateY(-1px)}
|
| 136 |
+
.btn-amber:disabled{opacity:.4;transform:none;cursor:not-allowed}
|
| 137 |
+
.btn-violet{background:rgba(108,92,231,.1);border:1px solid rgba(108,92,231,.2);color:var(--violet)}
|
| 138 |
+
.btn-violet:hover{background:rgba(108,92,231,.18)}
|
| 139 |
+
.btn-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}
|
| 140 |
+
|
| 141 |
+
/* ── PROGRESS ── */
|
| 142 |
+
.prog-wrap{margin-top:10px;display:none}
|
| 143 |
+
.prog-bar-bg{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden;margin-bottom:6px}
|
| 144 |
+
.prog-bar{height:100%;background:linear-gradient(90deg,var(--amber),var(--amber2));border-radius:3px;transition:width .4s ease;width:0%}
|
| 145 |
+
.prog-msg{font-size:.78rem;color:var(--muted2);line-height:1.5}
|
| 146 |
+
|
| 147 |
+
/* ── PREVIEW ── */
|
| 148 |
+
.preview-panel{position:sticky;top:70px}
|
| 149 |
+
.preview-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden}
|
| 150 |
+
.preview-top{padding:12px 14px;border-bottom:1px solid var(--border);font-size:.68rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:6px}
|
| 151 |
+
.preview-top i{color:var(--amber)}
|
| 152 |
+
.video-wrap{aspect-ratio:9/16;background:#f0f1f4;position:relative;max-height:400px;overflow:hidden}
|
| 153 |
+
.video-wrap video{width:100%;height:100%;object-fit:contain;position:relative;z-index:2}
|
| 154 |
+
.video-placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted)}
|
| 155 |
+
.video-placeholder i{font-size:2.5rem}
|
| 156 |
+
.video-placeholder p{font-size:.8rem}
|
| 157 |
+
.preview-bottom{padding:12px 14px}
|
| 158 |
+
.meta-title{font-size:.85rem;font-weight:600;color:var(--text);margin-bottom:4px;line-height:1.4}
|
| 159 |
+
.meta-tags{font-size:.72rem;color:var(--cyan)}
|
| 160 |
+
.download-btn{width:100%;margin-top:10px;padding:9px;background:rgba(0,184,148,.06);border:1px solid rgba(0,184,148,.2);border-radius:7px;color:var(--green);font-family:var(--sans);font-weight:600;font-size:.82rem;cursor:pointer;display:none;transition:.2s}
|
| 161 |
+
.download-btn:hover{background:rgba(0,184,148,.12)}
|
| 162 |
+
.copy-caption-btn{width:100%;margin-top:6px;padding:8px;background:rgba(9,132,227,.06);border:1px solid rgba(9,132,227,.2);border-radius:7px;color:var(--cyan);font-family:var(--sans);font-size:.82rem;font-weight:600;cursor:pointer;display:none;transition:.2s}
|
| 163 |
+
.copy-caption-btn:hover{background:rgba(9,132,227,.12)}
|
| 164 |
+
|
| 165 |
+
/* ── TOAST ── */
|
| 166 |
+
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);background:var(--text);color:var(--bg);padding:10px 20px;border-radius:8px;font-size:.82rem;font-weight:500;transition:.3s;z-index:999;opacity:0;white-space:nowrap}
|
| 167 |
+
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
| 168 |
+
|
| 169 |
+
/* ── ADMIN MODAL ── */
|
| 170 |
+
#admin-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;align-items:center;justify-content:center}
|
| 171 |
+
#admin-modal.open{display:flex}
|
| 172 |
+
.admin-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;padding:24px;width:500px;max-width:95vw;max-height:80vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,.1)}
|
| 173 |
+
.admin-box h3{font-size:1.1rem;font-weight:700;margin-bottom:18px;display:flex;align-items:center;gap:8px}
|
| 174 |
+
.admin-box h3 i{color:var(--amber)}
|
| 175 |
+
.admin-section{margin-bottom:20px}
|
| 176 |
+
.admin-section h4{font-size:.75rem;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
|
| 177 |
+
.admin-row{display:flex;gap:8px;margin-bottom:8px}
|
| 178 |
+
.admin-row .input{flex:1}
|
| 179 |
+
.btn-admin-action{padding:9px 16px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer;font-family:var(--sans);font-size:.82rem;white-space:nowrap;transition:.2s}
|
| 180 |
+
.btn-admin-action:hover{border-color:var(--amber);color:var(--amber)}
|
| 181 |
+
.users-table{width:100%;border-collapse:collapse;font-size:.78rem;margin-top:8px}
|
| 182 |
+
.users-table th{padding:7px 8px;text-align:left;color:var(--muted);font-weight:600;border-bottom:1px solid var(--border)}
|
| 183 |
+
.users-table td{padding:7px 8px;border-bottom:1px solid var(--border);color:var(--muted2)}
|
| 184 |
+
.users-table tr:hover td{color:var(--text)}
|
| 185 |
+
.close-admin{float:right;background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;transition:.2s}
|
| 186 |
+
.close-admin:hover{color:var(--red)}
|
| 187 |
+
|
| 188 |
+
/* ── MODE TABS ── */
|
| 189 |
+
.mode-tabs{display:flex;gap:0;margin-bottom:14px;background:var(--bg3);border-radius:8px;padding:3px}
|
| 190 |
+
.mode-tab{flex:1;padding:8px;border:none;background:transparent;color:var(--muted2);font-family:var(--sans);font-size:.82rem;font-weight:600;border-radius:6px;cursor:pointer;transition:.2s}
|
| 191 |
+
.mode-tab.active{background:var(--bg);color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
| 192 |
+
|
| 193 |
+
/* ── SCROLLBAR ── */
|
| 194 |
+
::-webkit-scrollbar{width:4px;height:4px}
|
| 195 |
+
::-webkit-scrollbar-track{background:transparent}
|
| 196 |
+
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
|
| 197 |
+
|
| 198 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 199 |
+
.spinning{animation:spin .8s linear infinite}
|
| 200 |
+
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
| 201 |
+
.fade-in{animation:fadeIn .3s ease forwards}
|
| 202 |
+
|
| 203 |
+
/* ── LANGUAGE SELECTOR ── */
|
| 204 |
+
.lang-btn{background:var(--bg3);border:1.5px solid var(--border);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;transition:.2s;font-size:.75rem;font-weight:600;color:var(--muted2);user-select:none;line-height:1.6}
|
| 205 |
+
.lang-btn:hover{border-color:var(--border2);color:var(--text)}
|
| 206 |
+
.lang-btn.active{border-color:var(--amber);background:rgba(245,166,35,.08);color:var(--amber2)}
|
| 207 |
+
|
| 208 |
+
/* ── LOGO POSITION GRID ── */
|
| 209 |
+
.pos-btn{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:6px 0;font-size:.9rem;cursor:pointer;transition:.2s;color:var(--muted2);font-family:var(--sans)}
|
| 210 |
+
.pos-btn:hover{border-color:var(--amber);color:var(--amber);background:rgba(245,166,35,.06)}
|
| 211 |
+
.pos-btn.active{border-color:var(--amber);background:rgba(245,166,35,.1);color:var(--amber2)}
|
| 212 |
+
|
| 213 |
+
/* ── BLUR BOX (drag overlay on video preview) ── */
|
| 214 |
+
.drag-box{position:absolute;display:none;z-index:50;touch-action:none}
|
| 215 |
+
.resize-handle{width:26px;height:26px;background:white;position:absolute;bottom:-8px;right:-8px;border-radius:50%;box-shadow:0 0 5px rgba(0,0,0,.4);z-index:60;cursor:se-resize}
|
| 216 |
+
#blurBox{background:rgba(239,68,68,.35);border:2px solid var(--red)}
|
| 217 |
+
#logoBox{background:transparent;border:2px dashed var(--green)}
|
| 218 |
+
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
</head>
|
| 220 |
<body>
|
| 221 |
+
|
| 222 |
+
<!-- LOGIN -->
|
| 223 |
+
<div id="login-screen">
|
| 224 |
+
<div class="login-box fade-in">
|
| 225 |
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
|
| 226 |
+
<div class="logo-icon"><i class="fas fa-film" style="color:#fff"></i></div>
|
| 227 |
+
<div>
|
| 228 |
+
<div style="font-weight:800;font-size:1.1rem">Recap Studio</div>
|
| 229 |
+
<div style="font-size:.72rem;color:var(--muted)">AI Video Recap Tool</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
<h2 id="auth-title">Sign In</h2>
|
| 233 |
+
<p id="auth-sub">Welcome back, please sign in</p>
|
| 234 |
+
<div id="auth-msg" class="msg-box"></div>
|
| 235 |
+
<div class="field"><label>Username</label><input class="input" id="auth-user" placeholder="Enter username" autocomplete="username"></div>
|
| 236 |
+
<div class="field"><label>Password</label><input class="input" id="auth-pass" type="password" placeholder="Enter password" autocomplete="current-password"></div>
|
| 237 |
+
<button class="btn-primary" onclick="doAuth()" id="auth-btn">Sign In</button>
|
| 238 |
+
<div style="margin-top:14px;text-align:center;font-size:.78rem;color:var(--muted2)">Don't have an account? <a href="http://t.me/PhoeShan2001" target="_blank" style="color:var(--cyan);text-decoration:none"><i class="fab fa-telegram"></i> Contact Admin</a></div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<!-- TOPBAR -->
|
| 243 |
+
<div class="topbar" id="topbar" style="display:none">
|
| 244 |
+
<div class="logo">
|
| 245 |
+
<div class="logo-icon"><i class="fas fa-film" style="color:#fff"></i></div>
|
| 246 |
+
Recap Studio
|
| 247 |
+
</div>
|
| 248 |
+
<div class="topbar-right">
|
| 249 |
+
<div style="font-size:.78rem;color:var(--muted2);font-weight:600"><i class="fas fa-user" style="color:var(--amber)"></i> <span id="tb-username"></span></div>
|
| 250 |
+
<div class="coin-badge"><i class="fas fa-coins"></i> <span id="tb-coins">0</span> Coins</div>
|
| 251 |
+
<button class="btn-sm btn-buy" id="buy-btn" onclick="openBuyModal()"><i class="fas fa-wallet"></i> Buy Credits</button>
|
| 252 |
+
<button class="btn-sm btn-admin" id="admin-btn" style="display:none" onclick="openAdmin()"><i class="fas fa-crown"></i> Admin</button>
|
| 253 |
+
<button class="btn-sm btn-logout" onclick="doLogout()"><i class="fas fa-sign-out-alt"></i></button>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<!-- MAIN APP -->
|
| 258 |
+
<div id="app-screen">
|
| 259 |
+
<div class="app-wrap">
|
| 260 |
+
<div>
|
| 261 |
+
<div class="mode-tabs" style="pointer-events:none;opacity:.8">
|
| 262 |
+
<button class="mode-tab active" id="tab-full"><i class="fas fa-magic"></i> Auto Process</button>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<!-- VIDEO INPUT -->
|
| 266 |
+
<div class="card">
|
| 267 |
+
<div class="card-label"><i class="fas fa-video"></i> VIDEO INPUT</div>
|
| 268 |
+
<div style="display:flex;gap:0;margin-bottom:12px;background:var(--bg3);border-radius:7px;padding:2px">
|
| 269 |
+
<button class="mode-tab active" id="input-tab-url" onclick="switchInputMode('url')" style="flex:1;padding:7px;font-size:.8rem"><i class="fas fa-link"></i> YouTube URL</button>
|
| 270 |
+
<button class="mode-tab" id="input-tab-upload" onclick="switchInputMode('upload')" style="flex:1;padding:7px;font-size:.8rem"><i class="fas fa-upload"></i> Upload File</button>
|
| 271 |
+
</div>
|
| 272 |
+
<div id="input-url-section">
|
| 273 |
+
<div class="input-row">
|
| 274 |
+
<input class="input" id="video-url" placeholder="https://youtube.com/watch?v=... paste URL here" style="flex:1">
|
| 275 |
+
<button class="paste-btn" onclick="pasteUrl()" title="Paste"><i class="fas fa-paste"></i></button>
|
| 276 |
+
</div>
|
| 277 |
+
<div id="url-platform" style="margin-top:8px;display:none">
|
| 278 |
+
<span class="platform-badge" id="url-badge" style="background:rgba(231,76,60,.06);border:1px solid rgba(231,76,60,.15);color:var(--red)"></span>
|
| 279 |
+
</div>
|
| 280 |
+
<div style="margin-top:6px;font-size:.72rem;color:var(--muted)">
|
| 281 |
+
<i class="fas fa-info-circle"></i> Supports YouTube, TikTok, Facebook, Instagram links
|
| 282 |
</div>
|
| 283 |
+
</div>
|
| 284 |
+
<div id="input-upload-section" style="display:none">
|
| 285 |
+
<div class="upload-area" id="upload-area" ondragover="dragOver(event)" ondragleave="dragLeave(event)" ondrop="dropFile(event)">
|
| 286 |
+
<input type="file" id="video-file" accept="video/*" onchange="onFileSelect(this)">
|
| 287 |
+
<div class="upload-icon"><i class="fas fa-cloud-upload-alt"></i></div>
|
| 288 |
+
<div class="upload-text">Drag & drop here or <span>browse files</span></div>
|
| 289 |
+
<div class="upload-name" id="upload-name"></div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
</div>
|
| 293 |
|
| 294 |
+
<!-- SCRIPT SECTIONS -->
|
| 295 |
+
<div id="draft-result-section" style="display:none">
|
| 296 |
+
<div class="card">
|
| 297 |
+
<div class="card-label"><i class="fas fa-file-alt"></i> SCRIPT</div>
|
| 298 |
+
<textarea class="script-area" id="script-out" placeholder="Script will appear here…"></textarea>
|
| 299 |
+
<div class="script-meta"><span id="script-chars">0 chars</span><span id="script-lang"></span></div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
<div id="manual-script-section" style="display:none">
|
| 303 |
+
<div class="card">
|
| 304 |
+
<div class="card-label"><i class="fas fa-keyboard"></i> ENTER SCRIPT</div>
|
| 305 |
+
<textarea class="script-area" id="script-in" placeholder="Type script here…" oninput="updateScriptCount()"></textarea>
|
| 306 |
+
<div class="script-meta"><span id="script-in-chars">0 chars</span></div>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
<!-- VOICE MODEL -->
|
| 311 |
+
<div class="card">
|
| 312 |
+
<div class="card-label"><i class="fas fa-microphone-alt"></i> VOICE MODEL</div>
|
| 313 |
+
<div class="vcat-tabs">
|
| 314 |
+
<button class="vcat-btn active" id="vcat-ms" onclick="switchVCat('ms')"><i class="fab fa-microsoft"></i> Microsoft</button>
|
| 315 |
+
<button class="vcat-btn" id="vcat-g" onclick="switchVCat('g')"><i class="fas fa-gem"></i> Gemini</button>
|
| 316 |
+
</div>
|
| 317 |
+
<input class="voice-search" id="voice-search" placeholder="Search voices…" oninput="filterVoices(this.value)">
|
| 318 |
+
<div class="voice-grid" id="voice-grid"></div>
|
| 319 |
+
<div class="speed-toggle" id="speed-toggle" onclick="toggleSpeed()">
|
| 320 |
+
<i class="fas fa-chevron-down"></i> <span>Speed Settings</span>
|
| 321 |
+
</div>
|
| 322 |
+
<div class="speed-row" id="speed-row">
|
| 323 |
+
<span class="speed-label"><i class="fas fa-tachometer-alt"></i> Speed</span>
|
| 324 |
+
<input type="range" id="speed-slider" min="-20" max="80" value="30" oninput="document.getElementById('speed-val').textContent=this.value+'%'">
|
| 325 |
+
<span class="speed-val" id="speed-val">30%</span>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<!-- OPTIONS -->
|
| 330 |
+
<div class="card">
|
| 331 |
+
<div class="card-label"><i class="fas fa-toggle-on"></i> OPTIONS</div>
|
| 332 |
+
<div class="checks-grid">
|
| 333 |
+
<div class="check-item" id="chk-fl" onclick="togCheck(this,'fl')"><div class="check-box"></div><span><i class="fas fa-arrows-alt-h" style="color:var(--green)"></i> Flip Video</span></div>
|
| 334 |
+
<div class="check-item" id="chk-ac" onclick="togCheck(this,'ac')"><div class="check-box"></div><span><i class="fas fa-magic" style="color:var(--violet)"></i> Auto Color</span></div>
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<!-- SETTINGS -->
|
| 339 |
+
<div class="card">
|
| 340 |
+
<div class="card-label"><i class="fas fa-cog"></i> SETTINGS</div>
|
| 341 |
+
|
| 342 |
+
<!-- LANGUAGE -->
|
| 343 |
+
<div style="margin-bottom:12px">
|
| 344 |
+
<div class="field-label"><i class="fas fa-globe"></i> Output Language</div>
|
| 345 |
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
|
| 346 |
+
<div class="lang-btn active" id="lang-my" onclick="switchLang('my')"><span style="font-size:1.1rem">🇲🇲</span><br><span>Myanmar</span></div>
|
| 347 |
+
<div class="lang-btn" id="lang-th" onclick="switchLang('th')"><span style="font-size:1.1rem">🇹🇭</span><br><span>Thailand</span></div>
|
| 348 |
+
<div class="lang-btn" id="lang-en" onclick="switchLang('en')"><span style="font-size:1.1rem">🇬🇧</span><br><span>English</span></div>
|
| 349 |
</div>
|
| 350 |
+
</div>
|
| 351 |
|
| 352 |
+
<div class="grid2">
|
| 353 |
+
<div>
|
| 354 |
+
<div class="field-label"><i class="fas fa-crop-alt"></i> Crop Ratio</div>
|
| 355 |
+
<select id="crop" class="input">
|
| 356 |
+
<option value="original" selected>🎬 Original</option>
|
| 357 |
+
<option value="9:16">📱 9:16 (TikTok)</option>
|
| 358 |
+
<option value="16:9">🖥️ 16:9 (YouTube)</option>
|
| 359 |
+
<option value="1:1">⬛ 1:1 (Square)</option>
|
| 360 |
+
</select>
|
| 361 |
+
</div>
|
| 362 |
+
<div>
|
| 363 |
+
<div class="field-label"><i class="fas fa-robot"></i> AI Model</div>
|
| 364 |
+
<select id="ai-model" class="input">
|
| 365 |
+
<option value="Gemini">✨ Gemini</option>
|
| 366 |
+
<option value="DeepSeek">🔮 DeepSeek</option>
|
| 367 |
+
</select>
|
| 368 |
+
</div>
|
| 369 |
+
<div>
|
| 370 |
+
<div class="field-label"><i class="fas fa-video"></i> Content Type</div>
|
| 371 |
+
<select id="content-type" class="input" onchange="onContentTypeChange(this.value)">
|
| 372 |
+
<option value="Movie Recap">🎬 Movie Recap</option>
|
| 373 |
+
<option value="Funny/Meme">😂 Funny / Meme</option>
|
| 374 |
+
<option value="Medical/Health">🏥 Medical/Health</option>
|
| 375 |
+
</select>
|
| 376 |
+
<div id="funny-notice" style="display:none;margin-top:6px;padding:8px 10px;background:#fff8e1;border-left:3px solid #f59e0b;border-radius:6px;font-size:12px;color:#92400e;">
|
| 377 |
+
🎭 <b>Funny/Meme Mode</b> — Gemini က video ထဲ လူရဲ့ expression, gesture, dialog ကိုသာ ကြည့်ပြီး script ရေးမည်။
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
<div>
|
| 381 |
+
<div class="field-label"><i class="fas fa-font"></i> Watermark</div>
|
| 382 |
+
<input class="input" id="watermark" placeholder="@username">
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
|
| 386 |
+
<!-- MUSIC -->
|
| 387 |
+
<div style="margin-top:8px">
|
| 388 |
+
<div class="field-label"><i class="fas fa-music"></i> Background Music (optional)</div>
|
| 389 |
+
<div class="upload-area" style="padding:12px" onclick="document.getElementById('music-file').click()">
|
| 390 |
+
<input type="file" id="music-file" accept="audio/*" style="display:none" onchange="onMusicSelect(this)">
|
| 391 |
+
<span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-music"></i> <span id="music-name">Choose MP3</span></span>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
|
| 395 |
+
<!-- ═══════════════════════════════════════════
|
| 396 |
+
BLUR BOX — drag red box on video preview
|
| 397 |
+
═══════════════════════════════════════════ -->
|
| 398 |
+
<div style="margin-top:12px">
|
| 399 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
| 400 |
+
<div class="field-label" style="margin:0"><i class="fas fa-eye-slash" style="color:var(--red)"></i> Subtitle Blur Mask</div>
|
| 401 |
+
<div class="check-item" id="chk-blur" onclick="toggleBlur(this)" style="padding:4px 12px;border-radius:20px;font-size:.75rem;width:auto;gap:6px">
|
| 402 |
+
<div class="check-box"></div><span id="blur-lbl">Off</span>
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
<div id="blur-section" style="display:none">
|
| 406 |
+
<div style="padding:10px;background:rgba(231,76,60,.04);border:1px solid rgba(231,76,60,.15);border-radius:8px;font-size:.75rem;color:var(--muted2);line-height:1.6">
|
| 407 |
+
<i class="fas fa-info-circle" style="color:var(--red)"></i>
|
| 408 |
+
ညာဘက် <b>Preview</b> ထဲမှာ <span style="color:var(--red);font-weight:700">🟥 Red Box</span> ကို subtitles ပေါ်သို့ ဆွဲ၍ ချိန်ညှိပါ။ Box ကို drag ပြောင်းနိုင်သည်၊ ထောင့်ဖြင့် resize လုပ်နိုင်သည်။
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<!-- ═══════════════════════════════
|
| 414 |
+
LOGO OVERLAY
|
| 415 |
+
═══════════════════════════════ -->
|
| 416 |
+
<div style="margin-top:12px">
|
| 417 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
| 418 |
+
<div class="field-label" style="margin:0"><i class="fas fa-image"></i> Logo Overlay</div>
|
| 419 |
+
<div class="check-item" id="chk-logo" onclick="toggleLogo(this)" style="padding:4px 12px;border-radius:20px;font-size:.75rem;width:auto;gap:6px">
|
| 420 |
+
<div class="check-box"></div><span id="logo-lbl">Off</span>
|
| 421 |
+
</div>
|
| 422 |
+
</div>
|
| 423 |
+
<div id="logo-section" style="display:none">
|
| 424 |
+
<div class="upload-area" style="padding:12px" onclick="document.getElementById('logo-file').click()">
|
| 425 |
+
<input type="file" id="logo-file" accept="image/*" style="display:none" onchange="onLogoSelect(this)">
|
| 426 |
+
<span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-image"></i> <span id="logo-name">Choose Logo (PNG/JPG)</span></span>
|
| 427 |
+
</div>
|
| 428 |
+
<!-- Logo position picker -->
|
| 429 |
+
<div id="logo-pos-wrap" style="display:none;margin-top:8px">
|
| 430 |
+
<div class="field-label" style="margin-bottom:6px"><i class="fas fa-crosshairs"></i> Logo Position — drag green box on preview</div>
|
| 431 |
+
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
| 432 |
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;flex:1;min-width:120px">
|
| 433 |
+
<button class="pos-btn" onclick="setLogoPos('tl')">↖</button>
|
| 434 |
+
<button class="pos-btn" onclick="setLogoPos('tc')">↑</button>
|
| 435 |
+
<button class="pos-btn" onclick="setLogoPos('tr')">↗</button>
|
| 436 |
+
<button class="pos-btn" onclick="setLogoPos('ml')">←</button>
|
| 437 |
+
<button class="pos-btn" onclick="setLogoPos('mc')">·</button>
|
| 438 |
+
<button class="pos-btn" onclick="setLogoPos('mr')">→</button>
|
| 439 |
+
<button class="pos-btn" onclick="setLogoPos('bl')">↙</button>
|
| 440 |
+
<button class="pos-btn" onclick="setLogoPos('bc')">↓</button>
|
| 441 |
+
<button class="pos-btn" onclick="setLogoPos('br')">↘</button>
|
| 442 |
+
</div>
|
| 443 |
+
<div style="flex:2;min-width:160px">
|
| 444 |
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px">
|
| 445 |
+
<span style="font-size:.7rem;color:var(--muted);white-space:nowrap">Size</span>
|
| 446 |
+
<input type="range" id="logo-size" min="40" max="300" value="80" style="flex:1;accent-color:var(--amber)" oninput="onLogoSizeChange(this.value)">
|
| 447 |
+
<span id="logo-size-val" style="font-size:.72rem;color:var(--amber2);min-width:32px">80px</span>
|
| 448 |
</div>
|
| 449 |
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
| 450 |
+
<span style="font-size:.7rem;color:var(--muted);min-width:10px">X</span>
|
| 451 |
+
<input type="range" id="logo-x-slider" min="0" max="660" value="10" style="flex:1;accent-color:var(--amber)" oninput="onLogoPosSlider()">
|
| 452 |
+
<span id="logo-x-val" style="font-size:.72rem;color:var(--muted2);min-width:32px">10</span>
|
|
|
|
|
|
|
|
|
|
| 453 |
</div>
|
| 454 |
+
<div style="display:flex;align-items:center;gap:6px">
|
| 455 |
+
<span style="font-size:.7rem;color:var(--muted);min-width:10px">Y</span>
|
| 456 |
+
<input type="range" id="logo-y-slider" min="0" max="1200" value="10" style="flex:1;accent-color:var(--amber)" oninput="onLogoPosSlider()">
|
| 457 |
+
<span id="logo-y-val" style="font-size:.72rem;color:var(--muted2);min-width:32px">10</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
</div>
|
| 459 |
+
</div>
|
|
|
|
|
|
|
| 460 |
</div>
|
| 461 |
+
</div>
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
</div><!-- end .card SETTINGS -->
|
| 465 |
+
|
| 466 |
+
<!-- ACTION BUTTONS -->
|
| 467 |
+
<div id="action-full">
|
| 468 |
+
<button class="btn-full btn-amber" id="btn-process-all" onclick="doProcessAll()"><i class="fas fa-magic"></i> Auto Process (2 Coins)</button>
|
| 469 |
+
</div>
|
| 470 |
+
<div id="action-draft" style="display:none">
|
| 471 |
+
<button class="btn-full btn-violet" id="btn-draft" onclick="doDraft()"><i class="fas fa-file-alt"></i> Draft Script (1 Coin)</button>
|
| 472 |
+
</div>
|
| 473 |
+
<div id="action-manual" style="display:none">
|
| 474 |
+
<button class="btn-full btn-amber" id="btn-process" onclick="doProcess()"><i class="fas fa-film"></i> Process Video (1 Coin)</button>
|
| 475 |
+
</div>
|
| 476 |
+
|
| 477 |
+
<div class="prog-wrap" id="prog-wrap">
|
| 478 |
+
<div class="prog-bar-bg"><div class="prog-bar" id="prog-bar"></div></div>
|
| 479 |
+
<div class="prog-msg" id="prog-msg">Please wait…</div>
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
<!-- PREVIEW PANEL — blur box + logo box live here -->
|
| 484 |
+
<div class="preview-panel">
|
| 485 |
+
<div class="preview-box">
|
| 486 |
+
<div class="preview-top"><i class="fas fa-play-circle"></i> PREVIEW</div>
|
| 487 |
+
<!-- video-wrap is the coordinate space for drag boxes -->
|
| 488 |
+
<div class="video-wrap" id="video-wrap-box" style="position:relative;overflow:hidden">
|
| 489 |
+
<img id="thumb-preview" style="display:none;width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1" alt="thumbnail">
|
| 490 |
+
<video id="preview-video" controls style="display:none;position:relative;z-index:2;width:100%;height:100%;object-fit:contain"></video>
|
| 491 |
+
<div class="video-placeholder" id="video-placeholder"><i class="fas fa-film"></i><p>Preview will appear here</p></div>
|
| 492 |
+
|
| 493 |
+
<!-- 🟥 BLUR BOX — drag to position over subtitles -->
|
| 494 |
+
<div id="blurBox" class="drag-box" style="width:200px;height:50px;left:50px;top:200px">
|
| 495 |
+
<div class="resize-handle"></div>
|
| 496 |
+
</div>
|
| 497 |
+
|
| 498 |
+
<!-- 🟩 LOGO BOX — drag to position logo -->
|
| 499 |
+
<div id="logoBox" class="drag-box" style="width:70px;height:70px;left:10px;top:10px">
|
| 500 |
+
<img id="logo-preview-img" style="width:100%;height:100%;object-fit:contain;pointer-events:none;display:none" draggable="false">
|
| 501 |
+
<div class="resize-handle"></div>
|
| 502 |
+
</div>
|
| 503 |
+
</div>
|
| 504 |
+
<div class="preview-bottom">
|
| 505 |
+
<div class="meta-title" id="meta-title" style="display:none"></div>
|
| 506 |
+
<div class="meta-tags" id="meta-tags" style="display:none"></div>
|
| 507 |
+
<button class="download-btn" id="download-btn" onclick="downloadVideo()"><i class="fas fa-download"></i> Download MP4</button>
|
| 508 |
+
<button class="copy-caption-btn" id="copy-caption-btn" onclick="copyCaption()"><i class="fas fa-copy"></i> Copy Caption</button>
|
| 509 |
+
<button class="copy-caption-btn" id="copy-link-btn" onclick="copyVideoLink()" style="display:none;margin-top:6px"><i class="fas fa-link"></i> Copy Video Link</button>
|
| 510 |
+
<button class="download-btn" id="download-btn2" onclick="downloadVideo()" style="display:none;margin-top:6px;background:rgba(9,132,227,.06);border-color:rgba(9,132,227,.2);color:var(--cyan)"><i class="fas fa-link"></i> Open Link</button>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
</div>
|
| 514 |
+
</div><!-- end .app-wrap -->
|
| 515 |
+
</div><!-- end #app-screen -->
|
| 516 |
+
|
| 517 |
+
<!-- ADMIN MODAL -->
|
| 518 |
+
<div id="admin-modal">
|
| 519 |
+
<div class="admin-box">
|
| 520 |
+
<h3><i class="fas fa-crown"></i> Admin Panel <button class="close-admin" onclick="closeAdmin()"><i class="fas fa-times"></i></button></h3>
|
| 521 |
+
<div class="admin-section">
|
| 522 |
+
<h4>Create User</h4>
|
| 523 |
+
<div class="admin-row">
|
| 524 |
+
<input class="input" id="new-uname" placeholder="Username (leave blank = auto)">
|
| 525 |
+
<input class="input" id="new-coins" type="number" value="10" style="width:80px;flex:none">
|
| 526 |
+
<button class="btn-admin-action" onclick="genUsername()">⚡ Gen</button>
|
| 527 |
+
<button class="btn-admin-action" onclick="adminCreateUser()">+ Create</button>
|
| 528 |
+
</div>
|
| 529 |
+
<div id="create-result" style="font-size:.78rem;margin-top:4px"></div>
|
| 530 |
+
</div>
|
| 531 |
+
<div class="admin-section">
|
| 532 |
+
<h4>Manage Coins</h4>
|
| 533 |
+
<div class="admin-row">
|
| 534 |
+
<input class="input" id="coin-user" placeholder="Username">
|
| 535 |
+
<input class="input" id="coin-amt" type="number" value="10" style="width:80px;flex:none">
|
| 536 |
+
<button class="btn-admin-action" onclick="adminCoins('add')">+ Add</button>
|
| 537 |
+
<button class="btn-admin-action" onclick="adminCoins('set')">= Set</button>
|
| 538 |
+
</div>
|
| 539 |
+
<div id="coin-result" style="font-size:.78rem;margin-top:4px"></div>
|
| 540 |
+
</div>
|
| 541 |
+
<div class="admin-section">
|
| 542 |
+
<h4>Users</h4>
|
| 543 |
+
<div id="users-wrap"></div>
|
| 544 |
</div>
|
| 545 |
+
</div>
|
| 546 |
</div>
|
| 547 |
+
|
| 548 |
+
<!-- BUY MODAL -->
|
| 549 |
+
<div id="buy-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;align-items:center;justify-content:center">
|
| 550 |
+
<div class="admin-box" style="max-width:360px">
|
| 551 |
+
<h3><i class="fas fa-wallet"></i> Buy Credits <button class="close-admin" onclick="closeBuyModal()"><i class="fas fa-times"></i></button></h3>
|
| 552 |
+
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:14px">
|
| 553 |
+
<div class="buy-pkg" onclick="selectPkg(this,10,10000)" style="padding:14px 16px;border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:.2s;display:flex;justify-content:space-between;align-items:center">
|
| 554 |
+
<div><div style="font-weight:700;font-size:1rem">🪙 10 Coins</div><div style="font-size:.75rem;color:var(--muted2);margin-top:2px">Process 5 videos</div></div>
|
| 555 |
+
<div style="font-weight:700;color:var(--amber)">10,000 MMK</div>
|
| 556 |
+
</div>
|
| 557 |
+
<div class="buy-pkg" onclick="selectPkg(this,20,18000)" style="padding:14px 16px;border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:.2s;display:flex;justify-content:space-between;align-items:center">
|
| 558 |
+
<div><div style="font-weight:700;font-size:1rem">🪙 20 Coins</div><div style="font-size:.75rem;color:var(--muted2);margin-top:2px">Process 10 videos</div></div>
|
| 559 |
+
<div style="font-weight:700;color:var(--amber)">18,000 MMK</div>
|
| 560 |
+
</div>
|
| 561 |
+
<div class="buy-pkg" onclick="selectPkg(this,30,28000)" style="padding:14px 16px;border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:.2s;display:flex;justify-content:space-between;align-items:center">
|
| 562 |
+
<div><div style="font-weight:700;font-size:1rem">🪙 30 Coins</div><div style="font-size:.75rem;color:var(--muted2);margin-top:2px">Process 15 videos</div></div>
|
| 563 |
+
<div style="font-weight:700;color:var(--amber)">28,000 MMK</div>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
<div id="buy-selected" style="display:none;padding:12px;background:var(--bg3);border-radius:8px;margin-bottom:14px;font-size:.82rem;color:var(--muted2);text-align:center">
|
| 567 |
+
<span id="buy-pkg-txt"></span><br>
|
| 568 |
+
<a href="http://t.me/PhoeShan2001" target="_blank" style="color:var(--cyan);font-size:.75rem;text-decoration:none"><i class="fab fa-telegram"></i> Contact Admin after payment → @PhoeShan2001</a>
|
| 569 |
+
</div>
|
| 570 |
+
<button onclick="closeBuyModal()" style="width:100%;padding:11px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;color:var(--muted2);font-family:var(--sans);font-size:.9rem;cursor:pointer">Close</button>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
|
| 574 |
+
<div class="toast" id="toast"></div>
|
| 575 |
+
|
| 576 |
+
<script>
|
| 577 |
+
/* ═══════════════════════════════════════════
|
| 578 |
+
STATE
|
| 579 |
+
═══════════════════════════════════════════ */
|
| 580 |
+
let CUR_USER = localStorage.getItem('recap_user') || '';
|
| 581 |
+
let CUR_COINS = 0;
|
| 582 |
+
let IS_ADMIN = false;
|
| 583 |
+
let AUTH_MODE = 'login';
|
| 584 |
+
let SELECTED_VOICE = 'my-MM-ThihaNeural';
|
| 585 |
+
let SELECTED_ENGINE = 'ms';
|
| 586 |
+
let VO_LANG = 'my';
|
| 587 |
+
let MODE = 'full';
|
| 588 |
+
let INPUT_MODE = 'url';
|
| 589 |
+
let VCAT = 'ms';
|
| 590 |
+
let CUR_OUTPUT_URL = '';
|
| 591 |
+
let CUR_CAPTION = '';
|
| 592 |
+
let CUR_HASHTAGS = '';
|
| 593 |
+
let CURRENT_TID = '';
|
| 594 |
+
let SSE_SOURCE = null;
|
| 595 |
+
|
| 596 |
+
// Logo state
|
| 597 |
+
let LOGO_X = 10, LOGO_Y = 10, LOGO_W = 80, LOGO_ENABLED = false;
|
| 598 |
+
|
| 599 |
+
// Blur state
|
| 600 |
+
let BLUR_ENABLED = false;
|
| 601 |
+
let BLUR_X = 0, BLUR_Y = 0, BLUR_W = 0, BLUR_H = 0;
|
| 602 |
+
|
| 603 |
+
/* ═══════════════════════════════════════════
|
| 604 |
+
VOICE DATA
|
| 605 |
+
═══════════════════════════════════════════ */
|
| 606 |
+
const MS_V = [
|
| 607 |
+
{id:'my-MM-ThihaNeural', name:'သီ���', sub:'ကျား — ယုံကြည်မှုရှိ၊ ကြည်လင်', lang:'my'},
|
| 608 |
+
{id:'my-MM-NilarNeural', name:'နီလာ', sub:'မိန်း — နူးညံ့၊ သာယာ', lang:'my'},
|
| 609 |
+
{id:'th-TH-PremwadeeNeural', name:'ပြမ်းဝါဒီ', sub:'မိန်း — နူးညံ့၊ သဘာဝကျ', lang:'th'},
|
| 610 |
+
{id:'th-TH-NiwatNeural', name:'နီဝတ်', sub:'ကျား — ပြတ်သား၊ ရှင်းလင်း', lang:'th'},
|
| 611 |
+
{id:'th-TH-AcharaNeural', name:'အာချာရာ', sub:'မိန်း — ဖော်ရွေ၊ သက်ဆင်း', lang:'th'},
|
| 612 |
+
{id:'en-US-AriaNeural', name:'Aria', sub:'မိန်း — ကြည်လင်၊ သဘာဝကျ', lang:'en'},
|
| 613 |
+
{id:'en-US-GuyNeural', name:'Guy', sub:'ကျား — နက်ရှိုင်း၊ ယုံကြည်မှုရှိ',lang:'en'},
|
| 614 |
+
{id:'en-US-JennyNeural', name:'Jenny', sub:'မိန်း — ဖော်ရွေ၊ ပူးပေါင်း', lang:'en'},
|
| 615 |
+
{id:'en-US-AnaNeural', name:'Ana', sub:'မိန်း — ချိုသာ၊ လတ်ဆတ်', lang:'en'},
|
| 616 |
+
{id:'en-US-DavisNeural', name:'Davis', sub:'ကျား — ပျော်ရွှင်၊ တက်ကြွ', lang:'en'},
|
| 617 |
+
{id:'en-US-EmmaNeural', name:'Emma', sub:'မိန်း — နူးညံ့၊ သာယာ', lang:'en'},
|
| 618 |
+
{id:'en-US-JasonNeural', name:'Jason', sub:'ကျား — နက်ရှိုင်း၊ တည်ငြိမ်', lang:'en'},
|
| 619 |
+
{id:'en-US-SaraNeural', name:'Sara', sub:'မိန်း — ကြည်လင်၊ ကျွမ်းကျင်', lang:'en'},
|
| 620 |
+
{id:'en-US-TonyNeural', name:'Tony', sub:'ကျား — တက်ကြွ၊ ထက်မြက်', lang:'en'},
|
| 621 |
+
{id:'en-GB-SoniaNeural', name:'Sonia', sub:'မိန်း (UK) — ယဉ်ကျေး', lang:'en'},
|
| 622 |
+
{id:'en-GB-RyanNeural', name:'Ryan', sub:'ကျား (UK) — ပြတ်သား', lang:'en'},
|
| 623 |
+
{id:'en-GB-LibbyNeural', name:'Libby', sub:'မိန်း (UK) — ဖော်ရွေ', lang:'en'},
|
| 624 |
+
{id:'en-GB-MaisieNeural', name:'Maisie', sub:'မိန်း (UK) — ချိုသာ', lang:'en'},
|
| 625 |
+
{id:'en-GB-ThomasNeural', name:'Thomas', sub:'ကျား (UK) — တည်ငြိမ်', lang:'en'},
|
| 626 |
+
];
|
| 627 |
+
let GEMINI_V = [];
|
| 628 |
+
|
| 629 |
+
/* ═══════════════════════════════════════════
|
| 630 |
+
INIT
|
| 631 |
+
═══════════════════════════════════════════ */
|
| 632 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 633 |
+
if(CUR_USER) autoLogin(); else renderVoices('ms');
|
| 634 |
+
makeInteractive('blurBox');
|
| 635 |
+
makeInteractive('logoBox');
|
| 636 |
+
window.addEventListener('resize', () => { syncBoxCoords('blurBox'); syncBoxCoords('logoBox'); });
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
/* ═══════════════════════════════════════════
|
| 640 |
+
AUTH
|
| 641 |
+
═══════════════════════════════════════════ */
|
| 642 |
+
async function autoLogin(){
|
| 643 |
+
try {
|
| 644 |
+
const r = await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:CUR_USER,password:''})});
|
| 645 |
+
const d = await r.json();
|
| 646 |
+
if(d.ok) showApp(CUR_USER, d.coins, d.is_admin);
|
| 647 |
+
else { localStorage.removeItem('recap_user'); renderVoices('ms'); }
|
| 648 |
+
} catch { localStorage.removeItem('recap_user'); renderVoices('ms'); }
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
async function doAuth(){
|
| 652 |
+
const u = document.getElementById('auth-user').value.trim();
|
| 653 |
+
const p = document.getElementById('auth-pass').value;
|
| 654 |
+
const msgEl = document.getElementById('auth-msg');
|
| 655 |
+
if(!u){ msgEl.textContent='Enter username'; msgEl.className='msg-box msg-err'; msgEl.style.display='block'; return; }
|
| 656 |
+
const btn = document.getElementById('auth-btn'); btn.disabled=true;
|
| 657 |
+
try {
|
| 658 |
+
const ep = AUTH_MODE==='register' ? '/api/register' : '/api/login';
|
| 659 |
+
const r = await fetch(ep,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
|
| 660 |
+
const d = await r.json();
|
| 661 |
+
msgEl.textContent = d.msg; msgEl.className = 'msg-box '+(d.ok?'msg-ok':'msg-err'); msgEl.style.display='block';
|
| 662 |
+
if(d.ok){
|
| 663 |
+
const uname = d.username || u;
|
| 664 |
+
localStorage.setItem('recap_user', uname); CUR_USER = uname;
|
| 665 |
+
setTimeout(()=>showApp(uname, d.coins, d.is_admin), 600);
|
| 666 |
+
}
|
| 667 |
+
} catch(e){ msgEl.textContent='Connection error'; msgEl.className='msg-box msg-err'; msgEl.style.display='block'; }
|
| 668 |
+
btn.disabled=false;
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
function showApp(user, coins, isAdmin){
|
| 672 |
+
CUR_USER=user; CUR_COINS=coins===-1?999:coins; IS_ADMIN=isAdmin;
|
| 673 |
+
document.getElementById('login-screen').style.display='none';
|
| 674 |
+
document.getElementById('app-screen').style.display='block';
|
| 675 |
+
document.getElementById('topbar').style.display='flex';
|
| 676 |
+
document.getElementById('tb-username').textContent = user;
|
| 677 |
+
document.getElementById('tb-coins').textContent = coins===-1?'∞':coins;
|
| 678 |
+
renderVoices('ms');
|
| 679 |
+
if(isAdmin) document.getElementById('admin-btn').style.display='';
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
function doLogout(){ localStorage.removeItem('recap_user'); location.reload(); }
|
| 683 |
+
|
| 684 |
+
/* ═══════════════════════════════════════════
|
| 685 |
+
DRAG BOX SYSTEM (blur + logo)
|
| 686 |
+
Reads actual pixel pos from #video-wrap-box
|
| 687 |
+
and converts to video coordinates.
|
| 688 |
+
═══════════════════════════════════════════ */
|
| 689 |
+
function makeInteractive(id){
|
| 690 |
+
const box = document.getElementById(id);
|
| 691 |
+
const handle = box.querySelector('.resize-handle');
|
| 692 |
+
if(!box || !handle) return;
|
| 693 |
+
let isDrag=false, isResize=false, startX, startY, sl, st, sw, sh;
|
| 694 |
+
|
| 695 |
+
const getContainer = () => document.getElementById('video-wrap-box');
|
| 696 |
+
|
| 697 |
+
const onStart = (e) => {
|
| 698 |
+
if(e.target === handle) return;
|
| 699 |
+
isDrag=true;
|
| 700 |
+
const p = e.type.includes('mouse') ? e : e.touches[0];
|
| 701 |
+
startX=p.clientX; startY=p.clientY; sl=box.offsetLeft; st=box.offsetTop;
|
| 702 |
+
};
|
| 703 |
+
const onResizeStart = (e) => {
|
| 704 |
+
e.stopPropagation(); isResize=true;
|
| 705 |
+
const p = e.type.includes('mouse') ? e : e.touches[0];
|
| 706 |
+
startX=p.clientX; startY=p.clientY; sw=box.offsetWidth; sh=box.offsetHeight;
|
| 707 |
+
};
|
| 708 |
+
const onMove = (e) => {
|
| 709 |
+
const c = getContainer();
|
| 710 |
+
if(!isDrag && !isResize) return;
|
| 711 |
+
if(e.cancelable) e.preventDefault();
|
| 712 |
+
const p = e.type.includes('mouse') ? e : e.touches[0];
|
| 713 |
+
if(isDrag){
|
| 714 |
+
let nl = sl+(p.clientX-startX);
|
| 715 |
+
let nt = st+(p.clientY-startY);
|
| 716 |
+
nl = Math.max(0, Math.min(nl, c.clientWidth-box.offsetWidth));
|
| 717 |
+
nt = Math.max(0, Math.min(nt, c.clientHeight-box.offsetHeight));
|
| 718 |
+
box.style.left=nl+'px'; box.style.top=nt+'px';
|
| 719 |
+
}
|
| 720 |
+
if(isResize){
|
| 721 |
+
let nw = Math.max(30, sw+(p.clientX-startX));
|
| 722 |
+
let nh = Math.max(20, sh+(p.clientY-startY));
|
| 723 |
+
box.style.width=nw+'px'; box.style.height=nh+'px';
|
| 724 |
+
}
|
| 725 |
+
syncBoxCoords(id);
|
| 726 |
+
};
|
| 727 |
+
const onEnd = () => { isDrag=false; isResize=false; };
|
| 728 |
+
|
| 729 |
+
box.addEventListener('mousedown', onStart);
|
| 730 |
+
box.addEventListener('touchstart', onStart, {passive:false});
|
| 731 |
+
handle.addEventListener('mousedown', onResizeStart);
|
| 732 |
+
handle.addEventListener('touchstart', onResizeStart, {passive:false});
|
| 733 |
+
window.addEventListener('mousemove', onMove);
|
| 734 |
+
window.addEventListener('touchmove', onMove, {passive:false});
|
| 735 |
+
window.addEventListener('mouseup', onEnd);
|
| 736 |
+
window.addEventListener('touchend', onEnd);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
function syncBoxCoords(id){
|
| 740 |
+
const container = document.getElementById('video-wrap-box');
|
| 741 |
+
const box = document.getElementById(id);
|
| 742 |
+
if(!container || !box) return;
|
| 743 |
+
|
| 744 |
+
// We use the container dimensions as the coordinate space.
|
| 745 |
+
// The backend will scale these to actual video resolution via the ratio.
|
| 746 |
+
const cw = container.clientWidth || 720;
|
| 747 |
+
const ch = container.clientHeight || 1280;
|
| 748 |
+
|
| 749 |
+
// video native dims (fallback to 9:16)
|
| 750 |
+
const vid = document.getElementById('preview-video');
|
| 751 |
+
const vw = (vid && vid.videoWidth) ? vid.videoWidth : 720;
|
| 752 |
+
const vh = (vid && vid.videoHeight) ? vid.videoHeight : 1280;
|
| 753 |
+
|
| 754 |
+
const scaleX = vw / cw;
|
| 755 |
+
const scaleY = vh / ch;
|
| 756 |
+
|
| 757 |
+
const x = Math.round(box.offsetLeft * scaleX);
|
| 758 |
+
const y = Math.round(box.offsetTop * scaleY);
|
| 759 |
+
const w = Math.round(box.offsetWidth * scaleX);
|
| 760 |
+
const h = Math.round(box.offsetHeight * scaleY);
|
| 761 |
+
|
| 762 |
+
if(id === 'blurBox'){
|
| 763 |
+
BLUR_X=x; BLUR_Y=y; BLUR_W=w; BLUR_H=h;
|
| 764 |
+
} else {
|
| 765 |
+
LOGO_X=x; LOGO_Y=y; LOGO_W=w;
|
| 766 |
+
// Update sliders to match drag
|
| 767 |
+
const sx = document.getElementById('logo-x-slider');
|
| 768 |
+
const sy = document.getElementById('logo-y-slider');
|
| 769 |
+
if(sx){ sx.value=x; document.getElementById('logo-x-val').textContent=x; }
|
| 770 |
+
if(sy){ sy.value=y; document.getElementById('logo-y-val').textContent=y; }
|
| 771 |
+
updateLogoBoxSize();
|
| 772 |
+
}
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
/* ═══════════════════════════════════════════
|
| 776 |
+
BLUR TOGGLE
|
| 777 |
+
═══════════════════════════════════════════ */
|
| 778 |
+
function toggleBlur(el){
|
| 779 |
+
el.classList.toggle('checked');
|
| 780 |
+
BLUR_ENABLED = el.classList.contains('checked');
|
| 781 |
+
el.querySelector('.check-box').innerHTML = BLUR_ENABLED ? '<i class="fas fa-check"></i>' : '';
|
| 782 |
+
document.getElementById('blur-lbl').textContent = BLUR_ENABLED ? 'On' : 'Off';
|
| 783 |
+
document.getElementById('blur-section').style.display = BLUR_ENABLED ? '' : 'none';
|
| 784 |
+
const box = document.getElementById('blurBox');
|
| 785 |
+
box.style.display = BLUR_ENABLED ? 'block' : 'none';
|
| 786 |
+
if(BLUR_ENABLED) syncBoxCoords('blurBox');
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
/* ���══════════════════════════════════════════
|
| 790 |
+
LOGO TOGGLE + HANDLERS
|
| 791 |
+
═══════════════════════════════════════════ */
|
| 792 |
+
function toggleLogo(el){
|
| 793 |
+
el.classList.toggle('checked');
|
| 794 |
+
LOGO_ENABLED = el.classList.contains('checked');
|
| 795 |
+
el.querySelector('.check-box').innerHTML = LOGO_ENABLED ? '<i class="fas fa-check"></i>' : '';
|
| 796 |
+
document.getElementById('logo-lbl').textContent = LOGO_ENABLED ? 'On' : 'Off';
|
| 797 |
+
document.getElementById('logo-section').style.display = LOGO_ENABLED ? '' : 'none';
|
| 798 |
+
const box = document.getElementById('logoBox');
|
| 799 |
+
box.style.display = LOGO_ENABLED ? 'block' : 'none';
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
function onLogoSelect(inp){
|
| 803 |
+
if(!inp.files[0]) return;
|
| 804 |
+
document.getElementById('logo-name').textContent = '🖼️ '+inp.files[0].name;
|
| 805 |
+
const img = document.getElementById('logo-preview-img');
|
| 806 |
+
img.src = URL.createObjectURL(inp.files[0]);
|
| 807 |
+
img.style.display = 'block';
|
| 808 |
+
document.getElementById('logo-pos-wrap').style.display = '';
|
| 809 |
+
syncBoxCoords('logoBox');
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
function updateLogoBoxSize(){
|
| 813 |
+
const box = document.getElementById('logoBox');
|
| 814 |
+
const container = document.getElementById('video-wrap-box');
|
| 815 |
+
if(!box || !container) return;
|
| 816 |
+
const cw = container.clientWidth || 720;
|
| 817 |
+
const vw = 720;
|
| 818 |
+
const displayW = Math.round(LOGO_W * cw / vw);
|
| 819 |
+
box.style.width = displayW+'px';
|
| 820 |
+
box.style.height = displayW+'px';
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
function onLogoSizeChange(val){
|
| 824 |
+
LOGO_W = parseInt(val);
|
| 825 |
+
document.getElementById('logo-size-val').textContent = val+'px';
|
| 826 |
+
updateLogoBoxSize();
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
function onLogoPosSlider(){
|
| 830 |
+
LOGO_X = parseInt(document.getElementById('logo-x-slider').value);
|
| 831 |
+
LOGO_Y = parseInt(document.getElementById('logo-y-slider').value);
|
| 832 |
+
document.getElementById('logo-x-val').textContent = LOGO_X;
|
| 833 |
+
document.getElementById('logo-y-val').textContent = LOGO_Y;
|
| 834 |
+
document.querySelectorAll('.pos-btn').forEach(b=>b.classList.remove('active'));
|
| 835 |
+
// Move logoBox on screen
|
| 836 |
+
const container = document.getElementById('video-wrap-box');
|
| 837 |
+
const box = document.getElementById('logoBox');
|
| 838 |
+
if(container && box){
|
| 839 |
+
const cw = container.clientWidth || 720;
|
| 840 |
+
const ch = container.clientHeight || 1280;
|
| 841 |
+
box.style.left = Math.round(LOGO_X * cw / 720)+'px';
|
| 842 |
+
box.style.top = Math.round(LOGO_Y * ch / 1280)+'px';
|
| 843 |
+
}
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
function setLogoPos(pos){
|
| 847 |
+
const dims = getCropDims();
|
| 848 |
+
const pad = 20;
|
| 849 |
+
const positions = {
|
| 850 |
+
tl:{x:pad, y:pad},
|
| 851 |
+
tc:{x:(dims.w-LOGO_W)/2, y:pad},
|
| 852 |
+
tr:{x:dims.w-LOGO_W-pad, y:pad},
|
| 853 |
+
ml:{x:pad, y:(dims.h-LOGO_W)/2},
|
| 854 |
+
mc:{x:(dims.w-LOGO_W)/2, y:(dims.h-LOGO_W)/2},
|
| 855 |
+
mr:{x:dims.w-LOGO_W-pad, y:(dims.h-LOGO_W)/2},
|
| 856 |
+
bl:{x:pad, y:dims.h-LOGO_W-pad},
|
| 857 |
+
bc:{x:(dims.w-LOGO_W)/2, y:dims.h-LOGO_W-pad},
|
| 858 |
+
br:{x:dims.w-LOGO_W-pad, y:dims.h-LOGO_W-pad},
|
| 859 |
+
};
|
| 860 |
+
const p = positions[pos];
|
| 861 |
+
LOGO_X = Math.max(0, Math.round(p.x));
|
| 862 |
+
LOGO_Y = Math.max(0, Math.round(p.y));
|
| 863 |
+
document.getElementById('logo-x-slider').value = LOGO_X;
|
| 864 |
+
document.getElementById('logo-y-slider').value = LOGO_Y;
|
| 865 |
+
document.getElementById('logo-x-val').textContent = LOGO_X;
|
| 866 |
+
document.getElementById('logo-y-val').textContent = LOGO_Y;
|
| 867 |
+
document.querySelectorAll('.pos-btn').forEach(b=>b.classList.remove('active'));
|
| 868 |
+
event.target.classList.add('active');
|
| 869 |
+
onLogoPosSlider();
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
function getCropDims(){
|
| 873 |
+
const crop = document.getElementById('crop').value;
|
| 874 |
+
if(crop==='16:9') return {w:1280,h:720};
|
| 875 |
+
if(crop==='1:1') return {w:720,h:720};
|
| 876 |
+
return {w:720,h:1280};
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
/* ═══════════════════════════════════════════
|
| 880 |
+
VIDEO INPUT
|
| 881 |
+
═══════════════════════════════════════════ */
|
| 882 |
+
function switchInputMode(mode){
|
| 883 |
+
INPUT_MODE=mode;
|
| 884 |
+
document.getElementById('input-tab-url').classList.toggle('active', mode==='url');
|
| 885 |
+
document.getElementById('input-tab-upload').classList.toggle('active', mode==='upload');
|
| 886 |
+
document.getElementById('input-url-section').style.display = mode==='url' ? '' : 'none';
|
| 887 |
+
document.getElementById('input-upload-section').style.display = mode==='upload' ? '' : 'none';
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
function pasteUrl(){
|
| 891 |
+
navigator.clipboard.readText().then(t=>{
|
| 892 |
+
document.getElementById('video-url').value = t.trim();
|
| 893 |
+
detectPlatform(t.trim()); fetchThumbnail(t.trim());
|
| 894 |
+
}).catch(()=>toast('Clipboard access denied'));
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
function detectPlatform(url){
|
| 898 |
+
const badge=document.getElementById('url-badge'), wrap=document.getElementById('url-platform');
|
| 899 |
+
let p='';
|
| 900 |
+
if(/youtu\.?be/i.test(url)) p='<i class="fab fa-youtube"></i> YouTube';
|
| 901 |
+
else if(/tiktok/i.test(url)) p='<i class="fab fa-tiktok"></i> TikTok';
|
| 902 |
+
else if(/facebook|fb\./i.test(url)) p='<i class="fab fa-facebook"></i> Facebook';
|
| 903 |
+
else if(/instagram/i.test(url)) p='<i class="fab fa-instagram"></i> Instagram';
|
| 904 |
+
else if(/twitter|x\.com/i.test(url)) p='<i class="fab fa-twitter"></i> Twitter/X';
|
| 905 |
+
else if(url) p='<i class="fas fa-globe"></i> Video URL';
|
| 906 |
+
if(p){ badge.innerHTML=p; wrap.style.display=''; } else { wrap.style.display='none'; }
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
document.addEventListener('DOMContentLoaded', ()=>{
|
| 910 |
+
const ui = document.getElementById('video-url');
|
| 911 |
+
if(ui) ui.addEventListener('input', e=>{ detectPlatform(e.target.value); fetchThumbnail(e.target.value); });
|
| 912 |
+
});
|
| 913 |
+
|
| 914 |
+
function hasVideoInput(){
|
| 915 |
+
if(INPUT_MODE==='url') return document.getElementById('video-url').value.trim().length>0;
|
| 916 |
+
return !!(document.getElementById('video-file').files[0]);
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
function onFileSelect(inp){
|
| 920 |
+
if(inp.files[0]){
|
| 921 |
+
const nm=document.getElementById('upload-name');
|
| 922 |
+
nm.textContent='📎 '+inp.files[0].name; nm.style.display='block';
|
| 923 |
+
showPreviewVideo(URL.createObjectURL(inp.files[0]));
|
| 924 |
+
}
|
| 925 |
+
}
|
| 926 |
+
function onMusicSelect(inp){ if(inp.files[0]) document.getElementById('music-name').textContent='🎵 '+inp.files[0].name; }
|
| 927 |
+
|
| 928 |
+
/* ═══════════════════════════════════════════
|
| 929 |
+
THUMBNAIL / DRAG-DROP
|
| 930 |
+
═══════════════════════════════════════════ */
|
| 931 |
+
async function fetchThumbnail(url){
|
| 932 |
+
if(!url||!url.startsWith('http')) return;
|
| 933 |
+
const thumb=document.getElementById('thumb-preview');
|
| 934 |
+
const yt=url.match(/(?:youtu\.be\/|[?&]v=|shorts\/)([A-Za-z0-9_-]{11})/);
|
| 935 |
+
if(yt){ thumb.src=`https://img.youtube.com/vi/${yt[1]}/hqdefault.jpg`; thumb.style.display='block'; document.getElementById('video-placeholder').style.display='none'; return; }
|
| 936 |
+
if(/tiktok\.com/i.test(url)){
|
| 937 |
+
try{ const r=await fetch(`https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`); const d=await r.json(); if(d.thumbnail_url){ thumb.src=d.thumbnail_url; thumb.style.display='block'; document.getElementById('video-placeholder').style.display='none'; return; } }catch(e){}
|
| 938 |
+
}
|
| 939 |
+
if(/facebook\.com|fb\.watch/i.test(url)){ thumb.style.display='none'; const ph=document.getElementById('video-placeholder'); ph.style.display='flex'; ph.innerHTML='<i class="fab fa-facebook" style="font-size:2.5rem;color:#1877f2"></i><p style="font-size:.8rem">Facebook Video</p>'; return; }
|
| 940 |
+
if(/instagram\.com/i.test(url)){ thumb.style.display='none'; const ph=document.getElementById('video-placeholder'); ph.style.display='flex'; ph.innerHTML='<i class="fab fa-instagram" style="font-size:2.5rem;color:#e1306c"></i><p style="font-size:.8rem">Instagram Video</p>'; return; }
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
function dragOver(e){ e.preventDefault(); document.getElementById('upload-area').classList.add('drag'); }
|
| 944 |
+
function dragLeave(){ document.getElementById('upload-area').classList.remove('drag'); }
|
| 945 |
+
function dropFile(e){
|
| 946 |
+
e.preventDefault(); dragLeave();
|
| 947 |
+
const f=e.dataTransfer.files[0];
|
| 948 |
+
if(f&&f.type.startsWith('video/')){
|
| 949 |
+
const inp=document.getElementById('video-file');
|
| 950 |
+
const dt=new DataTransfer(); dt.items.add(f); inp.files=dt.files; onFileSelect(inp);
|
| 951 |
+
}
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
/* ═══════════════════════════════════════════
|
| 955 |
+
SPEED TOGGLE
|
| 956 |
+
═══════════════════════════════════════════ */
|
| 957 |
+
function toggleSpeed(){
|
| 958 |
+
document.getElementById('speed-toggle').classList.toggle('open');
|
| 959 |
+
document.getElementById('speed-row').classList.toggle('visible');
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
/* ═══════════════════════════════════════════
|
| 963 |
+
LANGUAGE / VOICE
|
| 964 |
+
═══════════════════════════════════════════ */
|
| 965 |
+
const LANG_DEFAULT_VOICE = {
|
| 966 |
+
my:{id:'my-MM-ThihaNeural', engine:'ms'},
|
| 967 |
+
th:{id:'th-TH-PremwadeeNeural', engine:'ms'},
|
| 968 |
+
en:{id:'en-US-AriaNeural', engine:'ms'},
|
| 969 |
+
};
|
| 970 |
+
const LANG_DEFAULT_SPEED = {my:30, th:20, en:0};
|
| 971 |
+
|
| 972 |
+
function switchLang(lang){
|
| 973 |
+
VO_LANG=lang;
|
| 974 |
+
['my','th','en'].forEach(l=>document.getElementById('lang-'+l).classList.toggle('active', l===lang));
|
| 975 |
+
const spd=LANG_DEFAULT_SPEED[lang]??30;
|
| 976 |
+
const sl=document.getElementById('speed-slider');
|
| 977 |
+
if(sl){ sl.value=spd; document.getElementById('speed-val').textContent=spd+'%'; }
|
| 978 |
+
if(VCAT==='ms'||lang!=='my'){
|
| 979 |
+
if(lang!=='my'){ VCAT='ms'; SELECTED_ENGINE='ms'; document.getElementById('vcat-ms').classList.add('active'); document.getElementById('vcat-g').classList.remove('active'); }
|
| 980 |
+
const def=LANG_DEFAULT_VOICE[lang]; SELECTED_VOICE=def.id; SELECTED_ENGINE=def.engine;
|
| 981 |
+
}
|
| 982 |
+
renderVoices(VCAT);
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
function switchVCat(c){
|
| 986 |
+
VCAT=c;
|
| 987 |
+
document.getElementById('vcat-ms').classList.toggle('active', c==='ms');
|
| 988 |
+
document.getElementById('vcat-g').classList.toggle('active', c==='g');
|
| 989 |
+
SELECTED_ENGINE=c==='g'?'gemini':'ms';
|
| 990 |
+
if(c==='g'&&GEMINI_V.length===0){
|
| 991 |
+
fetch('/api/gemini_voices').then(r=>r.json()).then(d=>{ if(d.ok) d.voices.forEach(v=>GEMINI_V.push({id:v.id,name:v.name,sub:'',lang:'my'})); renderVoices('g'); });
|
| 992 |
+
} else renderVoices(c);
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
function renderVoices(cat){
|
| 996 |
+
const grid=document.getElementById('voice-grid');
|
| 997 |
+
const q=document.getElementById('voice-search').value.toLowerCase();
|
| 998 |
+
let voices = cat==='g' ? GEMINI_V : MS_V.filter(v=>v.lang===VO_LANG);
|
| 999 |
+
const filtered=voices.filter(v=>v.name.toLowerCase().includes(q)||v.id.toLowerCase().includes(q)||(v.sub||'').toLowerCase().includes(q));
|
| 1000 |
+
const isMS=cat!=='g';
|
| 1001 |
+
grid.innerHTML=filtered.map(v=>`
|
| 1002 |
+
<div class="vcard ${SELECTED_VOICE===v.id?'selected':''}" onclick="selectVoice('${v.id}','${v.lang||'my'}',this)">
|
| 1003 |
+
<div class="vcard-name">${v.name}</div>
|
| 1004 |
+
<div class="vcard-sub">${v.sub||''}</div>
|
| 1005 |
+
${isMS?`<div class="vcard-play" id="play-${v.id.replace(/[^a-z0-9]/gi,'_')}" onclick="event.stopPropagation();previewVoice('${v.id}',this)"><i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်</div>`:''}
|
| 1006 |
+
</div>`).join('');
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
function filterVoices(){ renderVoices(VCAT); }
|
| 1010 |
+
|
| 1011 |
+
let _previewAudio=null;
|
| 1012 |
+
async function previewVoice(voiceId, btnEl){
|
| 1013 |
+
if(_previewAudio){ _previewAudio.pause(); _previewAudio=null; }
|
| 1014 |
+
document.querySelectorAll('.vcard-play').forEach(b=>{ b.classList.remove('playing'); b.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; });
|
| 1015 |
+
btnEl.classList.add('playing'); btnEl.innerHTML='<i class="fas fa-spinner spinning" style="font-size:.55rem"></i> Loading…';
|
| 1016 |
+
try {
|
| 1017 |
+
const spd=document.getElementById('speed-slider').value;
|
| 1018 |
+
const r=await fetch('/api/preview_voice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:voiceId,speed:parseInt(spd),engine:'ms'})});
|
| 1019 |
+
const d=await r.json();
|
| 1020 |
+
if(!d.ok){ toast('❌ Preview failed'); btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; return; }
|
| 1021 |
+
_previewAudio=new Audio(d.url); _previewAudio.play();
|
| 1022 |
+
btnEl.innerHTML='<i class="fas fa-volume-up" style="font-size:.55rem"></i> ဖွင့်နေ…';
|
| 1023 |
+
_previewAudio.onended=()=>{ btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; _previewAudio=null; };
|
| 1024 |
+
} catch(e){ toast('❌ '+e); btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; }
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
function selectVoice(id, lang, el){
|
| 1028 |
+
SELECTED_VOICE=id; if(lang) VO_LANG=lang;
|
| 1029 |
+
document.querySelectorAll('.vcard').forEach(c=>c.classList.remove('selected')); el.classList.add('selected');
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
/* ═══════════════════════════════════════════
|
| 1033 |
+
OPTIONS / CHECKS
|
| 1034 |
+
═══════════════════════════════════════════ */
|
| 1035 |
+
function togCheck(el){
|
| 1036 |
+
el.classList.toggle('checked');
|
| 1037 |
+
el.querySelector('.check-box').innerHTML = el.classList.contains('checked') ? '<i class="fas fa-check"></i>' : '';
|
| 1038 |
+
}
|
| 1039 |
+
function isChecked(id){ return document.getElementById(id).classList.contains('checked'); }
|
| 1040 |
+
|
| 1041 |
+
function onContentTypeChange(val){
|
| 1042 |
+
const n=document.getElementById('funny-notice');
|
| 1043 |
+
if(n) n.style.display=(val==='Funny/Meme')?'block':'none';
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
/* ═══════════════════════════════════════════
|
| 1047 |
+
BUILD FORM DATA — includes blur + logo
|
| 1048 |
+
═══════════════════════════════════════════ */
|
| 1049 |
+
function buildFormData(includeScript){
|
| 1050 |
+
const fd=new FormData();
|
| 1051 |
+
fd.append('username', CUR_USER);
|
| 1052 |
+
fd.append('voice', SELECTED_VOICE);
|
| 1053 |
+
fd.append('engine', SELECTED_ENGINE);
|
| 1054 |
+
fd.append('vo_lang', VO_LANG);
|
| 1055 |
+
fd.append('speed', document.getElementById('speed-slider').value);
|
| 1056 |
+
fd.append('crop', document.getElementById('crop').value);
|
| 1057 |
+
fd.append('flip', isChecked('chk-fl')?'1':'0');
|
| 1058 |
+
fd.append('color', isChecked('chk-ac')?'1':'0');
|
| 1059 |
+
fd.append('watermark',document.getElementById('watermark').value);
|
| 1060 |
+
fd.append('content_type', document.getElementById('content-type').value);
|
| 1061 |
+
fd.append('ai_model', document.getElementById('ai-model').value);
|
| 1062 |
+
|
| 1063 |
+
// Video input
|
| 1064 |
+
if(INPUT_MODE==='url'){
|
| 1065 |
+
const url=document.getElementById('video-url').value.trim();
|
| 1066 |
+
if(url) fd.append('video_url', url);
|
| 1067 |
+
} else {
|
| 1068 |
+
const vf=document.getElementById('video-file').files[0];
|
| 1069 |
+
if(vf) fd.append('video_file', vf);
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
// Music
|
| 1073 |
+
const mf=document.getElementById('music-file').files[0];
|
| 1074 |
+
if(mf) fd.append('music_file', mf);
|
| 1075 |
+
|
| 1076 |
+
// ── Blur box ──
|
| 1077 |
+
fd.append('blur_enabled', BLUR_ENABLED ? '1' : '0');
|
| 1078 |
+
if(BLUR_ENABLED){
|
| 1079 |
+
fd.append('blur_x', BLUR_X);
|
| 1080 |
+
fd.append('blur_y', BLUR_Y);
|
| 1081 |
+
fd.append('blur_w', BLUR_W);
|
| 1082 |
+
fd.append('blur_h', BLUR_H);
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
// ── Logo ──
|
| 1086 |
+
if(LOGO_ENABLED){
|
| 1087 |
+
const lf=document.getElementById('logo-file').files[0];
|
| 1088 |
+
if(lf){
|
| 1089 |
+
fd.append('logo_file', lf);
|
| 1090 |
+
fd.append('logo_x', LOGO_X);
|
| 1091 |
+
fd.append('logo_y', LOGO_Y);
|
| 1092 |
+
fd.append('logo_w', LOGO_W);
|
| 1093 |
+
}
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
if(includeScript){
|
| 1097 |
+
const sc=document.getElementById('script-in').value.trim();
|
| 1098 |
+
if(sc) fd.append('script', sc);
|
| 1099 |
+
}
|
| 1100 |
+
return fd;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
/* ═══════════════════════════════════════════
|
| 1104 |
+
PROCESS ACTIONS
|
| 1105 |
+
═══════════════════════════════════════════ */
|
| 1106 |
+
async function doProcessAll(){
|
| 1107 |
+
if(!hasVideoInput()){ toast('❌ Select a video or enter a URL'); return; }
|
| 1108 |
+
CURRENT_TID=uuid8(); showProg(true); startSSE(CURRENT_TID);
|
| 1109 |
+
const fd=buildFormData(false); fd.append('tid', CURRENT_TID);
|
| 1110 |
+
try {
|
| 1111 |
+
const ctrl=new AbortController();
|
| 1112 |
+
const timer=setTimeout(()=>ctrl.abort(), 1200000);
|
| 1113 |
+
const r=await fetch('/api/process_all',{method:'POST',body:fd,signal:ctrl.signal});
|
| 1114 |
+
clearTimeout(timer);
|
| 1115 |
+
const d=await r.json();
|
| 1116 |
+
if(SSE_SOURCE){ SSE_SOURCE.close(); SSE_SOURCE=null; }
|
| 1117 |
+
if(d.ok){ updateCoins(d.coins); showResult(d.output_url,d.title,d.hashtags,d.caption); }
|
| 1118 |
+
else { showProg(false); toast(d.msg||'❌ Error'); }
|
| 1119 |
+
} catch(e){ showProg(false); toast('❌ '+e); }
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
async function doDraft(){
|
| 1123 |
+
if(!hasVideoInput()){ toast('❌ Select a video or enter a URL'); return; }
|
| 1124 |
+
const btn=document.getElementById('btn-draft'); btn.disabled=true; btn.innerHTML='<i class="fas fa-spinner spinning"></i> Drafting…';
|
| 1125 |
+
const fd=buildFormData(false);
|
| 1126 |
+
try {
|
| 1127 |
+
const r=await fetch('/api/draft',{method:'POST',body:fd});
|
| 1128 |
+
const d=await r.json();
|
| 1129 |
+
if(d.ok){
|
| 1130 |
+
document.getElementById('script-out').value=d.script;
|
| 1131 |
+
document.getElementById('script-chars').textContent=d.script.length+' chars';
|
| 1132 |
+
document.getElementById('script-lang').textContent=d.status||'';
|
| 1133 |
+
updateCoins(d.coins);
|
| 1134 |
+
document.getElementById('draft-result-section').style.display='';
|
| 1135 |
+
} else toast(d.msg||'❌ Error');
|
| 1136 |
+
} catch(e){ toast('❌ '+e); }
|
| 1137 |
+
btn.disabled=false; btn.innerHTML='<i class="fas fa-file-alt"></i> Draft Script (1 Coin)';
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
async function doProcess(){
|
| 1141 |
+
const sc=document.getElementById('script-in').value.trim();
|
| 1142 |
+
if(!hasVideoInput()){ toast('❌ Select a video or enter a URL'); return; }
|
| 1143 |
+
if(!sc){ toast('❌ Enter a script'); return; }
|
| 1144 |
+
const btn=document.getElementById('btn-process'); btn.disabled=true; btn.innerHTML='<i class="fas fa-spinner spinning"></i> Processing…';
|
| 1145 |
+
const fd=buildFormData(true);
|
| 1146 |
+
try {
|
| 1147 |
+
const r=await fetch('/api/process',{method:'POST',body:fd});
|
| 1148 |
+
const d=await r.json();
|
| 1149 |
+
if(d.ok){ updateCoins(d.coins); showResult(d.output_url,'','',''); }
|
| 1150 |
+
else toast(d.msg||'❌ Error');
|
| 1151 |
+
} catch(e){ toast('❌ '+e); }
|
| 1152 |
+
btn.disabled=false; btn.innerHTML='<i class="fas fa-film"></i> Process Video (1 Coin)';
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
/* ═══════════════════════════════════════════
|
| 1156 |
+
PROGRESS / RESULT
|
| 1157 |
+
═══════════════════════════════════════════ */
|
| 1158 |
+
function startSSE(tid){
|
| 1159 |
+
if(SSE_SOURCE) SSE_SOURCE.close();
|
| 1160 |
+
SSE_SOURCE=new EventSource('/api/progress/'+tid);
|
| 1161 |
+
SSE_SOURCE.onmessage=e=>{
|
| 1162 |
+
try {
|
| 1163 |
+
const p=JSON.parse(e.data);
|
| 1164 |
+
document.getElementById('prog-bar').style.width=(p.pct||0)+'%';
|
| 1165 |
+
document.getElementById('prog-msg').textContent=(p.msg||'').replace(/KEY-\d+\s*·\s*\S+/g,'').trim();
|
| 1166 |
+
if(p.done||p.error) SSE_SOURCE.close();
|
| 1167 |
+
} catch{}
|
| 1168 |
+
};
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
function showProg(show){
|
| 1172 |
+
document.getElementById('prog-wrap').style.display=show?'block':'none';
|
| 1173 |
+
document.getElementById('prog-bar').style.width='0%';
|
| 1174 |
+
document.getElementById('prog-msg').textContent='⏳ Starting…';
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
function showResult(url, title, hashtags, caption){
|
| 1178 |
+
showProg(false);
|
| 1179 |
+
CUR_OUTPUT_URL=url; CUR_CAPTION=caption||title; CUR_HASHTAGS=hashtags;
|
| 1180 |
+
showPreviewVideo(url);
|
| 1181 |
+
if(title){ const t=document.getElementById('meta-title'); t.textContent=title; t.style.display=''; }
|
| 1182 |
+
if(hashtags){ const h=document.getElementById('meta-tags'); h.textContent=hashtags; h.style.display=''; }
|
| 1183 |
+
document.getElementById('download-btn').style.display='block';
|
| 1184 |
+
document.getElementById('copy-caption-btn').style.display='block';
|
| 1185 |
+
document.getElementById('download-btn2').style.display='block';
|
| 1186 |
+
toast('✅ Video completed!');
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
function showPreviewVideo(url){
|
| 1190 |
+
const v=document.getElementById('preview-video');
|
| 1191 |
+
v.src=url; v.style.display='block';
|
| 1192 |
+
document.getElementById('video-placeholder').style.display='none';
|
| 1193 |
+
document.getElementById('thumb-preview').style.display='none';
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
function downloadVideo(){
|
| 1197 |
+
if(!CUR_OUTPUT_URL) return;
|
| 1198 |
+
const a=document.createElement('a'); a.href=CUR_OUTPUT_URL; a.download='recap_'+Date.now()+'.mp4'; a.click();
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
function copyCaption(){
|
| 1202 |
+
const text=[CUR_CAPTION,'',CUR_HASHTAGS].filter(x=>x!==undefined).join('\n').trim();
|
| 1203 |
+
if(!text){ toast('❌ No caption yet'); return; }
|
| 1204 |
+
navigator.clipboard.writeText(text).then(()=>toast('✅ Caption + Hashtags copied')).catch(()=>{
|
| 1205 |
+
const ta=document.createElement('textarea'); ta.value=text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); toast('✅ Caption copied');
|
| 1206 |
+
});
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
function copyVideoLink(){
|
| 1210 |
+
if(!CUR_OUTPUT_URL){ toast('❌ No video yet'); return; }
|
| 1211 |
+
const full=window.location.origin+CUR_OUTPUT_URL;
|
| 1212 |
+
navigator.clipboard.writeText(full).then(()=>toast('✅ Link copied')).catch(()=>toast('❌ Copy failed'));
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
function updateCoins(c){ if(c===-1) return; CUR_COINS=c; document.getElementById('tb-coins').textContent=c===-1?'∞':c; }
|
| 1216 |
+
function updateScriptCount(){ document.getElementById('script-in-chars').textContent=document.getElementById('script-in').value.length+' chars'; }
|
| 1217 |
+
function uuid8(){ return Math.random().toString(36).substring(2,10); }
|
| 1218 |
+
function toast(msg){ const el=document.getElementById('toast'); el.textContent=msg; el.classList.add('show'); setTimeout(()=>el.classList.remove('show'), 2800); }
|
| 1219 |
+
|
| 1220 |
+
/* ═══════════════════════════════════════════
|
| 1221 |
+
BUY MODAL
|
| 1222 |
+
═══════════════════════════════════════════ */
|
| 1223 |
+
function openBuyModal(){ document.getElementById('buy-modal').style.display='flex'; }
|
| 1224 |
+
function closeBuyModal(){
|
| 1225 |
+
document.getElementById('buy-modal').style.display='none';
|
| 1226 |
+
document.querySelectorAll('.buy-pkg').forEach(p=>p.style.border='1px solid var(--border)');
|
| 1227 |
+
document.getElementById('buy-selected').style.display='none';
|
| 1228 |
+
}
|
| 1229 |
+
function selectPkg(el, coins, price){
|
| 1230 |
+
document.querySelectorAll('.buy-pkg').forEach(p=>{ p.style.border='1px solid var(--border)'; p.style.background='transparent'; });
|
| 1231 |
+
el.style.border='2px solid var(--amber)'; el.style.background='rgba(245,166,35,.04)';
|
| 1232 |
+
document.getElementById('buy-pkg-txt').textContent=`🪙 ${coins} Coins — ${price.toLocaleString()} MMK selected`;
|
| 1233 |
+
document.getElementById('buy-selected').style.display='block';
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
/* ═══════════════════════════════════════════
|
| 1237 |
+
ADMIN
|
| 1238 |
+
═══════════════════════════════════════════ */
|
| 1239 |
+
function openAdmin(){ document.getElementById('admin-modal').classList.add('open'); loadUsers(); }
|
| 1240 |
+
function closeAdmin(){ document.getElementById('admin-modal').classList.remove('open'); }
|
| 1241 |
+
|
| 1242 |
+
async function genUsername(){
|
| 1243 |
+
const r=await fetch('/api/admin/gen_username?caller='+CUR_USER);
|
| 1244 |
+
const d=await r.json(); if(d.ok) document.getElementById('new-uname').value=d.username;
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
async function adminCreateUser(){
|
| 1248 |
+
const u=document.getElementById('new-uname').value.trim();
|
| 1249 |
+
const c=document.getElementById('new-coins').value;
|
| 1250 |
+
const r=await fetch('/api/admin/create_user',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,coins:parseInt(c),caller:CUR_USER})});
|
| 1251 |
+
const d=await r.json();
|
| 1252 |
+
const el=document.getElementById('create-result');
|
| 1253 |
+
el.textContent=d.msg; el.style.color=d.ok?'var(--green)':'var(--red)';
|
| 1254 |
+
if(d.ok){ document.getElementById('new-uname').value=''; loadUsers(); }
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
async function adminCoins(action){
|
| 1258 |
+
const u=document.getElementById('coin-user').value.trim();
|
| 1259 |
+
const n=document.getElementById('coin-amt').value;
|
| 1260 |
+
if(!u){ document.getElementById('coin-result').textContent='❌ Enter username'; return; }
|
| 1261 |
+
const r=await fetch('/api/admin/coins',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({caller:CUR_USER,username:u,amount:parseInt(n),action})});
|
| 1262 |
+
const d=await r.json();
|
| 1263 |
+
const el=document.getElementById('coin-result');
|
| 1264 |
+
el.textContent=d.msg; el.style.color=d.ok?'var(--green)':'var(--red)';
|
| 1265 |
+
if(d.ok) loadUsers();
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
async function deleteUser(u){
|
| 1269 |
+
if(!confirm(`Delete "${u}"? This cannot be undone.`)) return;
|
| 1270 |
+
const r=await fetch('/api/admin/delete_user',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({caller:CUR_USER,username:u})});
|
| 1271 |
+
const d=await r.json(); toast(d.msg); if(d.ok) loadUsers();
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
async function loadUsers(){
|
| 1275 |
+
const wrap=document.getElementById('users-wrap');
|
| 1276 |
+
wrap.innerHTML='<div style="color:var(--muted);font-size:.8rem">Loading…</div>';
|
| 1277 |
+
try {
|
| 1278 |
+
const r=await fetch('/api/admin/users?caller='+CUR_USER);
|
| 1279 |
+
const d=await r.json();
|
| 1280 |
+
if(!d.ok){ wrap.innerHTML='<div style="color:var(--red);font-size:.8rem">'+d.msg+'</div>'; return; }
|
| 1281 |
+
if(!d.users.length){ wrap.innerHTML='<div style="color:var(--muted);font-size:.8rem">No users yet</div>'; return; }
|
| 1282 |
+
wrap.innerHTML=`<table class="users-table">
|
| 1283 |
+
<tr><th>Username</th><th>Coins</th><th>TR</th><th>VD</th><th>Created</th><th></th></tr>
|
| 1284 |
+
${d.users.map(u=>`<tr>
|
| 1285 |
+
<td style="color:var(--text);font-weight:500">${u.username}</td>
|
| 1286 |
+
<td><b style="color:var(--amber)">${u.coins}</b></td>
|
| 1287 |
+
<td>${u.transcripts}</td><td>${u.videos}</td><td>${u.created}</td>
|
| 1288 |
+
<td><button onclick="deleteUser('${u.username}')" style="background:rgba(231,76,60,.08);border:1px solid rgba(231,76,60,.2);color:var(--red);border-radius:4px;padding:3px 8px;cursor:pointer;font-size:.7rem"><i class="fas fa-trash"></i></button></td>
|
| 1289 |
+
</tr>`).join('')}
|
| 1290 |
+
</table>`;
|
| 1291 |
+
} catch(e){ wrap.innerHTML='<div style="color:var(--red);font-size:.8rem">Error: '+e+'</div>'; }
|
| 1292 |
+
}
|
| 1293 |
+
</script>
|
| 1294 |
</body>
|
| 1295 |
+
</html>
|