Skip to content
Open
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
32 changes: 28 additions & 4 deletions workspace-mirror/skills/orchestrator/bin/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async function loadSkillsAndRouter() {
const { createBraveSearch, withSearchLogging } = await import(`${WORKSPACE}/skills/research/web-search.js`);
const { createSlideshowDraft } = await import(`${WORKSPACE}/skills/slideshow-draft/index.js`);
const { createPexelsClient } = await import(`${WORKSPACE}/skills/slideshow-draft/pexels.js`);
const { renderSlideshow } = await import(`${WORKSPACE}/skills/slideshow-render/index.js`);
const { createQuotecardDraft, createRenderCard } = await import(`${WORKSPACE}/skills/quotecard-draft/index.js`);
const { createClipExtract } = await import(`${WORKSPACE}/skills/clip-extract/index.js`);
const { createFfmpegRunner } = await import(`${WORKSPACE}/skills/clip-extract/ffmpeg.js`);
Expand Down Expand Up @@ -106,6 +107,32 @@ async function loadSkillsAndRouter() {
})
: { run: async () => { throw new Error("PEXELS_API_KEY not set"); } };

const { execFile: execFileCb } = await import("node:child_process");
const { promisify } = await import("node:util");
const runSub = promisify(execFileCb);

const ttsVoice = process.env.OPENCLAW_TTS_VOICE || "Alex";
const slideshowRender = {
run: ({ draftId, storyboard, draft }) =>
renderSlideshow({
draftId,
draftsRoot: DRAFTS,
storyboard,
draft,
fetchImage: async (url) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`fetchImage: HTTP ${res.status} ${url}`);
return new Uint8Array(await res.arrayBuffer());
},
speak: ({ text, outPath }) => runSub("say", ["-v", ttsVoice, "-o", outPath, text]),
runFfmpeg: (argv) => runSub("ffmpeg", argv, { maxBuffer: 32 * 1024 * 1024 }),
writeFile: (p, c) => writeFileSync(p, c),
writeDraft: commonWriteDraft,
mkdirp,
log: (m) => logger.jsonl({ event: "slideshow_render", msg: m }),
}),
};

