@ignix-social/editor 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ignix-social/editor",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Ignix Editor: render HTML video compositions (reels, edits, clips) on your own computer. Based on HyperFrames.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,6 +14,7 @@ Commands (`npx @ignix-social/editor <cmd>` if `ignix-editor` is not on the PATH)
14
14
  | `ignix-editor doctor` | Node 22+ and the H.264 encoder; downloads its own ffmpeg and whisper the first time. Run it first. |
15
15
  | `ignix-editor init <name>` | Creates `~/ignix-videos/<name>` with a working skeleton, GSAP, local fonts and `ignix-frame.js`. |
16
16
  | `ignix-editor transcribe <file> --lang es [--script "…"] -o words.json` | Words with start/end seconds, on this machine. |
17
+ | `ignix-editor faces <video> -o work/faces.json` | Where the face is over time (macOS, Apple Vision): a smoothed path `{t, cx, cy, size}` (0–1, top-left origin) to frame the person. |
17
18
  | `ignix-editor check [dir]` | HTML well-formed, no network assets, media rules, layout/contrast/motion audit. |
18
19
  | `ignix-editor snapshot [dir] --at 0,1.5,3` | PNG frames into `<dir>/snapshots/` to LOOK at. `[dir]` defaults to the current folder. |
19
20
  | `ignix-editor render [dir] [-o out.mp4]` | Renders to `<dir>/renders/<dir>.mp4`, audio normalised to −14 LUFS. |
