@effectnode/media 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/movie-backend/generation-queue.js +99 -43
- package/frontend/index.html +8 -1
- package/frontend/public/lambobo.png +0 -0
- package/frontend/src/movie-app/MediaStudio.tsx +6 -2
- package/frontend/src/movie-app/SetupPage.tsx +98 -63
- package/frontend/src/movie-app/components/Aurora.tsx +13 -0
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +5 -8
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +297 -189
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +345 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +40 -34
- package/frontend/src/movie-app/components/ProjectManager.tsx +80 -52
- package/frontend/src/movie-app/index.css +260 -19
- package/frontend/src/movie-app/stores/aiModelStore.ts +163 -0
- package/frontend/src/movie-app/stores/generationStore.ts +2 -2
- package/frontend/src/movie-app/stores/movieStudioStore.ts +45 -0
- package/package.json +1 -1
- package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +0 -881
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -38,6 +38,25 @@ function slugify(v) {
|
|
|
38
38
|
function isValidProjectId(id) {
|
|
39
39
|
return PROJECT_ID_RE.test(id);
|
|
40
40
|
}
|
|
41
|
+
const OUTPUT_DIR = join(APP_DATA_DIR, "output");
|
|
42
|
+
/** Return an existing output file's metadata if already generated, else null. */
|
|
43
|
+
function existingOutput(projectId, filename) {
|
|
44
|
+
const path = join(OUTPUT_DIR, projectId, filename);
|
|
45
|
+
if (!existsSync(path))
|
|
46
|
+
return null;
|
|
47
|
+
let updatedAt = Date.now();
|
|
48
|
+
try {
|
|
49
|
+
updatedAt = statSync(path).mtimeMs;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Fall back to now — only affects the cache-busting query param.
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
filename,
|
|
56
|
+
url: `/api/files?path=${encodeURIComponent(path)}`,
|
|
57
|
+
updatedAt,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
41
60
|
// In-memory queues, keyed by project id. Persisted to disk on every mutation so
|
|
42
61
|
// the queue survives app restarts and is visible at tasks/:projectId/queue.json.
|
|
43
62
|
const queues = new Map();
|
|
@@ -193,17 +212,27 @@ async function runRenderAssets(ctx, characters, places) {
|
|
|
193
212
|
statusText: `Generating character: ${c?.name || slug}`,
|
|
194
213
|
progress: { current, total },
|
|
195
214
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
kind: "character",
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
// Skip media that's already been generated for this character.
|
|
216
|
+
const existing = existingOutput(ctx.projectId, `character-${slug}.png`);
|
|
217
|
+
let entry;
|
|
218
|
+
if (existing) {
|
|
219
|
+
ctx.log(`Already generated: character-${slug}.png — skipping\n`);
|
|
220
|
+
entry = { kind: "character", slug, ...existing };
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const r = await generateAssetImage(ctx.projectId, "character", slug, prompt, ctx.log);
|
|
224
|
+
throwIfAborted(ctx.signal);
|
|
225
|
+
if ("error" in r)
|
|
226
|
+
throw new Error(r.error);
|
|
227
|
+
entry = {
|
|
228
|
+
kind: "character",
|
|
229
|
+
slug,
|
|
230
|
+
filename: r.filename,
|
|
231
|
+
url: r.url,
|
|
232
|
+
updatedAt: Date.now(),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
assets.push(entry);
|
|
207
236
|
ctx.update({ result: { assets: [...assets] } });
|
|
208
237
|
}
|
|
209
238
|
for (const p of placesList) {
|
|
@@ -217,17 +246,26 @@ async function runRenderAssets(ctx, characters, places) {
|
|
|
217
246
|
statusText: `Generating place: ${p?.name || slug}`,
|
|
218
247
|
progress: { current, total },
|
|
219
248
|
});
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
249
|
+
const existing = existingOutput(ctx.projectId, `place-${slug}.png`);
|
|
250
|
+
let entry;
|
|
251
|
+
if (existing) {
|
|
252
|
+
ctx.log(`Already generated: place-${slug}.png — skipping\n`);
|
|
253
|
+
entry = { kind: "place", slug, ...existing };
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const r = await generateAssetImage(ctx.projectId, "place", slug, prompt, ctx.log);
|
|
257
|
+
throwIfAborted(ctx.signal);
|
|
258
|
+
if ("error" in r)
|
|
259
|
+
throw new Error(r.error);
|
|
260
|
+
entry = {
|
|
261
|
+
kind: "place",
|
|
262
|
+
slug,
|
|
263
|
+
filename: r.filename,
|
|
264
|
+
url: r.url,
|
|
265
|
+
updatedAt: Date.now(),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
assets.push(entry);
|
|
231
269
|
ctx.update({ result: { assets: [...assets] } });
|
|
232
270
|
}
|
|
233
271
|
ctx.update({ result: { assets } });
|
|
@@ -248,16 +286,25 @@ async function runRenderSceneImages(ctx, scenes) {
|
|
|
248
286
|
statusText: `Generating scene image: ${slug}`,
|
|
249
287
|
progress: { current, total },
|
|
250
288
|
});
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
289
|
+
const existing = existingOutput(ctx.projectId, `scene-${slug}.png`);
|
|
290
|
+
let entry;
|
|
291
|
+
if (existing) {
|
|
292
|
+
ctx.log(`Already generated: scene-${slug}.png — skipping\n`);
|
|
293
|
+
entry = { slug, ...existing };
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const r = await generateSceneImage(ctx.projectId, sc, ctx.log);
|
|
297
|
+
throwIfAborted(ctx.signal);
|
|
298
|
+
if ("error" in r)
|
|
299
|
+
throw new Error(r.error);
|
|
300
|
+
entry = {
|
|
301
|
+
slug,
|
|
302
|
+
filename: r.filename,
|
|
303
|
+
url: r.url,
|
|
304
|
+
updatedAt: Date.now(),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
sceneImages.push(entry);
|
|
261
308
|
ctx.update({ result: { sceneImages: [...sceneImages] } });
|
|
262
309
|
}
|
|
263
310
|
ctx.update({ result: { sceneImages } });
|
|
@@ -279,16 +326,25 @@ async function runRenderVideos(ctx, uvPath, scenes, characters) {
|
|
|
279
326
|
statusText: `Generating video: ${slug}`,
|
|
280
327
|
progress: { current, total },
|
|
281
328
|
});
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
329
|
+
const existing = existingOutput(ctx.projectId, `scene-${slug}.mp4`);
|
|
330
|
+
let entry;
|
|
331
|
+
if (existing) {
|
|
332
|
+
ctx.log(`Already generated: scene-${slug}.mp4 — skipping\n`);
|
|
333
|
+
entry = { slug, ...existing };
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
const r = await generateSceneVideo(uvPath, ctx.projectId, sc, chars, ctx.log);
|
|
337
|
+
throwIfAborted(ctx.signal);
|
|
338
|
+
if ("error" in r)
|
|
339
|
+
throw new Error(r.error);
|
|
340
|
+
entry = {
|
|
341
|
+
slug,
|
|
342
|
+
filename: r.filename,
|
|
343
|
+
url: r.url,
|
|
344
|
+
updatedAt: Date.now(),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
videos.push(entry);
|
|
292
348
|
ctx.update({ result: { videos: [...videos] } });
|
|
293
349
|
}
|
|
294
350
|
ctx.update({ result: { videos } });
|
package/frontend/index.html
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
-
<
|
|
7
|
+
<meta name="theme-color" content="#eaf8f6" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link
|
|
11
|
+
href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300..700;1,300..600&display=swap"
|
|
12
|
+
rel="stylesheet"
|
|
13
|
+
/>
|
|
14
|
+
<title>Lambobo Studio</title>
|
|
8
15
|
</head>
|
|
9
16
|
|
|
10
17
|
<body>
|
|
Binary file
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import Aurora from "./components/Aurora";
|
|
1
2
|
import ProjectManager from "./components/ProjectManager";
|
|
2
3
|
|
|
3
4
|
export default function MediaStudio() {
|
|
4
5
|
return (
|
|
5
|
-
<div className="
|
|
6
|
-
<
|
|
6
|
+
<div className="relative min-h-screen">
|
|
7
|
+
<Aurora />
|
|
8
|
+
<div className="relative z-10 mx-auto w-full max-w-4xl px-6 py-12 md:py-16">
|
|
9
|
+
<ProjectManager />
|
|
10
|
+
</div>
|
|
7
11
|
</div>
|
|
8
12
|
);
|
|
9
13
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState, useRef } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
3
|
import { useLogStore, type LogEntry } from "./stores/logStore";
|
|
4
|
+
import Aurora from "./components/Aurora";
|
|
4
5
|
|
|
5
6
|
interface StepStatus {
|
|
6
7
|
step: string;
|
|
@@ -104,7 +105,7 @@ function SetupPage({ port = 8765 }) {
|
|
|
104
105
|
height="18"
|
|
105
106
|
viewBox="0 0 24 24"
|
|
106
107
|
fill="none"
|
|
107
|
-
stroke="
|
|
108
|
+
stroke="#4fc3bc"
|
|
108
109
|
strokeWidth="2"
|
|
109
110
|
>
|
|
110
111
|
<circle
|
|
@@ -131,7 +132,7 @@ function SetupPage({ port = 8765 }) {
|
|
|
131
132
|
height="18"
|
|
132
133
|
viewBox="0 0 24 24"
|
|
133
134
|
fill="none"
|
|
134
|
-
stroke="#
|
|
135
|
+
stroke="#0abab5"
|
|
135
136
|
strokeWidth="2"
|
|
136
137
|
>
|
|
137
138
|
<circle cx="12" cy="12" r="10" />
|
|
@@ -145,7 +146,7 @@ function SetupPage({ port = 8765 }) {
|
|
|
145
146
|
height="18"
|
|
146
147
|
viewBox="0 0 24 24"
|
|
147
148
|
fill="none"
|
|
148
|
-
stroke="#
|
|
149
|
+
stroke="#fb7185"
|
|
149
150
|
strokeWidth="2"
|
|
150
151
|
>
|
|
151
152
|
<circle cx="12" cy="12" r="10" />
|
|
@@ -159,7 +160,7 @@ function SetupPage({ port = 8765 }) {
|
|
|
159
160
|
height="18"
|
|
160
161
|
viewBox="0 0 24 24"
|
|
161
162
|
fill="none"
|
|
162
|
-
stroke="#
|
|
163
|
+
stroke="#83a0a7"
|
|
163
164
|
strokeWidth="2"
|
|
164
165
|
>
|
|
165
166
|
<circle cx="12" cy="12" r="10" />
|
|
@@ -177,7 +178,7 @@ function SetupPage({ port = 8765 }) {
|
|
|
177
178
|
height="14"
|
|
178
179
|
viewBox="0 0 24 24"
|
|
179
180
|
fill="none"
|
|
180
|
-
stroke="#
|
|
181
|
+
stroke="#fb7185"
|
|
181
182
|
strokeWidth="2"
|
|
182
183
|
className="shrink-0"
|
|
183
184
|
>
|
|
@@ -192,13 +193,13 @@ function SetupPage({ port = 8765 }) {
|
|
|
192
193
|
height="14"
|
|
193
194
|
viewBox="0 0 24 24"
|
|
194
195
|
fill="none"
|
|
195
|
-
stroke="#
|
|
196
|
+
stroke="#fbbf24"
|
|
196
197
|
strokeWidth="2"
|
|
197
198
|
className="shrink-0"
|
|
198
199
|
>
|
|
199
200
|
<path d="M12 2L2 22h20L12 2z" />
|
|
200
201
|
<line x1="12" y1="9" x2="12" y2="14" />
|
|
201
|
-
<circle cx="12" cy="18" r="0.5" fill="#
|
|
202
|
+
<circle cx="12" cy="18" r="0.5" fill="#fbbf24" />
|
|
202
203
|
</svg>
|
|
203
204
|
);
|
|
204
205
|
default:
|
|
@@ -208,7 +209,7 @@ function SetupPage({ port = 8765 }) {
|
|
|
208
209
|
height="14"
|
|
209
210
|
viewBox="0 0 24 24"
|
|
210
211
|
fill="none"
|
|
211
|
-
stroke="#
|
|
212
|
+
stroke="#5e7c83"
|
|
212
213
|
strokeWidth="2"
|
|
213
214
|
className="shrink-0"
|
|
214
215
|
>
|
|
@@ -225,65 +226,99 @@ function SetupPage({ port = 8765 }) {
|
|
|
225
226
|
};
|
|
226
227
|
|
|
227
228
|
return (
|
|
228
|
-
<div className="
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
<div className="relative min-h-screen">
|
|
230
|
+
<Aurora />
|
|
231
|
+
<div className="relative z-10 mx-auto w-full max-w-3xl px-6 py-12">
|
|
232
|
+
{/* Hero */}
|
|
233
|
+
<header>
|
|
234
|
+
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-tiffany-600">
|
|
235
|
+
Lambobo Studio
|
|
236
|
+
</p>
|
|
237
|
+
<h1 className="mt-3 font-display text-4xl font-light leading-[1.06] text-ink-900">
|
|
238
|
+
Setting up{" "}
|
|
239
|
+
<span className="wordmark font-medium italic">your studio</span>
|
|
240
|
+
</h1>
|
|
241
|
+
<p className="mt-4 text-sm leading-relaxed text-ink-600/80">
|
|
242
|
+
Gathering models and starting the local server. The first run takes
|
|
243
|
+
a minute.
|
|
244
|
+
</p>
|
|
245
|
+
</header>
|
|
232
246
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
{error && (
|
|
248
|
+
<div className="mt-8 rounded-2xl border border-rose-200 bg-rose-50/70 px-4 py-3 text-sm text-rose-600 backdrop-blur-sm">
|
|
249
|
+
Error: {error}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
{complete && !error && (
|
|
253
|
+
<div className="glass mt-8 rounded-2xl px-4 py-3 text-sm font-medium text-tiffany-700 shadow-card">
|
|
254
|
+
Setup complete — opening your studio…
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Checks */}
|
|
259
|
+
<div className="glass mt-8 rounded-3xl p-6 shadow-card">
|
|
260
|
+
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-ink-500">
|
|
261
|
+
Checks
|
|
262
|
+
</p>
|
|
263
|
+
<div className="mt-2">
|
|
264
|
+
{steps.length === 0 ? (
|
|
265
|
+
<p className="py-2 text-sm italic text-ink-500/70">
|
|
266
|
+
Starting the local server…
|
|
267
|
+
</p>
|
|
268
|
+
) : (
|
|
269
|
+
steps.map((step, i) => (
|
|
270
|
+
<div
|
|
271
|
+
key={i}
|
|
272
|
+
className="flex items-center gap-2.5 py-2 transition-opacity"
|
|
273
|
+
style={{ opacity: step.status === "pending" ? 0.45 : 1 }}
|
|
274
|
+
>
|
|
275
|
+
<StatusIcon status={step.status} />
|
|
276
|
+
<span className="text-sm text-ink-800">{step.label}</span>
|
|
277
|
+
</div>
|
|
278
|
+
))
|
|
279
|
+
)}
|
|
248
280
|
</div>
|
|
249
|
-
))}
|
|
250
|
-
</div>
|
|
251
|
-
{/* Log display panel */}
|
|
252
|
-
<div className="flex-1 flex flex-col min-h-0 border border-ink-200 rounded-2xl overflow-hidden bg-white shadow-card">
|
|
253
|
-
<div className="flex items-center justify-between px-4 py-2.5 bg-ink-50 border-b border-ink-200">
|
|
254
|
-
<span className="text-xs font-semibold text-ink-600/80 uppercase tracking-wider">
|
|
255
|
-
Logs
|
|
256
|
-
</span>
|
|
257
|
-
<span className="text-xs text-ink-500">{logs.length} entries</span>
|
|
258
281
|
</div>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
: "text-ink-700"
|
|
279
|
-
}
|
|
282
|
+
|
|
283
|
+
{/* Log console — the film-editing-bay contrast inside the dream */}
|
|
284
|
+
<div className="glass-dark mt-5 flex min-h-0 flex-col overflow-hidden rounded-3xl shadow-card">
|
|
285
|
+
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
|
286
|
+
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-tiffany-300/80">
|
|
287
|
+
Logs
|
|
288
|
+
</span>
|
|
289
|
+
<span className="text-xs text-ink-400">
|
|
290
|
+
{logs.length} {logs.length === 1 ? "entry" : "entries"}
|
|
291
|
+
</span>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="flex-1 overflow-y-auto p-5 font-mono text-xs leading-relaxed">
|
|
294
|
+
{logs.length === 0 ? (
|
|
295
|
+
<div className="italic text-ink-400">Waiting for logs…</div>
|
|
296
|
+
) : (
|
|
297
|
+
logs.map((entry) => (
|
|
298
|
+
<div
|
|
299
|
+
key={entry.id}
|
|
300
|
+
className="flex items-start gap-2 rounded px-1 py-0.5 hover:bg-white/5"
|
|
280
301
|
>
|
|
281
|
-
{entry.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
302
|
+
<LogIcon level={entry.level} />
|
|
303
|
+
<span className="shrink-0 select-none text-ink-400">
|
|
304
|
+
{formatTime(entry.timestamp)}
|
|
305
|
+
</span>
|
|
306
|
+
<span
|
|
307
|
+
className={
|
|
308
|
+
entry.level === "error"
|
|
309
|
+
? "text-rose-300"
|
|
310
|
+
: entry.level === "warn"
|
|
311
|
+
? "text-amber-300"
|
|
312
|
+
: "text-tiffany-100"
|
|
313
|
+
}
|
|
314
|
+
>
|
|
315
|
+
{entry.message}
|
|
316
|
+
</span>
|
|
317
|
+
</div>
|
|
318
|
+
))
|
|
319
|
+
)}
|
|
320
|
+
<div ref={logEndRef} />
|
|
321
|
+
</div>
|
|
287
322
|
</div>
|
|
288
323
|
</div>
|
|
289
324
|
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The signature atmosphere: a slow-drifting aurora of tiffany, periwinkle,
|
|
2
|
+
// blush and sky behind the frosted-glass interface. Purely decorative and
|
|
3
|
+
// pointer-transparent; animation is disabled for users who prefer reduced motion.
|
|
4
|
+
export default function Aurora() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="aurora" aria-hidden="true">
|
|
7
|
+
<div className="aurora-blob aurora-blob--tiffany" />
|
|
8
|
+
<div className="aurora-blob aurora-blob--periwinkle" />
|
|
9
|
+
<div className="aurora-blob aurora-blob--blush" />
|
|
10
|
+
<div className="aurora-blob aurora-blob--sky" />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -297,9 +297,7 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
297
297
|
<tr
|
|
298
298
|
key={rowIdx}
|
|
299
299
|
className={`border-b border-ink-200 transition-colors ${
|
|
300
|
-
isSelected
|
|
301
|
-
? "bg-white"
|
|
302
|
-
: "bg-ink-100/50 opacity-60"
|
|
300
|
+
isSelected ? "bg-white" : "bg-ink-100/50 opacity-60"
|
|
303
301
|
}`}
|
|
304
302
|
>
|
|
305
303
|
<td className="px-3 py-1.5">
|
|
@@ -335,9 +333,7 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
335
333
|
{!store.csvFilename && (
|
|
336
334
|
<p className="text-xs text-ink-600/50 mt-1.5">
|
|
337
335
|
Upload a CSV with a{" "}
|
|
338
|
-
<code className="text-[11px] bg-ink-100 px-1 rounded">
|
|
339
|
-
name
|
|
340
|
-
</code>{" "}
|
|
336
|
+
<code className="text-[11px] bg-ink-100 px-1 rounded">name</code>{" "}
|
|
341
337
|
column to batch-generate many videos. Use{" "}
|
|
342
338
|
<code className="text-[11px] bg-ink-100 px-1 rounded">{`{{column}}`}</code>{" "}
|
|
343
339
|
in your prompt as a template.
|
|
@@ -362,8 +358,9 @@ export default function GenerateVideoTab({ projectId }: Props) {
|
|
|
362
358
|
</p>
|
|
363
359
|
|
|
364
360
|
<a
|
|
365
|
-
href={`/lambobo.png
|
|
366
|
-
download="lambobo.
|
|
361
|
+
href={`/lambobo.png`}
|
|
362
|
+
download="lambobo.png"
|
|
363
|
+
target="_blank"
|
|
367
364
|
className="inline-flex items-center gap-1.5 mt-2 text-xs font-medium text-ink-600 hover:text-ink-900 transition-colors"
|
|
368
365
|
>
|
|
369
366
|
{DownloadIcon}
|