diff --git a/package.json b/package.json index 841b405..3305ae1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nixamp", - "version": "0.70.1", + "version": "0.70.2", "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.", "license": "MIT", "type": "module", diff --git a/src/ai-label.ts b/src/ai-label.ts index e8ea7d8..93539df 100644 --- a/src/ai-label.ts +++ b/src/ai-label.ts @@ -698,6 +698,11 @@ export async function writeVeoStory( return { prompt: clean(item.prompt, 400), cast: [...new Set(who)].slice(0, 3) }; }).filter((one) => one.prompt.length > 10).slice(0, scenes); if (out.length < 1) throw new Error("the model wrote no story"); + // A model asked for many scenes often writes fewer: the rest are its own scenes again, from other angles, + // so every shot the plan paid for has something to film. + const angles = ["a different angle and closer", "a wide establishing angle", "a slow tracking shot", "an overhead angle", "a low angle"]; + const written = out.length; + for (let i = 0; out.length < scenes; i++) out.push({ ...out[i % written]!, prompt: `${out[i % written]!.prompt} Filmed as ${angles[i % angles.length]}.` }); return out.map((scene) => ({ cast: scene.cast, prompt: `Music video for a fictional ${artist.genre} song. ${scene.prompt} Mood: ${song.style}`.slice(0, 900) diff --git a/test/ai-label.test.ts b/test/ai-label.test.ts index 0a130cf..bee93d9 100644 --- a/test/ai-label.test.ts +++ b/test/ai-label.test.ts @@ -6,7 +6,7 @@ import { createServer as createHttpServer } from "node:http"; import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; -import { AiLabel, cleanLyrics, closeUpTimes, fileName, planUsd, shotPlan, genreSlug, nextReset, periodOf, renderFilmedVideo, renderStoryVideo, renderVideo, retagVideo, ROSTER, songBase, syncLines, VeoUnavailable } from "../src/ai-label.ts"; +import { AiLabel, cleanLyrics, closeUpTimes, writeVeoStory, fileName, planUsd, shotPlan, genreSlug, nextReset, periodOf, renderFilmedVideo, renderStoryVideo, renderVideo, retagVideo, ROSTER, songBase, syncLines, VeoUnavailable } from "../src/ai-label.ts"; import { elevenLabsComposer, OutOfCredit, type Composer } from "../src/ai-radio.ts"; import type { askJson } from "../src/ai-text.ts"; import { Channels } from "../src/channels.ts"; @@ -375,6 +375,15 @@ test("a single is a short film: story scenes from its words, cut in turn with th } }); +test("a story asked for more scenes than the model wrote is filled with its own scenes from other angles", async () => { + const ask: typeof askJson = async () => ({ model: "m", value: { scenes: [{ prompt: "The singer walks out of a diner at dawn", cast: ["lead vocals"] }, { prompt: "Rain on a car windshield at night", cast: [] }] } }); + const scenes = await writeVeoStory({ genre: "Rock", sound: "loud" }, [{ role: "lead vocals", look: "woman, curly hair" }], { title: "Record", concept: "" }, { title: "Song", style: "fast", lyrics: "" }, 5, ask); + assert.equal(scenes.length, 5, "every paid-for shot has a scene"); + assert.match(scenes[2]!.prompt, /diner at dawn.*Filmed as a different angle/); + assert.deepEqual(scenes[2]!.cast, ["lead vocals"], "with the same cast"); + assert.match(scenes[3]!.prompt, /windshield.*Filmed as a wide establishing angle/); +}); + test("story scenes star the band as their profiles describe them, never strangers", async () => { const writer = fakeWriter(); const ask: typeof askJson = async (options) => {