mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 23:43:35 +00:00
Merge pull request #1075 from webadderallorg/codex/software-decode-retry
Recover Lightning decoding and add voluntary export error reports
This commit is contained in:
commit
94d2cde35a
19 files changed
+1038
-24
No files matched your search
@@ -15,3 +15,13 @@ On submission, the modal captures up to 100 recent renderer warnings/errors (2,0
|
||||
A local demo account can preview the form but cannot submit without a real Supabase session. Failed submissions retain the draft in memory; closing and reopening the same modal keeps it, but restarting the app does not. The function cleans up failed uploads on a best-effort basis. Periodically remove unattached objects left by interrupted sessions through the Storage API, and prune old `feedback_daily_usage` rows after their UTC day has ended.
|
||||
|
||||
Unit tests exercise the server handler with an injected storage/auth backend. `tests/feedback_limits.sql` verifies the migration's quotas and permissions in a disposable database with the migrations applied; it rolls back its test data. Live deployment and end-to-end Supabase uploads must be checked separately before release.
|
||||
|
||||
## User-triggered export error reports (no account link)
|
||||
|
||||
Apply `202610020001_export_error_reports.sql` and deploy `submit-export-error` using `services/supabase/config.toml` (`verify_jwt = false`). The new function is public and accepts only the validated, 4 KiB technical-report schema; clients cannot insert/read reports directly. No change to manual account-linked feedback is required. Configure the existing public URL/publishable key in the desktop build. Never expose a service-role key.
|
||||
|
||||
The export error dialog has **Report error**, then a preview and explicit **Send report** action. Nothing is sent automatically or on opening the preview. The optional cloud-plan category is self-reported (`paid`, `not_paid`, or `unknown`); it is not an authenticated billing check. Current main does not expose a billing entitlement to this UI. The client uses `fetch` with no session Authorization, cookies or referrer. Reports store no account ID/email, media, path, logs, persistent device ID or IP. The function rejects Authorization headers and unknown payload fields. Supabase/infrastructure connection logs can still contain request metadata/IP; do not describe this as guaranteed anonymity.
|
||||
|
||||
View `public.export_error_reports` through staff/service-role access. Reports contain allowlisted error codes, numerical failure positions for both decoding attempts, format, stage, output dimensions/FPS, app version and OS, plus the optional cloud-plan category. Creation times are rounded to the hour. A database quota caps accepted anonymous submissions at 20 per minute globally without storing identity; this is an aggregate abuse/cost control, not per-user protection, and can be exhausted by other callers. No raw error text is persisted. Failed requests are retryable by the user.
|
||||
|
||||
The migration enables Supabase Cron and schedules daily deletion of reports older than 30 days and aggregate quota counters older than one day. Confirm the job exists and succeeds before releasing the client. Apply and validate this migration/function separately; no live backend deployment is performed by the code change. Update your collection/privacy notice with the real Supabase hosting country, infrastructure logging, recipients and retention. Explicit click-to-send and minimisation reduce risk but do not establish legal compliance for every jurisdiction.
|
||||
@@ -3,3 +3,6 @@ project_id = "recordly-feedback"
|
||||
[functions.submit-feedback]
|
||||
# The handler verifies the bearer token with auth.getUser before doing any work.
|
||||
verify_jwt = false
|
||||
|
||||
[functions.submit-export-error]
|
||||
verify_jwt = false
|
||||
@@ -0,0 +1,99 @@
|
||||
export type CloudPlan = "paid" | "not_paid" | "unknown";
|
||||
export interface DecoderAttempt {
|
||||
code: string;
|
||||
sourceTimeSec?: number;
|
||||
chunkIndex?: number;
|
||||
chunkBytes?: number;
|
||||
}
|
||||
export interface ExportErrorReport {
|
||||
schemaVersion: number;
|
||||
cloudPlan: CloudPlan;
|
||||
format: "mp4" | "gif" | "unknown";
|
||||
failure: DecoderAttempt;
|
||||
stage: "input_decoding" | "saving" | "export";
|
||||
initialDecoder?: DecoderAttempt;
|
||||
softwareDecoder?: DecoderAttempt;
|
||||
appVersion?: string;
|
||||
platform: string;
|
||||
output?: { width: number; height: number; fps: number };
|
||||
}
|
||||
const codes = [
|
||||
"UNKNOWN",
|
||||
"SAVE_FAILED",
|
||||
"AUDIO_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"EXPORT_FAILED",
|
||||
"VIDEO_DECODE_ENCODING_ERROR",
|
||||
"VIDEO_DECODER_RESOURCE_EXHAUSTED",
|
||||
"VIDEO_DECODER_INVALID_STATE",
|
||||
"VIDEO_DECODE_FAILED",
|
||||
"VIDEO_CODEC_UNSUPPORTED",
|
||||
"VIDEO_DECODE_RECOVERY_FAILED",
|
||||
];
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
const keys = (value: Record<string, unknown>, allowed: string[]) =>
|
||||
Object.keys(value).every((key) => allowed.includes(key));
|
||||
const oneOf = (value: unknown, allowed: string[]) =>
|
||||
typeof value === "string" && allowed.includes(value);
|
||||
const number = (value: unknown, max: number, integer = false) =>
|
||||
typeof value === "number" &&
|
||||
Number.isFinite(value) &&
|
||||
value >= 0 &&
|
||||
value <= max &&
|
||||
(!integer || Number.isInteger(value));
|
||||
function attempt(value: unknown) {
|
||||
return (
|
||||
object(value) &&
|
||||
keys(value, ["code", "sourceTimeSec", "chunkIndex", "chunkBytes"]) &&
|
||||
typeof value.code === "string" &&
|
||||
codes.includes(value.code) &&
|
||||
(value.sourceTimeSec === undefined || number(value.sourceTimeSec, 86400)) &&
|
||||
(value.chunkIndex === undefined || number(value.chunkIndex, 100_000_000, true)) &&
|
||||
(value.chunkBytes === undefined || number(value.chunkBytes, 100_000_000, true))
|
||||
);
|
||||
}
|
||||
/** Reject arbitrary strings/extra fields, including any account or free-text diagnostics. */
|
||||
export function isExportErrorReport(value: unknown): value is ExportErrorReport {
|
||||
if (
|
||||
!object(value) ||
|
||||
!keys(value, [
|
||||
"schemaVersion",
|
||||
"cloudPlan",
|
||||
"format",
|
||||
"failure",
|
||||
"stage",
|
||||
"initialDecoder",
|
||||
"softwareDecoder",
|
||||
"appVersion",
|
||||
"platform",
|
||||
"output",
|
||||
])
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
!oneOf(value.cloudPlan, ["paid", "not_paid", "unknown"]) ||
|
||||
!oneOf(value.format, ["mp4", "gif", "unknown"]) ||
|
||||
!oneOf(value.stage, ["input_decoding", "saving", "export"]) ||
|
||||
!oneOf(value.platform, ["Windows", "macOS", "Linux", "unknown"]) ||
|
||||
!attempt(value.failure)
|
||||
)
|
||||
return false;
|
||||
if (value.initialDecoder !== undefined && !attempt(value.initialDecoder)) return false;
|
||||
if (value.softwareDecoder !== undefined && !attempt(value.softwareDecoder)) return false;
|
||||
if (
|
||||
value.appVersion !== undefined &&
|
||||
(typeof value.appVersion !== "string" ||
|
||||
!/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(value.appVersion))
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
value.output === undefined ||
|
||||
(object(value.output) &&
|
||||
keys(value.output, ["width", "height", "fps"]) &&
|
||||
number(value.output.width, 16384, true) &&
|
||||
number(value.output.height, 16384, true) &&
|
||||
number(value.output.fps, 240))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { isExportErrorReport, type ExportErrorReport } from "../_shared/exportErrorReport.ts";
|
||||
const MAX_BYTES = 4096;
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const reply = (status: number, code?: string) =>
|
||||
new Response(JSON.stringify(code ? { code } : { success: true }), { status, headers });
|
||||
export interface ExportErrorBackend {
|
||||
reserve(): Promise<boolean>;
|
||||
insert(report: ExportErrorReport): Promise<void>;
|
||||
}
|
||||
/** Public, minimal reports only. No identity/IP/device identifier is read or persisted. */
|
||||
export function exportErrorHandler(backend: ExportErrorBackend) {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers });
|
||||
if (request.method !== "POST") return reply(405, "METHOD_NOT_ALLOWED");
|
||||
if (request.headers.has("Authorization")) return reply(400, "IDENTITY_NOT_ACCEPTED");
|
||||
if (!request.headers.get("Content-Type")?.startsWith("application/json"))
|
||||
return reply(400, "INVALID_REPORT");
|
||||
let report: unknown;
|
||||
try {
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return reply(400, "INVALID_REPORT");
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > MAX_BYTES) return reply(413, "REPORT_TOO_LARGE");
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
report = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
return reply(400, "INVALID_REPORT");
|
||||
}
|
||||
if (!isExportErrorReport(report)) return reply(400, "INVALID_REPORT");
|
||||
try {
|
||||
if (!(await backend.reserve())) return reply(429, "REPORT_LIMIT");
|
||||
await backend.insert(report);
|
||||
return reply(200);
|
||||
} catch {
|
||||
return reply(500, "SUBMISSION_FAILED");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createClient } from "npm:@supabase/supabase-js@2.116.0";
|
||||
import { exportErrorHandler } from "./handler.ts";
|
||||
const client = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
{
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
},
|
||||
);
|
||||
Deno.serve(
|
||||
exportErrorHandler({
|
||||
async reserve() {
|
||||
const { data, error } = await client.rpc("reserve_export_error_quota");
|
||||
if (error) throw error;
|
||||
return data === true;
|
||||
},
|
||||
async insert(report) {
|
||||
const { error } = await client
|
||||
.from("export_error_reports")
|
||||
.insert({ cloud_plan: report.cloudPlan, diagnostics: report });
|
||||
if (error) throw error;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
begin;
|
||||
-- Deliberately separate from account-linked feedback: no user_id or identity fields.
|
||||
create table public.export_error_reports (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
created_at timestamptz not null default date_trunc('hour', now()),
|
||||
cloud_plan text not null check (cloud_plan in ('paid', 'not_paid', 'unknown')),
|
||||
diagnostics jsonb not null check (jsonb_typeof(diagnostics) = 'object' and octet_length(diagnostics::text) <= 4096)
|
||||
);
|
||||
create index export_error_reports_created_at on public.export_error_reports(created_at);
|
||||
alter table public.export_error_reports enable row level security;
|
||||
revoke all on public.export_error_reports from public, anon, authenticated;
|
||||
grant select, insert, delete on public.export_error_reports to service_role;
|
||||
|
||||
-- A global cap bounds anonymous writes without persisting IPs or device identifiers.
|
||||
create table public.export_error_usage (
|
||||
minute timestamptz primary key,
|
||||
attempts integer not null
|
||||
);
|
||||
alter table public.export_error_usage enable row level security;
|
||||
revoke all on public.export_error_usage from public, anon, authenticated;
|
||||
create function public.reserve_export_error_quota()
|
||||
returns boolean language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
insert into public.export_error_usage as usage (minute, attempts)
|
||||
values (date_trunc('minute', now()), 1)
|
||||
on conflict (minute) do update set attempts = usage.attempts + 1 where usage.attempts < 20;
|
||||
return found;
|
||||
end;
|
||||
$$;
|
||||
revoke all on function public.reserve_export_error_quota() from public, anon, authenticated;
|
||||
grant execute on function public.reserve_export_error_quota() to service_role;
|
||||
|
||||
-- Retain diagnostics for 30 days and aggregate quota counters for one day.
|
||||
create extension if not exists pg_cron with schema pg_catalog;
|
||||
select cron.schedule('recordly-export-report-retention', '17 3 * * *',
|
||||
$job$delete from public.export_error_reports where created_at < now() - interval '30 days';
|
||||
delete from public.export_error_usage where minute < now() - interval '1 day';$job$);
|
||||
commit;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Run as migration owner in a disposable Supabase database after the reporting migration.
|
||||
begin;
|
||||
delete from public.export_error_usage where minute = date_trunc('minute', now());
|
||||
do $$
|
||||
begin
|
||||
if has_table_privilege('anon', 'public.export_error_reports', 'INSERT')
|
||||
or has_table_privilege('authenticated', 'public.export_error_reports', 'SELECT')
|
||||
or has_function_privilege('anon', 'public.reserve_export_error_quota()', 'EXECUTE') then
|
||||
raise exception 'Client can bypass endpoint or read private reports';
|
||||
end if;
|
||||
if exists (select 1 from information_schema.columns where table_schema='public'
|
||||
and table_name='export_error_reports' and column_name in ('user_id','email','ip','device_id')) then
|
||||
raise exception 'Reports contain identity columns';
|
||||
end if;
|
||||
for i in 1..20 loop
|
||||
if not public.reserve_export_error_quota() then raise exception 'Valid quota reservation rejected'; end if;
|
||||
end loop;
|
||||
if public.reserve_export_error_quota() then raise exception 'Global minute cap bypassed'; end if;
|
||||
if not exists (select 1 from cron.job where jobname='recordly-export-report-retention' and active) then
|
||||
raise exception 'Retention job missing';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
rollback;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
buildExportErrorReport,
|
||||
submitExportErrorReport,
|
||||
} from "@/lib/feedback/exportErrorReporting";
|
||||
import type { CloudPlan } from "../../../services/supabase/functions/_shared/exportErrorReport";
|
||||
|
||||
export function ExportErrorReportButton({
|
||||
error,
|
||||
format,
|
||||
}: {
|
||||
error: string;
|
||||
format: "mp4" | "gif";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [plan, setPlan] = useState<CloudPlan>("unknown");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [sendError, setSendError] = useState("");
|
||||
const sendingRef = useRef(false);
|
||||
const report = buildExportErrorReport(error, plan, format);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{sent ? "Report sent" : "Report error"}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!sendingRef.current) setOpen(value);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{sent ? "Report sent" : "Report export error"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{sent ? (
|
||||
<p className="text-sm">
|
||||
Thanks. The technical report was sent to Recordly support.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send technical details to Recordly support through Supabase. No
|
||||
account ID, email, recording, file path, attachments or console logs
|
||||
are included. Supabase may keep connection logs separately.
|
||||
</p>
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
Cloud plan (optional, self-reported)
|
||||
<select
|
||||
value={plan}
|
||||
onChange={(event) => setPlan(event.target.value as CloudPlan)}
|
||||
disabled={sending}
|
||||
className="rounded-md border bg-background p-2"
|
||||
>
|
||||
<option value="unknown">Unknown / prefer not to say</option>
|
||||
<option value="paid">Paid cloud plan</option>
|
||||
<option value="not_paid">No paid cloud plan</option>
|
||||
</select>
|
||||
</label>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
View data to be sent
|
||||
</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words text-xs mt-2">
|
||||
{JSON.stringify(report, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reports are kept for 30 days, then removed by daily cleanup.
|
||||
</p>
|
||||
{sendError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{sendError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
disabled={sending}
|
||||
onClick={async () => {
|
||||
if (sendingRef.current) return;
|
||||
sendingRef.current = true;
|
||||
setSending(true);
|
||||
setSendError("");
|
||||
try {
|
||||
await submitExportErrorReport(report);
|
||||
setSent(true);
|
||||
} catch (error) {
|
||||
setSendError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not send the report. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sending ? "Sending…" : "Send report"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExportErrorReportButton } from "@/components/feedback/ExportErrorReportButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CloudArrowUp } from "@/components/ui/icons";
|
||||
import { CloudShareButton } from "../cloud/CloudShareButton";
|
||||
@@ -15,7 +16,7 @@ import type { useExportSettings } from "../export/useExportSettings";
|
||||
import type { useExportStatusViewModel } from "../export/useExportStatusViewModel";
|
||||
|
||||
type Props = {
|
||||
projectPath?: string | null;
|
||||
projectPath?: string | null;
|
||||
t: ReturnType<typeof useI18n>["t"];
|
||||
exportSettings: ReturnType<typeof useExportSettings>;
|
||||
exportSession: ReturnType<typeof useExportSession>;
|
||||
@@ -250,6 +251,11 @@ export function EditorExportMenu(props: Props) {
|
||||
>
|
||||
{t("editor.exportStatus.copyError", "Copy error")}
|
||||
</Button>
|
||||
<ExportErrorReportButton
|
||||
key={exportError}
|
||||
error={exportError}
|
||||
format={exportFormat}
|
||||
/>
|
||||
{hasPendingExportSave ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -360,7 +366,7 @@ export function EditorExportMenu(props: Props) {
|
||||
</Popover>
|
||||
{shareOpen && (
|
||||
<CloudShareButton
|
||||
projectPath={props.projectPath}
|
||||
projectPath={props.projectPath}
|
||||
hideTrigger
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
|
||||
@@ -1044,6 +1044,11 @@ export class ModernVideoExporter {
|
||||
const isVideoDecodeFailure = /VideoDecoder failure|VIDEO_DECODE|VIDEO_CODEC/i.test(message);
|
||||
|
||||
if (isVideoDecodeFailure) {
|
||||
if (message.includes("VIDEO_DECODE_RECOVERY_FAILED")) {
|
||||
guidance.add(
|
||||
"The default decoder and software retry both failed. Compare their source times below; failure near the same point suggests a source/codec issue but does not prove corruption.",
|
||||
);
|
||||
}
|
||||
guidance.add(
|
||||
"The input video decoder failed before Recordly could finish rendering the source frames.",
|
||||
);
|
||||
@@ -1051,7 +1056,7 @@ export class ModernVideoExporter {
|
||||
"If only this recording fails, remux or convert it to a standard H.264 MP4; the source may contain a damaged or unsupported frame.",
|
||||
);
|
||||
guidance.add(
|
||||
"If every recording fails, update the GPU/media driver and retry at 30 FPS to reduce decoder pressure.",
|
||||
"If every recording fails, update the GPU/media driver. Lowering export FPS does not change the input stream that must be decoded.",
|
||||
);
|
||||
} else {
|
||||
guidance.add(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
|
||||
import {
|
||||
buildVideoDecodeFailure,
|
||||
buildVideoDecodeRecoveryFailure,
|
||||
type DecodedVideoInfo,
|
||||
getDecodedFrameTimelineOffsetUs,
|
||||
preserveFirstVideoDecodeFailure,
|
||||
@@ -39,12 +40,77 @@ const STARTUP_MAX_PENDING_FRAMES = 28;
|
||||
|
||||
export async function decodeVideoStream(
|
||||
context: StreamingDecodeContext,
|
||||
targetFrameRate: number,
|
||||
trimRegions: TrimRegion[] | undefined,
|
||||
speedRegions: SpeedRegion[] | undefined,
|
||||
onFrame: OnFrameCallback,
|
||||
segmentsOverride?: VideoSegment[],
|
||||
): Promise<void> {
|
||||
let emittedFrames = 0;
|
||||
try {
|
||||
await decodeVideoStreamAttempt(
|
||||
context,
|
||||
targetFrameRate,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
async (...args) => {
|
||||
await onFrame(...args);
|
||||
emittedFrames++;
|
||||
},
|
||||
segmentsOverride,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const codec = context.metadata.codec.toLowerCase();
|
||||
// Only retry failures that completed the decode-error cleanup. Configuration
|
||||
// and renderer/encoder callback failures must propagate without replay.
|
||||
if (
|
||||
context.cancelled ||
|
||||
context.decoder !== null ||
|
||||
codec.includes("av01") ||
|
||||
codec.includes("av1") ||
|
||||
!/^\[VIDEO_DECODE_ENCODING_ERROR\]|^\[VIDEO_DECODER_RESOURCE_EXHAUSTED\]/.test(message)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
"[StreamingVideoDecoder] Decoder failed; replaying with software decoding",
|
||||
error,
|
||||
);
|
||||
// Replay from a keyframe to rebuild decoder state. Output already handed to
|
||||
// the renderer/encoder must not be submitted again.
|
||||
let replayedFrames = 0;
|
||||
try {
|
||||
await decodeVideoStreamAttempt(
|
||||
context,
|
||||
targetFrameRate,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
async (...args) => {
|
||||
if (replayedFrames++ < emittedFrames) return;
|
||||
await onFrame(...args);
|
||||
},
|
||||
segmentsOverride,
|
||||
true,
|
||||
);
|
||||
} catch (softwareError) {
|
||||
const softwareMessage =
|
||||
softwareError instanceof Error ? softwareError.message : String(softwareError);
|
||||
if (!/^\[VIDEO_(?:DECODE|DECODER|CODEC)_/.test(softwareMessage)) throw softwareError;
|
||||
throw buildVideoDecodeRecoveryFailure(error, softwareError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeVideoStreamAttempt(
|
||||
context: StreamingDecodeContext,
|
||||
|
||||
targetFrameRate: number,
|
||||
trimRegions: TrimRegion[] | undefined,
|
||||
speedRegions: SpeedRegion[] | undefined,
|
||||
onFrame: OnFrameCallback,
|
||||
segmentsOverride?: VideoSegment[],
|
||||
forceSoftwareDecode = false,
|
||||
): Promise<void> {
|
||||
if (!context.demuxer || !context.metadata) {
|
||||
throw new Error("Must call loadMetadata() before decodeAll()");
|
||||
@@ -52,7 +118,8 @@ export async function decodeVideoStream(
|
||||
|
||||
const decoderConfig = await context.demuxer.getDecoderConfig("video");
|
||||
const codec = context.metadata.codec.toLowerCase();
|
||||
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
|
||||
const shouldPreferSoftwareDecode =
|
||||
forceSoftwareDecode || codec.includes("av01") || codec.includes("av1");
|
||||
const effectiveVideoDuration = getEffectiveVideoStreamDurationSeconds({
|
||||
duration: context.metadata.duration,
|
||||
streamDuration: context.metadata.streamDuration,
|
||||
@@ -157,7 +224,7 @@ export async function decodeVideoStream(
|
||||
try {
|
||||
context.decoder.configure(preferredDecoderConfig);
|
||||
} catch (error) {
|
||||
if (!shouldPreferSoftwareDecode) {
|
||||
if (!shouldPreferSoftwareDecode || forceSoftwareDecode) {
|
||||
throw buildVideoDecodeFailure(error, getDecoderFailureContext());
|
||||
}
|
||||
// Fall back to default decoder config if software preference is unsupported.
|
||||
@@ -430,8 +497,11 @@ export async function decodeVideoStream(
|
||||
requiredEndSec - lastDecodedFrameSec > 1 &&
|
||||
exportFrameIndex < expectedOutputFrames
|
||||
) {
|
||||
throw new Error(
|
||||
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s; rendered ${exportFrameIndex}/${expectedOutputFrames} frames).`,
|
||||
throw buildVideoDecodeFailure(
|
||||
new Error(
|
||||
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s; rendered ${exportFrameIndex}/${expectedOutputFrames} frames).`,
|
||||
),
|
||||
getDecoderFailureContext(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,18 +139,19 @@ describe("StreamingVideoDecoder decode failures", () => {
|
||||
codedWidth: 1920,
|
||||
codedHeight: 1080,
|
||||
});
|
||||
mockDemuxerRead.mockReturnValue(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: "key",
|
||||
timestamp: 0,
|
||||
duration: 33_333,
|
||||
byteLength: 4,
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
mockDemuxerRead.mockImplementation(
|
||||
() =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: "key",
|
||||
timestamp: 0,
|
||||
duration: 33_333,
|
||||
byteLength: 4,
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
@@ -168,7 +169,7 @@ describe("StreamingVideoDecoder decode failures", () => {
|
||||
});
|
||||
|
||||
it("does not emit frozen remaining frames after a decoder error", async () => {
|
||||
const frame = { timestamp: 0, close: vi.fn() } as unknown as VideoFrame;
|
||||
const frames: Array<{ timestamp: number; close: ReturnType<typeof vi.fn> }> = [];
|
||||
class FailingVideoDecoder {
|
||||
state: CodecState = "unconfigured";
|
||||
decodeQueueSize = 0;
|
||||
@@ -182,7 +183,9 @@ describe("StreamingVideoDecoder decode failures", () => {
|
||||
this.state = "configured";
|
||||
}
|
||||
decode() {
|
||||
this.callbacks.output(frame);
|
||||
const frame = { timestamp: 0, close: vi.fn() };
|
||||
frames.push(frame);
|
||||
this.callbacks.output(frame as unknown as VideoFrame);
|
||||
this.callbacks.error(new DOMException("bad frame", "EncodingError"));
|
||||
}
|
||||
async flush() {}
|
||||
@@ -196,11 +199,193 @@ describe("StreamingVideoDecoder decode failures", () => {
|
||||
await decoder.loadMetadata("/tmp/failing.mp4");
|
||||
const onFrame = vi.fn(async () => {});
|
||||
|
||||
await expect(decoder.decodeAll(30, undefined, undefined, onFrame)).rejects.toThrow(
|
||||
"[VIDEO_DECODE_ENCODING_ERROR]",
|
||||
const failure = await decoder
|
||||
.decodeAll(30, undefined, undefined, onFrame)
|
||||
.catch((error) => error);
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure.message).toContain("[VIDEO_DECODE_RECOVERY_FAILED]");
|
||||
expect(failure.message).toContain("Initial decoder failure: [VIDEO_DECODE_ENCODING_ERROR]");
|
||||
expect(failure.message).toContain(
|
||||
"Software decoder failure: [VIDEO_DECODE_ENCODING_ERROR]",
|
||||
);
|
||||
expect(failure.message).toContain("hardwareAcceleration=prefer-software");
|
||||
expect(onFrame).not.toHaveBeenCalled();
|
||||
expect(frame.close).toHaveBeenCalledTimes(1);
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(mockDemuxerRead).toHaveBeenCalledTimes(2);
|
||||
for (const frame of frames) expect(frame.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves both failures when the software retry ends before a later kept segment", async () => {
|
||||
const configs: VideoDecoderConfig[] = [];
|
||||
const closeFrame = vi.fn();
|
||||
class EarlyEndDecoder {
|
||||
state: CodecState = "unconfigured";
|
||||
decodeQueueSize = 0;
|
||||
software = false;
|
||||
constructor(
|
||||
private callbacks: {
|
||||
output: (frame: VideoFrame) => void;
|
||||
error: (error: DOMException) => void;
|
||||
},
|
||||
) {}
|
||||
configure(config: VideoDecoderConfig) {
|
||||
configs.push(config);
|
||||
this.software = config.hardwareAcceleration === "prefer-software";
|
||||
this.state = "configured";
|
||||
}
|
||||
decode(chunk: EncodedVideoChunk) {
|
||||
if (!this.software) {
|
||||
this.callbacks.error(new DOMException("original bad frame", "EncodingError"));
|
||||
return;
|
||||
}
|
||||
this.callbacks.output({
|
||||
timestamp: chunk.timestamp,
|
||||
close: closeFrame,
|
||||
} as unknown as VideoFrame);
|
||||
}
|
||||
async flush() {}
|
||||
close() {
|
||||
this.state = "closed";
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("VideoDecoder", EarlyEndDecoder);
|
||||
const decoder = new StreamingVideoDecoder();
|
||||
await decoder.loadMetadata("/tmp/early-end.mp4");
|
||||
const failure = await decoder
|
||||
.decodeAll(30, [{ id: "cut", startMs: 1000, endMs: 3000 }], undefined, vi.fn())
|
||||
.catch((error) => error);
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure.message).toContain("[VIDEO_DECODE_RECOVERY_FAILED]");
|
||||
expect(failure.message).toContain("original bad frame");
|
||||
expect(failure.message).toContain("Software decoder failure: [VIDEO_DECODE_FAILED]");
|
||||
expect(failure.message).toContain("Video decode ended early");
|
||||
expect(failure.message).toContain("chunkIndex=0");
|
||||
expect(failure.message).toContain("hardwareAcceleration=prefer-software");
|
||||
expect(configs).toHaveLength(2);
|
||||
expect(closeFrame).toHaveBeenCalledTimes(1);
|
||||
decoder.destroy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"EncodingError",
|
||||
"QuotaExceededError",
|
||||
])("replays %s in software without duplicating exported frames", async (errorName) => {
|
||||
mockDemuxerRead.mockImplementation(
|
||||
() =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (let i = 0; i < 120; i++)
|
||||
controller.enqueue({ timestamp: (i * 1_000_000) / 30 });
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const configs: VideoDecoderConfig[] = [];
|
||||
const timestamps: number[] = [];
|
||||
let framesBeforeRetry = 0;
|
||||
const closedFrames: ReturnType<typeof vi.fn>[] = [];
|
||||
class RecoveringDecoder {
|
||||
state: CodecState = "unconfigured";
|
||||
decodeQueueSize = 0;
|
||||
software = false;
|
||||
constructor(
|
||||
private callbacks: {
|
||||
output: (frame: VideoFrame) => void;
|
||||
error: (error: DOMException) => void;
|
||||
},
|
||||
) {}
|
||||
configure(config: VideoDecoderConfig) {
|
||||
configs.push(config);
|
||||
this.software = config.hardwareAcceleration === "prefer-software";
|
||||
if (this.software) framesBeforeRetry = timestamps.length;
|
||||
this.state = "configured";
|
||||
}
|
||||
decode(chunk: EncodedVideoChunk) {
|
||||
if (!this.software && chunk.timestamp >= 500_000) {
|
||||
this.state = "closed";
|
||||
this.callbacks.error(new DOMException("driver decode failed", errorName));
|
||||
return;
|
||||
}
|
||||
const close = vi.fn();
|
||||
closedFrames.push(close);
|
||||
this.callbacks.output({
|
||||
timestamp: chunk.timestamp,
|
||||
close,
|
||||
} as unknown as VideoFrame);
|
||||
}
|
||||
async flush() {}
|
||||
close() {
|
||||
this.state = "closed";
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("VideoDecoder", RecoveringDecoder);
|
||||
const decoder = new StreamingVideoDecoder();
|
||||
await decoder.loadMetadata("/tmp/driver-failure.mp4");
|
||||
await decoder.decodeAll(30, undefined, undefined, async (_frame, timestamp) => {
|
||||
timestamps.push(timestamp);
|
||||
});
|
||||
expect(configs).toHaveLength(2);
|
||||
expect(configs[1].hardwareAcceleration).toBe("prefer-software");
|
||||
expect(framesBeforeRetry).toBeGreaterThan(0);
|
||||
expect(timestamps).toHaveLength(120);
|
||||
for (let i = 0; i < timestamps.length; i++)
|
||||
expect(timestamps[i]).toBeCloseTo((i * 1_000_000) / 30);
|
||||
expect(mockDemuxerRead).toHaveBeenCalledTimes(2);
|
||||
for (const close of closedFrames) expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
codec: "av01.0.04M.08",
|
||||
errorName: "EncodingError",
|
||||
cancelled: false,
|
||||
setupFailure: false,
|
||||
},
|
||||
{ codec: "avc1.640034", errorName: "DataError", cancelled: false, setupFailure: false },
|
||||
{ codec: "avc1.640034", errorName: "EncodingError", cancelled: true, setupFailure: false },
|
||||
{ codec: "avc1.640034", errorName: "EncodingError", cancelled: false, setupFailure: true },
|
||||
])("does not retry $codec / $errorName (cancelled: $cancelled, setup: $setupFailure)", async ({
|
||||
codec,
|
||||
errorName,
|
||||
cancelled,
|
||||
setupFailure,
|
||||
}) => {
|
||||
const info = await mockDemuxerGetMediaInfo();
|
||||
mockDemuxerGetMediaInfo.mockResolvedValueOnce({
|
||||
...info,
|
||||
streams: info.streams.map((stream: Record<string, unknown>) => ({
|
||||
...stream,
|
||||
codec_string: codec,
|
||||
})),
|
||||
});
|
||||
const configure = vi.fn();
|
||||
const decoder = new StreamingVideoDecoder();
|
||||
class NonRetryDecoder {
|
||||
state: CodecState = "unconfigured";
|
||||
decodeQueueSize = 0;
|
||||
constructor(private callbacks: { error: (error: DOMException) => void }) {}
|
||||
configure() {
|
||||
configure();
|
||||
if (setupFailure) throw new DOMException("setup failed", errorName);
|
||||
this.state = "configured";
|
||||
}
|
||||
decode() {
|
||||
if (cancelled) decoder.cancel();
|
||||
this.state = "closed";
|
||||
this.callbacks.error(new DOMException("decode failed", errorName));
|
||||
}
|
||||
async flush() {}
|
||||
close() {
|
||||
this.state = "closed";
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("VideoDecoder", NonRetryDecoder);
|
||||
await decoder.loadMetadata("/tmp/no-retry.mp4");
|
||||
await expect(decoder.decodeAll(30, undefined, undefined, vi.fn())).rejects.toThrow(
|
||||
setupFailure ? "setup failed" : "decode failed",
|
||||
);
|
||||
expect(configure).toHaveBeenCalledTimes(1);
|
||||
decoder.destroy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -92,6 +92,22 @@ export function buildVideoDecodeFailure(error: unknown, context: VideoDecodeFail
|
||||
return failure;
|
||||
}
|
||||
|
||||
/** Preserve both decoder attempts without claiming that a damaged source is proven. */
|
||||
export function buildVideoDecodeRecoveryFailure(initial: unknown, software: unknown): Error {
|
||||
const describe = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||
const failure = new Error(
|
||||
[
|
||||
"[VIDEO_DECODE_RECOVERY_FAILED] Default decoding and the software retry both failed.",
|
||||
`Initial decoder failure: ${describe(initial)}`,
|
||||
`Software decoder failure: ${describe(software)}`,
|
||||
"Chunk positions are the last submitted input, not necessarily the exact failing frame.",
|
||||
"Source corruption is not confirmed. Test this file with an independent decoder; if other files also fail, investigate the system decoder/driver.",
|
||||
].join("\n"),
|
||||
);
|
||||
(failure as Error & { cause?: unknown }).cause = { initial, software };
|
||||
return failure;
|
||||
}
|
||||
|
||||
/** Keeps the original decoder failure when cleanup triggers secondary errors. */
|
||||
export function preserveFirstVideoDecodeFailure(
|
||||
existingError: Error | null,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { buildExportErrorReport, submitExportErrorReport } from "./exportErrorReporting";
|
||||
import { buildVideoDecodeRecoveryFailure } from "../exporter/streamingDecoderSupport";
|
||||
import { isExportErrorReport } from "../../../services/supabase/functions/_shared/exportErrorReport";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_SUPABASE_URL", "https://support.example.test");
|
||||
vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_KEY", "public-key");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("keeps both decoder failures and allowlists numerical positions without uploading private text", () => {
|
||||
const first = new Error(
|
||||
"[VIDEO_DECODE_ENCODING_ERROR] VideoDecoder failure: private /Users/name/video.mp4 user@example.com (sourceTimeSec=0.500, chunkIndex=25, chunkBytes=101256)",
|
||||
);
|
||||
const second = new Error(
|
||||
"[VIDEO_DECODE_ENCODING_ERROR] VideoDecoder failure: Bearer secret (sourceTimeSec=0.520, chunkIndex=26, chunkBytes=5)",
|
||||
);
|
||||
const combined = buildVideoDecodeRecoveryFailure(first, second);
|
||||
expect(combined.message).toContain("Source corruption is not confirmed");
|
||||
expect(combined.cause).toEqual({ initial: first, software: second });
|
||||
const report = buildExportErrorReport(
|
||||
`${combined.message}\nFailure stage: Input video decoding\nOutput: 1920x1080 @ 30 FPS\nPlatform: Windows\nRecordly version: 1.4.0`,
|
||||
"paid",
|
||||
"mp4",
|
||||
);
|
||||
expect(report.failure.code).toBe("VIDEO_DECODE_RECOVERY_FAILED");
|
||||
expect(report.initialDecoder).toEqual({
|
||||
code: "VIDEO_DECODE_ENCODING_ERROR",
|
||||
sourceTimeSec: 0.5,
|
||||
chunkIndex: 25,
|
||||
chunkBytes: 101256,
|
||||
});
|
||||
expect(report.softwareDecoder?.sourceTimeSec).toBe(0.52);
|
||||
expect(isExportErrorReport(report)).toBe(true);
|
||||
const payload = JSON.stringify(report);
|
||||
for (const privateValue of [
|
||||
"/Users/",
|
||||
"name",
|
||||
"user@example.com",
|
||||
"Bearer",
|
||||
"secret",
|
||||
"video.mp4",
|
||||
])
|
||||
expect(payload).not.toContain(privateValue);
|
||||
});
|
||||
it("handles GIF, legacy and save failures with no free-text fallback", () => {
|
||||
const report = buildExportErrorReport(
|
||||
"Failed to save GIF at C:\\Users\\Alice\\private.gif",
|
||||
"not_paid",
|
||||
"gif",
|
||||
);
|
||||
expect(report).toMatchObject({
|
||||
cloudPlan: "not_paid",
|
||||
format: "gif",
|
||||
failure: { code: "SAVE_FAILED" },
|
||||
stage: "saving",
|
||||
platform: "unknown",
|
||||
});
|
||||
expect(isExportErrorReport(report)).toBe(true);
|
||||
expect(JSON.stringify(report)).not.toContain("Alice");
|
||||
});
|
||||
it("sends only after explicit submission without session auth, cookies, or a referrer", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const report = buildExportErrorReport("Export failed");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
await submitExportErrorReport(report);
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://support.example.test/functions/v1/submit-export-error");
|
||||
expect(options.headers).toEqual({ "Content-Type": "application/json", apikey: "public-key" });
|
||||
expect(options.credentials).toBe("omit");
|
||||
expect(options.referrerPolicy).toBe("no-referrer");
|
||||
expect(JSON.parse(options.body)).toEqual(JSON.parse(JSON.stringify(report)));
|
||||
expect(options.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
it.each([
|
||||
429, 500, 200,
|
||||
])("does not claim success after an unconfirmed/failed response (%s)", async (status) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: "private backend detail" }), { status }),
|
||||
),
|
||||
);
|
||||
await expect(submitExportErrorReport(buildExportErrorReport("error"))).rejects.toThrow(
|
||||
status === 429 ? "busy" : "Could not send",
|
||||
);
|
||||
});
|
||||
it("does not send when support is unconfigured", async () => {
|
||||
vi.stubEnv("VITE_SUPABASE_URL", "");
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(submitExportErrorReport(buildExportErrorReport("error"))).rejects.toThrow(
|
||||
"unavailable",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<html>Gateway</html>",
|
||||
"OK",
|
||||
"null",
|
||||
])("shows a safe fallback for malformed successful replies (%s)", async (body) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body)));
|
||||
await expect(submitExportErrorReport(buildExportErrorReport("error"))).rejects.toThrow(
|
||||
"Could not send the report. Please try again.",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import packageInfo from "../../../package.json";
|
||||
import type {
|
||||
CloudPlan,
|
||||
ExportErrorReport,
|
||||
} from "../../../services/supabase/functions/_shared/exportErrorReport";
|
||||
|
||||
const FAILURE_CODES = new Set([
|
||||
"VIDEO_DECODE_ENCODING_ERROR",
|
||||
"VIDEO_DECODER_RESOURCE_EXHAUSTED",
|
||||
"VIDEO_DECODER_INVALID_STATE",
|
||||
"VIDEO_DECODE_FAILED",
|
||||
"VIDEO_CODEC_UNSUPPORTED",
|
||||
"VIDEO_DECODE_RECOVERY_FAILED",
|
||||
]);
|
||||
function decodeAttempt(message: string) {
|
||||
const numeric = (key: string) => {
|
||||
const match = message.match(new RegExp(`\\b${key}=([0-9]+(?:\\.[0-9]+)?)(?=[,;)\\s]|$)`));
|
||||
const value = match ? Number(match[1]) : undefined;
|
||||
return value !== undefined && Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const match = message.match(/\[(VIDEO_[A-Z_]+)\]/)?.[1];
|
||||
return {
|
||||
code: match && FAILURE_CODES.has(match) ? match : "UNKNOWN",
|
||||
sourceTimeSec: numeric("sourceTimeSec"),
|
||||
chunkIndex: numeric("chunkIndex"),
|
||||
chunkBytes: numeric("chunkBytes"),
|
||||
};
|
||||
}
|
||||
/** Allowlist only: never upload free-text errors, stack traces, paths, logs, or media. */
|
||||
export function buildExportErrorReport(
|
||||
message: string,
|
||||
cloudPlan: CloudPlan = "unknown",
|
||||
format: "mp4" | "gif" | "unknown" = "unknown",
|
||||
): ExportErrorReport {
|
||||
const bounded = message.slice(0, 32_000);
|
||||
const software = bounded.split("Software decoder failure: ")[1];
|
||||
const initial = bounded
|
||||
.split("Initial decoder failure: ")[1]
|
||||
?.split("\nSoftware decoder failure:")[0];
|
||||
const output = bounded.match(/Output: (\d{1,5})x(\d{1,5}) @ (\d{1,3}(?:\.\d{1,3})?) FPS/);
|
||||
const version =
|
||||
bounded.match(/Recordly version: (\d{1,4}\.\d{1,4}\.\d{1,4})\b/)?.[1] ??
|
||||
packageInfo.version;
|
||||
const userAgent = typeof navigator === "undefined" ? "" : navigator.userAgent;
|
||||
const platform =
|
||||
bounded.match(/Platform: (Windows|macOS|Linux)\b/)?.[1] ??
|
||||
(/Windows/.test(userAgent)
|
||||
? "Windows"
|
||||
: /Macintosh|Mac OS/.test(userAgent)
|
||||
? "macOS"
|
||||
: /Linux/.test(userAgent)
|
||||
? "Linux"
|
||||
: "unknown");
|
||||
const failure = decodeAttempt(bounded);
|
||||
const inputDecoding = failure.code !== "UNKNOWN";
|
||||
if (!inputDecoding) {
|
||||
failure.code = /Failed to save (?:video|GIF)|No pending export to save/i.test(bounded)
|
||||
? "SAVE_FAILED"
|
||||
: /audio/i.test(bounded)
|
||||
? "AUDIO_FAILED"
|
||||
: /encod|ffmpeg/i.test(bounded)
|
||||
? "ENCODE_FAILED"
|
||||
: "EXPORT_FAILED";
|
||||
}
|
||||
return {
|
||||
cloudPlan,
|
||||
format,
|
||||
schemaVersion: 1,
|
||||
failure,
|
||||
stage:
|
||||
inputDecoding || /Failure stage: Input video decoding/.test(bounded)
|
||||
? "input_decoding"
|
||||
: /Failed to save (?:video|GIF)|No pending export to save/i.test(bounded)
|
||||
? "saving"
|
||||
: "export",
|
||||
initialDecoder: initial ? decodeAttempt(initial) : undefined,
|
||||
softwareDecoder: software ? decodeAttempt(software) : undefined,
|
||||
appVersion: version,
|
||||
platform: platform ?? "unknown",
|
||||
output: output
|
||||
? { width: Number(output[1]), height: Number(output[2]), fps: Number(output[3]) }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Send only after a user clicks Send report. Never use the signed-in Supabase client. */
|
||||
export async function submitExportErrorReport(report: ExportErrorReport): Promise<void> {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL?.trim();
|
||||
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim();
|
||||
if (!url || !key)
|
||||
throw new Error("Error reporting is unavailable until support services are configured.");
|
||||
const response = await fetch(`${url.replace(/\/$/, "")}/functions/v1/submit-export-error`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", apikey: key },
|
||||
credentials: "omit",
|
||||
referrerPolicy: "no-referrer",
|
||||
body: JSON.stringify(report),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.status === 429) throw new Error("Reporting is busy. Please try again later.");
|
||||
const body = response.ok ? await response.json().catch(() => null) : null;
|
||||
if (body?.success !== true) throw new Error("Could not send the report. Please try again.");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportErrorHandler } from "../../../services/supabase/functions/submit-export-error/handler";
|
||||
import { buildExportErrorReport } from "./exportErrorReporting";
|
||||
const backend = { reserve: vi.fn(), insert: vi.fn() };
|
||||
const handle = exportErrorHandler(backend);
|
||||
const valid = () =>
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
buildExportErrorReport(
|
||||
"[VIDEO_DECODE_ENCODING_ERROR] failure (sourceTimeSec=0.500, chunkIndex=25)",
|
||||
"unknown",
|
||||
"mp4",
|
||||
),
|
||||
),
|
||||
);
|
||||
const request = (body: unknown, headers?: Record<string, string>) =>
|
||||
new Request("https://example.test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
backend.reserve.mockResolvedValue(true);
|
||||
backend.insert.mockResolvedValue(undefined);
|
||||
});
|
||||
it("accepts a no-account report and reserves bounded quota before storage", async () => {
|
||||
const report = valid();
|
||||
expect((await handle(request(report))).status).toBe(200);
|
||||
expect(backend.insert).toHaveBeenCalledWith(report);
|
||||
expect(backend.reserve.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
backend.insert.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(backend.insert.mock.calls[0][0]).not.toHaveProperty("user_id");
|
||||
});
|
||||
it("rejects identity-bearing headers or unexpected fields instead of storing them", async () => {
|
||||
for (const req of [
|
||||
request(valid(), { Authorization: "Bearer user-session" }),
|
||||
request({ ...valid(), user_id: "account" }),
|
||||
request({ ...valid(), logs: "private" }),
|
||||
request({ ...valid(), failure: { code: "UNKNOWN", message: "private path" } }),
|
||||
])
|
||||
expect((await handle(req)).status).toBe(400);
|
||||
expect(backend.reserve).not.toHaveBeenCalled();
|
||||
expect(backend.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
{ cloudPlan: "user@example.com" },
|
||||
{ cloudPlan: { toString: "private" } },
|
||||
{ appVersion: "private" },
|
||||
{ platform: "private" },
|
||||
{ failure: { code: "PRIVATE_ERROR" } },
|
||||
{ failure: { code: "UNKNOWN", sourceTimeSec: -1 } },
|
||||
{ output: { width: 999999, height: 1080, fps: 30 } },
|
||||
{ schemaVersion: 2 },
|
||||
])("rejects malformed fields %j", async (fields) => {
|
||||
expect((await handle(request({ ...valid(), ...fields }))).status).toBe(400);
|
||||
expect(backend.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects streamed oversized bodies without trusting Content-Length", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(5000));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const req = new Request("https://example.test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Content-Length": "1" },
|
||||
body,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
expect((await handle(req)).status).toBe(413);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(backend.reserve).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns a quota error and hides backend failures", async () => {
|
||||
backend.reserve.mockResolvedValueOnce(false);
|
||||
expect((await handle(request(valid()))).status).toBe(429);
|
||||
expect(backend.insert).not.toHaveBeenCalled();
|
||||
backend.insert.mockRejectedValue(new Error("private database detail"));
|
||||
const response = await handle(request(valid()));
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ code: "SUBMISSION_FAILED" });
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Export error report</title></head><body><div id="root"></div><script type="module" src="./export-error-report.tsx"></script></body></html>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("export reports preview only technical data and submit explicitly without account identity", async ({
|
||||
page,
|
||||
}) => {
|
||||
const reports: unknown[] = [];
|
||||
await page.route("**/functions/v1/submit-export-error", async (route) => {
|
||||
const req = route.request();
|
||||
expect(req.headers().authorization).toBeUndefined();
|
||||
expect(req.headers().cookie).toBeUndefined();
|
||||
expect(req.headers().referer).toBeUndefined();
|
||||
reports.push(req.postDataJSON());
|
||||
await route.fulfill({
|
||||
status: reports.length === 1 ? 500 : 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
reports.length === 1 ? { code: "SUBMISSION_FAILED" } : { success: true },
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.goto("/tests/ui/export-error-report.html");
|
||||
await page.getByRole("button", { name: "Report error", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Report export error", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(reports).toHaveLength(0);
|
||||
await dialog.getByText("View data to be sent", { exact: true }).click();
|
||||
await expect(dialog.locator("pre")).toContainText("VIDEO_DECODE_ENCODING_ERROR");
|
||||
await expect(dialog.locator("pre")).not.toContainText("Alice");
|
||||
await dialog.getByLabel("Cloud plan", { exact: false }).selectOption("paid");
|
||||
await expect(dialog.locator("pre")).toContainText('"cloudPlan": "paid"');
|
||||
await page.screenshot({ path: "test-results/export-error-report.png" });
|
||||
await dialog.getByRole("button", { name: "Send report", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("Could not send");
|
||||
expect(reports).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Send report", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Report sent", exact: true })).toBeVisible();
|
||||
expect(reports).toHaveLength(2);
|
||||
expect(reports[1]).toMatchObject({ cloudPlan: "paid", format: "mp4" });
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { I18nProvider } from "@/contexts/I18nContext";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ExportErrorReportButton } from "@/components/feedback/ExportErrorReportButton";
|
||||
import "@/index.css";
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<main className="p-8">
|
||||
<ExportErrorReportButton
|
||||
format="mp4"
|
||||
error="[VIDEO_DECODE_ENCODING_ERROR] private /Users/Alice/video.mp4 alice@example.com (sourceTimeSec=0.500, chunkIndex=25)"
|
||||
/>
|
||||
</main>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
Reference in new issue
Block a user