Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 158 additions & 1 deletion app/src/components/MediaAddPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { useRef, useState } from "react";
import { api } from "../api/client";
import { useActiveProject } from "../stores/activeProject";
import { useProject } from "../stores/project";
import {
describeLineage,
detectRenderLineage,
type RenderLineageMatch,
} from "../lib/renderLineage";

const VIDEO_EXTS = ["mov", "mp4", "mkv", "m4v", "avi", "webm"];
const isVideoFile = (name: string) =>
Expand All @@ -26,17 +32,38 @@ export function MediaAddPanel() {
| { paths: string[]; suggestedMode: "copy" | "reference" }
| null
>(null);
// Lineage prompt: shown when the user tries to add a file that's
// actually a render in this project's history. Distinct from the add
// modal so the user can pick "continue editing source" without going
// through the copy-vs-reference dance.
const [lineagePrompt, setLineagePrompt] = useState<
| { addedPath: string; match: RenderLineageMatch; suggestedMode: "copy" | "reference" }
| null
>(null);
const [uploads, setUploads] = useState<Record<string, number>>({});
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);

const projectAvailable = !!project;

/** Append a path to the pending-add queue, opening the modal if needed. */
/** Append a path to the pending-add queue, opening the modal if needed.
*
* Special case: if the path is one of this project's previous renders,
* we surface a "continue editing the source instead?" prompt rather
* than letting the user accidentally add a derived MP4 as a fresh
* source (which would force a full pipeline re-run and burn tokens).
*/
const queueAdd = (path: string, suggestedMode: "copy" | "reference") => {
const trimmed = path.trim();
if (!trimmed) return;
setError(null);
if (project) {
const match = detectRenderLineage(project, trimmed);
if (match) {
setLineagePrompt({ addedPath: trimmed, match, suggestedMode });
return;
}
}
setPendingAdd((prev) => {
if (prev) {
if (prev.paths.includes(trimmed)) return prev;
Expand All @@ -46,6 +73,31 @@ export function MediaAddPanel() {
});
};

/** "Continue editing source" button on the lineage prompt: jump the
* AI tab to the source video the render was derived from. Source must
* already be in this project's sources (which it almost always is —
* a render can't exist without its source having been added). */
const continueEditingSource = () => {
if (!lineagePrompt?.match.sourceAbsPath) return;
useProject.getState().setActive(lineagePrompt.match.sourceAbsPath);
setLineagePrompt(null);
};

/** "Add anyway" escape hatch: drop the lineage match and treat the file
* as a brand-new source. The user is taking the token hit knowingly. */
const addAnywayDespiteLineage = () => {
if (!lineagePrompt) return;
const { addedPath, suggestedMode } = lineagePrompt;
setLineagePrompt(null);
setPendingAdd((prev) => {
if (prev) {
if (prev.paths.includes(addedPath)) return prev;
return { ...prev, paths: [...prev.paths, addedPath] };
}
return { paths: [addedPath], suggestedMode };
});
};

const performAdd = async (mode: "copy" | "reference") => {
if (!project || !pendingAdd) return;
try {
Expand Down Expand Up @@ -150,6 +202,111 @@ export function MediaAddPanel() {
onConfirm={performAdd}
/>
)}

{lineagePrompt && (
<RenderLineageModal
addedPath={lineagePrompt.addedPath}
match={lineagePrompt.match}
onContinueEditingSource={continueEditingSource}
onAddAnyway={addAnywayDespiteLineage}
onCancel={() => setLineagePrompt(null)}
/>
)}
</div>
);
}

/**
* "Hey, this is one of your renders" prompt.
*
* Surfaced whenever a user tries to add a file that matches an entry in
* the project's render_history. The default path here is to send them
* back to the original source so their next render reuses the cached
* analysis and classification, costing zero new tokens. The "Add anyway"
* button is the escape hatch for users who genuinely want to treat the
* render as a fresh source (e.g. to A/B different classifier settings
* against the rendered output, or because they edited it externally).
*/
function RenderLineageModal({
addedPath,
match,
onContinueEditingSource,
onAddAnyway,
onCancel,
}: {
addedPath: string;
match: RenderLineageMatch;
onContinueEditingSource: () => void;
onAddAnyway: () => void;
onCancel: () => void;
}) {
const fileName = addedPath.split("/").pop() ?? addedPath;
const sourceName = match.sourceAbsPath?.split("/").pop() ?? null;
const canContinue = Boolean(match.sourceAbsPath);

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onCancel}
>
<div
className="w-[520px] max-w-[92vw] rounded-lg border border-border bg-bg-panel shadow-2xl p-5"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-text-primary mb-1">
That's one of your renders
</h3>
<p className="text-xs text-text-muted mb-3 leading-snug">
<code className="font-mono">{fileName}</code> was produced by this
project: {describeLineage(match)}.
</p>
<p className="text-xs text-text-muted mb-4 leading-snug">
{canContinue ? (
<>
Adding it as a new source would re-run the full AI pipeline
(transcription + classification) on the rendered audio,
charging Anthropic and Groq tokens again. If you just want to
iterate on this render, continue editing{" "}
<code className="font-mono">{sourceName}</code>: tweak cuts,
overrides, or audio settings and re-render. No new AI cost.
</>
) : (
<>
This is a splice render, so there isn't a single source to
jump back to. Adding it as a new source will re-run the full
pipeline and charge new tokens.
</>
)}
</p>

<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="h-8 px-3 rounded bg-bg-elevated hover:bg-border text-sm"
>
Cancel
</button>
<button
type="button"
onClick={onAddAnyway}
className="h-8 px-3 rounded bg-bg-elevated hover:bg-border text-sm text-text-secondary"
title="Treat this render as a new source. The full pipeline will run and charge tokens."
>
Add anyway
</button>
{canContinue && (
<button
type="button"
onClick={onContinueEditingSource}
className="h-8 px-4 rounded bg-accent hover:bg-accent/80 text-white text-sm font-medium"
title="Switch the AI tab to the original source. Pipeline artifacts already cached."
>
Continue editing {sourceName}
</button>
)}
</div>
</div>
</div>
);
}
Expand Down
10 changes: 6 additions & 4 deletions app/src/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,8 @@ function PacingTab({ item, onOpenReview, onRender }: PacingTabProps) {
{audioOn
? " and bakes in the audio settings from the Audio tab."
: "."}{" "}
Produces a new MP4 in the project's renders folder.
Produces a new MP4 in the project's renders folder. No AI cost on
re-render: cuts and overrides reuse the cached classification.
</p>
{isRendering ? (
<div className="space-y-1.5 px-1">
Expand Down Expand Up @@ -688,9 +689,10 @@ function AudioTab({ item, onChange, onRender }: AudioTabProps) {

<Section title="Render">
<p className="text-[10px] text-text-muted px-1 mb-2 leading-snug">
Applies just the audio settings — no AI cuts. Produces a new MP4
in the project's renders folder. Doesn't require running the
pipeline first.
Audio-only render. No AI cost: tweak enhancement, denoise engine,
or ducking and re-render as often as you like without burning any
Anthropic or Groq tokens. Produces a new MP4 in the project's
renders folder.
</p>
{isRendering ? (
<div className="space-y-1.5 px-1">
Expand Down
7 changes: 5 additions & 2 deletions app/src/components/SplicingView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,11 @@ function Preview() {
const at = clipAtPlayhead(timeline, playhead);
const total = totalDuration(timeline);

const onVideoClip = at && at.clip.kind === "video";
const currentSrc = onVideoClip ? api.sourceUrl(at!.clip.sourcePath) : null;
// Narrow `at.clip` inline so TypeScript sees the "video" variant
// (discriminated union narrowing doesn't follow through an extracted
// boolean).
const currentSrc =
at && at.clip.kind === "video" ? api.sourceUrl(at.clip.sourcePath) : null;
const lastSrcRef = useRef<string | null>(null);

// Seek + swap source whenever the underlying clip changes. Only meaningful
Expand Down
5 changes: 3 additions & 2 deletions app/src/lib/projectDigest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,5 +257,6 @@ function relTime(iso: string): string {
return `${Math.floor(diff / 86400)}d ago`;
}

// Re-export so callers don't reach across modules for these.
export { ProjectSpliceClip };
// Re-export so callers don't reach across modules for these. `export type`
// (not `export {}`) is required by tsconfig's `verbatimModuleSyntax: true`.
export type { ProjectSpliceClip };
116 changes: 116 additions & 0 deletions app/src/lib/renderLineage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Project, ProjectRenderHistoryEntry } from "../api/types";

/**
* Render lineage detection.
*
* Goal: stop users from accidentally treating a previously-rendered MP4
* as a fresh source. Doing that would force the full pipeline to run
* again (analyze + classify, ~$0.55 in tokens) when in reality the user
* is just trying to iterate on a render they already produced. The
* correct workflow is to go back to the original source, tweak the
* project state (custom cuts, overrides, audio settings), and re-render.
*
* The project manifest's `render_history` is the source of truth: every
* render writes an entry with `{output, source, settings, label, …}`,
* including the project-relative output path. When a user drops a file
* onto the AddPanel, we check whether that path matches any entry's
* output. If so, we know:
* - what original source it was derived from
* - what settings produced it (cuts, audio, etc.)
* - how to label the offer in the UI
*
* This module is the lookup-side; the UI uses the result to surface a
* "this is a render of X — continue editing the source instead?" modal.
*/

export interface RenderLineageMatch {
/** The render_history entry that matched. */
entry: ProjectRenderHistoryEntry;
/** Absolute filesystem path of the source video this render was made
* from. Null when the entry didn't record one (older splice renders). */
sourceAbsPath: string | null;
}

/**
* Test whether ``addedPath`` is a previously-recorded render of the
* given project. Returns the matching history entry (with resolved
* source path) or null.
*
* Matching is by absolute path: the render_history stores
* project-relative paths (e.g. `renders/r001.intro.paced.mp4`), so we
* combine with `project.path` to get the absolute form and compare.
*/
export function detectRenderLineage(
project: Project,
addedPath: string,
): RenderLineageMatch | null {
const normalized = stripFileScheme(addedPath);
const projectRoot = project.path;
if (!projectRoot) return null;

// Walk newest-first so if a user has somehow re-rendered to the same
// path (rare; we use rNNN prefixes that should be unique) the most
// recent metadata wins.
for (const entry of [...project.render_history].reverse()) {
const absOutput = joinPath(projectRoot, entry.output);
if (samePath(absOutput, normalized)) {
return {
entry,
sourceAbsPath: entry.source
? joinPath(projectRoot, entry.source)
: null,
};
}
}
return null;
}

/**
* Human-readable one-liner for the lineage modal:
* "AI render of intro.mov (15 paced cuts + medium neural denoise)"
*
* Falls back to the stored `label` when settings parsing isn't useful.
*/
export function describeLineage(match: RenderLineageMatch): string {
const e = match.entry;
const sourceName = match.sourceAbsPath
? baseName(match.sourceAbsPath)
: null;
const kind = e.type === "splice_render" ? "Splice render" : "AI render";
if (sourceName) {
return `${kind} of ${sourceName} (${e.label})`;
}
return `${kind} (${e.label})`;
}

// ─── helpers ───────────────────────────────────────────────────────────────

function stripFileScheme(p: string): string {
return p.startsWith("file://") ? p.slice("file://".length) : p;
}

function joinPath(root: string, rel: string): string {
// Strip leading "./", "/" so we always join exactly one slash.
const cleanRel = rel.replace(/^\.?\/+/, "");
return root.endsWith("/")
? root + cleanRel
: `${root}/${cleanRel}`;
}

/**
* Compare two absolute paths for equality. Tolerates a single trailing
* slash difference and a double-slash collapse, both of which crop up
* with concatenated path components. Case-sensitive (we only run on
* macOS / Linux where APFS / ext4 default to case-sensitive matches
* for tools like ffprobe — being stricter here than the filesystem is
* the safer error direction).
*/
function samePath(a: string, b: string): boolean {
const norm = (s: string) => s.replace(/\/+/g, "/").replace(/\/$/, "");
return norm(a) === norm(b);
}

function baseName(p: string): string {
const segs = p.split("/");
return segs[segs.length - 1] || p;
}
Loading
Loading