@ignix-social/editor 0.1.3 → 0.1.5

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/src/prepare.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // con 4 decimales, 97/30 = 3.2333 daba el fotograma 96 en vez del 97);
4
4
  // · ignix-frame.js de esta versión del editor, siempre al día.
5
5
  // El proyecto del usuario no se toca.
6
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
6
+ import { copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
@@ -12,6 +12,17 @@ const SKIP = new Set(['.ignix-build', 'renders', 'snapshots', 'node_modules', '.
12
12
  // Vídeo y audio no se copian (pueden pesar gigas): enlace a la ruta absoluta del original.
13
13
  const MEDIA = /\.(mp4|mov|m4v|webm|mkv|avi|wav|mp3|m4a|aac|ogg|flac)$/i;
14
14
 
15
+ // macOS/Linux: enlace simbólico. Windows: los simbólicos piden permisos de administrador o el
16
+ // modo desarrollador, así que enlace duro (mismo disco) y, si no se puede, una copia.
17
+ export function linkMedia(target, to) {
18
+ if (process.platform !== 'win32') return symlinkSync(target, to);
19
+ try {
20
+ linkSync(target, to);
21
+ } catch {
22
+ copyFileSync(target, to);
23
+ }
24
+ }
25
+
15
26
  function mirror(src, dest) {
16
27
  mkdirSync(dest, { recursive: true });
17
28
  for (const name of readdirSync(src)) {
@@ -20,7 +31,7 @@ function mirror(src, dest) {
20
31
  const to = join(dest, name);
21
32
  const st = statSync(from);
22
33
  if (st.isDirectory()) mirror(from, to);
23
- else if (MEDIA.test(name)) symlinkSync(realpathSync(from), to);
34
+ else if (MEDIA.test(name)) linkMedia(realpathSync(from), to);
24
35
  else copyFileSync(from, to);
25
36
  }
26
37
  }
@@ -3,7 +3,7 @@
3
3
  // el guion en las palabras y whisper en los tiempos: whisper confunde palabras («ADM», «tiene»)
4
4
  // y a veces se come el final de una frase.
5
5
  import { spawnSync } from 'node:child_process';
6
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
6
+ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
 
@@ -69,3 +69,67 @@ export function alignToScript(script, tokens, end) {
69
69
  }
70
70
  return out.map((w) => ({ t: w.t, s: +w.s.toFixed(3), e: +w.e.toFixed(3) }));
71
71
  }
72
+
73
+ /**
74
+ * Transcripción completa para Creator: idioma detectado (si no se da), cada palabra con su
75
+ * confianza (media de sus tokens) y las frases (cortadas en un punto o en una pausa).
76
+ */
77
+ export function transcribeDetailed({ ffmpeg, whisper, model, input, language = null, threads = 8, maxWords = 6000, maxSegments = 800 }) {
78
+ const tmp = mkdtempSync(join(tmpdir(), 'ignix-editor-asr-'));
79
+ try {
80
+ const wav = join(tmp, 'audio.wav');
81
+ const a = spawnSync(ffmpeg, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-i', input, '-vn', '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], { encoding: 'utf8' });
82
+ if (a.status !== 0) throw new Error(`Could not read the audio of ${input}: ${a.stderr.slice(-300)}`);
83
+ const duration = statSync(wav).size > 44 ? (statSync(wav).size - 44) / 32000 : 0;
84
+ const out = join(tmp, 'out');
85
+ const args = ['-m', model, '-l', language || 'auto', '-ml', '1', '-sow', '-ojf', '-of', out, '-t', String(threads), '-f', wav];
86
+ const run = (extra) => spawnSync(whisper, [...extra, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
87
+ const intelMac = process.platform === 'darwin' && process.arch === 'x64';
88
+ let w = run(intelMac ? ['-ng'] : []);
89
+ let device = intelMac ? 'cpu' : 'gpu';
90
+ if (w.status !== 0 && !intelMac) {
91
+ w = run(['-ng']);
92
+ device = 'cpu';
93
+ }
94
+ if (w.status !== 0) throw new Error(`whisper failed: ${(w.stderr || '').slice(-400)}`);
95
+ const j = JSON.parse(readFileSync(`${out}.json`, 'utf8'));
96
+ const words = [];
97
+ let aside = false; // dentro de una acotación: [MÚSICA], (upbeat music)…
98
+ for (const seg of j.transcription ?? []) {
99
+ const text = seg.text.trim();
100
+ if (!text) continue;
101
+ if (aside || /^[[(]/.test(text)) {
102
+ aside = !/[\])]$/.test(text);
103
+ continue;
104
+ }
105
+ const toks = (seg.tokens ?? []).filter((t) => !/^\[_|^<\|/.test(t.text) && t.text.trim());
106
+ const p = toks.length ? toks.reduce((s, t) => s + (t.p ?? 0), 0) / toks.length : 0;
107
+ words.push({ text: text.slice(0, 60), start: seg.offsets.from / 1000, end: seg.offsets.to / 1000, p: +Math.min(1, Math.max(0, p)).toFixed(3) });
108
+ if (words.length >= maxWords) break;
109
+ }
110
+ // Frases: hasta un punto final o una pausa de más de 0,7 s.
111
+ const segments = [];
112
+ let cur = null;
113
+ for (const wd of words) {
114
+ if (cur && wd.start - cur.end > 0.7) {
115
+ segments.push(cur);
116
+ cur = null;
117
+ }
118
+ cur = cur ? { start: cur.start, end: wd.end, text: `${cur.text} ${wd.text}` } : { start: wd.start, end: wd.end, text: wd.text };
119
+ if (/[.!?…。]$/.test(wd.text)) {
120
+ segments.push(cur);
121
+ cur = null;
122
+ }
123
+ }
124
+ if (cur) segments.push(cur);
125
+ return {
126
+ language: j.result?.language ?? language ?? null,
127
+ duration: +duration.toFixed(3),
128
+ device,
129
+ words,
130
+ segments: segments.slice(0, maxSegments).map((s) => ({ ...s, text: s.text.slice(0, 500) })),
131
+ };
132
+ } finally {
133
+ rmSync(tmp, { recursive: true, force: true });
134
+ }
135
+ }
@@ -7,6 +7,7 @@
7
7
  "patches": [
8
8
  "no-telemetry",
9
9
  "no-auto-update",
10
+ "mf-encoder",
10
11
  "no-desktop-promo"
11
12
  ]
12
13
  }
@@ -7405,14 +7405,16 @@ var GPU_ENCODER_CANDIDATES = [
7405
7405
  "videotoolbox",
7406
7406
  "vaapi",
7407
7407
  "qsv",
7408
- "amf"
7408
+ "amf",
7409
+ "mf" // ignix-editor: Media Foundation (Windows)
7409
7410
  ];
7410
7411
  var H264_ENCODER_BY_GPU = {
7411
7412
  nvenc: "h264_nvenc",
7412
7413
  videotoolbox: "h264_videotoolbox",
7413
7414
  vaapi: "h264_vaapi",
7414
7415
  qsv: "h264_qsv",
7415
- amf: "h264_amf"
7416
+ amf: "h264_amf",
7417
+ mf: "h264_mf"
7416
7418
  };
7417
7419
  var GPU_PROBE_TIMEOUT_MS = 2e3;
7418
7420
  var GPU_PROBE_KILL_GRACE_MS = 1e3;
@@ -7476,6 +7478,8 @@ function getGpuEncoderName(encoder, codec) {
7476
7478
  return codec === "h264" ? "h264_qsv" : "hevc_qsv";
7477
7479
  case "amf":
7478
7480
  return codec === "h264" ? "h264_amf" : "hevc_amf";
7481
+ case "mf":
7482
+ return codec === "h264" ? "h264_mf" : "hevc_mf";
7479
7483
  default:
7480
7484
  return codec === "h264" ? "libx264" : "libx265";
7481
7485
  }
@@ -8595,6 +8599,11 @@ function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = null) {
8595
8599
  if (bitrate) args.push("-b:v", bitrate);
8596
8600
  else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
8597
8601
  break;
8602
+ case "mf": {
8603
+ const vt = buildVideoToolboxRateControlArgs({ bitrate, width: options.width, height: options.height, fps: fpsToNumber(fps), quality });
8604
+ args.push("-b:v", vt[1], "-rate_control", "u_vbr");
8605
+ break;
8606
+ }
8598
8607
  }
8599
8608
  if (codec === "h264" && (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi" || gpuEncoder === "amf")) {
8600
8609
  args.push("-bf", "0");
@@ -9248,6 +9257,11 @@ function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
9248
9257
  if (bitrate) args.push("-b:v", bitrate);
9249
9258
  else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
9250
9259
  break;
9260
+ case "mf": {
9261
+ const vt = buildVideoToolboxRateControlArgs({ bitrate, width: options.width, height: options.height, fps: fpsToNumber(fps), quality });
9262
+ args.push("-b:v", vt[1], "-rate_control", "u_vbr");
9263
+ break;
9264
+ }
9251
9265
  }
9252
9266
  if (codec === "h264" && (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi" || gpuEncoder === "amf")) {
9253
9267
  args.push("-bf", "0");
@@ -195,7 +195,7 @@ var H264EncoderUnavailableError = class extends Error {
195
195
  function resolveH264EncoderMode(ffmpegEncodersOutput, gpuRequested) {
196
196
  if (gpuRequested) return "gpu";
197
197
  if (/\blibx264\b/.test(ffmpegEncodersOutput)) return "software";
198
- if (/\bh264_videotoolbox\b/.test(ffmpegEncodersOutput)) return "gpu";
198
+ if (/\bh264_(videotoolbox|mf)\b/.test(ffmpegEncodersOutput)) return "gpu";
199
199
  throw new H264EncoderUnavailableError();
200
200
  }
201
201
  function detectH264EncoderMode(ffmpegPath, gpuRequested) {
@@ -1770,7 +1770,7 @@ async function executeLocalRender(projectDir, outputPath, options, cancellation)
1770
1770
  }
1771
1771
  if (encoderMode === "gpu") {
1772
1772
  console.warn(
1773
- c.warn(" FFmpeg does not include libx264; falling back to VideoToolbox H.264 encoding.")
1773
+ c.warn(" FFmpeg does not include libx264; using the system's hardware H.264 encoder (VideoToolbox on macOS, Media Foundation on Windows).")
1774
1774
  );
1775
1775
  options = { ...options, gpu: true };
1776
1776
  }
@@ -1,60 +0,0 @@
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
- }