diff --git a/README.md b/README.md index f6c0695..487c269 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,12 @@ CSVs, or everything at once as a zip: Seedream, Midjourney). Paste one into ChatGPT, Claude, Gemini, a local model — no integration needed. Scope any export to scene ranges, page ranges, or your current filters, or download everything as one zip bundle. +- **Shooting schedule & Day Out of Days** — a draft stripboard that groups + scenes into shoot days by location, day/night, and int/ext under a + pages-per-day budget, plus the standard cast **Day Out of Days** grid + (Start / Work / Hold / Finish per shoot day). Print/PDF or CSV. It's a + starting point for your 1st AD — cast presence is read from dialogue, so + silent/background cast aren't detected; verify before scheduling. - **Zero setup** — no server, no database, no account. Install the app, load a script, and go. ## Why no API keys @@ -220,13 +226,15 @@ claude mcp add scriptbreak \ -- node /absolute/path/to/scriptbreak/mcp/scriptbreak-mcp.mjs ``` -The agent gets nine read-only tools: `get_breakdown`, `list_scenes`, +The agent gets eleven read-only tools: `get_breakdown`, `list_scenes`, `get_scene`, `list_elements`, `get_character_bible`, `get_location_bible`, -`get_shot_list`, `list_generators`, and `export_prompt_pack` — the last -reproduces ScriptBreak's AI video, storyboard-frame, coverage-consult, and -script-companion packs (with the same per-generator dialects) for any scene -range, page range, or filter. See **[mcp/README.md](mcp/README.md)** for the -full tool reference and Codex/Hermes/generic config. +`get_shot_list`, `list_generators`, `export_prompt_pack`, `get_schedule`, and +`get_day_out_of_days`. `export_prompt_pack` reproduces ScriptBreak's AI video, +storyboard-frame, coverage-consult, and script-companion packs (with the same +per-generator dialects) for any scene range, page range, or structured filter; +`get_schedule` and `get_day_out_of_days` return the draft shooting schedule and +cast Day Out of Days. See **[mcp/README.md](mcp/README.md)** for the full tool +reference and Codex/Hermes/generic config. ## Privacy diff --git a/mcp/README.md b/mcp/README.md index 79ede23..d770055 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -2,7 +2,7 @@ Headless MCP (Model Context Protocol) server for **[ScriptBreak](https://github.com/wassermanproductions/scriptbreak)** — the local-first screenplay breakdown app. With this server connected, an AI agent can read a ScriptBreak breakdown and export its prompt packs **without opening the desktop app** — it works directly on a saved `.scriptbreak` project file on disk. -An agent can pull the breakdown summary, list and read scenes, filter elements by category, read the character and location bibles, get the shot list, and reproduce ScriptBreak's own AI prompt packs (video, stills, coverage consult, script companion) for any scene range, page range, or filter — byte-for-byte the same markdown the app writes. +An agent can pull the breakdown summary, list and read scenes, filter elements by category, read the character and location bibles, get the shot list, reproduce ScriptBreak's own AI prompt packs (video, stills, coverage consult, script companion) for any scene range, page range, or structured filter — the same markdown the app writes — and generate a draft shooting schedule and cast Day Out of Days. Zero dependencies. Node ≥ 18. One file. @@ -77,7 +77,7 @@ mcp_servers: } ``` -## Tools (9) +## Tools (11) Call **`get_breakdown` first** — its response explains the data conventions (scenes, elements by category, page counts in 1/8ths, bibles, generators). @@ -92,20 +92,24 @@ Call **`get_breakdown` first** — its response explains the data conventions (s | `get_shot_list` | The shot list grouped by scene (size, angle, movement, lens, description). Optionally scoped. | | `list_generators` | The prompt-pack targets: video (Veo 3, Runway, Kling, ComfyUI, Wan 2.2, LTX 2.3, Seedance), stills (GPT Image 2, Nano Banana Pro, Krea 2, Seedream, Midjourney), plus coverage consult and script companion. | | `export_prompt_pack` | Reproduce ScriptBreak's own prompt pack as markdown for a chosen `generator` and scope — the video/stills pack (PROJECT LOOK + bibles + per-generator platform style guide + scene-by-scene shots), the coverage consult, or the script companion. | +| `get_schedule` | Reproduce ScriptBreak's suggested **shooting schedule** (draft stripboard): scenes grouped into synthetic shoot days by location → day/night → int/ext under a page budget (`pagesPerDay`, default 5). Returns the ordered day list (location, I/E, D/N, scenes, page eighths, cast IDs) plus a byte-identical `csv`. | +| `get_day_out_of_days` | Reproduce ScriptBreak's cast **Day Out of Days** grid from that schedule — one row per cast member, one column per shoot day, standard codes (SW/W/WF/SWF/H) and Work/Hold/Total counts, plus a byte-identical `csv`. | + +> ⚠️ `get_schedule` / `get_day_out_of_days` are a **draft**. Cast presence is inferred from **dialogue cues only**, so silent/background cast are not detected and the DOOD under-reports who's needed; page counts may be estimated; and no cast/location availability, company moves, or turnaround are modelled. Every response includes a `caveat` field saying so. Treat it as a starting point for a 1st AD, not a locked schedule. ### Scope selectors -`list_scenes`, `get_shot_list`, and `export_prompt_pack` accept the same scope selectors as ScriptBreak's export scope bar: +`list_scenes`, `get_shot_list`, `export_prompt_pack`, `get_schedule`, and `get_day_out_of_days` accept the same scope selectors as ScriptBreak's export scope bar: - `scope: "all"` (default) — every scene. - `scope: "scenes"` + `sceneRange: "1-20, 34, 50A"` — a scene-number range/list. - `scope: "pages"` + `pageRange: "1-12"` — a page range. - `scope: "shots"` — only scenes that have a shot list. -- `scope: "filter"` + any of `int`, `ext`, `day`, `night`, `character`, `location` — the breakdown filters. +- `scope: "filter"` + any of `int`, `ext`, `day`, `night`, `character`, `location` — the breakdown's **structured** filters. (The app's free-text search box is a live-UI concept and is not reproduced here; `character`/`location` match exactly. With no criteria set, `filter` returns all scenes.) (Passing `sceneRange` / `pageRange` / a filter without `scope` implies the matching scope.) -The prompt-pack markdown — header, PROJECT LOOK block, character/location/prop bibles, the per-generator platform style guide, and the scene-by-scene shot listing — is generated from the same templates and per-generator dialects ScriptBreak uses internally, so packs produced here match the app's exports exactly. +The prompt-pack markdown — header, PROJECT LOOK block, character/location/prop bibles, the per-generator platform style guide, and the scene-by-scene shot listing — is generated from the same templates and per-generator dialects ScriptBreak uses internally, so packs produced here match the app's exports byte-for-byte for `all` / scene / page scopes (the `filter` scope reproduces the structured toggles only, and the header's generation-date line is naturally locale- and day-dependent). A typical agent session: `get_breakdown` → `list_scenes` (with a filter) → `get_scene` for the beats that matter → `export_prompt_pack` with a video or stills generator over a scene range → hand the markdown to your generator. diff --git a/mcp/scriptbreak-mcp.mjs b/mcp/scriptbreak-mcp.mjs index 7aafb1f..3986983 100755 --- a/mcp/scriptbreak-mcp.mjs +++ b/mcp/scriptbreak-mcp.mjs @@ -589,6 +589,107 @@ ${['characters', 'locations', 'props'].map((t) => { return md } +/* -------------------- shooting schedule & Day Out of Days ---------------- + * These helpers are duplicated VERBATIM from ScriptBreak's index.html so the + * app and this server produce the same suggested stripboard + DOOD. Keep the + * two copies identical. Cast presence is inferred from dialogue only (silent / + * background cast are not detected), so this is a draft — not a locked + * schedule. See SCHED_CAVEAT below and the get_schedule / get_day_out_of_days + * tool descriptions. + */ +function csvCell(v) { v = String(v == null ? '' : v); if (/^[=+\-@\t\r]/.test(v)) v = "'" + v; return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v } + +const SCHED_REGIME_RANK = { DAY: 0, MAGIC: 1, NIGHT: 2 } +const SCHED_REGIME_LABEL = { DAY: 'DAY', MAGIC: 'DUSK/DAWN', NIGHT: 'NIGHT' } +function schedRegime(sc) { const b = sc.todBucket || ''; if (b === 'DUSK' || b === 'DAWN') return 'MAGIC'; if (b === 'NIGHT') return 'NIGHT'; return 'DAY' } +function schedLocKey(sc) { return sc.master || sc.location || '(Unspecified location)' } +function schedIeLabel(sc) { return sc.intExt === 'INT/EXT' ? 'INT/EXT' : (sc.intExt === 'EXT' ? 'EXT' : 'INT') } +function schedIeRank(sc, regime) { const ext = sc.intExt === 'EXT' || sc.intExt === 'INT/EXT'; return regime === 'DAY' ? (ext ? 0 : 1) : (ext ? 1 : 0) } +function schedNatKey(num) { const s = String(num == null ? '' : num); const m = s.match(/^(\d+)(.*)$/); return m ? [parseInt(m[1], 10), m[2]] : [Number.MAX_SAFE_INTEGER, s] } + +function buildScheduleDays(scenes, opts) { + opts = opts || {} + const target = Math.max(8, Math.round((opts.pagesPerDay || 5) * 8)) + const tol = (opts.tolerance != null ? opts.tolerance : 8) + const castOrder = [], castId = {} + scenes.forEach((sc) => (sc.characters || []).forEach((c) => { if (!(c in castId)) { castId[c] = castOrder.length + 1; castOrder.push(c) } })) + const locFirst = new Map() + scenes.forEach((sc, i) => { const k = schedLocKey(sc); if (!locFirst.has(k)) locFirst.set(k, i) }) + const groups = new Map() + scenes.forEach((sc) => { + const loc = schedLocKey(sc), reg = schedRegime(sc), key = loc + ' ' + reg + if (!groups.has(key)) groups.set(key, { loc, regime: reg, scenes: [] }) + groups.get(key).scenes.push(sc) + }) + const groupList = [...groups.values()].sort((a, b) => { + const la = locFirst.get(a.loc), lb = locFirst.get(b.loc) + if (la !== lb) return la - lb + return SCHED_REGIME_RANK[a.regime] - SCHED_REGIME_RANK[b.regime] + }) + groupList.forEach((g) => g.scenes.sort((a, b) => { + const ra = schedIeRank(a, g.regime), rb = schedIeRank(b, g.regime) + if (ra !== rb) return ra - rb + const ka = schedNatKey(a.num), kb = schedNatKey(b.num) + if (ka[0] !== kb[0]) return ka[0] - kb[0] + return ka[1] < kb[1] ? -1 : ka[1] > kb[1] ? 1 : 0 + })) + const days = [] + for (const g of groupList) { + let cur = null + for (const sc of g.scenes) { + const e = sc.eighths || 0 + if (!cur || (cur.eighths > 0 && cur.eighths + e > target + tol)) { cur = { loc: g.loc, regime: g.regime, scenes: [], eighths: 0 }; days.push(cur) } + cur.scenes.push(sc); cur.eighths += e + } + } + const rows = days.map((d, idx) => { + const cast = new Set() + d.scenes.forEach((sc) => (sc.characters || []).forEach((c) => cast.add(c))) + const ieset = [...new Set(d.scenes.map(schedIeLabel))] + return { + n: idx + 1, location: d.loc, regime: d.regime, regimeLabel: SCHED_REGIME_LABEL[d.regime], + ie: ieset.join(' / '), sceneNums: d.scenes.map((sc) => sc.num), eighths: d.eighths, + castNames: castOrder.filter((c) => cast.has(c)), castIds: castOrder.filter((c) => cast.has(c)).map((c) => castId[c]) + } + }) + return { days: rows, castOrder, castId, targetEighths: target, tolerance: tol } +} +function buildDood(plan) { + const dayCount = plan.days.length + const worksOn = {} + plan.castOrder.forEach((c) => worksOn[c] = new Set()) + plan.days.forEach((d, di) => d.castNames.forEach((c) => worksOn[c].add(di))) + const cast = plan.castOrder.map((name) => { + const set = worksOn[name], wd = [...set].sort((a, b) => a - b) + const codes = new Array(dayCount).fill('') + let work = 0, hold = 0 + if (wd.length) { + const first = wd[0], last = wd[wd.length - 1] + for (let d = first; d <= last; d++) { + if (set.has(d)) { codes[d] = first === last ? 'SWF' : d === first ? 'SW' : d === last ? 'WF' : 'W'; work++ } + else { codes[d] = 'H'; hold++ } + } + } + return { id: plan.castId[name], name, codes, work, hold, total: work + hold } + }) + return { dayCount, cast } +} +function buildScheduleCSV(scenes, opts) { + const plan = buildScheduleDays(scenes, opts) + const rows = [[SCHED_CSV_NOTE], ['Day', 'Location', 'I/E', 'Day/Night', 'Scenes', 'Pages (1/8)', 'Cast IDs', 'Cast']] + plan.days.forEach((d) => rows.push([d.n, d.location, d.ie, d.regimeLabel, d.sceneNums.join(' '), eighthsFmt(d.eighths), d.castIds.join(' '), d.castNames.join('; ')])) + return rows.map((r) => r.map(csvCell).join(',')).join('\n') +} +function buildDoodCSV(scenes, opts) { + const plan = buildScheduleDays(scenes, opts), dood = buildDood(plan) + const header = ['ID', 'Character', ...plan.days.map((d) => 'Day ' + d.n), 'Work', 'Hold', 'Total'] + const rows = [[SCHED_CSV_NOTE], header] + dood.cast.forEach((c) => rows.push([c.id, c.name, ...c.codes, c.work, c.hold, c.total])) + return rows.map((r) => r.map(csvCell).join(',')).join('\n') +} +const SCHED_CAVEAT = 'Draft stripboard generated from ScriptBreak’s auto-parsed breakdown. Cast presence is inferred from dialogue only — silent / background cast are not detected, and page counts may be estimated. This is a starting point, not a locked schedule: it does not account for cast or location availability, company moves, day↔night turnaround, or child / stunt constraints. Verify with your 1st AD before scheduling.' +const SCHED_CSV_NOTE = 'DRAFT — auto-parsed from dialogue only. Cast presence may be incomplete. Not a locked schedule — verify with your 1st AD.' + /* ------------------------------ generators ------------------------------ */ const VIDEO_GENERATORS = Object.keys(PLATFORMS) @@ -733,6 +834,34 @@ const TOOLS = [ required: ['generator'], additionalProperties: false } + }, + { + name: 'get_schedule', + description: + 'Reproduce ScriptBreak\'s suggested SHOOTING SCHEDULE (draft stripboard): scenes grouped into synthetic shoot days by master location, then lighting regime (DAY / dusk-dawn "MAGIC" / NIGHT), then INT vs EXT, then scene number, and split under a page budget (default 5 pages/day, ±1 page tolerance; one location per day). Returns the ordered day list — each with location, INT/EXT, day/night, scene numbers, page eighths, and the cast IDs working that day — plus the cast key and a byte-identical `csv` of the same table (matching the desktop app). IMPORTANT: cast presence is inferred from DIALOGUE CUES ONLY, so silent/background cast are not detected, and page counts may be estimated — this is a starting point for a 1st AD, not a locked schedule (see the `caveat` field). Scope selectors mirror the app\'s export scope bar; a partial scope yields a partial schedule.', + inputSchema: { + type: 'object', + properties: { + projectPath: PROJECT_PATH_FIELD, + pagesPerDay: { type: 'number', description: 'Target page budget per shoot day (default 5). Days are split when they exceed this by more than ~1 page.' }, + ...SCOPE_FIELDS + }, + additionalProperties: false + } + }, + { + name: 'get_day_out_of_days', + description: + 'Reproduce ScriptBreak\'s cast DAY OUT OF DAYS (DOOD) grid from the same suggested schedule as get_schedule: one row per cast member (numbered by first appearance), one column per shoot day, with standard status codes — SW (Start Work), W (Work), WF (Work Finish), SWF (single-day Start-Work-Finish), H (Hold: carried and paid between a performer\'s first and last day) — plus per-cast Work / Hold / Total(span) day counts. Returns the structured grid and a byte-identical `csv` (matching the desktop app). SAME CAVEAT as get_schedule: cast presence is inferred from DIALOGUE ONLY, so a performer on set with no line in a scene is not counted — the DOOD under-reports who is needed; treat it as a preliminary draft. Accepts the same pagesPerDay and scope selectors as get_schedule.', + inputSchema: { + type: 'object', + properties: { + projectPath: PROJECT_PATH_FIELD, + pagesPerDay: { type: 'number', description: 'Target page budget per shoot day (default 5), matching get_schedule.' }, + ...SCOPE_FIELDS + }, + additionalProperties: false + } } ] @@ -912,6 +1041,47 @@ function runTool(name, args) { markdown } } + case 'get_schedule': { + const path = resolveProjectPath(args) + const { S } = loadProject(path) + const exp = scopeFromArgs(args) + const _ppd = Number(args.pagesPerDay); const opts = { pagesPerDay: _ppd > 0 ? _ppd : 5 } + const scenes = exportScenes(S, exp) + const plan = buildScheduleDays(scenes, opts) + return { + projectPath: path, + scope: scopeLabel(S, exp), + pagesPerDay: opts.pagesPerDay, + shootDays: plan.days.length, + cast: plan.castOrder.map((name) => ({ id: plan.castId[name], name })), + days: plan.days.map((d) => ({ + day: d.n, location: d.location, intExt: d.ie, dayNight: d.regimeLabel, + scenes: d.sceneNums, eighths: d.eighths, pages: +(d.eighths / 8).toFixed(2), + castIds: d.castIds, cast: d.castNames + })), + csv: buildScheduleCSV(scenes, opts), + caveat: SCHED_CAVEAT + } + } + case 'get_day_out_of_days': { + const path = resolveProjectPath(args) + const { S } = loadProject(path) + const exp = scopeFromArgs(args) + const _ppd = Number(args.pagesPerDay); const opts = { pagesPerDay: _ppd > 0 ? _ppd : 5 } + const scenes = exportScenes(S, exp) + const plan = buildScheduleDays(scenes, opts) + const dood = buildDood(plan) + return { + projectPath: path, + scope: scopeLabel(S, exp), + pagesPerDay: opts.pagesPerDay, + shootDays: dood.dayCount, + legend: { SW: 'Start Work', W: 'Work', WF: 'Work Finish', SWF: 'Start-Work-Finish (single day)', H: 'Hold (carried & paid between first and last day)' }, + cast: dood.cast.map((c) => ({ id: c.id, name: c.name, codes: c.codes, work: c.work, hold: c.hold, total: c.total })), + csv: buildDoodCSV(scenes, opts), + caveat: SCHED_CAVEAT + } + } default: { const e = new Error(`Unknown tool: ${name}`) e.userFacing = true diff --git a/src/index.html b/src/index.html index 681d69a..672e4a2 100644 --- a/src/index.html +++ b/src/index.html @@ -671,7 +671,7 @@ let ok=false; try{ ok = document.execCommand('copy'); }catch(e){} ta.remove(); return ok; } -function csvCell(v){ v = String(v??''); return /[",\n]/.test(v) ? '"'+v.replace(/"/g,'""')+'"' : v; } +function csvCell(v){ v = String(v??''); if (/^[=+\-@\t\r]/.test(v)) v = "'"+v; return /[",\n]/.test(v) ? '"'+v.replace(/"/g,'""')+'"' : v; } /* ================= fountain / fdx / txt parsing ================= */ function parseScript(text, filename){ @@ -1953,6 +1953,7 @@
The Scope bar limits any export to scene ranges (1-20, 34, 50A), page ranges, scenes with shots, or your current filters. Everything downloads the lot as one zip.
`], @@ -2559,6 +2560,7 @@ /* ---------- export scope ---------- */ let EXP = { mode:'all', range:'' }; +let SCHED = { pagesPerDay:5 }; // target page budget per synthetic shoot day (schedule/DOOD) function scenePageStart(i){ if (S.scenes[i] && S.scenes[i].page) return S.scenes[i].page; let e = 0; for (let j=0;j lighting regime +// (DAY / MAGIC=dusk-dawn / NIGHT) -> INT vs EXT -> scene#, then break into +// ~pagesPerDay days (one location per day, ±1 page soft overflow). +function buildScheduleDays(scenes, opts){ + opts = opts||{}; + const target = Math.max(8, Math.round((opts.pagesPerDay||5)*8)); + const tol = (opts.tolerance!=null ? opts.tolerance : 8); + const castOrder = [], castId = {}; + scenes.forEach(sc=>(sc.characters||[]).forEach(c=>{ if (!(c in castId)){ castId[c]=castOrder.length+1; castOrder.push(c); } })); + const locFirst = new Map(); + scenes.forEach((sc,i)=>{ const k = schedLocKey(sc); if (!locFirst.has(k)) locFirst.set(k,i); }); + const groups = new Map(); + scenes.forEach(sc=>{ const loc = schedLocKey(sc), reg = schedRegime(sc), key = loc+' '+reg; + if (!groups.has(key)) groups.set(key, { loc, regime:reg, scenes:[] }); + groups.get(key).scenes.push(sc); }); + const groupList = [...groups.values()].sort((a,b)=>{ + const la = locFirst.get(a.loc), lb = locFirst.get(b.loc); + if (la!==lb) return la-lb; + return SCHED_REGIME_RANK[a.regime]-SCHED_REGIME_RANK[b.regime]; }); + groupList.forEach(g=>g.scenes.sort((a,b)=>{ + const ra = schedIeRank(a,g.regime), rb = schedIeRank(b,g.regime); + if (ra!==rb) return ra-rb; + const ka = schedNatKey(a.num), kb = schedNatKey(b.num); + if (ka[0]!==kb[0]) return ka[0]-kb[0]; + return ka[1]