mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:08:46 +02:00
Add Playwright assistant regression coverage
This commit is contained in:
113
frontend/e2e/asistente.spec.ts
Normal file
113
frontend/e2e/asistente.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test.setTimeout(150_000);
|
||||
|
||||
async function startFreshAssistant(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await page.waitForURL("/");
|
||||
|
||||
const initialConnect = page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await page.goto("/asistente");
|
||||
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||
await (await initialConnect).finished();
|
||||
|
||||
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
|
||||
if (await newConversation.isEnabled()) {
|
||||
await newConversation.click();
|
||||
const reconnect = page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await page.getByRole("button", { name: "Borrar historial" }).click();
|
||||
await (await reconnect).finished();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
|
||||
).toBeEnabled({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function ask(page: Page, question: string, expectedTool: string) {
|
||||
// The BFF response is the exact CopilotKit stream consumed by the browser.
|
||||
// Checking it catches server-side RUN_ERROR events even if the UI changes.
|
||||
const stream = page.waitForResponse(
|
||||
(response) => {
|
||||
if (
|
||||
!response.url().includes("/api/copilotkit") ||
|
||||
response.request().method() !== "POST"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
|
||||
await expect(composer).toBeEnabled({ timeout: 30_000 });
|
||||
await composer.fill(question);
|
||||
const sendButton = page.getByTestId("copilot-send-button");
|
||||
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
|
||||
await sendButton.click();
|
||||
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
|
||||
|
||||
const response = await stream;
|
||||
await response.finished();
|
||||
const events = await response.text();
|
||||
expect(events).not.toContain("RUN_ERROR");
|
||||
expect(events).toContain(expectedTool);
|
||||
|
||||
// The HTTP stream may finish before CopilotKit has committed the tool run
|
||||
// and released its client-side running state. Waiting for the page action
|
||||
// avoids losing the next Enter keypress during that short interval.
|
||||
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
|
||||
await startFreshAssistant(page);
|
||||
|
||||
await ask(page, "What date is it today?", "get_current_date");
|
||||
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
|
||||
await ask(page, "What is my net worth?", "get_net_worth");
|
||||
await ask(page, "How much did I spend today?", "get_daily_spending");
|
||||
await ask(page, "Show my spending by category this month.", "render_spending_summary");
|
||||
|
||||
const spendingCard = page.getByTestId("spending-summary-card");
|
||||
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
|
||||
|
||||
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
|
||||
});
|
||||
127
frontend/e2e/feature-doc.ts
Normal file
127
frontend/e2e/feature-doc.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, type Page, type TestInfo } from "@playwright/test";
|
||||
import * as narration from "./narration";
|
||||
import { renderFinalVideo } from "./video-render";
|
||||
import type { Clip } from "./subtitles";
|
||||
|
||||
const DEMO = !!process.env.PW_DEMO;
|
||||
|
||||
/**
|
||||
* Wraps test.step() so every step of a demo spec produces a captioned
|
||||
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
|
||||
* recording with cursor annotations, chapter-title cards, burned-in
|
||||
* subtitles and (when `say` is available) spoken Spanish narration.
|
||||
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
|
||||
* attached to the Playwright HTML report; save() stitches everything into
|
||||
* a Markdown walkthrough that can be published as-is.
|
||||
*/
|
||||
export class FeatureDoc {
|
||||
private steps: { title: string; caption: string; file: string }[] = [];
|
||||
private clips: Clip[] = [];
|
||||
private readonly dir: string;
|
||||
private recordingStartedAt = 0;
|
||||
|
||||
constructor(
|
||||
private page: Page,
|
||||
private testInfo: TestInfo,
|
||||
private slug: string,
|
||||
private title: string,
|
||||
private intro: string,
|
||||
) {
|
||||
this.dir = path.resolve("e2e-docs", slug);
|
||||
fs.rmSync(this.dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
/** Starts the native screencast recording. Call before the first step(). */
|
||||
async start() {
|
||||
if (!DEMO) return;
|
||||
this.recordingStartedAt = Date.now();
|
||||
await this.page.screencast.start({
|
||||
path: path.join(this.dir, "raw.webm"),
|
||||
size: { width: 1920, height: 1080 },
|
||||
});
|
||||
await this.page.screencast.showActions({ cursor: "pointer" });
|
||||
}
|
||||
|
||||
async step(title: string, caption: string, fn: () => Promise<void>) {
|
||||
const narrationPromise = DEMO
|
||||
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const stepStart = Date.now();
|
||||
if (DEMO) {
|
||||
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
|
||||
}
|
||||
|
||||
await test.step(title, fn);
|
||||
|
||||
const narrationResult = await narrationPromise;
|
||||
if (DEMO) {
|
||||
const elapsed = Date.now() - stepStart;
|
||||
const target = (narrationResult?.durationMs ?? 0) + 300;
|
||||
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
|
||||
}
|
||||
|
||||
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]+/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")}.png`;
|
||||
const body = await this.page.screenshot({
|
||||
path: path.join(this.dir, file),
|
||||
animations: "disabled",
|
||||
});
|
||||
await this.testInfo.attach(title, { body, contentType: "image/png" });
|
||||
this.steps.push({ title, caption, file });
|
||||
|
||||
if (DEMO) {
|
||||
this.clips.push({
|
||||
title,
|
||||
caption,
|
||||
file,
|
||||
startMs: stepStart - this.recordingStartedAt,
|
||||
endMs: Date.now() - this.recordingStartedAt,
|
||||
narration: narrationResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
let videoName = "video.mp4";
|
||||
if (DEMO) {
|
||||
await this.page.screencast.stop();
|
||||
const { videoFile, degraded } = await renderFinalVideo({
|
||||
rawWebmPath: path.join(this.dir, "raw.webm"),
|
||||
clips: this.clips,
|
||||
outDir: this.dir,
|
||||
});
|
||||
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
|
||||
videoName = path.basename(videoFile);
|
||||
await this.testInfo.attach("video", {
|
||||
path: videoFile,
|
||||
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
|
||||
});
|
||||
}
|
||||
|
||||
const md = [
|
||||
`# ${this.title}`,
|
||||
"",
|
||||
this.intro,
|
||||
"",
|
||||
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\` — ${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
|
||||
"",
|
||||
...this.steps.flatMap((s, i) => [
|
||||
`## ${i + 1}. ${s.title}`,
|
||||
"",
|
||||
s.caption,
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
]),
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(this.dir, "index.md"), md);
|
||||
}
|
||||
}
|
||||
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { FeatureDoc } from "./feature-doc";
|
||||
|
||||
/**
|
||||
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
|
||||
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
|
||||
* captioned, screenshot-per-step Markdown doc plus the full video (native
|
||||
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
|
||||
* HTML report.
|
||||
*/
|
||||
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
|
||||
// video breaks page.screencast's internal sizing (verified: it then records
|
||||
// a shrunken frame letterboxed inside the requested canvas). The fixture's
|
||||
// own video ends up redundant/unused but harmless.
|
||||
|
||||
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
|
||||
const doc = new FeatureDoc(
|
||||
page,
|
||||
testInfo,
|
||||
"financiamientos",
|
||||
"Financiamientos — Tasa Cero",
|
||||
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
|
||||
);
|
||||
await doc.start();
|
||||
|
||||
await doc.step(
|
||||
"Iniciar sesión",
|
||||
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
|
||||
async () => {
|
||||
await page.goto("/login");
|
||||
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
|
||||
// the recording — it isn't part of the product.
|
||||
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
|
||||
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Panel de inicio",
|
||||
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
|
||||
async () => {
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await page.waitForURL("/");
|
||||
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Modo privacidad",
|
||||
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
|
||||
async () => {
|
||||
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
|
||||
await expect(page.locator("html")).toHaveClass(/privacy/);
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Página de Financiamientos",
|
||||
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
|
||||
async () => {
|
||||
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
|
||||
await page.waitForURL("/financiamientos");
|
||||
await expect(page.getByText("Saldo faltante total")).toBeVisible();
|
||||
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Tabla de planes",
|
||||
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
|
||||
async () => {
|
||||
const rows = page.getByRole("button", { name: /editar plan de/i });
|
||||
await expect(rows.first()).toBeVisible();
|
||||
expect(await rows.count()).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Detalle de un plan",
|
||||
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
|
||||
async () => {
|
||||
await page.getByRole("button", { name: /editar plan de/i }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Cerrar el detalle",
|
||||
"El plan queda igual que estaba — el recorrido es de solo lectura.",
|
||||
async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.save();
|
||||
});
|
||||
59
frontend/e2e/narration.ts
Normal file
59
frontend/e2e/narration.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let sayChecked = false;
|
||||
let sayAvailable = false;
|
||||
|
||||
function hasSay() {
|
||||
if (!sayChecked) {
|
||||
sayChecked = true;
|
||||
try {
|
||||
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
|
||||
sayAvailable = true;
|
||||
} catch {
|
||||
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
|
||||
}
|
||||
}
|
||||
return sayAvailable;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
|
||||
|
||||
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
|
||||
export function synthesize(
|
||||
text: string,
|
||||
outDir: string,
|
||||
index: number,
|
||||
voice = "Paulina",
|
||||
): Promise<{ path: string; durationMs: number } | null> {
|
||||
const key = `${voice}:${text}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = (async () => {
|
||||
if (!hasSay()) return null;
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
|
||||
try {
|
||||
await execFileAsync("say", ["-v", voice, "-o", file, text]);
|
||||
const { stdout } = await execFileAsync("ffprobe", [
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
file,
|
||||
]);
|
||||
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
|
||||
return { path: file, durationMs };
|
||||
} catch (err) {
|
||||
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
|
||||
fs.rmSync(file, { force: true });
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
cache.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
33
frontend/e2e/subtitles.ts
Normal file
33
frontend/e2e/subtitles.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Clip {
|
||||
title: string;
|
||||
caption: string;
|
||||
file: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
narration: { path: string; durationMs: number } | null;
|
||||
}
|
||||
|
||||
export function msToSrtTimestamp(ms: number): string {
|
||||
const clamped = Math.max(0, Math.round(ms));
|
||||
const hours = Math.floor(clamped / 3_600_000);
|
||||
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
|
||||
const seconds = Math.floor((clamped % 60_000) / 1000);
|
||||
const millis = clamped % 1000;
|
||||
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
|
||||
}
|
||||
|
||||
/** One subtitle cue per step, spanning its full on-screen duration. */
|
||||
export function buildSrt(clips: Clip[]): string {
|
||||
return clips
|
||||
.map((clip, i) => {
|
||||
const index = i + 1;
|
||||
return [
|
||||
String(index),
|
||||
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
|
||||
clip.caption,
|
||||
"",
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
88
frontend/e2e/video-render.ts
Normal file
88
frontend/e2e/video-render.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { buildSrt, type Clip } from "./subtitles";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function hasFfmpeg() {
|
||||
try {
|
||||
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SUBTITLE_STYLE =
|
||||
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
|
||||
|
||||
function escapeForFilter(p: string) {
|
||||
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
|
||||
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
||||
}
|
||||
|
||||
export async function renderFinalVideo({
|
||||
rawWebmPath,
|
||||
clips,
|
||||
outDir,
|
||||
}: {
|
||||
rawWebmPath: string;
|
||||
clips: Clip[];
|
||||
outDir: string;
|
||||
}): Promise<{ videoFile: string; degraded: string[] }> {
|
||||
const degraded: string[] = [];
|
||||
const srtPath = path.join(outDir, "captions.srt");
|
||||
fs.writeFileSync(srtPath, buildSrt(clips));
|
||||
|
||||
if (!hasFfmpeg()) {
|
||||
const dest = path.join(outDir, "video.webm");
|
||||
fs.renameSync(rawWebmPath, dest);
|
||||
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
|
||||
return { videoFile: dest, degraded };
|
||||
}
|
||||
|
||||
const narrated = clips.filter((c) => c.narration);
|
||||
const mp4Path = path.join(outDir, "video.mp4");
|
||||
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
|
||||
|
||||
if (narrated.length === 0) {
|
||||
degraded.push("No narration available — burned-in subtitles only.");
|
||||
await execFileAsync("ffmpeg", [
|
||||
"-y", "-i", rawWebmPath,
|
||||
"-vf", srtArg,
|
||||
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-an",
|
||||
mp4Path,
|
||||
]);
|
||||
} else {
|
||||
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
|
||||
const adelayParts = narrated.map(
|
||||
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
|
||||
);
|
||||
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
|
||||
const filterComplex = [
|
||||
`[0:v]${srtArg}[vout]`,
|
||||
...adelayParts,
|
||||
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
|
||||
].join(";");
|
||||
|
||||
await execFileAsync("ffmpeg", [
|
||||
"-y", "-i", rawWebmPath, ...audioInputs,
|
||||
"-filter_complex", filterComplex,
|
||||
"-map", "[vout]", "-map", "[aout]",
|
||||
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
"-movflags", "+faststart",
|
||||
mp4Path,
|
||||
]);
|
||||
}
|
||||
|
||||
fs.rmSync(rawWebmPath, { force: true });
|
||||
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
|
||||
|
||||
return { videoFile: mp4Path, degraded };
|
||||
}
|
||||
Reference in New Issue
Block a user