const quotecardDraft = createQuotecardDraft({
router,
renderCard: createRenderCard({
Expand All @@ -130,9 +157,6 @@ async function loadSkillsAndRouter() {
idGenerator: () => idFor("clip"),
});

const { execFile: execFileCb } = await import("node:child_process");
const { promisify } = await import("node:util");
const runSub = promisify(execFileCb);
const sourceDiscovery = {
async runPull(niche) {
await runSub(process.execPath, [
Expand All @@ -144,7 +168,7 @@ async function loadSkillsAndRouter() {

return {
router, draftStore, telegramClient, chatId,
skills: { research, slideshowDraft, quotecardDraft, clipExtract },
skills: { research, slideshowDraft, slideshowRender, quotecardDraft, clipExtract },
sourceDiscovery,
approval: { sendForApproval: (id) => sendForApproval(id, { telegramClient, draftStore, chatId }) },
};
Expand Down
14 changes: 12 additions & 2 deletions workspace-mirror/skills/orchestrator/daily-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,18 @@ async function callSkill(mode, skills, topic, episode, transcripts, sourcesById)
videoPath: transcript.video_path,
})).draft;
}
case "slideshow":
return (await skills.slideshowDraft.run({ topic: topic.topic, niche: topic.niche })).draft;
case "slideshow": {
const result = await skills.slideshowDraft.run({ topic: topic.topic, niche: topic.niche });
// Render the mp4 immediately so the approval card refers to a real video
// rather than only a storyboard.json. Render failures propagate through
// callSkill's caller so they show up in the daily-loop summary.
await skills.slideshowRender.run({
draftId: result.draft.id,
storyboard: result.storyboard,
draft: result.draft,
});
return result.draft;
}
Comment on lines +55 to +65
case "quotecard":
return (await skills.quotecardDraft.run({ topic: topic.topic, niche: topic.niche })).draft;
default:
Expand Down
37 changes: 36 additions & 1 deletion workspace-mirror/skills/orchestrator/daily-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ function makeSkills(overrides = {}) {
]),
},
clipExtract: { run: vi.fn().mockResolvedValue({ draft: { id: "d-clip-1", mode: "clip" } }) },
slideshowDraft: { run: vi.fn().mockResolvedValue({ draft: { id: "d-slide-1", mode: "slideshow" } }) },
slideshowDraft: { run: vi.fn().mockResolvedValue({
draft: { id: "d-slide-1", mode: "slideshow" },
storyboard: { script: "stub", duration_s: 60, beats: [{ text: "b", duration_s: 60, image_url: "https://x/x.jpg" }] },
}) },
slideshowRender: { run: vi.fn().mockResolvedValue({ videoPath: "/d/pending/d-slide-1/media/video.mp4" }) },
quotecardDraft: { run: vi.fn().mockResolvedValue({ draft: { id: "d-quote-1", mode: "quotecard" } }) },
...overrides,
};
Expand Down Expand Up @@ -92,6 +96,37 @@ describe("runDailyLoop — steps 1-3 + wiring", () => {
const quoteTopic = deps.skills.quotecardDraft.run.mock.calls[0][0].topic;
expect(slideTopic).not.toBe(quoteTopic);
});

it("slideshow mode renders the video via slideshowRender after slideshowDraft", async () => {
const deps = makeDeps();
await runDailyLoop(deps);
expect(deps.skills.slideshowRender.run).toHaveBeenCalledTimes(1);
const renderCall = deps.skills.slideshowRender.run.mock.calls[0][0];
expect(renderCall.draftId).toBe("d-slide-1");
expect(renderCall.draft.id).toBe("d-slide-1");
expect(renderCall.storyboard.beats).toHaveLength(1);
// Render happens before sendForApproval so the approval card references a real video.
const draftCallOrder = deps.skills.slideshowDraft.run.mock.invocationCallOrder[0];
const renderCallOrder = deps.skills.slideshowRender.run.mock.invocationCallOrder[0];
const approvalCallOrder = deps.approval.sendForApproval.mock.invocationCallOrder
.find(o => o > draftCallOrder);
expect(renderCallOrder).toBeGreaterThan(draftCallOrder);
expect(approvalCallOrder).toBeGreaterThan(renderCallOrder);
});

it("slideshow render failure surfaces as slideshow result.ok=false (no approval send)", async () => {
const renderRun = vi.fn().mockRejectedValue(new Error("ffmpeg crashed"));
const deps = makeDeps({ skills: makeSkills({
slideshowRender: { run: renderRun },
})});
const res = await runDailyLoop(deps);
const slide = res.drafts.find(d => d.mode === "slideshow");
expect(slide.ok).toBe(false);
expect(slide.reason).toMatch(/ffmpeg/);
// sendForApproval still happens for quotecard but not for the failed slideshow.
const approvalIds = deps.approval.sendForApproval.mock.calls.map(c => c[0]);
expect(approvalIds).not.toContain("d-slide-1");
});
});

describe("runDailyLoop — retry + failure + quiet-hours", () => {
Expand Down
10 changes: 9 additions & 1 deletion workspace-mirror/skills/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function formatTemplateA(draft) {
lines.push("");
lines.push(draft.hashtags.join(" "));
if (draft.media && draft.media.length > 0) {
const m = draft.media[0];
const m = primaryMedia(draft.media);
const parts = [m.type];
if (m.duration_s) parts.push(`${m.duration_s}s`);
lines.push("");
Expand All @@ -35,6 +35,14 @@ export function formatTemplateA(draft) {
return lines.join("\n");
}

// Pick the artifact most worth showing the user — slideshow drafts include a
// storyboard.json alongside the rendered video, and the video is what the user
// actually approves on.
function primaryMedia(media) {
const rank = { video: 3, image: 2 };
return [...media].sort((a, b) => (rank[b.type] ?? 0) - (rank[a.type] ?? 0))[0];
}

export function formatTemplateB(draft, destDir) {
const lines = [];
lines.push(`✅ READY TO POST • Draft ${draft.id}`);
Expand Down
21 changes: 21 additions & 0 deletions workspace-mirror/skills/shared/tests/constants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ describe("constants", () => {
expect(text).toContain("🎬 Media: video, 47s");
});

test("formatTemplateA surfaces video over storyboard when both are present", () => {
// Slideshow pipeline writes storyboard first then appends the rendered
// video. The approval card should advertise the video — the storyboard is
// an internal artifact the user doesn't need to think about.
const draft = {
id: "2026-04-16-slideshow-001",
mode: "slideshow",
topic: "AI",
caption: "test",
hashtags: ["#x"],
media: [
{ path: "media/storyboard.json", type: "storyboard", duration_s: 60 },
{ path: "media/video.mp4", type: "video", duration_s: 60 },
],
source: null,
};
const text = formatTemplateA(draft);
expect(text).toContain("🎬 Media: video, 60s");
expect(text).not.toContain("Media: storyboard");
});

test("formatTemplateA omits source and media when absent", () => {
const draft = {
id: "2026-04-16-quote-001",
Expand Down