@@ -94,16 +95,26 @@ Decide the zones once per video (e.g. header y 270–440, stage y 460–1300, ca
94
95
  - Transcribe on the machine: `ignix-editor transcribe assets/take.mp4 --lang es -o work/words.json`. Show the user the text and fix obvious mishearings (names, brands) without rewriting what they said.
95
96
  - Cuts: one `<video>` per kept range, edge to edge (`data-start` of the next = previous start + duration). Build a map from source time to edited time and pass every word time, zoom, overlay and sound through it.
96
97
  - Voice clean-up before building: `ignix-editor ffmpeg -i take.mp4 -vn -ac 1 -ar 48000 -af "highpass=f=80,acompressor=threshold=-22dB:ratio=3:attack=5:release=120:makeup=2" assets/voice.wav`, then one `<audio>` per kept range with `data-media-start` and 8 ms fade lanes, and the videos `muted`.
97
- - Reframing a horizontal source to 9:16 and following the face: a fixed card/frame element with `overflow:hidden`; inside it a wrapper sized like the card, `transform-origin:0 0`, moved with `translate(-x·z, -y·z) scale(z)` per frame from a face path (one point per frame, smoothed). Zooms multiply `z`.
98
+ - Reframing a horizontal source to 9:16 and following the face: `ignix-editor faces assets/take.mp4 -o work/faces.json` gives a smoothed path (`cx`, `cy` = centre of the face, `size` = its height, all 0–1). Map it through your cut map like the words. Then a fixed card/frame element with `overflow:hidden`; inside it a wrapper sized like the card, `transform-origin:0 0`, moved with `translate(-x·z, -y·z) scale(z)` per frame from that path (interpolate between samples). Zooms multiply `z`.
99
+ - The cover is the first frame: the face AND the hook. If the person is not in shot yet in the first second (sitting down, walking in), use a still of their face from later (`ignix-editor ffmpeg -ss <t> -i take.mp4 -frames:v 1 assets/cover.jpg`) under the hook for ~1 s, with a slow push, then cut to the footage.
100
+ - The same file in two places at once (main shot + a small picture-in-picture window): give the second one its own copy (`cp take.mp4 take-pip.mp4`); `check` flags two simultaneous clips of one file.
98
101
  - Blurred background of the same footage: a second set of the same `<video>` elements behind, in a wrapper with `filter: blur(60px) brightness(.32)` and `transform: scale(1.25)`. (Heavy without a GPU: on a machine without one, prefer a pre-blurred copy made with ffmpeg.)
99
102
  - Long sources (lives, podcasts) are fine: the editor seeks straight to `data-media-start`.
100
103
 
104
+ ### The bundled ffmpeg (LGPL)
105
+
106
+ `ignix-editor ffmpeg` is a licence-clean build: everything you need for audio and simple video prep, but no GPL parts.
107
+ - Encode H.264 with `-c:v h264_videotoolbox -b:v 10M -allow_sw 1` (there is no `libx264` and no `-crf`). AAC audio: `-c:a aac -b:a 192k`.
108
+ - Present: `scale`, `crop`, `pad`, `overlay`, `trim`/`atrim`, `concat`, `gblur`, `avgblur`, `curves`, `lutyuv`, `colorbalance`, `colorchannelmixer`, `fade`, `afade`, `highpass`, `lowpass`, `acompressor`, `loudnorm`, `ebur128`, `silencedetect`, `amix`, `volume`.
109
+ - Missing (GPL): `eq` (use `curves`/`lutyuv` for contrast and warmth), `boxblur` (use `gblur`), `delogo` (cover a burned-in label with a cropped, `gblur`red patch of the same frame placed by `overlay`, or with a card in the HTML).
110
+ - Grading and blur are usually easier in the HTML anyway (CSS `filter: contrast() saturate() blur()`).
111
+
101
112
  ## 4. Audio and captions for reels from scratch
102
113
 
103
114
  - One `<audio>` per voice line (Ignix `generate_voiceover`), placed back to back with ~0.25 s of air: the scenes follow the voice.
104
115
  - Word times: `ignix-editor transcribe assets/vo/l1.wav --lang es --script "<the exact line>" -o work/l1.json`. With `--script` the words are the script's and the times are whisper's (whisper mishears and sometimes drops the last words; the script wins).
105
116
  - Anchor every gesture (click, pop, highlight, cut) to a word time: `cue = line.start + word.s`. Sound effects start on their gesture.
106
- - Captions: groups of 2–3 words that never cross the end of a line; current word in the brand accent; font size shrinks for long groups so they fit the ~820 px column.
117
+ - Captions: groups of 2–3 words that never cross the end of a line; current word in the brand accent; font size shrinks for long groups so they fit the ~820 px column. Keep a real gap between words (`gap: 0 0.3em` on the flex row): if the active word grows (`scale`), it eats a smaller gap and «EDITORES DE» reads «EDITORESDE» — grow it at most ~5 %, or only change its colour.
107
118
  - The final mix is normalised to −14 LUFS by `render` (`--lufs` to change, `--no-normalize` to skip).
108
119
 
109
120
  ## 5. Generate the HTML from data
@@ -124,7 +135,7 @@ Re-run it whenever the voice or the cuts change; the animation code reads `windo
124
135
  - Emojis render with the operating system's emoji font (different design on other machines).
125
136
  - An element that ends past `data-duration` is cut; a timeline shorter than it holds the last frame.
126
137
  - `check` also reads HTML comments: never leave example `<video>`/`<audio>` tags inside `<!-- -->` (it reports their files as missing).
127
- - `check` reports layout overlaps: real ones (a zoomed window under the header) must be fixed; intentional ones get `data-layout-allow-overlap` / `data-layout-allow-overflow` on the element.
138
+ - `check` reports layout overlaps: real ones (a zoomed window under the header) must be fixed; intentional ones get `data-layout-allow-overlap` / `data-layout-allow-overflow` on the element. A title split in lines (one element per line) is reported line against line: put `data-layout-allow-overlap` on EACH line, not only on the container.
128
139
 
129
140
  ## 7. Review before showing anything
130
141
 
package/src/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // debajo con nuestro entorno: sin telemetría, sin actualizaciones solas, con nuestros binarios.
3
3
  // Los mensajes van en inglés, como el manual y la skill: los lee sobre todo el agente.
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
5
6
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
6
7
  import { createRequire } from 'node:module';
7
8
  import { homedir } from 'node:os';
@@ -231,6 +232,53 @@ const commands = {
231
232
  } else console.log(json);
232
233
  },
233
234
 
235
+ // Dónde está la cara a lo largo del vídeo, para encuadrar en vertical siguiéndola.
236
+ // macOS: Apple Vision desde un Swift propio (src/native/faces.swift), compilado una vez.
237
+ async faces(argv) {
238
+ const { pos, opts } = parseArgs(argv, { every: 'str', output: 'str', smooth: 'str' });
239
+ const input = pos[0] && resolve(pos[0]);
240
+ if (!input || !existsSync(input)) fail('Usage: ignix-editor faces <video> [--every 0.2] [--smooth 1] [-o work/faces.json]');
241
+ if (process.platform !== 'darwin') fail('faces needs macOS for now. Elsewhere, frame the person by hand from stills (snapshot) or keep a centred crop.');
242
+ const src = join(PKG_DIR, 'src', 'native', 'faces.swift');
243
+ const hash = createHash('sha256').update(readFileSync(src)).digest('hex').slice(0, 12);
244
+ const bin = join(HOME, 'bin', PLATFORM, `faces-${hash}`);
245
+ if (!existsSync(bin)) {
246
+ mkdirSync(dirname(bin), { recursive: true });
247
+ log('Compiling the face tracker (first time only)…');
248
+ const c = spawnSync('xcrun', ['swiftc', '-O', '-parse-as-library', src, '-o', bin], { encoding: 'utf8' });
249
+ if (c.status !== 0) fail(`Could not compile the face tracker. It needs Apple's Command Line Tools (xcode-select --install).\n${(c.stderr || c.error?.message || '').slice(-400)}`);
250
+ }
251
+ const every = Math.max(0.05, Number(opts.every ?? 0.2));
252
+ const r = spawnSync(bin, [input, String(every)], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
253
+ if (r.status !== 0) fail(`Face tracking failed: ${(r.stderr || '').slice(-400)}`);
254
+ const raw = JSON.parse(r.stdout);
255
+ const S = raw.samples;
256
+ // Huecos sin cara: la posición conocida más cercana; si no hay ninguna, el centro.
257
+ const known = S.map((x, i) => (x.w ? i : -1)).filter((i) => i >= 0);
258
+ const filled = S.map((x, i) => {
259
+ if (x.w) return x;
260
+ if (!known.length) return { t: x.t, x: 0.4, y: 0.3, w: 0.2, h: 0.2 };
261
+ const k = known.reduce((a, b) => (Math.abs(b - i) < Math.abs(a - i) ? b : a));
262
+ return { ...S[k], t: x.t };
263
+ });
264
+ // Suavizado: media móvil de ~1 s (configurable) sobre el centro y el tamaño.
265
+ const half = Math.max(1, Math.round(Number(opts.smooth ?? 1) / every / 2));
266
+ const avg = (i, f) => {
267
+ let sum = 0, n = 0;
268
+ for (let j = Math.max(0, i - half); j <= Math.min(filled.length - 1, i + half); j++) (sum += f(filled[j])), n++;
269
+ return +(sum / n).toFixed(4);
270
+ };
271
+ const path = filled.map((x, i) => ({ t: x.t, cx: avg(i, (y) => y.x + y.w / 2), cy: avg(i, (y) => y.y + y.h / 2), size: avg(i, (y) => y.h) }));
272
+ const out = { source: input, width: raw.width, height: raw.height, duration: raw.duration, every, found: known.length, samples: S.length, path };
273
+ const json = JSON.stringify(out);
274
+ log(`✓ Face found in ${known.length}/${S.length} samples${known.length < S.length ? ' (gaps filled with the nearest position)' : ''}`);
275
+ if (opts.output) {
276
+ mkdirSync(dirname(resolve(opts.output)), { recursive: true });
277
+ writeFileSync(resolve(opts.output), json);
278
+ console.log(resolve(opts.output));
279
+ } else console.log(json);
280
+ },
281
+
234
282
  async ffmpeg(argv) {
235
283
  const r = spawnSync(await ensureTool('ffmpeg', log), argv, { stdio: 'inherit' });
236
284
  process.exit(r.status ?? 1);
@@ -264,6 +312,7 @@ const commands = {
264
312
  ignix-editor render [folder] [-o out.mp4] [--lufs -14] [--no-normalize]
265
313
  ignix-editor preview [folder] live preview in the browser
266
314
  ignix-editor transcribe <file> [--lang es] [--script "text"] [-o words.json]
315
+ ignix-editor faces <video> [--every 0.2] [-o faces.json] where the face is, smoothed (macOS)
267
316
  ignix-editor ffmpeg|ffprobe <args> Ignix Editor's own ffmpeg
268
317
  ignix-editor skills add installs the skill for Claude
269
318
 
@@ -0,0 +1,60 @@
1
+ // faces — dónde está la cara en cada momento de un vídeo (Apple Vision, macOS 13+).
2
+ // Uso: faces <vídeo> <cada_segundos> → JSON por stdout:
3
+ // { "duration": s, "width": px, "height": px, "samples": [ { "t": s, "x":…, "y":…, "w":…, "h":… } | { "t": s } ] }
4
+ // x, y, w, h normalizados 0–1 con origen ARRIBA a la izquierda (como CSS). Sin cara → solo "t".
5
+ import AVFoundation
6
+ import Foundation
7
+ import Vision
8
+
9
+ @main
10
+ struct Faces {
11
+ static func main() async {
12
+ let args = CommandLine.arguments
13
+ guard args.count >= 2 else {
14
+ FileHandle.standardError.write("usage: faces <video> [every_seconds]\n".data(using: .utf8)!)
15
+ exit(2)
16
+ }
17
+ let url = URL(fileURLWithPath: args[1])
18
+ let every = args.count >= 3 ? (Double(args[2]) ?? 0.2) : 0.2
19
+ let asset = AVURLAsset(url: url)
20
+ do {
21
+ let duration = try await asset.load(.duration).seconds
22
+ guard let track = try await asset.loadTracks(withMediaType: .video).first else {
23
+ FileHandle.standardError.write("no video track\n".data(using: .utf8)!)
24
+ exit(1)
25
+ }
26
+ let size = try await track.load(.naturalSize).applying(try await track.load(.preferredTransform))
27
+ let gen = AVAssetImageGenerator(asset: asset)
28
+ gen.appliesPreferredTrackTransform = true
29
+ gen.requestedTimeToleranceBefore = CMTime(value: 1, timescale: 30)
30
+ gen.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
31
+ gen.maximumSize = CGSize(width: 960, height: 960)
32
+
33
+ var samples: [[String: Double]] = []
34
+ var t = 0.0
35
+ while t < duration {
36
+ var sample: [String: Double] = ["t": (t * 1000).rounded() / 1000]
37
+ if let (image, _) = try? await gen.image(at: CMTime(seconds: t, preferredTimescale: 600)) {
38
+ let req = VNDetectFaceRectanglesRequest()
39
+ try? VNImageRequestHandler(cgImage: image, options: [:]).perform([req])
40
+ // La cara más grande: la de quien habla a cámara.
41
+ if let f = (req.results ?? []).max(by: { $0.boundingBox.width * $0.boundingBox.height < $1.boundingBox.width * $1.boundingBox.height }) {
42
+ let b = f.boundingBox // Vision: origen ABAJO a la izquierda
43
+ sample["x"] = b.minX
44
+ sample["y"] = 1 - b.maxY
45
+ sample["w"] = b.width
46
+ sample["h"] = b.height
47
+ }
48
+ }
49
+ samples.append(sample)
50
+ t += every
51
+ }
52
+ let out: [String: Any] = ["duration": duration, "width": abs(size.width), "height": abs(size.height), "samples": samples]
53
+ let data = try JSONSerialization.data(withJSONObject: out)
54
+ FileHandle.standardOutput.write(data)
55
+ } catch {
56
+ FileHandle.standardError.write("faces failed: \(error)\n".data(using: .utf8)!)
57
+ exit(1)
58
+ }
59
+ }
60
+ }