From a2a9952c6cda3298fecc32ad1dc618e618aea178 Mon Sep 17 00:00:00 2001
From: Riccardo Pallaoro <95774148+pallaoro@users.noreply.github.com>
Date: Tue, 10 Nov 2026 15:32:43 +0000
Subject: [PATCH 1/2] AI changes made in a quick answer write only over the cut
they read, and can be put back
---
schema.sql | 3 +++
src/client/assistant.tsx | 21 +++++++++++++++---
src/server/ask-jobs.ts | 46 +++++++++++++++++++++++++++++++++++++++-
src/server/index.ts | 45 +++++++++++++++++++++++++++++++--------
test/e2e/run.mjs | 39 ++++++++++++++++++++++++++++++++++
5 files changed, 141 insertions(+), 13 deletions(-)
diff --git a/schema.sql b/schema.sql
index 90d1258..19164fd 100644
--- a/schema.sql
+++ b/schema.sql
@@ -469,3 +469,6 @@ ALTER TABLE ask_messages ADD COLUMN job_id TEXT;
-- Projects an AI message made (clips cut from a long video), as JSON [{id, name}].
ALTER TABLE ask_messages ADD COLUMN made TEXT;
+
+-- When an applied AI change was put back to the cut before it (POST …/ask/jobs/{job}/revert).
+ALTER TABLE ask_jobs ADD COLUMN reverted_at TEXT;
diff --git a/src/client/assistant.tsx b/src/client/assistant.tsx
index 11cd15c..70b9921 100644
--- a/src/client/assistant.tsx
+++ b/src/client/assistant.tsx
@@ -47,6 +47,9 @@ export interface AskJob {
error: string | null;
/** Clip projects made so far, while it runs. */
made?: Made[];
+ /** An applied change that can still be put back. */
+ revertible?: boolean;
+ reverted_at?: string | null;
}
const ACTIVE: AskJobStatus[] = ["queued", "running"];
@@ -129,8 +132,12 @@ export function useAsk(projectId: string, getEdl: () => Edl, onApplied: (edl: Ed
}, [active, refresh]);
const act = useCallback(
- async (jobId: string, action: "run" | "cancel" | "apply") => {
- const out = await api.send<{ result_edl?: Edl | null }>("POST", `/api/projects/${projectId}/ask/jobs/${jobId}/${action}`);
+ async (jobId: string, action: "run" | "cancel" | "apply" | "revert") => {
+ const out = await api.send<{ result_edl?: Edl | null; base_edl?: Edl | null }>("POST", `/api/projects/${projectId}/ask/jobs/${jobId}/${action}`);
+ // Put back: the cut before the change returns, as one undo step here too.
+ if (action === "revert" && out.base_edl && canonical(latest.current.getEdl()) !== canonical(out.base_edl)) {
+ latest.current.onApplied(out.base_edl);
+ }
if (action === "run") seenActive.current.add(jobId);
if (action === "apply" && out.result_edl) {
handled.current.add(jobId);
@@ -313,7 +320,7 @@ export function AssistantPanel({
}
};
- const doAct = async (jobId: string, action: "run" | "cancel" | "apply") => {
+ const doAct = async (jobId: string, action: "run" | "cancel" | "apply" | "revert") => {
setActing(`${jobId}:${action}`);
setActErr("");
try {
@@ -375,6 +382,14 @@ export function AssistantPanel({
);
}
+ if (job.revertible && !saving) {
+ return (
+
+ );
+ }
+ if (job.reverted_at) return
Put back.
;
if (job.status === "stale" || notTaken.has(job.id)) {
return (
diff --git a/src/server/ask-jobs.ts b/src/server/ask-jobs.ts
index 0265316..673189b 100644
--- a/src/server/ask-jobs.ts
+++ b/src/server/ask-jobs.ts
@@ -61,6 +61,7 @@ export interface AskJobRow {
step_booked_at: string | null;
started_at: string | null;
finished_at: string | null;
+ reverted_at: string | null;
created_at: string;
updated_at: string;
}
@@ -94,6 +95,9 @@ export function jobView(j: AskJobRow, withDocs = false) {
error: j.error,
started_at: j.started_at,
finished_at: j.finished_at,
+ // An applied change can be put back while the cut is still what it made.
+ revertible: j.status === "applied" && !!j.base_edl && !!j.result_edl && !j.reverted_at,
+ reverted_at: j.reverted_at,
created_at: j.created_at,
...(withDocs
? {
@@ -320,8 +324,48 @@ async function say(
);
}
+/**
+ * A quick answer's change, recorded like a job's: the cut before it and after,
+ * so it can be put back from anywhere (another tab, the dashboard's chat, an
+ * agent), and written only over the cut it read. Returns the job, `applied`
+ * or, when the cut changed while the model worked, `stale` (kept aside).
+ */
+export async function recordQuick(
+ projectId: string,
+ instruction: string,
+ author: string | null,
+ base: string,
+ out: { edl: Edl; said: string; applied: AppliedOp[] },
+): Promise {
+ const result = JSON.stringify(out.edl);
+ const written = await get<{ id: string }>(
+ "UPDATE edit_projects SET edl = ?, updated_at = datetime('now') WHERE id = ? AND edl = ? RETURNING id",
+ [result, projectId, base],
+ );
+ const job = await get(
+ `INSERT INTO ask_jobs (project_id, instruction, status, base_edl, result_edl, said, applied, author, started_at, finished_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) RETURNING *`,
+ [projectId, instruction, written ? "applied" : "stale", base, result, out.said, JSON.stringify(out.applied), author],
+ );
+ return job!;
+}
+
+/** Put an applied change back to the cut before it, only while the cut is still what the change made. */
+export async function revertJob(projectId: string, jobId: string): Promise<{ job: AskJobRow } | { refused: "not_found" | "not_applied" | "changed" }> {
+ const job = await get("SELECT * FROM ask_jobs WHERE id = ? AND project_id = ?", [jobId, projectId]);
+ if (!job) return { refused: "not_found" };
+ if (job.status !== "applied" || !job.base_edl || !job.result_edl || job.reverted_at) return { refused: "not_applied" };
+ const back = await get<{ id: string }>(
+ "UPDATE edit_projects SET edl = ?, updated_at = datetime('now') WHERE id = ? AND edl = ? RETURNING id",
+ [job.base_edl, projectId, job.result_edl],
+ );
+ if (!back) return { refused: "changed" };
+ await run("UPDATE ask_jobs SET reverted_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", [jobId]);
+ return { job: (await get("SELECT * FROM ask_jobs WHERE id = ?", [jobId]))! };
+}
+
/** Whether a result is the cut the job started from: both read in the schema's key order. */
-function unchanged(base: string | null, result: string): boolean {
+export function unchanged(base: string | null, result: string): boolean {
if (!base) return false;
const checked = validateEdl(JSON.parse(base));
return !("invalid" in checked) && JSON.stringify(checked.edl) === result;
diff --git a/src/server/index.ts b/src/server/index.ts
index c9ca00f..8fdf0d0 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -47,7 +47,7 @@ import { starterEdl, validateEdl, type Edl } from "./edl";
import { findFormat, renderDoc } from "../shared/formats";
import { isAbandonedExport, renderKey, renderKeyFor } from "../shared/renders";
import { HISTORY_TURNS, QUICK_WATCH_SECONDS, instructEdit, type Assembler, type InstructContext, type InstructHighlight, type InstructTurn, type InstructVideo } from "./instruct";
-import { applyKept, bookJob, createJob, dropProposals, jobView, listJobs, proposalText, reviveJobs, runJobStep, type AskJobRow, type JobDeps } from "./ask-jobs";
+import { applyKept, bookJob, createJob, dropProposals, jobView, listJobs, proposalText, recordQuick, revertJob, reviveJobs, runJobStep, unchanged, type AskJobRow, type JobDeps } from "./ask-jobs";
import { caller, user } from "@clawnify/app";
import { analyzeAsset, autocutAssets, autocutEdl, copyOutput, pollEdit, resolveEdlSources, startEdit, type ExportConfig, type ExportFailure } from "./export";
import { makeShareToken, notePage, sharePage } from "./share";
@@ -1061,18 +1061,45 @@ app.post("/api/projects/:id/instruct", async (c) => {
await run("INSERT INTO ask_messages (project_id, role, text, asked, job_id) VALUES (?, 'assistant', ?, 1, ?)", [project.id, said, job.id]);
return c.json({ edl: current.edl, said, applied: [], asked: true, job: jobView(job) });
}
- await run("INSERT INTO ask_messages (project_id, role, text, applied, asked) VALUES (?, 'assistant', ?, ?, ?)", [
+ // A question back, or nothing changed: only the conversation records it.
+ if (out.asked || out.applied.length === 0 || unchanged(project.edl, JSON.stringify(out.edl))) {
+ await run("INSERT INTO ask_messages (project_id, role, text, applied, asked) VALUES (?, 'assistant', ?, ?, ?)", [
+ project.id,
+ out.said,
+ JSON.stringify(out.asked ? [] : out.applied),
+ out.asked ? 1 : 0,
+ ]);
+ return c.json({ edl: current.edl, said: out.said, applied: [], asked: out.asked === true });
+ }
+ // Written only over the cut it read, and kept with the cut before it, so it
+ // can be put back later from anywhere.
+ const job = await recordQuick(project.id, instruction, author, project.edl, out);
+ const stale = job.status === "stale";
+ const said = stale ? `${out.said} The cut changed while this ran, so it was kept aside instead of applied.` : out.said;
+ await run("INSERT INTO ask_messages (project_id, role, text, applied, asked, job_id) VALUES (?, 'assistant', ?, ?, 0, ?)", [
project.id,
- out.said,
+ said,
JSON.stringify(out.applied),
- out.asked ? 1 : 0,
+ job.id,
]);
+ return c.json(
+ stale
+ ? { edl: current.edl, said, applied: [], asked: false, job: jobView(job) }
+ : { edl: out.edl, said, applied: out.applied, asked: false, job: jobView(job) },
+ );
+});
- await run("UPDATE edit_projects SET edl = ?, updated_at = datetime('now') WHERE id = ?", [
- JSON.stringify(out.edl),
- project.id,
- ]);
- return c.json({ edl: out.edl, said: out.said, applied: out.applied, asked: out.asked === true });
+/** Put an AI change back: the cut before it returns, while the cut is still what the change made. */
+app.post("/api/projects/:id/ask/jobs/:job/revert", async (c) => {
+ const out = await revertJob(c.req.param("id"), c.req.param("job"));
+ if ("refused" in out) {
+ const detail =
+ out.refused === "changed"
+ ? "The cut has changed since this was applied, so putting it back would undo those changes too. Undo it in the editor instead."
+ : "There is no applied change to put back.";
+ return c.json({ error: out.refused, detail }, out.refused === "not_found" ? 404 : 409);
+ }
+ return c.json(jobView(out.job, true));
});
/** One background job, with the cut it started from and its result. */
diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs
index 4948fdc..ed16b25 100644
--- a/test/e2e/run.mjs
+++ b/test/e2e/run.mjs
@@ -1470,6 +1470,45 @@ console.log("14 ok: review comments off by default, on per link, pinned to an ex
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM ask_jobs WHERE project_id = ?").get(pa).n, 0, "jobs deleted with the project");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM ask_messages WHERE project_id = ?").get(pa).n, 0, "deleted with the project");
world.askReplies = null;
+ // A quick answer's change keeps the cut before it, can be put back from
+ // anywhere, and is never written over an edit made while the model worked.
+ {
+ r = await call("POST", "/api/projects", { name: "Safe" });
+ const ps = r.data.id;
+ const cut2 = (n) => ({ version: 1, output: { width: 1280, height: 720, fps: 30 }, main: { elements: [
+ { id: "a", type: "video", src: `asset:${one.id}`, duration: n }, { id: "b", type: "video", src: `asset:${two.id}`, duration: n }] }, overlays: [], audio: [] });
+ assert.equal((await call("PUT", `/api/projects/${ps}`, { edl: cut2(2) })).status, 200);
+ world.askReplies = [{ tool_calls: [{ id: "d", function: { name: "delete_clip", arguments: JSON.stringify({ clip: 1 }) } }] }, { content: "Dropped the second clip." }];
+ r = await call("POST", `/api/projects/${ps}/instruct`, { instruction: "drop the second clip" });
+ assert.equal(r.data.applied.length, 1, JSON.stringify(r.data));
+ assert.equal(r.data.job.status, "applied");
+ assert.equal(r.data.job.revertible, true);
+ const quick = r.data.job.id;
+ assert.equal((await call("GET", `/api/projects/${ps}`)).data.edl.main.elements.length, 1);
+ r = await call("POST", `/api/projects/${ps}/ask/jobs/${quick}/revert`);
+ assert.equal(r.status, 200, JSON.stringify(r.data));
+ assert.equal((await call("GET", `/api/projects/${ps}`)).data.edl.main.elements.length, 2, "put back");
+ assert.equal((await call("POST", `/api/projects/${ps}/ask/jobs/${quick}/revert`)).status, 409, "once");
+ // Applied again, then edited by a person: putting it back would undo their edit too.
+ world.askReplies = [{ tool_calls: [{ id: "d", function: { name: "delete_clip", arguments: JSON.stringify({ clip: 1 }) } }] }, { content: "Dropped it." }];
+ const again = (await call("POST", `/api/projects/${ps}/instruct`, { instruction: "drop it again" })).data.job.id;
+ const edited = (await call("GET", `/api/projects/${ps}`)).data.edl;
+ edited.main.elements[0].duration = 1.5;
+ assert.equal((await call("PUT", `/api/projects/${ps}`, { edl: edited })).status, 200);
+ assert.equal((await call("POST", `/api/projects/${ps}/ask/jobs/${again}/revert`)).status, 409, "refused over a later edit");
+ // A person saves while the model is working: the answer is kept aside.
+ assert.equal((await call("PUT", `/api/projects/${ps}`, { edl: cut2(2) })).status, 200);
+ world.askReplies = [{ tool_calls: [{ id: "d", function: { name: "delete_clip", arguments: JSON.stringify({ clip: 1 }) } }] }, { content: "Dropped it." }];
+ world.beforeModel = async () => { await call("PUT", `/api/projects/${ps}`, { edl: cut2(3) }); };
+ r = await call("POST", `/api/projects/${ps}/instruct`, { instruction: "drop the second" });
+ world.beforeModel = null;
+ assert.equal(r.data.job.status, "stale", JSON.stringify(r.data));
+ assert.deepEqual(r.data.applied, []);
+ assert.equal((await call("GET", `/api/projects/${ps}`)).data.edl.main.elements[0].duration, 3, "the person's edit stays");
+ await call("DELETE", `/api/projects/${ps}`);
+ world.askReplies = null;
+ console.log("18 ok: a quick AI change is put back from anywhere, only over itself, and never written over an edit made meanwhile");
+ }
// The client's request: clips with a hook from one long talk, made as their
// own 9:16 projects and framed on the speaker, in the background.
{
From 7394d87a7e13b984219cae338b2f0998b5366874 Mon Sep 17 00:00:00 2001
From: Riccardo Pallaoro <95774148+pallaoro@users.noreply.github.com>
Date: Tue, 10 Nov 2026 15:41:02 +0000
Subject: [PATCH 2/2] Projects hold videos: clips the AI cuts live inside the
project they came from
---
README.md | 3 +-
agent.md | 29 +++++++-
schema.sql | 6 ++
src/client/app.tsx | 7 +-
src/client/edit.tsx | 44 ++++++++----
src/client/videos.tsx | 155 +++++++++++++++++++++++++++++++++++++++++
src/server/clips.ts | 4 +-
src/server/index.ts | 108 +++++++++++++++++++++++++---
src/server/instruct.ts | 6 +-
test/e2e/run.mjs | 24 ++++++-
10 files changed, 349 insertions(+), 37 deletions(-)
create mode 100644 src/client/videos.tsx
diff --git a/README.md b/README.md
index 999c93e..3ff36d9 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,8 @@ Most editors keep a project in a format built for their own app, not for you or
- **Transitions**: put a dissolve, a fade through black or white, a wipe, a slide, a blur or a pixelate on any cut, from the mark between two clips on the timeline, or on every cut at once. A transition is centred on its cut and moves no clip, so titles and music stay where you put them, and the preview draws each one frame for frame as it exports.
- **Captions**: switch them on for the whole video, pick one of twelve languages and one style. The words come from each clip's transcript, so captions follow every trim, split and reorder.
- **AI, in one conversation**: add your clips and say what you want beside the cut, in your own words: it builds the cut from them ("a 30 second video from my clips") or changes the one you have. It remembers the conversation, so "shorter" or "a square one too" knows what it refers to, and it asks back instead of guessing when a request could mean two things. A request that would watch a lot of footage says how much first and, on a yes, runs in the background: keep editing or close the tab, and it never lands on top of a change you made meanwhile. The model calls a fixed set of checked operations, so the edit is always a valid document. One request, one undo.
-- **Clips from a long video**: "5 vertical clips with a strong hook from this interview" reads what is said, picks the moments that stand on their own, opens each on its strongest line, and makes each its own 9:16 project with captions, framed on the person speaking. Shots it is unsure how to frame keep the whole picture, and it tells you which. "Frame it for vertical" does the same for the cut you have.
+- **Projects hold videos**: a project is your footage and every video you make from it (the full cut, short clips, a teaser), each with its own timeline, shapes and export, listed together in the project.
+- **Clips from a long video**: "5 vertical clips with a strong hook from this interview" reads what is said, picks the moments that stand on their own, opens each on its strongest line, and makes each its own 9:16 video inside the project, with captions, framed on the person speaking. Shots it is unsure how to frame keep the whole picture, and it tells you which. "Frame it for vertical" does the same for the cut you have.
- **AI assist**: Auto-cut watches several clips together and assembles the strongest sequence for what the video is for, captions included; Clean up trims one clip down to its good parts.
- **Export to MP4** in draft, standard or high quality, on Clawnify's managed edit service.
- **Share by link**: one link per project plays an export to anyone you send it to, with no sign-in, and a Download button. The link stays on the export you shared until you move it to a newer one, so a draft never reaches viewers; turning it off ends it for everyone.
diff --git a/agent.md b/agent.md
index 481876e..692d3f5 100644
--- a/agent.md
+++ b/agent.md
@@ -231,16 +231,39 @@ tell the person it is running: the editor shows its progress. Read `GET
/api/projects/{id}/ask` for how it ended, and pass on what `said` says,
including any shot it was unsure how to frame.
+### Projects hold videos
+
+A project is its footage plus every video made from it. The row you create
+with `POST /api/projects` is the project and its first video; it holds the
+footage, highlights and conversation. Every other video (a cut-down, a
+teaser) is a row of its own with `parent_id` set to it, with its own
+timeline, formats, exports, share link and comments, and is left out of `GET
+/api/projects` (which counts them in `videos`). `GET
+/api/projects/{id}/videos` lists a project's videos from any of them; `POST
+/api/projects/{id}/videos { name, from? }` makes a new one, empty (9:16) or a
+copy of `from`; `PUT /api/projects/{id} { parent_id }` moves a project with no
+footage or videos of its own in. Deleting the project deletes its videos;
+deleting a video deletes only that video.
+
+### Putting an AI change back
+
+Every change `/instruct` makes is recorded as a job with the cut before and
+after it, and is written only over the cut it read (otherwise it comes back
+`stale`, kept aside, and the cut stays as the person left it). `POST
+/api/projects/{id}/ask/jobs/{job}/revert` puts the cut back as it was, while
+the cut is still what that change made (`revertible: true`); after a later
+edit it answers 409, and the person undoes it in the editor instead.
+
### Clips from a long video, and framing for vertical
"5 vertical clips with a strong hook from this interview" is one request:
`/instruct` reads the video's transcript, picks the moments that stand on
their own (about 45 seconds unless asked otherwise, at most 8), opens each on
its strongest line (played first when it comes later in the moment), and makes
-each one **its own project**: 1080×1920 with captions on, framed on the person
-speaking. The project the request was sent to is not changed. The finished
+each one **its own video in the project**: 1080×1920 with captions on, framed on the person
+speaking. The video the request was sent to is not changed. The finished
message, and the job while it runs, list them in `made: [{ id, name }]`; open
-one at `/edits/{id}`. "Frame it for vertical" frames the project's own cut the
+one at `/edits/{id}`. "Frame it for vertical" frames the video it was sent to the
same way, in its 9:16 version (added when missing). Shots the framing is unsure
of keep the whole picture, and the message says how many: a person checks them
in the editor's AI reframe. Both need the video on the media service (an
diff --git a/schema.sql b/schema.sql
index 19164fd..d066274 100644
--- a/schema.sql
+++ b/schema.sql
@@ -472,3 +472,9 @@ ALTER TABLE ask_messages ADD COLUMN made TEXT;
-- When an applied AI change was put back to the cut before it (POST …/ask/jobs/{job}/revert).
ALTER TABLE ask_jobs ADD COLUMN reverted_at TEXT;
+
+-- Videos of one project: every edit made from a project's footage (a cut-down,
+-- a teaser) is its own row, pointing at the row that holds the footage, its
+-- highlights and its conversation. NULL: a project (and its first video).
+ALTER TABLE edit_projects ADD COLUMN parent_id TEXT;
+CREATE INDEX IF NOT EXISTS idx_edit_projects_parent ON edit_projects(parent_id);
diff --git a/src/client/app.tsx b/src/client/app.tsx
index 51805d8..854d89e 100644
--- a/src/client/app.tsx
+++ b/src/client/app.tsx
@@ -29,7 +29,7 @@ function useRouter() {
* it.
*/
function HostNav({ active, path, navigate }: { active: string; path: string; navigate: (to: string) => void }) {
- const [projects, setProjects] = useState[]>([]);
+ const [projects, setProjects] = useState<(Omit & { video_ids?: string[] })[]>([]);
useEffect(() => {
fetch("/api/projects")
.then((r) => (r.ok ? (r.json() as Promise[]>) : []))
@@ -37,11 +37,14 @@ function HostNav({ active, path, navigate }: { active: string; path: string; nav
.catch(() => {});
reportLocation(window.location.pathname + window.location.search);
}, [path]);
+ // A project's other videos are inside it: opening one keeps the project marked.
+ const owner = projects.find((p) => p.video_ids?.some((v) => `edits/${v}` === active));
+ const marked = owner ? `edits/${owner.id}` : active || "home";
return (
& {
cover_media: string | null;
/** Clips taken from the project's Drive folders. */
footage: number;
+ /** How many videos the project holds, its first included. */
+ videos?: number;
};
/** The frame a project is recognised by: its opening shot, or a blank tile. */
@@ -907,8 +912,8 @@ export function ProjectsHome({ navigate }: { navigate: (to: string) => void }) {
)}
- One project is one video: upload your footage, trim it, put the clips in order, add
- text and music, and export it to MP4.
+ A project holds your footage and every video you make from it: the full cut,
+ short clips, a teaser. Each video has its own timeline, shapes and export.
@@ -988,7 +993,9 @@ export function ProjectsHome({ navigate }: { navigate: (to: string) => void }) {
1
+ ? `The project, its ${confirmDel.videos} videos and their exports are deleted. Shared highlights and videos used by other projects are kept.`
+ : confirmDel.footage
? "The project and its export history are deleted. Shared highlights and videos used by them or other projects are kept. Unused Drive-folder copies are removed; originals in Google Drive are kept."
: "The project and its export history go with it. Your footage stays in the media library."
}
@@ -1041,12 +1048,10 @@ export function EditRoute({ id, navigate }: { id: string; navigate: (to: string)
setAssets(null);
setFootage(null);
setErr("");
- Promise.all([
- api.get(`/api/projects/${id}`),
- api.get("/api/assets"),
- // A first look only: the panel's own reads move the footage on.
- api.get(`/api/projects/${id}/footage?logs=0&step=0`),
- ])
+ Promise.all([api.get(`/api/projects/${id}`), api.get("/api/assets")])
+ // The footage is the project's, whichever of its videos is open. A
+ // first look only: the panel's own reads move the footage on.
+ .then(async ([p, a]) => [p, a, await api.get(`/api/projects/${p.parent_id ?? p.id}/footage?logs=0&step=0`)] as const)
.then(([p, a, f]) => {
if (!current) return;
setProject(p);
@@ -1089,7 +1094,8 @@ export function EditRoute({ id, navigate }: { id: string; navigate: (to: string)
initial={project}
initialAssets={assets}
initialFootage={footage}
- onOpenHighlights={() => navigate(`/edits/${id}/highlights`)}
+ projectId={project.parent_id ?? project.id}
+ onOpenHighlights={() => navigate(`/edits/${project.parent_id ?? project.id}/highlights`)}
onOpenProject={(other) => navigate(`/edits/${other}`)}
/>
);
@@ -1101,14 +1107,17 @@ export function EditEditor({
initialFootage,
onOpenHighlights,
onOpenProject,
+ projectId,
}: {
initial: EditProject;
initialAssets: Asset[];
initialFootage: FootageList;
/** Opens the footage's highlights. */
onOpenHighlights?: () => void;
- /** Opens another project, such as a clip the AI made. */
+ /** Opens another video, such as a clip the AI made. */
onOpenProject?: (id: string) => void;
+ /** The project this video belongs to: its footage and highlights. */
+ projectId?: string;
}) {
const [name, setName] = useState(initial.name);
const [brief, setBrief] = useState(initial.brief ?? "");
@@ -1228,6 +1237,12 @@ export function EditEditor({
[commit],
);
const ask = useAsk(initial.id, () => edlRef.current, applyAi);
+ const videos = useProjectVideos(initial.id);
+ // Clips the AI makes become videos of this project: count them as they come.
+ const madeCount = (ask.messages ?? []).reduce((n, m) => n + (m.made?.length ?? 0), 0) + ask.jobs.reduce((n, j) => n + (j.made?.length ?? 0), 0);
+ useEffect(() => {
+ if (madeCount) void videos.reload();
+ }, [madeCount, videos.reload]);
// Inside Clawnify the conversation is the dashboard's chat. It knows which
// project is open, offers its requests in the "/" menu, and calls this app's
@@ -1548,7 +1563,7 @@ export function EditEditor({
setName(e.target.value)}
- aria-label="Project name"
+ aria-label="Video name"
className="min-w-24 flex-1 lg:flex-none lg:w-64 bg-transparent text-heading-3 outline-none rounded-sm h-7 px-1.5 -mx-1.5 hover:bg-surface-sunken focus:bg-surface focus:shadow-edge"
/>
{saveState === "saved" ? "Saved" : saveState === "saving" ? "Saving…" : saveState}
+