@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 +1 -1
- package/skills/ignix-editor/SKILL.md +14 -3
- package/src/cli.mjs +49 -0
- package/src/native/faces.swift +60 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
+
}
|