@ignix-social/editor 0.1.5 → 0.1.7

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.
@@ -0,0 +1,58 @@
1
+ // Ajuste de textos de las plantillas de Creator con las medidas REALES de la fuente (Inter),
2
+ // al generar el proyecto (en Node): el dibujante nunca mide la página. Mismas reglas que en
3
+ // Creator: el tamaño más grande (de `max` hacia abajo, de 4 en 4) cuyas líneas caben en `width`
4
+ // y en `maxLines`; si no, el mínimo tal cual.
5
+ import { createRequire } from 'node:module';
6
+ import { dirname, join } from 'node:path';
7
+ import * as fontkit from 'fontkit';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ export const INTER_DIR = join(dirname(require.resolve('@fontsource/inter/package.json')), 'files');
11
+
12
+ // Las mismas caras que cargaba Creator: 400, 600 y 800 (y latin-ext 400 y 800). Un 700 o un 900
13
+ // se dibuja con la 800 (la más cercana por encima que hay), así que se mide con ella.
14
+ export const INTER_FACES = [
15
+ { weight: 400, file: 'inter-latin-400-normal.woff2' },
16
+ { weight: 600, file: 'inter-latin-600-normal.woff2' },
17
+ { weight: 800, file: 'inter-latin-800-normal.woff2' },
18
+ { weight: 400, file: 'inter-latin-ext-400-normal.woff2', range: 'U+0100-024F' },
19
+ { weight: 800, file: 'inter-latin-ext-800-normal.woff2', range: 'U+0100-024F' },
20
+ ];
21
+
22
+ const fonts = new Map();
23
+ function fontFor(weight) {
24
+ const w = Number(weight) >= 700 ? 800 : Number(weight) >= 600 ? 600 : 400;
25
+ if (!fonts.has(w)) fonts.set(w, fontkit.openSync(join(INTER_DIR, `inter-latin-${w}-normal.woff2`)));
26
+ return fonts.get(w);
27
+ }
28
+
29
+ /** Ancho en px de `text` a `size` px, con el espaciado de letras de las plantillas (−0,02 em). */
30
+ export function measure(text, size, weight, letterSpacingEm = -0.02) {
31
+ const f = fontFor(weight);
32
+ const run = f.layout(text);
33
+ return (run.advanceWidth / f.unitsPerEm) * size + letterSpacingEm * size * [...text].length;
34
+ }
35
+
36
+ export function fitLines(text, { width, max, min, maxLines, weight }) {
37
+ const words = String(text).split(/\s+/).filter(Boolean);
38
+ for (let size = max; size >= min; size -= 4) {
39
+ const lines = [];
40
+ let current = '';
41
+ let ok = true;
42
+ for (const word of words) {
43
+ if (measure(word, size, weight) > width) {
44
+ ok = false;
45
+ break;
46
+ }
47
+ const next = current ? `${current} ${word}` : word;
48
+ if (measure(next, size, weight) <= width) current = next;
49
+ else {
50
+ lines.push(current);
51
+ current = word;
52
+ }
53
+ }
54
+ if (current) lines.push(current);
55
+ if (ok && lines.length <= maxLines) return { size, lines };
56
+ }
57
+ return { size: min, lines: [text] };
58
+ }
@@ -0,0 +1,163 @@
1
+ // `ignix-editor creator-job --job <dir> [--browser <abs>] [--workers N]`: el proceso de render de
2
+ // Ignix Creator (ADR-0023 E2), con el mismo contrato que su motor anterior, para que Creator solo
3
+ // cambie qué lanza:
4
+ //
5
+ // <dir>/inputs.json { assetRef: { file, kind, w?, h? } }, ficheros DENTRO de <dir>
6
+ // <dir>/composition.json la composición (CompositionSpec ya validado por Creator), o
7
+ // <dir>/thumbnails.json { count, native } para fotogramas de referencia
8
+ // → <dir>/video.mp4 (composición) o <dir>/t<n>_<k>.jpg (miniaturas)
9
+ //
10
+ // Informa por stdout con líneas JSON cerradas: progress {stage, fraction}, verified
11
+ // {durationSec, width, height, audio}, output {file}, done, error {code}. SIGTERM, o perder
12
+ // el proceso padre, cancela.
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs';
15
+ import { basename, isAbsolute, join, relative } from 'node:path';
16
+ import { HOME, ensureTool } from '../binaries.mjs';
17
+ import { renderProject } from '../engine.mjs';
18
+ import { probe } from '../analysis/media.mjs';
19
+ import { buildProject } from './build.mjs';
20
+
21
+ const INPUT_NAME = /^m\d{1,3}\.(png|jpg|webp|mp4|mov|webm|m4a|mp3|wav)$/;
22
+ const ASSET = /^asset_[A-Za-z0-9]{12,40}$/;
23
+ const emit = (e) => process.stdout.write(`${JSON.stringify(e)}\n`);
24
+ class JobError extends Error {
25
+ constructor(code) {
26
+ super(code);
27
+ this.code = code;
28
+ }
29
+ }
30
+ const fail = (code) => {
31
+ throw new JobError(code);
32
+ };
33
+
34
+ function readJson(file, max) {
35
+ let st;
36
+ try {
37
+ st = lstatSync(file);
38
+ } catch {
39
+ return fail('invalid_job');
40
+ }
41
+ if (!st.isFile() || st.size > max) fail('invalid_job');
42
+ try {
43
+ return JSON.parse(readFileSync(file, 'utf8'));
44
+ } catch {
45
+ return fail('invalid_job');
46
+ }
47
+ }
48
+
49
+ function readInputs(job) {
50
+ const raw = readJson(join(job, 'inputs.json'), 100_000);
51
+ if (!raw || typeof raw !== 'object' || Object.keys(raw).length > 40) fail('invalid_job');
52
+ const out = {};
53
+ for (const [ref, v] of Object.entries(raw)) {
54
+ if (!ASSET.test(ref) || !v || !INPUT_NAME.test(v.file) || !['image', 'video', 'audio'].includes(v.kind)) fail('invalid_job');
55
+ const path = join(job, v.file);
56
+ if (relative(job, path) !== basename(path)) fail('invalid_job');
57
+ if (!existsSync(path) || !lstatSync(path).isFile()) fail('missing_input');
58
+ out[ref] = { ...v, path };
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Fotogramas de referencia: en un marco vertical fijo, o con la forma del original (lado mayor 1280). */
64
+ async function thumbnails(job, inputs, signal) {
65
+ const spec = readJson(join(job, 'thumbnails.json'), 10_000);
66
+ const count = Number(spec.count);
67
+ if (!Number.isInteger(count) || count < 1 || count > 6) fail('invalid_job');
68
+ const native = spec.native === true;
69
+ const ffmpeg = await ensureTool('ffmpeg');
70
+ const ffprobe = await ensureTool('ffprobe');
71
+ emit({ t: 'progress', stage: 'preparing', fraction: 0 });
72
+ const visual = Object.values(inputs)
73
+ .filter((i) => i.kind !== 'audio')
74
+ .sort((x, y) => x.file.localeCompare(y.file, 'en', { numeric: true }))
75
+ .slice(0, 6);
76
+ const total = visual.reduce((n, i) => n + (i.kind === 'video' ? count : 1), 0);
77
+ let made = 0;
78
+ for (const [n, input] of visual.entries()) {
79
+ const info = probe(ffprobe, input.path);
80
+ const times = input.kind === 'video' ? Array.from({ length: count }, (_, k) => ((k + 0.5) * Math.max(0.1, info.duration)) / count) : [0];
81
+ const w0 = native ? (input.w ?? info.width) : 0;
82
+ const h0 = native ? (input.h ?? info.height) : 0;
83
+ const k = native ? Math.min(1, 1280 / Math.max(w0, h0)) : 0;
84
+ const even = (x) => Math.max(2, Math.round((x * k) / 2) * 2);
85
+ const vf = native && w0 && h0 ? `scale=${even(w0)}:${even(h0)}` : 'scale=540:960:force_original_aspect_ratio=decrease,pad=540:960:(ow-iw)/2:(oh-ih)/2:color=0x111111';
86
+ for (const [j, t] of times.entries()) {
87
+ if (signal.aborted) fail('cancelled');
88
+ const file = `t${n + 1}_${j}.jpg`;
89
+ const r = spawnSync(ffmpeg, ['-nostdin', '-loglevel', 'error', '-y', ...(input.kind === 'video' ? ['-ss', (Math.round(t * 100) / 100).toFixed(2)] : []), '-i', input.path, '-frames:v', '1', '-vf', vf, '-q:v', native ? '3' : '4', join(job, file)], { encoding: 'utf8' });
90
+ if (r.status !== 0) fail('render_failed');
91
+ emit({ t: 'output', file });
92
+ made += 1;
93
+ emit({ t: 'progress', stage: 'rendering', fraction: Math.min(0.99, made / total) });
94
+ }
95
+ }
96
+ }
97
+
98
+ async function composition(job, inputs, signal, browser, workers) {
99
+ const spec = readJson(join(job, 'composition.json'), 2_000_000);
100
+ if (!spec || !Array.isArray(spec.scenes) || typeof spec.format !== 'string') fail('invalid_job');
101
+ for (const ref of JSON.stringify(spec).match(/asset_[A-Za-z0-9]{12,40}/g) ?? []) if (!inputs[ref]) fail('missing_input');
102
+ emit({ t: 'progress', stage: 'preparing', fraction: 0 });
103
+ const ffmpeg = await ensureTool('ffmpeg');
104
+ const ffprobe = await ensureTool('ffprobe');
105
+ mkdirSync(join(HOME, 'work'), { recursive: true });
106
+ const dir = mkdtempSync(join(HOME, 'work', 'creator-'));
107
+ const out = join(job, 'video.partial.mp4');
108
+ try {
109
+ try {
110
+ buildProject({ spec, media: inputs, dir, ffmpeg, ffprobe });
111
+ } catch (e) {
112
+ process.stderr.write(`${e.stack ?? e}\n`);
113
+ fail(/missing input/.test(e.message) ? 'missing_input' : 'invalid_job');
114
+ }
115
+ try {
116
+ const engineArgs = workers ? ['--workers', String(workers)] : [];
117
+ await renderProject(dir, out, { onProgress: (p) => emit({ t: 'progress', ...p }), signal, browser, engineArgs });
118
+ } catch (e) {
119
+ if (signal.aborted) fail('cancelled');
120
+ process.stderr.write(`${e.stack ?? e}\n`);
121
+ fail(/browser|chrome/i.test(e.message) ? 'browser_failed' : 'render_failed');
122
+ }
123
+ // Comprobado antes de darlo por bueno: dura lo que debe, tiene imagen y el tamaño del formato.
124
+ const info = probe(ffprobe, out);
125
+ if (!info.hasVideo || info.duration <= 0) fail('verify_failed');
126
+ emit({ t: 'verified', durationSec: +info.duration.toFixed(3), width: info.width, height: info.height, audio: info.hasAudio });
127
+ renameSync(out, join(job, 'video.mp4'));
128
+ } finally {
129
+ rmSync(dir, { recursive: true, force: true });
130
+ rmSync(out, { force: true });
131
+ }
132
+ }
133
+
134
+ export async function creatorJob(argv) {
135
+ const at = (k) => {
136
+ const i = argv.indexOf(k);
137
+ return i >= 0 ? argv[i + 1] : undefined;
138
+ };
139
+ const job = at('--job');
140
+ const browser = at('--browser');
141
+ const workers = at('--workers');
142
+ const ctrl = new AbortController();
143
+ const quit = () => ctrl.abort();
144
+ process.on('SIGTERM', quit);
145
+ process.on('SIGINT', quit);
146
+ const parent = process.ppid;
147
+ setInterval(() => process.ppid !== parent && quit(), 1000).unref();
148
+ try {
149
+ if (!job || !isAbsolute(job) || !existsSync(job)) fail('invalid_job');
150
+ if (browser && !isAbsolute(browser)) fail('invalid_job');
151
+ if (workers !== undefined && !/^([1-9]|1[0-6])$/.test(workers)) fail('invalid_job');
152
+ const inputs = readInputs(job);
153
+ if (existsSync(join(job, 'thumbnails.json'))) await thumbnails(job, inputs, ctrl.signal);
154
+ else await composition(job, inputs, ctrl.signal, browser, workers ? Number(workers) : null);
155
+ emit({ t: 'done' });
156
+ process.exit(0);
157
+ } catch (e) {
158
+ const code = e instanceof JobError ? e.code : ctrl.signal.aborted ? 'cancelled' : 'render_failed';
159
+ if (!(e instanceof JobError)) process.stderr.write(`${e?.stack ?? e}\n`);
160
+ emit({ t: 'error', code });
161
+ process.exit(code === 'cancelled' ? 2 : 1);
162
+ }
163
+ }
@@ -0,0 +1,22 @@
1
+ // Renderizar una composición de Ignix Creator con Ignix Editor (ADR-0023 E2): se genera el
2
+ // proyecto en una carpeta de trabajo, se renderiza y se borra la carpeta.
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { HOME, ensureTool } from '../binaries.mjs';
6
+ import { renderProject } from '../engine.mjs';
7
+ import { buildProject } from './build.mjs';
8
+ import { mkdirSync } from 'node:fs';
9
+
10
+ export async function renderComposition({ spec, media, out, keep = false, log = () => {} }) {
11
+ const ffmpeg = await ensureTool('ffmpeg', log);
12
+ const ffprobe = await ensureTool('ffprobe', log);
13
+ mkdirSync(join(HOME, 'work'), { recursive: true });
14
+ const dir = mkdtempSync(join(HOME, 'work', 'creator-'));
15
+ try {
16
+ const meta = buildProject({ spec, media, dir, ffmpeg, ffprobe });
17
+ const r = await renderProject(dir, out, { quiet: true, log });
18
+ return { ...meta, output: r.output, loudness: r.loudness, project: keep ? dir : null };
19
+ } finally {
20
+ if (!keep) rmSync(dir, { recursive: true, force: true });
21
+ }
22
+ }