@hraness/slopcamera 3.3.6 → 3.5.0
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/README.md +30 -16
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +1 -1
- package/apps/desktop/cli/capability-manifest.ts +13 -0
- package/apps/desktop/cli/commands.ts +8 -1
- package/apps/desktop/cli/help.ts +45 -10
- package/apps/desktop/cli/html-overlay-renderer.ts +16 -0
- package/apps/desktop/cli/menubar.ts +91 -15
- package/apps/desktop/cli/portable-surface.ts +2 -0
- package/apps/desktop/code/public.ts +1 -0
- package/apps/desktop/core/visual-style-look.ts +34 -0
- package/apps/desktop/dist/cli/main.js +338 -282
- package/apps/desktop/html-overlay/catalog.ts +1 -0
- package/apps/desktop/html-overlay/runtime.ts +15 -0
- package/apps/desktop/html-overlay/spatial.ts +10 -6
- package/apps/desktop/workflows/index.ts +1 -0
- package/dist/cli.js +6 -2
- package/dist/{index-sy1n4zfy.js → index-24w75s6a.js} +1 -1
- package/dist/{index-qtn3s421.js → index-8bhp7cj2.js} +1 -1
- package/dist/{index-10bz03js.js → index-jab2mg0s.js} +4 -3
- package/dist/{index-qry58nj2.js → index-mnp0bftq.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/vectorize/worker.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +4 -3
- package/examples/studio/vgpu/README.md +1 -1
- package/examples/style-portfolio/ANIMATION-STUDIES.md +34 -0
- package/examples/style-portfolio/FILM-PLATE.md +19 -0
- package/examples/style-portfolio/README.md +137 -0
- package/examples/style-portfolio/animation-studies.html +597 -0
- package/examples/style-portfolio/assets/instrument-serif-OFL.txt +93 -0
- package/examples/style-portfolio/assets/instrument-serif-latin-400.woff2 +0 -0
- package/examples/style-portfolio/build-gallery.ts +403 -0
- package/examples/style-portfolio/film-plate.html +76 -0
- package/examples/style-portfolio/finish-film.ts +152 -0
- package/examples/style-portfolio/market-street/README.md +43 -0
- package/examples/style-portfolio/market-street/mesh.py +145 -0
- package/examples/style-portfolio/market-street/scene.py +760 -0
- package/examples/style-portfolio/market-street/source.json +1 -0
- package/examples/style-portfolio/market-street/street_data.py +72 -0
- package/examples/style-portfolio/render-film-plate.ts +128 -0
- package/examples/style-portfolio/render.ts +89 -0
- package/examples/style-portfolio/verify-animation-source.mjs +94 -0
- package/package.json +14 -5
- package/skills/slopcamera/SKILL.md +13 -3
- package/skills/slopcamera/references/brand-illustrations.md +237 -0
- package/skills/slopcamera/references/gateway-media.md +6 -1
- package/skills/slopcamera/references/install.md +10 -1
- package/skills/slopcamera/references/music-video.md +5 -0
- package/skills/slopcamera/references/native-studio.md +4 -0
- package/skills/slopcamera/references/patent-drawings.md +4 -4
- package/skills/slopcamera/references/support.md +1 -1
- package/skills/slopcamera/references/vectorization.md +3 -1
- package/skills/slopcamera/references/visual-style-direction.md +143 -0
- package/src/cli.ts +28 -0
- package/src/code/public-operations.ts +9 -0
- package/src/index.ts +1 -0
- package/src/mcp/tools.ts +4 -0
- package/src/operations.ts +4 -0
- package/src/support.ts +1 -1
- package/src/vectorize/command.ts +6 -1
- package/src/vectorize/supervisor.ts +23 -3
- package/src/vectorize/tool.ts +8 -4
- package/src/version.ts +1 -1
- package/src/visual-style.ts +378 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"engine":"blender","entrypoint":{"kind":"python","path":"scene.py"},"files":["mesh.py","scene.py","street_data.py"]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Evidence for the historical interpretation, separate from art direction.
|
|
2
|
+
|
|
3
|
+
The scene is a compressed invented block inspired by the 1906 film. Dimensions,
|
|
4
|
+
shop occupants, figure performances, and most facades are authored estimates.
|
|
5
|
+
No building footprint is claimed to have been digitized from a Sanborn plate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
SOURCES = [
|
|
9
|
+
{
|
|
10
|
+
'id': 'loc-film',
|
|
11
|
+
'url': 'https://www.loc.gov/item/00694408/',
|
|
12
|
+
'title': 'A trip down Market Street before the fire',
|
|
13
|
+
'institution': 'Library of Congress',
|
|
14
|
+
'supports': ['1906 setting', 'cable-car viewpoint toward Ferry Building',
|
|
15
|
+
'black-and-white 35 mm source', 'mixed street traffic',
|
|
16
|
+
'damp roadway visible in the film'],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
'id': 'sfmta-conversion',
|
|
20
|
+
'url': 'https://www.sfmta.com/blog/how-1906-earthquake-transformed-our-public-transit-system',
|
|
21
|
+
'title': 'How the 1906 Earthquake Transformed Our Public Transit System',
|
|
22
|
+
'institution': 'San Francisco Municipal Transportation Agency',
|
|
23
|
+
'supports': ['electric conversion of Market Street followed the earthquake'],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
'id': 'sfplanning-lamps',
|
|
27
|
+
'url': 'https://commissions.sfplanning.org/hpcpackets/2014.794A.pdf',
|
|
28
|
+
'title': 'Path of Gold Light Standards landmark case report',
|
|
29
|
+
'institution': 'San Francisco Planning Department',
|
|
30
|
+
'supports': ['Path of Gold bases date to 1908, globe tops to 1916'],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
INTERPRETATION = {
|
|
35
|
+
'depictedPeriod': 'San Francisco, before the April 1906 earthquake',
|
|
36
|
+
'kind': 'procedural historical interpretation',
|
|
37
|
+
'historicalReconstruction': False,
|
|
38
|
+
'authoredEstimates': [
|
|
39
|
+
'All metric geometry, street compression, camera lens and height',
|
|
40
|
+
'Facades, shop names, costumes, individual vehicles and performances',
|
|
41
|
+
'Monochrome material response, weather, lighting and capture cadence',
|
|
42
|
+
],
|
|
43
|
+
'excludedAnachronisms': ['Path of Gold globes', 'Market Street traction catenary'],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# +Y runs inland. The camera travels toward -Y. The shot faces the Ferry,
|
|
47
|
+
# where screen-left is +X and screen-right is -X. Parcel coordinates are
|
|
48
|
+
# composition coordinates, not surveyed geographic coordinates.
|
|
49
|
+
ROAD_WIDTH = 26.0
|
|
50
|
+
SIDEWALK_WIDTH = 5.3
|
|
51
|
+
FERRY_Y = -12.0
|
|
52
|
+
CAPTURE_FPS = 16
|
|
53
|
+
DURATION_SECONDS = 6.0
|
|
54
|
+
|
|
55
|
+
# Art-directed frontage parcels with separate setbacks at side-street cuts.
|
|
56
|
+
# Values are (center Y, frontage, height, bays, material, roof treatment).
|
|
57
|
+
PARCELS = {
|
|
58
|
+
-1: [(12, 22, 18, 6, 'stone', 'flat'),
|
|
59
|
+
(40, 23, 25, 6, 'brick', 'pediment'),
|
|
60
|
+
(70, 25, 20, 7, 'plaster', 'flat'),
|
|
61
|
+
(105, 30, 31, 8, 'brick_light', 'mansard'),
|
|
62
|
+
(146, 35, 24, 9, 'stone', 'pediment'),
|
|
63
|
+
(187, 31, 36, 8, 'brick', 'flat'),
|
|
64
|
+
(224, 30, 23, 8, 'plaster', 'flat')],
|
|
65
|
+
1: [(14, 24, 21, 7, 'brick_light', 'flat'),
|
|
66
|
+
(46, 25, 18, 7, 'stone', 'flat'),
|
|
67
|
+
(77, 27, 30, 8, 'brick', 'pediment'),
|
|
68
|
+
(111, 24, 22, 6, 'stone', 'flat'),
|
|
69
|
+
(148, 35, 33, 9, 'brick_light', 'mansard'),
|
|
70
|
+
(190, 34, 25, 9, 'plaster', 'pediment'),
|
|
71
|
+
(230, 33, 31, 9, 'brick', 'flat')],
|
|
72
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Retain one local photographic plate and render a bounded single-image transfer study. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, open, realpath, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { HtmlSceneInputSchema } from "../../apps/desktop/html-overlay/scene";
|
|
9
|
+
|
|
10
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
const maxBytes = 32 * 1024 * 1024;
|
|
12
|
+
const maxPixels = 33_000_000;
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const options = new Map<string, string>();
|
|
15
|
+
let dryRun = false;
|
|
16
|
+
for (let index = 0; index < args.length; index++) {
|
|
17
|
+
const arg = args[index]!;
|
|
18
|
+
if (arg === "--dry-run" && !dryRun) dryRun = true;
|
|
19
|
+
else if (["--image", "--run", "--width", "--height"].includes(arg) && args[index + 1] !== undefined) {
|
|
20
|
+
if (options.has(arg)) throw new Error(`Duplicate option ${arg}.`);
|
|
21
|
+
options.set(arg, args[++index]!);
|
|
22
|
+
} else throw new Error(`Unknown, duplicate, or incomplete option ${arg}.`);
|
|
23
|
+
}
|
|
24
|
+
const imageArg = options.get("--image");
|
|
25
|
+
const run = options.get("--run");
|
|
26
|
+
if (!imageArg || !run) throw new Error("Usage: bun examples/style-portfolio/render-film-plate.ts --image <local.png|local.jpg> --run <new-id> [--width <maximum>] [--height <maximum>] [--dry-run]");
|
|
27
|
+
if (imageArg.length > 4096 || /[\0\r\n]/u.test(imageArg) || /^[a-z][a-z0-9+.-]*:\/\//iu.test(imageArg)) throw new Error("--image must be an explicit bounded local path, not a URL.");
|
|
28
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(run)) throw new Error("--run must be a new short lowercase filename token.");
|
|
29
|
+
function bound(name: string, fallback: number) {
|
|
30
|
+
const value = options.get(name);
|
|
31
|
+
if (value === undefined) return fallback;
|
|
32
|
+
if (!/^[0-9]{1,4}$/u.test(value)) throw new Error(`${name} must be an integer in [16,8192].`);
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
if (parsed < 16 || parsed > 8192) throw new Error(`${name} must be an integer in [16,8192].`);
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
const hasSize = options.has("--width") || options.has("--height");
|
|
38
|
+
const maxWidth = bound("--width", hasSize ? 8192 : 1440);
|
|
39
|
+
const maxHeight = bound("--height", hasSize ? 8192 : 1080);
|
|
40
|
+
const input = await realpath(resolve(root, imageArg));
|
|
41
|
+
// Read from one handle with a fixed byte ceiling, then bind the retained copy.
|
|
42
|
+
const handle = await open(input, "r");
|
|
43
|
+
let bytes: Buffer;
|
|
44
|
+
try {
|
|
45
|
+
const before = await handle.stat();
|
|
46
|
+
if (!before.isFile() || !Number.isSafeInteger(before.size) || before.size < 1 || before.size > maxBytes) throw new Error("Use a regular PNG/JPEG file no larger than 32 MiB.");
|
|
47
|
+
bytes = Buffer.alloc(before.size);
|
|
48
|
+
let position = 0;
|
|
49
|
+
while (position < bytes.length) {
|
|
50
|
+
const result = await handle.read(bytes, position, bytes.length - position, position);
|
|
51
|
+
if (result.bytesRead === 0) throw new Error("The plate changed while it was read.");
|
|
52
|
+
position += result.bytesRead;
|
|
53
|
+
}
|
|
54
|
+
const extra = await handle.read(Buffer.alloc(1), 0, 1, position);
|
|
55
|
+
const after = await handle.stat();
|
|
56
|
+
if (extra.bytesRead !== 0 || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ino !== before.ino || after.dev !== before.dev) throw new Error("The plate changed while it was read.");
|
|
57
|
+
} finally { await handle.close(); }
|
|
58
|
+
const metadata = await sharp(bytes, { limitInputPixels: maxPixels, failOn: "error", animated: true }).metadata();
|
|
59
|
+
if (!["png", "jpeg"].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1) throw new Error("Use one static PNG or JPEG photograph.");
|
|
60
|
+
const sourceWidth = metadata.autoOrient.width;
|
|
61
|
+
const sourceHeight = metadata.autoOrient.height;
|
|
62
|
+
if (!Number.isInteger(sourceWidth) || !Number.isInteger(sourceHeight) || sourceWidth < 16 || sourceHeight < 16 || sourceWidth * sourceHeight > maxPixels) throw new Error("The decoded plate must be at least 16 pixels per side and at most 33 megapixels.");
|
|
63
|
+
const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight);
|
|
64
|
+
const width = Math.floor(sourceWidth * scale / 2) * 2;
|
|
65
|
+
const height = Math.floor(sourceHeight * scale / 2) * 2;
|
|
66
|
+
if (width < 2 || height < 2 || width * height > maxPixels) throw new Error("The requested aspect ratio does not fit a bounded even raster.");
|
|
67
|
+
const sourceSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
68
|
+
const extension = metadata.format === "png" ? "png" : "jpg";
|
|
69
|
+
const outputRoot = join(root, "artifacts/style-portfolio/film-plate");
|
|
70
|
+
await mkdir(outputRoot, { recursive: true });
|
|
71
|
+
if (await realpath(outputRoot) !== outputRoot) throw new Error("The output directory and its parents must not be symlinks.");
|
|
72
|
+
const output = join(outputRoot, run);
|
|
73
|
+
// The run directory is the exclusive claim. A previous or ambiguous run is
|
|
74
|
+
// never overwritten or automatically resumed, even when only planning failed.
|
|
75
|
+
try { await mkdir(output); }
|
|
76
|
+
catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Run ${run} already exists. Inspect its intent, log, and host receipts; no overwrite or retry was made.`);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
const retainedPlate = join(output, `plate.${extension}`);
|
|
81
|
+
await writeFile(retainedPlate, bytes, { flag: "wx", mode: 0o600 });
|
|
82
|
+
const retainedRelative = relative(root, retainedPlate).split(sep).join("/");
|
|
83
|
+
if (isAbsolute(retainedRelative) || retainedRelative.startsWith("../")) throw new Error("The retained plate must stay inside the repository artifacts directory.");
|
|
84
|
+
const source = HtmlSceneInputSchema.parse({
|
|
85
|
+
kind: "slopcamera.html-scene", schemaVersion: 1,
|
|
86
|
+
name: "Single-image film-transfer study",
|
|
87
|
+
document: { path: "examples/style-portfolio/film-plate.html" },
|
|
88
|
+
canvas: { width, height, deviceScaleFactor: 1 },
|
|
89
|
+
timing: { durationUs: 6_000_000, fps: 16 },
|
|
90
|
+
seed: 19060414, background: "#000000", libraries: [],
|
|
91
|
+
parameters: { kind: "single-image-film-transfer", sourceWidth, sourceHeight, sourceSha256 },
|
|
92
|
+
resources: [{ name: "plate", path: retainedRelative, urlPath: `assets/plate.${extension}`, mediaType: metadata.format === "png" ? "image/png" : "image/jpeg" }],
|
|
93
|
+
});
|
|
94
|
+
const requestPath = join(output, "scene.json");
|
|
95
|
+
const intentPath = join(output, "intent.json");
|
|
96
|
+
await writeFile(requestPath, JSON.stringify(source, null, 2) + "\n", { flag: "wx" });
|
|
97
|
+
const intent = {
|
|
98
|
+
kind: "slopcamera.single-image-film-transfer-intent", schemaVersion: 1,
|
|
99
|
+
mode: dryRun ? "plan" : "render", input, sourceSha256, sourceBytes: bytes.length,
|
|
100
|
+
sourceDimensions: { width: sourceWidth, height: sourceHeight }, outputDimensions: { width, height },
|
|
101
|
+
requestedMaximum: { width: maxWidth, height: maxHeight }, retainedPlate: retainedRelative,
|
|
102
|
+
requestPath, timing: source.timing,
|
|
103
|
+
limitations: ["One photographic image with optical movement; no subject animation.", "Delivery never exceeds input dimensions. A small optical crop is resampled within that raster.", "Grain and diffusion are a separate explicit finishing operation."],
|
|
104
|
+
};
|
|
105
|
+
await writeFile(intentPath, JSON.stringify(intent, null, 2) + "\n", { flag: "wx" });
|
|
106
|
+
const argv = [process.execPath, join(root, "apps/desktop/dist/cli/main.js"), "html", "render", "--input", requestPath, "--json"];
|
|
107
|
+
if (dryRun) argv.push("--dry-run");
|
|
108
|
+
const logPath = join(output, "render.log");
|
|
109
|
+
const log = await open(logPath, "wx", 0o600);
|
|
110
|
+
let code: number;
|
|
111
|
+
let stdout: string;
|
|
112
|
+
try {
|
|
113
|
+
const child = Bun.spawn(argv, { cwd: root, env: process.env, stdout: "pipe", stderr: log.fd });
|
|
114
|
+
const result = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
115
|
+
[code, stdout] = result;
|
|
116
|
+
} finally { await log.close(); }
|
|
117
|
+
if (code !== 0) {
|
|
118
|
+
await writeFile(join(output, "failed.json"), JSON.stringify({ code, stdout }, null, 2) + "\n", { flag: "wx" });
|
|
119
|
+
throw new Error(`Slopcamera ${dryRun ? "plan" : "render"} failed (${code}); inspect ${logPath} and retained host receipts. No retry was made.`);
|
|
120
|
+
}
|
|
121
|
+
const result: unknown = JSON.parse(stdout);
|
|
122
|
+
const resultPath = join(output, dryRun ? "plan.json" : "result.json");
|
|
123
|
+
await writeFile(resultPath, JSON.stringify(result, null, 2) + "\n", { flag: "wx" });
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
const completion = z.object({ kind: z.literal("slopcamera.html-scene-export"), verification: z.object({ width: z.number(), height: z.number(), frameCount: z.number(), durationUs: z.number() }) }).parse(result);
|
|
126
|
+
if (completion.verification.width !== width || completion.verification.height !== height || completion.verification.frameCount !== 96 || completion.verification.durationUs !== 6_000_000) throw new Error("Rendered dimensions or timing differ from the admitted request; retain this result for review.");
|
|
127
|
+
}
|
|
128
|
+
console.log(JSON.stringify({ state: dryRun ? "planned" : "rendered", interpretation: "single-image-film-transfer", sourceDimensions: intent.sourceDimensions, dimensions: { width, height }, fps: 16, durationSeconds: 6, sourceSha256, resultPath, intentPath }));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Render original style studies through the canonical Slopcamera host. */
|
|
2
|
+
import { mkdir, open, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getVisualStyleProfile } from "../../src/visual-style";
|
|
6
|
+
|
|
7
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
8
|
+
const studies = ["theatrical-cel", "watercolor-storybook", "pixel-art", "math-explainer",
|
|
9
|
+
"midcentury-limited", "rubber-hose", "cut-paper", "stopmotion-clay", "engraving",
|
|
10
|
+
"ink-sketch", "rotoscope", "clean-motion"] as const;
|
|
11
|
+
export async function renderStudies(args: readonly string[], workspaceRoot = root, log: (message: string) => void = console.log): Promise<void> {
|
|
12
|
+
const flagNames = new Set(["--style", "--width", "--offset", "--run"]);
|
|
13
|
+
const options = new Map<string, string>();
|
|
14
|
+
const flags = new Set<string>();
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i]!;
|
|
17
|
+
if (arg === "--all" || arg === "--still" || arg === "--dry-run") {
|
|
18
|
+
if (flags.has(arg)) throw new Error(`Duplicate flag ${arg}`);
|
|
19
|
+
flags.add(arg);
|
|
20
|
+
} else if (flagNames.has(arg) && args[i + 1] !== undefined) {
|
|
21
|
+
if (options.has(arg)) throw new Error(`Duplicate option ${arg}`);
|
|
22
|
+
options.set(arg, args[++i]!);
|
|
23
|
+
} else throw new Error(`Unknown or incomplete option ${arg}`);
|
|
24
|
+
}
|
|
25
|
+
const still = flags.has("--still");
|
|
26
|
+
const width = Number(options.get("--width") ?? 3840);
|
|
27
|
+
const offset = Number(options.get("--offset") ?? (still ? 3 : 0));
|
|
28
|
+
const run = options.get("--run") ?? (still ? "stills" : "films");
|
|
29
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(run)) throw new Error("--run must be a short lowercase filename token.");
|
|
30
|
+
if (![1280, 1920, 2560, 3840].includes(width)) throw new Error("--width must be 1280, 1920, 2560, or 3840.");
|
|
31
|
+
if (!Number.isFinite(offset) || offset < 0 || offset >= 6 || (!still && offset !== 0)) throw new Error("--offset must be in [0,6), and requires --still.");
|
|
32
|
+
const requested = options.get("--style");
|
|
33
|
+
if ((requested === undefined) === !flags.has("--all")) throw new Error("Choose exactly one of --style <id> or --all.");
|
|
34
|
+
const selected = requested === undefined ? studies : studies.filter(id => id === requested);
|
|
35
|
+
if (selected.length === 0) throw new Error(`Study not found. Choose: ${studies.join(", ")}`);
|
|
36
|
+
const canonicalRoot = await realpath(workspaceRoot);
|
|
37
|
+
const output = join(canonicalRoot, "artifacts", "style-portfolio", run);
|
|
38
|
+
await mkdir(output, { recursive: true });
|
|
39
|
+
if (await realpath(output) !== output) throw new Error("Output directories must not be symlinks.");
|
|
40
|
+
for (const style of selected) {
|
|
41
|
+
// Plans have their own immutable attempt, so inspecting a new width never
|
|
42
|
+
// replaces an execution's scene, log, result, or native receipt references.
|
|
43
|
+
const prefix = `${style}${flags.has("--dry-run") ? ".plan" : ""}`;
|
|
44
|
+
const resultPath = join(output, `${prefix}.result.json`);
|
|
45
|
+
const requestPath = join(output, `${prefix}.scene.json`);
|
|
46
|
+
const intentPath = join(output, `${prefix}.intent.json`);
|
|
47
|
+
// A retained intent without a result is ambiguous. Inspect its log and host
|
|
48
|
+
// receipts before selecting an explicitly new --run, never auto-resubmit.
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(intentPath, JSON.stringify({ kind: "slopcamera.style-study-intent", style, requestPath, width, still,
|
|
51
|
+
mode: flags.has("--dry-run") ? "plan" : "render" }) + "\n", { flag: "wx", mode: 0o600 });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
54
|
+
log(JSON.stringify({ style, state: "already-attempted", intentPath, resultPath }));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const profile = getVisualStyleProfile(style);
|
|
58
|
+
const scene = {
|
|
59
|
+
kind: "slopcamera.html-scene", schemaVersion: 1,
|
|
60
|
+
name: `${profile.name} / ${still ? "master still" : "motion study"}`,
|
|
61
|
+
document: { path: "examples/style-portfolio/animation-studies.html" },
|
|
62
|
+
canvas: { width, height: width * 9 / 16, deviceScaleFactor: 1 },
|
|
63
|
+
timing: { durationUs: still ? 1 : 6_000_000, fps: 24 },
|
|
64
|
+
seed: 20260923, libraries: [], resources: [],
|
|
65
|
+
parameters: { style, direction: profile, timeOffsetSeconds: offset },
|
|
66
|
+
background: profile.palette.background,
|
|
67
|
+
};
|
|
68
|
+
await writeFile(requestPath, JSON.stringify(scene, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
69
|
+
const childArgs = [process.execPath, join(canonicalRoot, "apps/desktop/dist/cli/main.js"), "html", "render", "--input", requestPath, "--json"];
|
|
70
|
+
if (flags.has("--dry-run")) childArgs.push("--dry-run");
|
|
71
|
+
const logPath = join(output, `${prefix}.log`);
|
|
72
|
+
const logFile = await open(logPath, "wx", 0o600);
|
|
73
|
+
let code: number;
|
|
74
|
+
let stdout: string;
|
|
75
|
+
try {
|
|
76
|
+
const child = Bun.spawn(childArgs, { cwd: canonicalRoot, env: process.env, stdout: "pipe", stderr: logFile.fd });
|
|
77
|
+
[code, stdout] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
78
|
+
} finally { await logFile.close(); }
|
|
79
|
+
if (code !== 0) {
|
|
80
|
+
await writeFile(join(output, `${prefix}.failed.json`), JSON.stringify({ code, stdout }) + "\n", { flag: "wx" });
|
|
81
|
+
throw new Error(`${style} render failed (${code}); inspect ${logPath}. No automatic retry was made.`);
|
|
82
|
+
}
|
|
83
|
+
const result: unknown = JSON.parse(stdout);
|
|
84
|
+
await writeFile(resultPath, JSON.stringify(result, null, 2) + "\n", { flag: "wx" });
|
|
85
|
+
log(JSON.stringify({ style, state: flags.has("--dry-run") ? "planned" : "rendered", resultPath }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (import.meta.main) await renderStudies(process.argv.slice(2));
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Source checks only. This does not replace rendered-frame and motion review. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { runInNewContext } from "node:vm";
|
|
6
|
+
import { getVisualStyleProfile } from "../../src/visual-style.ts";
|
|
7
|
+
|
|
8
|
+
const html = await readFile(new URL("./animation-studies.html", import.meta.url), "utf8");
|
|
9
|
+
const scripts = [...html.matchAll(/<script\s*>([\s\S]*?)<\/script\s*>/giu)];
|
|
10
|
+
assert.equal((html.match(/<script\b/giu) ?? []).length, 1, "The self-contained scene has one script element.");
|
|
11
|
+
assert.equal(scripts.length, 1, "The scene script is inline and has no attributes.");
|
|
12
|
+
const script = scripts[0]?.[1];
|
|
13
|
+
assert.ok(script, "The inline scene script is nonempty.");
|
|
14
|
+
new Function(script);
|
|
15
|
+
let digest = createHash("sha256");
|
|
16
|
+
const contexts = [];
|
|
17
|
+
const record = (name, values) => digest.update(JSON.stringify([name, values]));
|
|
18
|
+
function context(name) {
|
|
19
|
+
let depth = 0;
|
|
20
|
+
const methods = {
|
|
21
|
+
save() { depth++; record(`${name}.save`, []); },
|
|
22
|
+
restore() { assert.ok(depth > 0, "Canvas restore has an owned save."); depth--; record(`${name}.restore`, []); },
|
|
23
|
+
createLinearGradient(...args) { return gradient("linear", args); },
|
|
24
|
+
createRadialGradient(...args) { return gradient("radial", args); },
|
|
25
|
+
};
|
|
26
|
+
const gradient = (kind, args) => {
|
|
27
|
+
args.forEach(value => assert.ok(Number.isFinite(value), "Gradient coordinates are finite."));
|
|
28
|
+
const result = { kind, args, stops: [], addColorStop(position, color) {
|
|
29
|
+
assert.ok(position >= 0 && position <= 1, "Gradient stop lies in [0, 1].");
|
|
30
|
+
result.stops.push([position, color]);
|
|
31
|
+
} };
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
const proxy = new Proxy(methods, {
|
|
35
|
+
get(target, property) {
|
|
36
|
+
if (property in target) return target[property];
|
|
37
|
+
return (...args) => {
|
|
38
|
+
for (const value of args) if (typeof value === "number") assert.ok(Number.isFinite(value), `${String(property)} receives finite coordinates.`);
|
|
39
|
+
if (property === "arc") assert.ok(args[2] >= 0, "Arc radius is nonnegative.");
|
|
40
|
+
if (property === "ellipse") assert.ok(args[2] >= 0 && args[3] >= 0, "Ellipse radii are nonnegative.");
|
|
41
|
+
record(`${name}.${String(property)}`, args);
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
set(target, property, value) { target[property] = value; record(`${name}.${String(property)}=`, [value]); return true; },
|
|
45
|
+
});
|
|
46
|
+
contexts.push(() => assert.equal(depth, 0, "Scene leaves a balanced Canvas save stack."));
|
|
47
|
+
return proxy;
|
|
48
|
+
}
|
|
49
|
+
const main = { width: 1920, height: 1080, getContext: () => context("main") };
|
|
50
|
+
const pixel = { width: 320, height: 180, getContext: () => context("pixel") };
|
|
51
|
+
let frame;
|
|
52
|
+
const host = { parameters: { style: "theatrical-cel" }, onFrame(callback) { frame = callback; } };
|
|
53
|
+
const window = { innerWidth: 1920, innerHeight: 1080, devicePixelRatio: 1, SlopcameraOverlay: host };
|
|
54
|
+
runInNewContext(script, {
|
|
55
|
+
window, SlopcameraOverlay: host,
|
|
56
|
+
document: { getElementById: () => main, createElement: tag => { assert.equal(tag, "canvas"); return pixel; } },
|
|
57
|
+
Path2D: class { constructor(data) { assert.equal(typeof data, "string"); assert.ok(!/NaN|Infinity/u.test(data), "Path coordinates are finite."); this.data = data; } },
|
|
58
|
+
}, { timeout: 10_000 });
|
|
59
|
+
const drawing = window.PortfolioDrawing;
|
|
60
|
+
assert.equal(drawing.styles.length, 12);
|
|
61
|
+
const fingerprint = (time, style, direction) => {
|
|
62
|
+
digest = createHash("sha256");
|
|
63
|
+
drawing.render(time, style, direction);
|
|
64
|
+
contexts.forEach(check => check());
|
|
65
|
+
return digest.digest("hex");
|
|
66
|
+
};
|
|
67
|
+
let samples = 0;
|
|
68
|
+
for (const style of drawing.styles) {
|
|
69
|
+
const profile = getVisualStyleProfile(style);
|
|
70
|
+
drawing.validateDirection(profile, style);
|
|
71
|
+
for (const time of [0, .25, .84, 1.68, 2.65, 3, 5.999, 6]) {
|
|
72
|
+
const first = fingerprint(time, style, profile);
|
|
73
|
+
fingerprint((time + 1.3) % 6, "clean-motion", getVisualStyleProfile("clean-motion"));
|
|
74
|
+
assert.equal(fingerprint(time, style, profile), first, `${style} at ${time}s is independent of seek order.`);
|
|
75
|
+
samples++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
assert.throws(() => drawing.render(1, "unknown"), /Unsupported style/u);
|
|
79
|
+
assert.throws(() => drawing.render(NaN, "theatrical-cel"), /finite number/u);
|
|
80
|
+
assert.throws(() => drawing.render(-1, "theatrical-cel"), /finite number/u);
|
|
81
|
+
assert.throws(() => drawing.render(6.1, "theatrical-cel"), /finite number/u);
|
|
82
|
+
assert.throws(() => drawing.validateDirection({}, "theatrical-cel"), /required/u);
|
|
83
|
+
assert.throws(() => drawing.validateDirection({ ...getVisualStyleProfile("theatrical-cel"), id: "pixel-art" }, "theatrical-cel"), /must match/u);
|
|
84
|
+
assert.throws(() => drawing.validateDirection({ ...getVisualStyleProfile("theatrical-cel"), unsupported: 1 }, "theatrical-cel"), /unsupported/u);
|
|
85
|
+
assert.throws(() => drawing.validateDirection({ ...getVisualStyleProfile("theatrical-cel"), cadence: { ...getVisualStyleProfile("theatrical-cel").cadence, fps: 0 } }, "theatrical-cel"), /integer/u);
|
|
86
|
+
window.innerWidth = Infinity;
|
|
87
|
+
assert.throws(() => drawing.render(0, "theatrical-cel"), /canvas CSS width/u);
|
|
88
|
+
window.innerWidth = 1920;
|
|
89
|
+
window.devicePixelRatio = 9;
|
|
90
|
+
assert.throws(() => drawing.render(0, "theatrical-cel"), /devicePixelRatio/u);
|
|
91
|
+
window.devicePixelRatio = 1;
|
|
92
|
+
assert.throws(() => frame({ timeMs: -1 }), /timeMs/u);
|
|
93
|
+
assert.throws(() => frame({ timeMs: 6001 }), /timeMs/u);
|
|
94
|
+
console.log(`Passed: 12 canonical profiles, ${samples} deterministic drawing-command samples, balanced Canvas stacks, finite geometry, and invalid-input checks. Pixel and motion quality still require real renders.`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hraness/slopcamera",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.5.0",
|
|
4
|
+
"description": "Slopcamera lets your coding agent make images, diagrams, animation, 3D scenes, and video from source files it can keep revising.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
@@ -65,7 +65,6 @@
|
|
|
65
65
|
"video-generation",
|
|
66
66
|
"video-editing",
|
|
67
67
|
"image-generation",
|
|
68
|
-
"screen-recording",
|
|
69
68
|
"motion-graphics",
|
|
70
69
|
"captions",
|
|
71
70
|
"speech-generation",
|
|
@@ -162,6 +161,15 @@
|
|
|
162
161
|
"examples/studio/hybrid-scene.ts",
|
|
163
162
|
"examples/html/*.html",
|
|
164
163
|
"examples/html/*.json",
|
|
164
|
+
"examples/style-portfolio/**/*.html",
|
|
165
|
+
"examples/style-portfolio/**/*.ts",
|
|
166
|
+
"examples/style-portfolio/**/*.mjs",
|
|
167
|
+
"!examples/style-portfolio/**/*.test.ts",
|
|
168
|
+
"examples/style-portfolio/**/*.py",
|
|
169
|
+
"examples/style-portfolio/**/*.json",
|
|
170
|
+
"examples/style-portfolio/**/*.md",
|
|
171
|
+
"examples/style-portfolio/**/*.woff2",
|
|
172
|
+
"examples/style-portfolio/**/*.txt",
|
|
165
173
|
"examples/studio/vgpu/heat-field.mjs",
|
|
166
174
|
"apps/desktop/studio/assets/README.md",
|
|
167
175
|
"!apps/desktop/cli/**/*.test.ts",
|
|
@@ -227,8 +235,9 @@
|
|
|
227
235
|
"benchmark:code-concurrency": "bun run ./apps/desktop/code/concurrency-benchmark.ts",
|
|
228
236
|
"check:release-workflows": "bun test ./scripts/release-workflow.test.ts ./scripts/github-release.test.ts",
|
|
229
237
|
"check:schema": "bun run ./scripts/validate-schema.ts",
|
|
230
|
-
"check:skill": "bun run ./scripts/validate-skill.ts && bun test ./scripts/social-collage-compositor.test.ts",
|
|
238
|
+
"check:skill": "bun run ./scripts/validate-skill.ts && bun test ./scripts/social-collage-compositor.test.ts && bun examples/style-portfolio/verify-animation-source.mjs",
|
|
231
239
|
"check:standalone": "bun test ./scripts/standalone-support-fixture.test.ts && bun run ./scripts/check-standalone.ts",
|
|
240
|
+
"check:copy": "bun test ./scripts/check-copy.test.ts && bun run ./scripts/check-copy.ts",
|
|
232
241
|
"test:vectorize:official": "bun run ./scripts/official-vectorizer-smoke.ts",
|
|
233
242
|
"test:package": "bun run ./scripts/package-smoke.ts",
|
|
234
243
|
"check:sdk": "bun run typecheck:sdk && bun run lint:sdk && bun run build:sdk && bun run test:sdk && bun run check:release-workflows && bun run check:schema && bun run check:skill",
|
|
@@ -239,7 +248,7 @@
|
|
|
239
248
|
"kb:check": "bunx --bun github:hraness/kb#v0.15.2 check --root kb && bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .",
|
|
240
249
|
"kb:catalog": "bunx --bun github:hraness/kb#v0.15.2 catalog --root kb",
|
|
241
250
|
"check:cost-surfaces": "bun ./scripts/check-cost-surfaces.mjs",
|
|
242
|
-
"check": "bun run check:cost-surfaces && bun run check:standalone && bun run check:sdk && bun run check:desktop && bun run check:api && bun run check:web && bun run check:standalone && bun run test:package",
|
|
251
|
+
"check": "bun run check:cost-surfaces && bun run check:standalone && bun run check:copy && bun run check:sdk && bun run check:desktop && bun run check:api && bun run check:web && bun run check:standalone && bun run test:package",
|
|
243
252
|
"prepack": "bun run check",
|
|
244
253
|
"check:effect": "bun run ./scripts/check-effect-policy.ts && bun test ./scripts/check-effect-architecture.test.ts",
|
|
245
254
|
"typecheck:api": "tsc --noEmit -p apps/api/tsconfig.json",
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slopcamera
|
|
3
|
-
description: Create, inspect, edit and render visual media with the Slopcamera CLI and SDK. Use for diagrams, raster-to-SVG conversion, screen or camera recordings, video editing and captions, social collage banners, music videos from authored scenes and local tracks, AI images/video/speech/transcription, Three.js scenes and world-space media, Blender rigs and simulation, CadQuery models, parametric architectural designs, Manim educational films, HTML/WGSL motion graphics, and durable agent workflows.
|
|
3
|
+
description: Create, inspect, edit and render visual media with the Slopcamera CLI and SDK. Use for diagrams, raster-to-SVG conversion, existing screen or camera recordings, video editing and captions, social collage banners, music videos from authored scenes and local tracks, AI images/video/speech/transcription, Three.js scenes and world-space media, Blender rigs and simulation, CadQuery models, parametric architectural designs, Manim educational films, HTML/WGSL motion graphics, and durable agent workflows.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Create visual media with Slopcamera
|
|
7
7
|
|
|
8
8
|
Start from the requested result and existing source. Use the installed `slopcamera` CLI, inspect its real capabilities and retain the authored source beside its derived media. Read only the references needed by the task.
|
|
9
9
|
|
|
10
|
+
For creative film and animation, choose coherent art direction before rendering.
|
|
11
|
+
Use [visual style direction](references/visual-style-direction.md) for historical
|
|
12
|
+
footage, animation families, resolution, and visual review. Default to native 4K
|
|
13
|
+
for an unspecified creative master when the renderer and source support it;
|
|
14
|
+
preserve deliberate pixel grids, period aspect ratios, source limits, and the
|
|
15
|
+
user's explicit settings. Treat quality as staging,
|
|
16
|
+
materials, movement, and finishing together.
|
|
17
|
+
|
|
10
18
|
## Establish the host
|
|
11
19
|
|
|
12
20
|
Run `slopcamera --help` and use `slopcamera doctor --json` for the selected local tools. If installation or a version mismatch blocks the task, read [installation and readiness](references/install.md). A skill installation does not install the CLI or native engines.
|
|
@@ -17,14 +25,16 @@ Slopcamera installs from its verified release archive or from source. Historical
|
|
|
17
25
|
|
|
18
26
|
| Requested result | Read |
|
|
19
27
|
| --- | --- |
|
|
28
|
+
| Historical film, cel animation, pixel art, classic animation styles, or a demo portfolio | [Visual style direction](references/visual-style-direction.md) |
|
|
20
29
|
| Editable diagram, flowchart or visual explanation | [Diagrams](references/diagrams.md) and [visual communication](references/visual-communication.md) |
|
|
21
|
-
| Patent-style drawing sheets, physical margins, monochrome SVGs or a drawing PDF | [Drawing sheets](references/patent-drawings.md)
|
|
30
|
+
| Patent-style drawing sheets, physical margins, monochrome SVGs or a drawing PDF | [Drawing sheets](references/patent-drawings.md) |
|
|
22
31
|
| Custom diagram themes, fonts or icon packages | [Customization](references/customization.md) |
|
|
23
32
|
| Raster artwork converted to SVG | [Vectorization](references/vectorization.md) |
|
|
24
|
-
|
|
|
33
|
+
| Clean up, caption, frame, or deliver existing recordings and footage | [Video projects](references/video-projects.md) |
|
|
25
34
|
| Music video, dancing mascot or timed HTML scene with a local soundtrack | [Music videos](references/music-video.md) |
|
|
26
35
|
| Authorized YouTube or other public web-media excerpt (`yt-dlp` / `youtube-dl`) | [Web-media excerpts](references/web-media-excerpts.md) |
|
|
27
36
|
| Generate images, clips, narration or transcripts | [Gateway media](references/gateway-media.md) |
|
|
37
|
+
| Product mark, marketing illustration or topic icon | [Brand illustrations](references/brand-illustrations.md) |
|
|
28
38
|
| Compose an editorial or social banner from generated layers, local type and diagrams | [Social collage banners](references/social-collage-banners.md) |
|
|
29
39
|
| Review alternative textures, skyboxes, backdrops, design candidates or whole-scene variants | [Image galleries](references/image-galleries.md) |
|
|
30
40
|
| Budgeted short clips, reviewed takes and continuity | [Directing video](references/directing-video.md) |
|