takomi 2.1.32 → 2.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/oauth-router/config.ts +14 -5
- package/.pi/extensions/oauth-router/index.ts +130 -130
- package/.pi/extensions/oauth-router/provider.ts +29 -0
- package/.pi/extensions/oauth-router/state.ts +372 -372
- package/.pi/extensions/oauth-router/types.ts +1 -1
- package/.pi/extensions/takomi-runtime/command-text.ts +2 -4
- package/.pi/extensions/takomi-runtime/commands.ts +15 -21
- package/.pi/extensions/takomi-runtime/index.ts +127 -53
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +296 -296
- package/.pi/extensions/takomi-runtime/ui.ts +18 -11
- package/.pi/extensions/takomi-subagents/index.ts +2 -0
- package/.pi/extensions/takomi-subagents/native-render.ts +27 -5
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +1 -1
- package/.pi/extensions/takomi-subagents/tool-runner.ts +1 -0
- package/assets/.agent/skills/photo-book-builder/SKILL.md +96 -0
- package/assets/.agent/skills/photo-book-builder/references/layout_templates.md +72 -0
- package/assets/.agent/skills/photo-book-builder/scripts/create_full_bleed_layouts.py +212 -0
- package/assets/.agent/skills/photo-book-builder/scripts/organize_photos.py +99 -0
- package/assets/.agent/skills/photo-book-builder/scripts/revert_organization.py +61 -0
- package/assets/.agent/skills/photo-book-builder/scripts/upscale_covers.py +47 -0
- package/assets/.agent/skills/youtube-pipeline/SKILL.md +73 -62
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Application.md +1 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 1.md +86 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 2.md +106 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 3.md +112 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phase 4.md +90 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Phased Outline.md +58 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Repurposing.md +1 -0
- package/assets/.agent/skills/youtube-pipeline/resources/knowledge/Research.md +438 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Shorts Bridge Protocol.md +159 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Title Thumbnail Picker Prompt.md +144 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Transcription Extraction Prompt v2.md +190 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Transcription Extraction Prompt.md +156 -0
- package/assets/.agent/skills/youtube-pipeline/resources/prompts/Video QA Prompt.md +133 -0
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase1-strategy.md +28 -18
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase2-packaging.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase3-scripting.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase3.5-shorts.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-phase4-production.md +2 -2
- package/assets/.agent/skills/youtube-pipeline/resources/youtube-pipeline.md +15 -15
- package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/package.json +17 -0
- package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/pnpm-lock.yaml +31 -0
- package/assets/.agent/skills/youtube-pipeline/scripts/google-trends/search.js +168 -0
- package/package.json +1 -1
- package/src/cli.js +13 -4
- package/src/pi-harness.js +36 -14
- package/src/pi-optional-features.js +190 -190
- package/src/postinstall.js +27 -27
- package/src/skills-catalog.js +245 -245
- package/src/skills-installer.js +244 -244
- package/src/skills-selection-tui.js +200 -200
- package/src/store.js +418 -418
- package/src/takomi-stats.d.ts +3 -3
- package/src/takomi-stats.js +442 -35
|
@@ -27,6 +27,8 @@ export type RuntimeHudState = {
|
|
|
27
27
|
activeSessionId?: string;
|
|
28
28
|
launchMode?: string;
|
|
29
29
|
subagentsEnabled?: boolean;
|
|
30
|
+
modeSource?: "idle" | "manual" | "model" | "board";
|
|
31
|
+
modeReason?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
type Tone = "accent" | "warning" | "success" | "error" | "muted" | "dim" | "thinkingMinimal";
|
|
@@ -70,24 +72,30 @@ function badge(theme: Theme, label: string, tone: Tone): string {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export function renderRuntimeStatus(theme: Theme, state: RuntimeHudState): string {
|
|
75
|
+
const source = state.modeSource ?? "idle";
|
|
76
|
+
if (!state.enabled) return [theme.fg("accent", "Takomi"), theme.fg("dim", "off")].join(" ");
|
|
77
|
+
if (source === "idle") return [theme.fg("accent", "Takomi"), theme.fg("dim", "idle")].join(" ");
|
|
78
|
+
|
|
73
79
|
const primary = state.stage ?? state.role;
|
|
74
80
|
const stageBadge = badge(theme, primary, stageTone(state.stage));
|
|
75
|
-
const
|
|
76
|
-
const
|
|
81
|
+
const sourceLabel = source === "manual" ? theme.fg("warning", "manual") : theme.fg("success", source);
|
|
82
|
+
const role = state.stage && state.role !== state.stage ? theme.fg("dim", state.role) : "";
|
|
77
83
|
const plan = state.planMode ? theme.fg("warning", "plan") : theme.fg("dim", "direct");
|
|
78
|
-
const
|
|
79
|
-
|
|
84
|
+
const gate = state.launchMode === "manual" ? theme.fg("warning", "review-gate") : "";
|
|
85
|
+
const subagents = state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : "";
|
|
86
|
+
return [theme.fg("accent", "Takomi"), sourceLabel, stageBadge, role, gate || plan, subagents].filter(Boolean).join(" ");
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
export function renderRuntimeWidget(theme: Theme, state: RuntimeHudState): string[] {
|
|
83
|
-
if (!state.enabled) return [];
|
|
90
|
+
if (!state.enabled || (state.modeSource ?? "idle") === "idle") return [];
|
|
84
91
|
const primary = state.stage ?? state.role;
|
|
92
|
+
const sourceLabel = state.modeSource === "manual" ? theme.fg("warning", "manual") : theme.fg("success", state.modeSource ?? "model");
|
|
85
93
|
const parts = [
|
|
86
94
|
theme.fg("accent", "Takomi"),
|
|
95
|
+
sourceLabel,
|
|
87
96
|
badge(theme, primary, stageTone(state.stage)),
|
|
88
|
-
theme.fg("dim", `role:${state.role}`),
|
|
89
|
-
state.
|
|
90
|
-
state.launchMode === "manual" ? theme.fg("warning", "review-gate") : theme.fg("accent", "auto-gate"),
|
|
97
|
+
state.stage && state.role !== state.stage ? theme.fg("dim", `role:${state.role}`) : "",
|
|
98
|
+
state.launchMode === "manual" ? theme.fg("warning", "review-gate") : "",
|
|
91
99
|
state.planMode ? theme.fg("warning", "plan") : theme.fg("dim", "direct"),
|
|
92
100
|
state.subagentsEnabled === false ? theme.fg("error", "subagents:off") : theme.fg("dim", "subagents:on"),
|
|
93
101
|
state.workflow ? theme.fg("dim", `wf:${state.workflow}`) : "",
|
|
@@ -115,7 +123,7 @@ export class TakomiFooterComponent implements Component {
|
|
|
115
123
|
this.unsubscribeBranchChange();
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
invalidate(): void {}
|
|
126
|
+
invalidate(): void { }
|
|
119
127
|
|
|
120
128
|
render(width: number): string[] {
|
|
121
129
|
const state = this.getState();
|
|
@@ -141,8 +149,7 @@ export class TakomiFooterComponent implements Component {
|
|
|
141
149
|
.filter(([key]) => key !== "takomi-runtime")
|
|
142
150
|
.map(([, value]) => value)
|
|
143
151
|
.filter(Boolean);
|
|
144
|
-
const
|
|
145
|
-
const left = [runtimeStatus, ...extensionStatuses].join(this.theme.fg("dim", " | "));
|
|
152
|
+
const left = extensionStatuses.join(this.theme.fg("dim", " | "));
|
|
146
153
|
const branch = this.footerData.getGitBranch();
|
|
147
154
|
const rightText = [this.ctx.model?.id || "no-model", branch ? `git:${branch}` : ""].filter(Boolean).join(" | ");
|
|
148
155
|
const right = this.theme.fg("dim", rightText);
|
|
@@ -44,6 +44,7 @@ const SubagentParameters = Type.Object({
|
|
|
44
44
|
tasks: Type.Optional(Type.Array(TaskSchema, { description: "Parallel subagent tasks" })),
|
|
45
45
|
confirmLaunch: Type.Optional(Type.Boolean({ description: "Required to launch immediately in manual Takomi launch mode" })),
|
|
46
46
|
previewOnly: Type.Optional(Type.Boolean({ description: "Return the delegation plan without launching" })),
|
|
47
|
+
clarify: Type.Optional(Type.Boolean({ description: "Show the native pi-subagents TUI to preview/edit before execution. Especially useful for chains; requires an interactive Pi TUI." })),
|
|
47
48
|
chain: Type.Optional(Type.Array(TaskSchema, { description: "Sequential chain of subagent tasks" })),
|
|
48
49
|
agentScope: Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("project"), Type.Literal("both")])),
|
|
49
50
|
confirmProjectAgents: Type.Optional(Type.Boolean({ description: "Prompt before running project-local agents. Default: true." })),
|
|
@@ -59,6 +60,7 @@ function registerSubagentTool(pi: ExtensionAPI): void {
|
|
|
59
60
|
promptGuidelines: [
|
|
60
61
|
"Use this tool during orchestration when a specialist should handle a task.",
|
|
61
62
|
"Use tasks for independent parallel work and chain for dependent handoffs with {previous}.",
|
|
63
|
+
"Set clarify=true when the user asks to preview/edit a subagent run in the native Pi TUI before launch.",
|
|
62
64
|
"Use model, fallbackModels, and thinking only when deliberate; otherwise let the agent/profile defaults apply.",
|
|
63
65
|
"If review sends work back to the same agent, reuse the same conversationId for continuity.",
|
|
64
66
|
"If a launch is blocked, cancelled, paused, or review-gated, do not retry automatically; wait for the user's next prompt. Use overrideUserBlock only after explicit user approval.",
|
|
@@ -30,15 +30,37 @@ export function renderTakomiSubagentCall(params: TakomiSubagentToolParams, theme
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function resultText(result: ToolResult): string {
|
|
34
|
+
return typeof (result as any)?.content === "string"
|
|
35
|
+
? (result as any).content
|
|
36
|
+
: Array.isArray((result as any)?.content)
|
|
37
|
+
? (result as any).content.map((part: any) => part?.text ?? "").filter(Boolean).join("\n")
|
|
38
|
+
: JSON.stringify((result as any)?.details ?? {}, null, 2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function summarizeCollapsedResult(text: string, status: string, theme: Theme): string {
|
|
42
|
+
const policyMatch = text.match(/Required policies:\n((?:- .+\n?)+)/);
|
|
43
|
+
const policies = policyMatch?.[1]
|
|
44
|
+
?.split("\n")
|
|
45
|
+
.map((line) => line.replace(/^[-\s]+/, "").trim())
|
|
46
|
+
.filter(Boolean) ?? [];
|
|
47
|
+
const lineCount = text ? text.split(/\r?\n/).length : 0;
|
|
48
|
+
const label = policies.length > 0
|
|
49
|
+
? `policy context loaded: ${policies.join(", ")}`
|
|
50
|
+
: `${lineCount} result line${lineCount === 1 ? "" : "s"} hidden`;
|
|
51
|
+
const icon = status === "failed" ? "⚠" : "✓";
|
|
52
|
+
const color = status === "failed" ? "warning" : "success";
|
|
53
|
+
return `${theme.fg(color, `${icon} takomi_subagent ${status}`)}${theme.fg("dim", ` · ${label} (ctrl+o to expand)`)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
export function renderTakomiSubagentResult(result: ToolResult, options: { expanded?: boolean; isPartial?: boolean }, theme: Theme, context: any): any {
|
|
34
57
|
const native = renderNativeSubagentResult(result, options, theme, context);
|
|
35
58
|
if (native) return native;
|
|
36
59
|
|
|
37
60
|
const status = (result as any)?.isError ? "failed" : "completed";
|
|
38
|
-
const text =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
: JSON.stringify((result as any)?.details ?? {}, null, 2);
|
|
61
|
+
const text = resultText(result);
|
|
62
|
+
if (!options.expanded && !options.isPartial) {
|
|
63
|
+
return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
|
|
64
|
+
}
|
|
43
65
|
return new Text(`${theme.fg("toolTitle", theme.bold(`takomi_subagent ${status}`))}\n${text || "No result content."}`, 0, 0);
|
|
44
66
|
}
|
|
@@ -186,7 +186,7 @@ function toSubagentParams(params: TakomiSubagentToolParams, rootCwd: string, dis
|
|
|
186
186
|
cwd: rootCwd,
|
|
187
187
|
context: "fresh" as const,
|
|
188
188
|
async: false,
|
|
189
|
-
clarify:
|
|
189
|
+
clarify: params.clarify === true,
|
|
190
190
|
includeProgress: true,
|
|
191
191
|
sessionDir: stableConversationSessionDir(rootCwd, tasks),
|
|
192
192
|
};
|
|
@@ -28,6 +28,7 @@ export type TakomiSubagentToolParams = Partial<TakomiSubagentToolTask> & {
|
|
|
28
28
|
chain?: TakomiSubagentToolTask[];
|
|
29
29
|
confirmLaunch?: boolean;
|
|
30
30
|
previewOnly?: boolean;
|
|
31
|
+
clarify?: boolean;
|
|
31
32
|
agentScope?: TakomiAgentScope;
|
|
32
33
|
confirmProjectAgents?: boolean;
|
|
33
34
|
overrideUserBlock?: boolean;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: photo-book-builder
|
|
3
|
+
description: >
|
|
4
|
+
Automates high-resolution photo book creation, including chronological sorting, orientation balancing (landscape vs. portrait), print-ready full-bleed masonry layout generation, and cover art upscaling. Use this skill when asked to: (1) Organize a set of photos into chronological pages, (2) Generate 12" x 30" open spreads (9000 x 3600 px at 300 DPI) with exact gutter margins (150 px vertical center gutter) and custom margins, (3) Create or assemble full-bleed masonry grid layouts, (4) Upscale front, back, or briefcase box covers using high-quality LANCZOS interpolation.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Photo Book Builder
|
|
8
|
+
|
|
9
|
+
This skill provides a complete system for organizing, structuring, and generating print-ready, high-resolution photo books. It is designed for 12" × 30" open spreads (15" × 12" closed book size) at 300 DPI, enforcing strict gutter margins to prevent content from being lost in the binding.
|
|
10
|
+
|
|
11
|
+
## Core Capabilities
|
|
12
|
+
|
|
13
|
+
The photo book builder relies on four main stages:
|
|
14
|
+
|
|
15
|
+
```mermaid
|
|
16
|
+
graph TD
|
|
17
|
+
A[Stage 1: Prep & Organize] -->|organize_photos.py| B[Page Folders & Mapping JSON]
|
|
18
|
+
B --> C[Stage 2 & 3: Layout Generation]
|
|
19
|
+
C -->|create_full_bleed_layouts.py| D[9000x3600 px Print Spreads]
|
|
20
|
+
E[Stage 4: Cover Upscaling] -->|upscale_covers.py| F[Print-Ready Cover JPEGs]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Stage-by-Stage Workflow
|
|
26
|
+
|
|
27
|
+
### Stage 1: Preparation & Balanced Page Sorting
|
|
28
|
+
Chronological sequence must be preserved at a macro level, but photos should be scrambled within local windows (chunks) to balance the number of landscape ("L") and portrait ("P") images per page folder.
|
|
29
|
+
* **Target Pages**: 20 page folders (`page 1` to `page 20`).
|
|
30
|
+
* **Balanced Distribution**: Target 11 or 12 photos per page (e.g. 9 Landscapes and 2-3 Portraits per page).
|
|
31
|
+
* **JSON Map**: A mapping file `photo_organization_map.json` is generated to track which photos belong to which page.
|
|
32
|
+
|
|
33
|
+
**Execution**:
|
|
34
|
+
Run the organizer script, specifying the source folder containing the flat list of JPEG images and the target output directory:
|
|
35
|
+
```bash
|
|
36
|
+
python scripts/organize_photos.py --dir "path/to/photos" --out "path/to/workspace" --seed 42
|
|
37
|
+
```
|
|
38
|
+
*Note: If running on Windows, use `python -Xutf8` to avoid console encoding issues with status output.*
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
### Stage 2: Grid Layout & Coordinates
|
|
43
|
+
Spreads are generated at $9000 \times 3600$ px (300 DPI). Each page half gets an active grid area of $4275 \times 3300$ px after subtracting margins and gutter seams:
|
|
44
|
+
* **Outer Margins**: 150 px top, bottom, and outside edges.
|
|
45
|
+
* **Vertical Center Gutter**: 150 px total gutter seam (no image content between $x = 4425$ and $x = 4575$).
|
|
46
|
+
* **Image Gaps**: 40 px spacing horizontally and vertically.
|
|
47
|
+
|
|
48
|
+
Layout configurations are determined dynamically based on photo counts and orientations:
|
|
49
|
+
* **Template 5A (5 Photos: 4L, 1P)**: One large landscape col, one portrait + landscape col.
|
|
50
|
+
* **Template 6A (6 Photos: 5L, 1P)**: Two landscape rows (4 total), one portrait + landscape col.
|
|
51
|
+
* **Template 6B (6 Photos: 4L, 2P)**: Two large landscape rows (2 total), one double-portrait + double-landscape col.
|
|
52
|
+
|
|
53
|
+
For a complete coordinate reference and details on column configurations, see [layout_templates.md](references/layout_templates.md).
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### Stage 3: Spread Assembly & Rendering
|
|
58
|
+
The layout compiler reads the page directories, detects photo orientations on the fly, applies the coordinate grids, crops photos to fill cells (using centered LANCZOS fit-scale), and merges them into $9000 \times 3600$ px JPEG spreads.
|
|
59
|
+
* **Page Swapping**: Alternating layout orientation avoids monotonous designs. Left page columns swap if `page_num % 2 == 0`, and right page columns swap if `page_num % 3 == 0`.
|
|
60
|
+
|
|
61
|
+
**Execution**:
|
|
62
|
+
Compile the print-ready layouts:
|
|
63
|
+
```bash
|
|
64
|
+
python scripts/create_full_bleed_layouts.py --workspace-dir "path/to/workspace" --out-dir "path/to/output/layouts" --quality 95
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### Stage 4: Cover Art Upscaling
|
|
70
|
+
AI-generated cover art and presentation box lid files need to be upscaled to final high-resolution print dimensions using high-quality LANCZOS interpolation.
|
|
71
|
+
* **Front/Back Cover size**: $4500 \times 3600$ px.
|
|
72
|
+
* **Presentation Briefcase Box size**: $4950 \times 4050$ px.
|
|
73
|
+
|
|
74
|
+
**Execution**:
|
|
75
|
+
Upscale cover background textures or complete cover designs:
|
|
76
|
+
```bash
|
|
77
|
+
python scripts/upscale_covers.py --src "path/to/cover.png" --out "path/to/upscaled_cover.jpg" --width 4500 --height 3600 --quality 95
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Revert Option
|
|
83
|
+
If you need to restart the process or undo the page folder classification, run the revert tool to move photos back to the root workspace directory and remove empty page folders:
|
|
84
|
+
```bash
|
|
85
|
+
python scripts/revert_organization.py --workspace-dir "path/to/workspace"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Bundled Resources
|
|
89
|
+
|
|
90
|
+
* **Scripts**:
|
|
91
|
+
* [organize_photos.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/organize_photos.py) - Groups and balances photo directories.
|
|
92
|
+
* [create_full_bleed_layouts.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/create_full_bleed_layouts.py) - Assembles masonry grid pages into print-ready spreads.
|
|
93
|
+
* [upscale_covers.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/upscale_covers.py) - General-purpose upscaling script.
|
|
94
|
+
* [revert_organization.py](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/scripts/revert_organization.py) - Restores files back to flat source directories.
|
|
95
|
+
* **References**:
|
|
96
|
+
* [layout_templates.md](file:///C:/Users/johno/.gemini/config/skills/photo-book-builder/references/layout_templates.md) - Exact coordinates, dimensions, and grid cells.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Photo Book Layout Grid Templates Reference
|
|
2
|
+
|
|
3
|
+
This reference documents the exact coordinate maps, margins, gaps, and cell dimensions for the 12" × 30" (9000 × 3600 px at 300 DPI) full-bleed masonry layout spreads.
|
|
4
|
+
|
|
5
|
+
## Page Dimensions
|
|
6
|
+
* **Total Spread Canvas**: $9000 \times 3600$ pixels.
|
|
7
|
+
* **Page Halves**:
|
|
8
|
+
* **Left Page (Page A)**: $x \in [0, 4500]$, $y \in [0, 3600]$
|
|
9
|
+
* **Right Page (Page B)**: $x \in [4500, 9000]$, $y \in [0, 3600]$
|
|
10
|
+
* **Outer Margins**: Exactly **150 pixels** on the top, bottom, and outside edges of each page half.
|
|
11
|
+
* **Gaps Between Images**: Exactly **40 pixels** horizontally and vertically.
|
|
12
|
+
* **Center Seam Gutter**: Exactly **150 pixels** total (no image placed between $x = 4425$ and $x = 4575$).
|
|
13
|
+
* **Active Page Half Grid Size**: $4275 \times 3300$ pixels.
|
|
14
|
+
* Left Page active $x$ range: $[150, 4425]$
|
|
15
|
+
* Right Page active $x$ range: $[4575, 8850]$
|
|
16
|
+
* Active $y$ range: $[150, 3450]$
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Column Configurations & Dimensions
|
|
21
|
+
|
|
22
|
+
To cover the active area completely without vertical white spaces, we use three specific column widths:
|
|
23
|
+
* **Large Landscape Column**: $2550$ px wide.
|
|
24
|
+
* **Medium Landscape Column**: $1350$ px wide (only when paired with a $2550$ px column).
|
|
25
|
+
* **Narrow Landscape / Portrait Column**: $1685$ px wide.
|
|
26
|
+
* **Symmetric Double-Portrait Column**: $2118$ / $2117$ px wide.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Template Layouts
|
|
31
|
+
|
|
32
|
+
### Template 5A (5 Photos: 4 Landscapes, 1 Portrait)
|
|
33
|
+
* **Column A (2550 px wide)**:
|
|
34
|
+
* Row 1: 1 Large Landscape ($2550 \times 1630$ px)
|
|
35
|
+
* Row 2: 2 Small Landscapes side-by-side ($1255 \times 1630$ px each)
|
|
36
|
+
* **Column B (1685 px wide)**:
|
|
37
|
+
* Row 1: 1 Portrait ($1685 \times 2480$ px)
|
|
38
|
+
* Row 2: 1 Panoramic Landscape ($1685 \times 780$ px)
|
|
39
|
+
* *Sum of widths*: $2550 + 1685 + 40 \text{ (gap)} = 4275$ px.
|
|
40
|
+
* *Sum of heights*:
|
|
41
|
+
* Col A: $1630 + 1630 + 40 = 3300$ px.
|
|
42
|
+
* Col B: $2480 + 780 + 40 = 3300$ px.
|
|
43
|
+
|
|
44
|
+
### Template 6A (6 Photos: 5 Landscapes, 1 Portrait)
|
|
45
|
+
* **Column A (2550 px wide)**:
|
|
46
|
+
* Row 1: 2 Small Landscapes side-by-side ($1255 \times 1630$ px each)
|
|
47
|
+
* Row 2: 2 Small Landscapes side-by-side ($1255 \times 1630$ px each)
|
|
48
|
+
* **Column B (1685 px wide)**:
|
|
49
|
+
* Row 1: 1 Portrait ($1685 \times 2480$ px)
|
|
50
|
+
* Row 2: 1 Panoramic Landscape ($1685 \times 780$ px)
|
|
51
|
+
* *Sum of widths*: $2550 + 1685 + 40 \text{ (gap)} = 4275$ px.
|
|
52
|
+
* *Sum of heights*: $3300$ px.
|
|
53
|
+
|
|
54
|
+
### Template 6B (6 Photos: 4 Landscapes, 2 Portraits)
|
|
55
|
+
* **Column A (2118 px wide)**:
|
|
56
|
+
* Row 1: 1 Large Landscape ($2118 \times 1630$ px)
|
|
57
|
+
* Row 2: 1 Large Landscape ($2118 \times 1630$ px)
|
|
58
|
+
* **Column B (2117 px wide)**:
|
|
59
|
+
* Row 1: 2 Portraits side-by-side ($1038 \times 1630$ px each)
|
|
60
|
+
* Row 2: 2 Landscapes side-by-side ($1038 \times 1630$ px each)
|
|
61
|
+
* *Sum of widths*: $2118 + 2117 + 40 \text{ (gap)} = 4275$ px.
|
|
62
|
+
* *Sum of heights*: $3300$ px.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Deterministic Page Swapping
|
|
67
|
+
|
|
68
|
+
To vary the page designs dynamically across the book:
|
|
69
|
+
* Swap Left Page columns if `page_num % 2 == 0`.
|
|
70
|
+
* Swap Right Page columns if `page_num % 3 == 0`.
|
|
71
|
+
|
|
72
|
+
When swapping a column layout, Column B's starting $x$ coordinate is placed at the outer margin, and Column A's starting $x$ coordinate is placed after it.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import argparse
|
|
4
|
+
from PIL import Image, ImageDraw
|
|
5
|
+
|
|
6
|
+
def fit_image(image_path, target_w, target_h):
|
|
7
|
+
with Image.open(image_path) as img:
|
|
8
|
+
img_w, img_h = img.size
|
|
9
|
+
scale = max(target_w / img_w, target_h / img_h)
|
|
10
|
+
new_w = int(img_w * scale)
|
|
11
|
+
new_h = int(img_h * scale)
|
|
12
|
+
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
13
|
+
|
|
14
|
+
left = (new_w - target_w) // 2
|
|
15
|
+
top = (new_h - target_h) // 2
|
|
16
|
+
right = left + target_w
|
|
17
|
+
bottom = top + target_h
|
|
18
|
+
|
|
19
|
+
return resized.crop((left, top, right, bottom))
|
|
20
|
+
|
|
21
|
+
def get_page_cells(is_left, count, landscapes, portraits, swap_cols):
|
|
22
|
+
cells = []
|
|
23
|
+
offset = 0 if is_left else 4500
|
|
24
|
+
start_x_offset = 150 if is_left else 75
|
|
25
|
+
|
|
26
|
+
if count == 5:
|
|
27
|
+
# 4L, 1P -> Template 5A
|
|
28
|
+
# Col 1: width 2550. Col 2: width 1685. Gap 40.
|
|
29
|
+
if not swap_cols:
|
|
30
|
+
col1_x = offset + start_x_offset
|
|
31
|
+
col2_x = offset + start_x_offset + 2550 + 40
|
|
32
|
+
else:
|
|
33
|
+
col2_x = offset + start_x_offset
|
|
34
|
+
col1_x = offset + start_x_offset + 1685 + 40
|
|
35
|
+
|
|
36
|
+
# Left Col (2550 wide)
|
|
37
|
+
cells.append((landscapes[0], (col1_x, 150, 2550, 1630)))
|
|
38
|
+
cells.append((landscapes[1], (col1_x, 1820, 1255, 1630)))
|
|
39
|
+
cells.append((landscapes[2], (col1_x + 1255 + 40, 1820, 1255, 1630)))
|
|
40
|
+
# Right Col (1685 wide)
|
|
41
|
+
cells.append((portraits[0], (col2_x, 150, 1685, 2480)))
|
|
42
|
+
cells.append((landscapes[3], (col2_x, 2670, 1685, 780)))
|
|
43
|
+
|
|
44
|
+
elif count == 6 and len(portraits) == 1:
|
|
45
|
+
# 5L, 1P -> Template 6A
|
|
46
|
+
# Col 1: width 2550. Col 2: width 1685. Gap 40.
|
|
47
|
+
if not swap_cols:
|
|
48
|
+
col1_x = offset + start_x_offset
|
|
49
|
+
col2_x = offset + start_x_offset + 2550 + 40
|
|
50
|
+
else:
|
|
51
|
+
col2_x = offset + start_x_offset
|
|
52
|
+
col1_x = offset + start_x_offset + 1685 + 40
|
|
53
|
+
|
|
54
|
+
# Left Col (2550 wide)
|
|
55
|
+
cells.append((landscapes[0], (col1_x, 150, 1255, 1630)))
|
|
56
|
+
cells.append((landscapes[1], (col1_x + 1255 + 40, 150, 1255, 1630)))
|
|
57
|
+
cells.append((landscapes[2], (col1_x, 1820, 1255, 1630)))
|
|
58
|
+
cells.append((landscapes[3], (col1_x + 1255 + 40, 1820, 1255, 1630)))
|
|
59
|
+
# Right Col (1685 wide)
|
|
60
|
+
cells.append((portraits[0], (col2_x, 150, 1685, 2480)))
|
|
61
|
+
cells.append((landscapes[4], (col2_x, 2670, 1685, 780)))
|
|
62
|
+
|
|
63
|
+
elif count == 6 and len(portraits) == 2:
|
|
64
|
+
# 4L, 2P -> Template 6B
|
|
65
|
+
# Col 1: width 2118. Col 2: width 2117. Gap 40.
|
|
66
|
+
if not swap_cols:
|
|
67
|
+
col1_x = offset + start_x_offset
|
|
68
|
+
col2_x = offset + start_x_offset + 2118 + 40
|
|
69
|
+
else:
|
|
70
|
+
col2_x = offset + start_x_offset
|
|
71
|
+
col1_x = offset + start_x_offset + 2117 + 40
|
|
72
|
+
|
|
73
|
+
# Left Col (2118 wide)
|
|
74
|
+
cells.append((landscapes[0], (col1_x, 150, 2118, 1630)))
|
|
75
|
+
cells.append((landscapes[1], (col1_x, 1820, 2118, 1630)))
|
|
76
|
+
# Right Col (2117 wide)
|
|
77
|
+
cells.append((portraits[0], (col2_x, 150, 1038, 1630)))
|
|
78
|
+
cells.append((portraits[1], (col2_x + 1038 + 40, 150, 1038, 1630)))
|
|
79
|
+
cells.append((landscapes[2], (col2_x, 1820, 1038, 1630)))
|
|
80
|
+
cells.append((landscapes[3], (col2_x + 1038 + 40, 1820, 1038, 1630)))
|
|
81
|
+
|
|
82
|
+
return cells
|
|
83
|
+
|
|
84
|
+
def generate_layouts(workspace_dir, out_dir, quality=95):
|
|
85
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
# Load photo organization mapping
|
|
88
|
+
map_path = os.path.join(workspace_dir, "photo_organization_map.json")
|
|
89
|
+
if not os.path.exists(map_path):
|
|
90
|
+
print(f"Error: Organization map '{map_path}' not found! Run organization step first.")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
with open(map_path, "r") as f:
|
|
94
|
+
pages = json.load(f)
|
|
95
|
+
|
|
96
|
+
canvas_w = 9000
|
|
97
|
+
canvas_h = 3600
|
|
98
|
+
|
|
99
|
+
for p_str, page_files in pages.items():
|
|
100
|
+
p = int(p_str)
|
|
101
|
+
print(f"Generating optimized full-bleed print spread for Page {p}...")
|
|
102
|
+
|
|
103
|
+
# Analyze photo orientations on the fly
|
|
104
|
+
orientations = {}
|
|
105
|
+
for filename in page_files:
|
|
106
|
+
img_path = os.path.join(workspace_dir, f"page {p}", filename)
|
|
107
|
+
if not os.path.exists(img_path):
|
|
108
|
+
# Fallback check at root level of workspace
|
|
109
|
+
img_path = os.path.join(workspace_dir, filename)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with Image.open(img_path) as img:
|
|
113
|
+
w, h = img.size
|
|
114
|
+
orientations[filename] = "L" if w > h else "P"
|
|
115
|
+
except Exception as e:
|
|
116
|
+
print(f"Warning: Could not open {filename} to determine orientation: {e}")
|
|
117
|
+
orientations[filename] = "L" # default fallback
|
|
118
|
+
|
|
119
|
+
l_files = [f for f in page_files if orientations.get(f, "L") == "L"]
|
|
120
|
+
p_files = [f for f in page_files if orientations.get(f, "L") == "P"]
|
|
121
|
+
|
|
122
|
+
# Determine total photos for layout selection
|
|
123
|
+
total_photos = len(page_files)
|
|
124
|
+
|
|
125
|
+
# Distribute photos between left and right pages
|
|
126
|
+
if total_photos == 11:
|
|
127
|
+
left_count = 5
|
|
128
|
+
# Left gets 4L, 1P
|
|
129
|
+
left_l = l_files[:4]
|
|
130
|
+
left_p = p_files[:1]
|
|
131
|
+
# Right gets 5L, 1P
|
|
132
|
+
right_l = l_files[4:]
|
|
133
|
+
right_p = p_files[1:]
|
|
134
|
+
elif total_photos == 12:
|
|
135
|
+
left_count = 6
|
|
136
|
+
if len(p_files) >= 3:
|
|
137
|
+
# 9L, 3P distribution
|
|
138
|
+
# Left gets 5L, 1P (Template 6A)
|
|
139
|
+
left_l = l_files[:5]
|
|
140
|
+
left_p = p_files[:1]
|
|
141
|
+
# Right gets 4L, 2P (Template 6B)
|
|
142
|
+
right_l = l_files[5:]
|
|
143
|
+
right_p = p_files[1:]
|
|
144
|
+
else:
|
|
145
|
+
# If we don't have enough portraits for a 6B template on the right,
|
|
146
|
+
# fallback or adapt. Let's assume standard distribution:
|
|
147
|
+
# Left gets 5L, 1P (Template 6A)
|
|
148
|
+
left_l = l_files[:5]
|
|
149
|
+
left_p = p_files[:1] if p_files else []
|
|
150
|
+
# Right gets 4L, 2P (Template 6B) if possible, otherwise adjust counts
|
|
151
|
+
right_l = l_files[5:]
|
|
152
|
+
right_p = p_files[1:] if len(p_files) > 1 else p_files
|
|
153
|
+
else:
|
|
154
|
+
# Fallback for unexpected photo count per page
|
|
155
|
+
print(f"Warning: Page {p} has {total_photos} photos (expected 11 or 12). Making standard splits...")
|
|
156
|
+
left_count = total_photos // 2
|
|
157
|
+
# distribute list evenly
|
|
158
|
+
left_l = l_files[:len(l_files)//2]
|
|
159
|
+
left_p = p_files[:len(p_files)//2]
|
|
160
|
+
right_l = l_files[len(l_files)//2:]
|
|
161
|
+
right_p = p_files[len(p_files)//2:]
|
|
162
|
+
|
|
163
|
+
left_swap = (p % 2 == 0)
|
|
164
|
+
right_swap = (p % 3 == 0)
|
|
165
|
+
|
|
166
|
+
left_cells = get_page_cells(is_left=True, count=left_count, landscapes=left_l, portraits=left_p, swap_cols=left_swap)
|
|
167
|
+
right_cells = get_page_cells(is_left=False, count=6, landscapes=right_l, portraits=right_p, swap_cols=right_swap)
|
|
168
|
+
|
|
169
|
+
# Create canvas with a light background
|
|
170
|
+
canvas = Image.new("RGB", (canvas_w, canvas_h), "#F4F4F6")
|
|
171
|
+
|
|
172
|
+
# Render Left Page
|
|
173
|
+
for filename, (x, y, w, h) in left_cells:
|
|
174
|
+
img_path = os.path.join(workspace_dir, f"page {p}", filename)
|
|
175
|
+
if not os.path.exists(img_path):
|
|
176
|
+
img_path = os.path.join(workspace_dir, filename)
|
|
177
|
+
try:
|
|
178
|
+
fitted = fit_image(img_path, w, h)
|
|
179
|
+
canvas.paste(fitted, (x, y))
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print(f"Error drawing {filename} on left page {p}: {e}")
|
|
182
|
+
draw = ImageDraw.Draw(canvas)
|
|
183
|
+
draw.rectangle([x, y, x + w, y + h], fill="#CCCCCC")
|
|
184
|
+
|
|
185
|
+
# Render Right Page
|
|
186
|
+
for filename, (x, y, w, h) in right_cells:
|
|
187
|
+
img_path = os.path.join(workspace_dir, f"page {p}", filename)
|
|
188
|
+
if not os.path.exists(img_path):
|
|
189
|
+
img_path = os.path.join(workspace_dir, filename)
|
|
190
|
+
try:
|
|
191
|
+
fitted = fit_image(img_path, w, h)
|
|
192
|
+
canvas.paste(fitted, (x, y))
|
|
193
|
+
except Exception as e:
|
|
194
|
+
print(f"Error drawing {filename} on right page {p}: {e}")
|
|
195
|
+
draw = ImageDraw.Draw(canvas)
|
|
196
|
+
draw.rectangle([x, y, x + w, y + h], fill="#CCCCCC")
|
|
197
|
+
|
|
198
|
+
# Save layout spread
|
|
199
|
+
output_path = os.path.join(out_dir, f"page_{p:02d}_layout.jpg")
|
|
200
|
+
canvas.save(output_path, "JPEG", quality=quality)
|
|
201
|
+
print(f"Saved layout to {output_path}")
|
|
202
|
+
|
|
203
|
+
print("\nAll optimized full-bleed print layouts successfully created!")
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
parser = argparse.ArgumentParser(description="Generate full-bleed print spreads for the photo book.")
|
|
207
|
+
parser.add_argument("--workspace-dir", required=True, help="Directory containing page folders and organization map.")
|
|
208
|
+
parser.add_argument("--out-dir", required=True, help="Directory where generated layout spreads should be saved.")
|
|
209
|
+
parser.add_argument("--quality", type=int, default=95, help="JPEG export quality (default: 95).")
|
|
210
|
+
args = parser.parse_args()
|
|
211
|
+
|
|
212
|
+
generate_layouts(args.workspace_dir, args.out_dir, args.quality)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import random
|
|
4
|
+
import json
|
|
5
|
+
import argparse
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
def organize_photos(directory, output_dir, seed=42):
|
|
9
|
+
# Set seed for reproducible/stable randomness
|
|
10
|
+
random.seed(seed)
|
|
11
|
+
|
|
12
|
+
# 1. Find all jpeg files in the source directory
|
|
13
|
+
all_files = [f for f in os.listdir(directory) if f.lower().endswith(('.jpg', '.jpeg'))]
|
|
14
|
+
all_files.sort() # chronological sort by filename
|
|
15
|
+
|
|
16
|
+
if not all_files:
|
|
17
|
+
print(f"Error: No JPEG images found in {directory}!")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
# 2. Classify by orientation
|
|
21
|
+
landscapes = []
|
|
22
|
+
portraits = []
|
|
23
|
+
|
|
24
|
+
for f in all_files:
|
|
25
|
+
filepath = os.path.join(directory, f)
|
|
26
|
+
try:
|
|
27
|
+
with Image.open(filepath) as img:
|
|
28
|
+
w, h = img.size
|
|
29
|
+
if w > h:
|
|
30
|
+
landscapes.append(f)
|
|
31
|
+
else:
|
|
32
|
+
portraits.append(f)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"Error reading {f}: {e}")
|
|
35
|
+
|
|
36
|
+
print(f"Found {len(landscapes)} landscapes and {len(portraits)} portraits.")
|
|
37
|
+
|
|
38
|
+
# Initialize pages (pages 1 to 20)
|
|
39
|
+
pages = {i: [] for i in range(1, 21)}
|
|
40
|
+
|
|
41
|
+
# 3. Distribute landscapes
|
|
42
|
+
chunk_size = 20
|
|
43
|
+
for i in range(0, len(landscapes), chunk_size):
|
|
44
|
+
chunk = landscapes[i:i+chunk_size]
|
|
45
|
+
random.shuffle(chunk)
|
|
46
|
+
if len(chunk) == chunk_size:
|
|
47
|
+
for page_idx, filename in enumerate(chunk):
|
|
48
|
+
pages[page_idx + 1].append(filename)
|
|
49
|
+
else:
|
|
50
|
+
chosen_pages = random.sample(range(1, 21), len(chunk))
|
|
51
|
+
for page_num, filename in zip(chosen_pages, chunk):
|
|
52
|
+
pages[page_num].append(filename)
|
|
53
|
+
|
|
54
|
+
# 4. Distribute portraits
|
|
55
|
+
for i in range(0, len(portraits), chunk_size):
|
|
56
|
+
chunk = portraits[i:i+chunk_size]
|
|
57
|
+
random.shuffle(chunk)
|
|
58
|
+
if len(chunk) == chunk_size:
|
|
59
|
+
for page_idx, filename in enumerate(chunk):
|
|
60
|
+
pages[page_idx + 1].append(filename)
|
|
61
|
+
else:
|
|
62
|
+
chosen_pages = random.sample(range(1, 21), len(chunk))
|
|
63
|
+
for page_num, filename in zip(chosen_pages, chunk):
|
|
64
|
+
pages[page_num].append(filename)
|
|
65
|
+
|
|
66
|
+
# 5. Verify & Print Proposed Distribution
|
|
67
|
+
print("\n--- Proposed Distribution ---")
|
|
68
|
+
for page_num in range(1, 21):
|
|
69
|
+
files = pages[page_num]
|
|
70
|
+
l_count = sum(1 for f in files if f in landscapes)
|
|
71
|
+
p_count = sum(1 for f in files if f in portraits)
|
|
72
|
+
print(f"Page {page_num}: {len(files)} photos (Landscapes: {l_count}, Portraits: {p_count})")
|
|
73
|
+
|
|
74
|
+
# Save the mapping as JSON in the output directory
|
|
75
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
76
|
+
mapping_path = os.path.join(output_dir, 'photo_organization_map.json')
|
|
77
|
+
with open(mapping_path, 'w') as f:
|
|
78
|
+
json.dump(pages, f, indent=4)
|
|
79
|
+
|
|
80
|
+
# Create page folders and move files
|
|
81
|
+
print("\nMoving files into page folders...")
|
|
82
|
+
for page_num, files in pages.items():
|
|
83
|
+
page_dir = os.path.join(output_dir, f"page {page_num}")
|
|
84
|
+
os.makedirs(page_dir, exist_ok=True)
|
|
85
|
+
for filename in files:
|
|
86
|
+
src = os.path.join(directory, filename)
|
|
87
|
+
dst = os.path.join(page_dir, filename)
|
|
88
|
+
shutil.move(src, dst)
|
|
89
|
+
|
|
90
|
+
print("Organization complete!")
|
|
91
|
+
|
|
92
|
+
if __name__ == '__main__':
|
|
93
|
+
parser = argparse.ArgumentParser(description="Sort photos chronologically into 20 balanced page folders.")
|
|
94
|
+
parser.add_argument("--dir", default=".", help="Source directory containing unorganized photos.")
|
|
95
|
+
parser.add_argument("--out", default=".", help="Output directory where page folders will be created.")
|
|
96
|
+
parser.add_argument("--seed", type=int, default=42, help="Random seed for stable reproducibility.")
|
|
97
|
+
args = parser.parse_args()
|
|
98
|
+
|
|
99
|
+
organize_photos(args.dir, args.out, args.seed)
|