@telepat/rilo 0.1.0 → 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 @@
1
+ :root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:15px;--bg: #0d1117;--bg-card: #161b22;--bg-elevated: #1c2330;--border: #30363d;--border-subtle: #21262d;--text: #e6edf3;--text-muted: #7d8590;--text-subtle: #484f58;--accent: #ff4e00;--accent-hover: #ff7a00;--green: #3fb950;--yellow: #d29922;--orange: #db6d28;--red: #f85149;--flame: #ff4e00;--flame-mid: #ff7a00;--flame-yellow: #ffcc00;--sidebar-w: 260px;background:var(--bg);color:var(--text)}*,*:before,*:after{box-sizing:border-box}body{margin:0;height:100dvh;overflow:hidden;background:var(--bg)}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer}p,h2,h3{margin:0}textarea,input{border:1px solid var(--border);border-radius:8px;padding:.55rem .75rem;background:var(--bg);color:var(--text);width:100%;resize:vertical;transition:border-color .15s}textarea:focus,input:focus{outline:none;border-color:var(--accent)}.app-shell{display:grid;grid-template-columns:var(--sidebar-w) 1fr;height:100dvh;overflow:hidden}.sidebar{display:flex;flex-direction:column;border-right:1px solid var(--border);background:var(--bg-card);overflow:hidden}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:1.1rem 1rem;border-bottom:1px solid var(--border-subtle);flex-shrink:0}.brand-wordmark{font-size:1.1rem;font-weight:800;letter-spacing:.02em;text-transform:uppercase;color:var(--text);flex-shrink:0}.sidebar-section{flex:1;display:flex;flex-direction:column;gap:.65rem;padding:.9rem;overflow:hidden}.sidebar-row{display:flex;align-items:center;justify-content:space-between}.sidebar-section-title{font-size:.78rem;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted)}.project-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;overflow-y:auto;flex:1;min-height:0}.project-item{width:100%;text-align:left;background:transparent;border:1px solid transparent;border-radius:6px;padding:.55rem .75rem;font-size:.88rem;color:var(--text-muted);transition:background .1s,color .1s,border-color .1s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.project-item:hover{background:var(--bg-elevated);color:var(--text)}.project-item-active{background:var(--bg-elevated);border-color:var(--border);border-left-color:var(--accent);color:var(--text);font-weight:500}.sidebar-footer{padding:.6rem .75rem;border-top:1px solid var(--border-subtle);flex-shrink:0}.main-area{display:flex;flex-direction:column;overflow:hidden}.project-header{flex-shrink:0;background:var(--bg-card);border-bottom:1px solid var(--border);padding:1rem 1.5rem 0;display:flex;flex-direction:column;gap:.75rem;z-index:10}.project-header-top{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap}.project-title-row{display:flex;align-items:center;gap:.6rem;min-width:0}.project-name{font-size:1.05rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.header-actions{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap;flex-shrink:0}.step-dots{display:flex;gap:0;align-items:center;overflow-x:auto;padding-bottom:.15rem}.dot{display:flex;align-items:center;gap:.35rem;font-size:.75rem;padding:.2rem .65rem;border-radius:20px;transition:background .2s;white-space:nowrap;flex-shrink:0}.dot:before{content:"";width:7px;height:7px;border-radius:50%;flex-shrink:0}.dot-done{color:var(--green)}.dot-done:before{background:var(--green)}.dot-running{color:var(--yellow);animation:pulse 1.2s ease-in-out infinite}.dot-running:before{background:var(--yellow)}.dot-idle{color:var(--text-muted)}.dot-idle:before{background:var(--border)}.dot-label{font-weight:500}@keyframes pulse{0%,to{opacity:1}50%{opacity:.45}}.tab-spacer{flex:1}.analytics-pane{gap:1.25rem}.analytics-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem}@media(max-width:800px){.analytics-summary{grid-template-columns:repeat(2,1fr)}}.analytics-stat-card{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:10px;padding:1.2rem 1.4rem;display:flex;flex-direction:column;gap:.4rem}.stat-value{font-size:1.5rem;font-weight:600;color:var(--text);line-height:1}.stat-label{font-size:.78rem;color:var(--text-muted)}.stat-ok{color:var(--green)}.stat-fail{color:var(--red)}.analytics-empty{margin-top:.25rem}.analytics-error{color:var(--red)}.analytics-runs{display:flex;flex-direction:column;gap:.35rem}.analytics-runs-title{font-size:.78rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem}.run-row{background:var(--bg-card);border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden}.run-row-header{display:flex;align-items:center;gap:.75rem;width:100%;background:transparent;border:none;padding:.8rem 1rem;text-align:left;cursor:pointer;transition:background .12s}.run-row-header:hover{background:var(--bg-elevated)}.run-badge{font-size:.72rem;font-weight:600;padding:.15em .55em;border-radius:6px;flex-shrink:0}.run-badge-ok{background:color-mix(in srgb,var(--green) 15%,transparent);color:var(--green)}.run-badge-fail{background:color-mix(in srgb,var(--red) 15%,transparent);color:var(--red)}.run-badge-running{background:color-mix(in srgb,var(--yellow) 15%,transparent);color:var(--yellow)}.run-row-date{font-size:.82rem;color:var(--text);flex:1}.run-row-meta{font-size:.82rem;color:var(--text-muted);flex-shrink:0}.run-row-cost{min-width:5rem;text-align:right}.run-row-chevron{font-size:.65rem;color:var(--text-subtle);flex-shrink:0}.run-stages{border-top:1px solid var(--border-subtle);padding:.75rem 1rem 1rem}.stages-table{width:100%;border-collapse:collapse;font-size:.82rem}.stages-table th{text-align:left;color:var(--text-muted);font-weight:500;padding:.2rem .6rem .4rem;border-bottom:1px solid var(--border-subtle)}.stages-table td{padding:.3rem .6rem;color:var(--text)}.stage-name-cell{display:flex;align-items:center;gap:.45rem}.stage-status-cell{color:var(--text-muted)}.stage-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block}.stage-dot-ok{background:var(--green)}.stage-dot-reused{background:var(--accent)}.stage-dot-fail{background:var(--red)}.stage-dot-running{background:var(--yellow)}.stage-dot-idle{background:var(--border)}.stage-row-pending td{color:var(--text-subtle)}.tab-bar{display:flex;gap:0;border-bottom:none;margin:0 -1.5rem;padding:0 1.5rem;overflow-x:auto}.tab{background:transparent;border:none;border-bottom:2px solid transparent;border-radius:0;padding:.7rem 1rem;font-size:.88rem;font-weight:500;color:var(--text-muted);transition:color .15s,border-color .15s;white-space:nowrap;flex-shrink:0;display:flex;align-items:center;gap:.4rem}.tab:hover{color:var(--text)}.tab-active{color:var(--text);border-bottom-color:var(--accent)}.tab-count{font-size:.72rem;background:var(--bg-elevated);border:1px solid var(--border-subtle);border-radius:10px;padding:.05em .45em;color:var(--text-muted);font-weight:500;min-width:1.4em;text-align:center}.tab-content{flex:1;overflow-y:auto;padding:1.5rem}.tab-pane{display:flex;flex-direction:column;gap:2rem}.config-inner-tab-bar{display:flex;gap:0;border-bottom:1px solid var(--border-subtle);margin-bottom:1.5rem;overflow-x:auto}.config-inner-tab{background:transparent;border:none;border-bottom:2px solid transparent;border-radius:0;padding:.45rem .85rem;font-size:.82rem;font-weight:500;color:var(--text-muted);cursor:pointer;transition:color .15s,border-color .15s;white-space:nowrap;margin-bottom:-1px}.config-inner-tab:hover{color:var(--text)}.config-inner-tab-active{color:var(--text);border-bottom-color:var(--accent)}.btn{background:var(--bg-elevated);border:1px solid var(--border);border-radius:6px;padding:.45rem .9rem;font-size:.88rem;font-weight:500;color:var(--text);transition:background .15s,border-color .15s,opacity .15s;display:inline-flex;align-items:center;gap:.3rem;white-space:nowrap}.btn:hover:not(:disabled){background:#2d3748;border-color:#4a5568}.btn:disabled{opacity:.45;cursor:default}.btn-sm{padding:.3rem .65rem;font-size:.8rem}.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn-primary:hover:not(:disabled){background:var(--accent-hover);border-color:var(--accent-hover)}.btn-secondary{background:color-mix(in srgb,var(--accent) 12%,var(--bg-elevated));border-color:color-mix(in srgb,var(--accent) 35%,transparent);color:var(--accent-hover)}.btn-secondary:hover:not(:disabled){background:color-mix(in srgb,var(--accent) 22%,var(--bg-elevated))}.btn-ghost{background:transparent;border-color:transparent}.btn-ghost:hover:not(:disabled){background:var(--bg-elevated);border-color:var(--border)}.btn-danger{color:var(--red)}.btn-danger:hover:not(:disabled){border-color:var(--red)}.full-width{width:100%;justify-content:center}.badge{font-size:.72rem;font-weight:600;padding:.15em .55em;border-radius:10px;border:1px solid currentColor;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0}.badge-ok{color:var(--green)}.badge-running{color:var(--yellow);animation:pulse 1.2s ease-in-out infinite}.badge-pending{color:var(--orange)}.badge-fail{color:var(--red)}.badge-paused{color:var(--yellow)}.pause-banner{font-size:.82rem;color:var(--yellow);background:color-mix(in srgb,var(--yellow) 10%,transparent);border:1px solid color-mix(in srgb,var(--yellow) 30%,transparent);border-radius:6px;padding:.5rem .75rem;margin-bottom:.5rem}.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}@keyframes toast-in{0%{opacity:0;transform:translateY(.75rem)}to{opacity:1;transform:translateY(0)}}.toast-popup{position:fixed;bottom:1.25rem;right:1.25rem;z-index:200;display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-width:240px;max-width:400px;padding:.65rem .9rem;border-radius:10px;font-size:.84rem;background:var(--bg-elevated);border:1px solid var(--border);box-shadow:0 8px 32px #00000059;animation:toast-in .18s ease-out}.toast-ok{border-left:3px solid var(--green)}.toast-error{border-left:3px solid var(--red)}.toast-close{background:transparent;border:none;padding:0 .2rem;color:var(--text-muted);font-size:.8rem;flex-shrink:0;cursor:pointer}.toast-close:hover{color:var(--text)}.modal-overlay{position:fixed;inset:0;background:#000000a6;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:100;padding:1rem}.modal-panel{background:var(--bg-card);border:1px solid var(--border);border-radius:14px;width:100%;max-width:540px;max-height:90dvh;overflow-y:auto;box-shadow:0 24px 64px #00000080;outline:none}.modal-header{display:flex;align-items:center;justify-content:space-between;padding:1.15rem 1.5rem;border-bottom:1px solid var(--border-subtle)}.modal-title{font-size:1rem;font-weight:600}.modal-close{background:transparent;border:none;font-size:1rem;color:var(--text-muted);padding:.15rem .35rem;border-radius:4px}.modal-close:hover{color:var(--text);background:var(--bg-elevated)}.modal-body{padding:1.5rem}.modal-form{display:flex;flex-direction:column;gap:1rem}.modal-actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.25rem}.field{display:flex;flex-direction:column;gap:.5rem}.field-label{font-size:.82rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em}.field-hint{font-size:.78rem;color:var(--text-muted);font-weight:400;text-transform:none;letter-spacing:0}.editor-grid{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr))}.editor-grid .full-width{grid-column:1 / -1}.save-row{display:flex;align-items:center;gap:.75rem}.output-actions-row{margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap}.config-grid{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.config-item{border:1px solid var(--border-subtle);border-radius:10px;background:var(--bg-card);padding:.7rem .8rem;display:flex;flex-direction:column;gap:.3rem}.config-key{font-size:.74rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;font-weight:600}.config-value{font-size:.95rem;color:var(--text);font-weight:500;word-break:break-word}.config-json{margin:0;border:1px solid var(--border-subtle);border-radius:10px;background:var(--bg-card);padding:.85rem;overflow:auto;font-size:.8rem;line-height:1.45;color:var(--text)}.config-form{display:flex;flex-direction:column;gap:1.5rem;margin-bottom:2rem}.config-form-fields{display:flex;flex-direction:column;gap:1rem}.config-group{border:1px solid var(--border);border-radius:10px;background:color-mix(in srgb,var(--bg-elevated) 65%,transparent);padding:1rem 1.15rem}.config-group-header{margin-bottom:.9rem}.config-group-title{margin:0;font-size:.86rem;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:var(--text)}.config-group-note{margin:.2rem 0 0}.config-group-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.config-form-field{display:flex;flex-direction:column;gap:.35rem}.config-form-field:has(input[type=checkbox]){flex-direction:row;align-items:center;gap:.5rem}.config-form-field:has(input[type=checkbox]) input[type=checkbox]{width:1rem;height:1rem;flex-shrink:0;cursor:pointer;order:-1}.config-form-field--readonly{justify-content:flex-end;padding:.4rem 0 .1rem}.config-form-label{font-size:.74rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;font-weight:600}.config-form-optional{font-weight:400;text-transform:none;letter-spacing:0;opacity:.7}.config-select,.config-input{background:var(--bg-elevated);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:.9rem;padding:.45rem .65rem;width:100%;outline:none;transition:border-color .15s;box-sizing:border-box;font-family:inherit;appearance:auto}.config-select:focus,.config-input:focus{border-color:var(--accent)}.config-form-actions{display:flex;align-items:center;gap:.75rem}.config-color-input-row{display:flex;align-items:center;gap:.5rem}.config-inline-actions{display:flex;gap:.5rem;flex-wrap:wrap}.config-color-picker{width:2.35rem;height:2.1rem;border:1px solid var(--border);border-radius:7px;padding:.1rem;background:var(--bg-elevated);cursor:pointer;flex-shrink:0}@media(max-width:860px){.config-group-grid{grid-template-columns:1fr}}.combo-box{position:relative}.combo-box-list{position:absolute;top:100%;left:0;right:0;z-index:200;margin:2px 0 0;padding:0;list-style:none;background:var(--bg-elevated);border:1px solid var(--border);border-radius:7px;box-shadow:0 4px 16px #00000040;max-height:220px;overflow-y:auto}.combo-box-option{padding:.45rem .65rem;font-size:.9rem;color:var(--text);cursor:pointer}.combo-box-option:hover,.combo-box-option--active{background:var(--accent);color:#fff}.model-config-section{border:1px solid var(--border);border-radius:10px;margin-bottom:1.5rem;overflow:hidden}.model-config-toggle{all:unset;display:flex;align-items:center;justify-content:space-between;width:100%;gap:.75rem;padding:.9rem 1.15rem;cursor:pointer;background:var(--bg-elevated);box-sizing:border-box;transition:background .15s}.model-config-toggle:hover{background:var(--bg-hover, color-mix(in srgb, var(--bg-elevated) 85%, var(--accent)))}.model-config-summary{display:flex;flex-direction:column;gap:.2rem;min-width:0}.model-config-name{font-size:.92rem;font-weight:600;color:var(--text)}.model-config-url{font-size:.78rem}a.model-config-url{color:var(--accent);text-decoration:none}a.model-config-url:hover{text-decoration:underline}.model-config-pricing{font-size:.78rem}.model-config-chevron{flex-shrink:0;font-size:.72rem;color:var(--text-muted)}.model-config-body{padding:1.15rem 1.25rem;border-top:1px solid var(--border);background:var(--bg)}.model-config-body .config-form{margin-bottom:0}.asset-grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(var(--grid-col-min, 200px),1fr))}.asset-card{border:1px solid var(--border-subtle);border-radius:10px;padding:.65rem;display:flex;flex-direction:column;gap:.6rem;background:var(--bg-card)}.asset-card>.btn:last-child{margin-top:auto}.asset-index{font-size:.72rem;color:var(--text-muted);font-weight:600;padding:0 .15rem}.asset-status-row{display:flex;align-items:center;justify-content:space-between;gap:.45rem}.card-prompt{display:flex;flex-direction:column;gap:.3rem}.card-prompt-header{display:flex;align-items:center;justify-content:space-between}.card-prompt-edit-btn{padding:.1rem .35rem;font-size:.75rem;opacity:.6;flex-shrink:0}.card-prompt-edit-btn:hover:not(:disabled){opacity:1}.card-prompt-row{display:flex;align-items:flex-start;gap:.35rem}.prompt-text{font-size:.78rem;color:var(--text-muted);line-height:1.45;margin:0;flex:1}.prompt-textarea{font-size:.8rem;resize:none;overflow:hidden;min-height:3.5rem}.prompt-edit-actions{display:flex;gap:.4rem}.media-wrap{aspect-ratio:var(--ar, 9/16);width:100%;background:#000;border-radius:8px;overflow:hidden;display:flex;align-items:center;justify-content:center}.media-wrap img,.media-wrap video{width:100%;height:100%;object-fit:cover;display:block}.media-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--bg-elevated)}.media-placeholder-content{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.45rem;text-align:center;padding:.65rem}.media-placeholder-title{font-size:.76rem;color:var(--text-muted);font-weight:500}.final-video-section{margin-bottom:.5rem}.final-video-wrap{margin:0 auto}.tab-content:has(.tab-pane-output){overflow:hidden;display:flex;flex-direction:column}.tab-pane-output{flex:1;min-height:0;display:flex;flex-direction:column;gap:0}.tab-pane-output .final-video-section{flex:1;min-height:0;margin-bottom:0;display:flex;flex-direction:column}.tab-pane-output .final-video-wrap{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}.tab-pane-output .media-wrap{aspect-ratio:unset;height:100%;width:auto;max-width:100%}.section-label{font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin-bottom:.65rem}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.85rem;padding:3.5rem 1.5rem;text-align:center;height:100%;min-height:200px}.empty-state-title{font-size:1.15rem;font-weight:600;color:var(--text)}.empty-state-brand{font-size:clamp(1.8rem,5vw,2.4rem);font-weight:900;letter-spacing:.04em;text-transform:uppercase;color:var(--text);opacity:.9;margin-bottom:.5rem}.muted{color:var(--text-muted)}.size-sm{font-size:.82rem}.size-xs{font-size:.75rem}@media(max-width:900px){:root{--sidebar-w: 200px}.editor-grid{grid-template-columns:1fr}.project-header-top{gap:.5rem}}@media(max-width:640px){.app-shell{grid-template-columns:1fr}.sidebar{display:none}body{overflow:auto}}
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2
+ <defs>
3
+ <linearGradient id="flameGrad" x1="0%" y1="100%" x2="0%" y2="0%">
4
+ <stop offset="0%" stop-color="#ff3d00"/>
5
+ <stop offset="60%" stop-color="#ff7a00"/>
6
+ <stop offset="100%" stop-color="#ffcc00"/>
7
+ </linearGradient>
8
+ </defs>
9
+
10
+ <!-- Rounded square background -->
11
+ <rect width="32" height="32" rx="7" fill="#111111"/>
12
+
13
+ <!-- Flame: main triangle, centered in 32x32, with ~4px padding -->
14
+ <!-- Points: tip at top-center, base spanning bottom -->
15
+ <polygon points="16,5 26,27 6,27" fill="#ff4e00"/>
16
+ <!-- Inner lighter triangle -->
17
+ <polygon points="16,12 22,27 10,27" fill="#ff7a00"/>
18
+
19
+ </svg>
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>Rilo</title>
8
+ <script type="module" crossorigin src="/assets/index-BvNWHzrr.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Wcsyfv3q.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@telepat/rilo",
3
- "version": "0.1.0",
3
+ "version": "0.1.7",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "rilo": "src/cli/index.js"
7
7
  },
8
8
  "files": [
9
9
  "src/",
10
+ "frontend/dist/",
10
11
  "models/",
11
12
  "index.js",
12
13
  "README.md"
@@ -30,6 +31,7 @@
30
31
  "frontend:lint": "npm --prefix frontend run lint",
31
32
  "frontend:build": "npm --prefix frontend run build",
32
33
  "frontend:preview": "npm --prefix frontend run preview",
34
+ "prepack": "node -e \"const fs=require('node:fs'); if(!fs.existsSync('frontend/dist/index.html')) { console.error('Missing frontend build output at frontend/dist/index.html. Run npm run build before publish.'); process.exit(1); }\"",
33
35
  "docs:start": "npm --prefix docs-site run start",
34
36
  "docs:build": "npm --prefix docs-site run build",
35
37
  "docs:serve": "npm --prefix docs-site run serve",
@@ -5,6 +5,32 @@ function unauthorized(res) {
5
5
  res.status(401).json({ error: 'Unauthorized' });
6
6
  }
7
7
 
8
+ function isLoopbackAddress(address) {
9
+ if (!address || typeof address !== 'string') {
10
+ return false;
11
+ }
12
+
13
+ const normalized = address.toLowerCase();
14
+ return normalized === '127.0.0.1'
15
+ || normalized === '::1'
16
+ || normalized === '::ffff:127.0.0.1';
17
+ }
18
+
19
+ function shouldBypassAuth(req, options) {
20
+ if (!options.previewMode) {
21
+ return false;
22
+ }
23
+
24
+ if (options.allowUnauthenticatedExposedPreview) {
25
+ return true;
26
+ }
27
+
28
+ const socketAddress = typeof req?.socket?.remoteAddress === 'string'
29
+ ? req.socket.remoteAddress
30
+ : '';
31
+ return isLoopbackAddress(socketAddress);
32
+ }
33
+
8
34
  function getTokenFromAuthorizationHeader(req) {
9
35
  const authHeader = req.get('authorization');
10
36
  if (!authHeader) {
@@ -51,20 +77,50 @@ export function isAuthorizedApiRequest(req, { allowQueryAccessToken = false } =
51
77
  return isMatchingToken(queryToken);
52
78
  }
53
79
 
54
- export function requireBearerToken(req, res, next) {
55
- if (!isAuthorizedApiRequest(req)) {
56
- unauthorized(res);
57
- return;
80
+ export function createAuthGuards(options = {}) {
81
+ const authOptions = {
82
+ previewMode: Boolean(options.previewMode),
83
+ allowUnauthenticatedExposedPreview: Boolean(options.allowUnauthenticatedExposedPreview)
84
+ };
85
+
86
+ function requireBearerTokenWithOptions(req, res, next) {
87
+ if (shouldBypassAuth(req, authOptions)) {
88
+ next();
89
+ return;
90
+ }
91
+
92
+ if (!isAuthorizedApiRequest(req)) {
93
+ unauthorized(res);
94
+ return;
95
+ }
96
+
97
+ next();
58
98
  }
59
99
 
60
- next();
61
- }
100
+ function requireBearerTokenOrAccessTokenWithOptions(req, res, next) {
101
+ if (shouldBypassAuth(req, authOptions)) {
102
+ next();
103
+ return;
104
+ }
62
105
 
63
- export function requireBearerTokenOrAccessToken(req, res, next) {
64
- if (!isAuthorizedApiRequest(req, { allowQueryAccessToken: true })) {
65
- unauthorized(res);
66
- return;
106
+ if (!isAuthorizedApiRequest(req, { allowQueryAccessToken: true })) {
107
+ unauthorized(res);
108
+ return;
109
+ }
110
+
111
+ next();
67
112
  }
68
113
 
69
- next();
114
+ return {
115
+ requireBearerToken: requireBearerTokenWithOptions,
116
+ requireBearerTokenOrAccessToken: requireBearerTokenOrAccessTokenWithOptions
117
+ };
118
+ }
119
+
120
+ export function requireBearerToken(req, res, next) {
121
+ return createAuthGuards().requireBearerToken(req, res, next);
122
+ }
123
+
124
+ export function requireBearerTokenOrAccessToken(req, res, next) {
125
+ return createAuthGuards().requireBearerTokenOrAccessToken(req, res, next);
70
126
  }
@@ -57,7 +57,8 @@ const schemas = {
57
57
  properties: {
58
58
  story: { type: 'string' },
59
59
  project: { type: 'string' },
60
- forceRestart: { type: 'boolean' }
60
+ forceRestart: { type: 'boolean' },
61
+ pauseAfterKeyframes: { type: 'boolean' }
61
62
  },
62
63
  required: ['story'],
63
64
  additionalProperties: true
@@ -180,6 +181,7 @@ const schemas = {
180
181
  type: 'object',
181
182
  properties: {
182
183
  forceRestart: { type: 'boolean' },
184
+ pauseAfterKeyframes: { type: 'boolean' },
183
185
  targetType: {
184
186
  type: 'string',
185
187
  enum: ['script', 'voiceover', 'keyframe', 'segment', 'align', 'burnin']
@@ -7,12 +7,27 @@ export function createJobsRouter() {
7
7
  const router = express.Router();
8
8
 
9
9
  router.post('/', async (req, res) => {
10
- const { story, project, forceRestart } = req.body || {};
10
+ const { story, project, forceRestart, pauseAfterKeyframes } = req.body || {};
11
11
  if (!story) {
12
12
  res.status(400).json({ error: 'story is required' });
13
13
  return;
14
14
  }
15
15
 
16
+ if (typeof story !== 'string') {
17
+ res.status(400).json({ error: 'story must be a string' });
18
+ return;
19
+ }
20
+
21
+ if (forceRestart !== undefined && typeof forceRestart !== 'boolean') {
22
+ res.status(400).json({ error: 'forceRestart must be a boolean when provided' });
23
+ return;
24
+ }
25
+
26
+ if (pauseAfterKeyframes !== undefined && typeof pauseAfterKeyframes !== 'boolean') {
27
+ res.status(400).json({ error: 'pauseAfterKeyframes must be a boolean when provided' });
28
+ return;
29
+ }
30
+
16
31
  const resolvedProject = resolveProjectName(project || `api-${Date.now()}`);
17
32
  const existingActiveJob = findActiveJobByProject(resolvedProject);
18
33
  if (existingActiveJob) {
@@ -28,7 +43,11 @@ export function createJobsRouter() {
28
43
  await writeProjectStory(resolvedProject, story);
29
44
 
30
45
  const job = createJob({ story, project: resolvedProject });
31
- setImmediate(() => runPipeline(job.id, { forceRestart: Boolean(forceRestart) }));
46
+ const pipelineOptions = { forceRestart: forceRestart === true };
47
+ if (pauseAfterKeyframes !== undefined) {
48
+ pipelineOptions.pauseAfterKeyframes = pauseAfterKeyframes;
49
+ }
50
+ setImmediate(() => runPipeline(job.id, pipelineOptions));
32
51
  res.status(202).json({ jobId: job.id, status: job.status });
33
52
  });
34
53
 
@@ -578,6 +578,7 @@ export function createProjectsRouter(deps = {}) {
578
578
 
579
579
  const {
580
580
  forceRestart,
581
+ pauseAfterKeyframes,
581
582
  targetType,
582
583
  index
583
584
  } = req.body || {};
@@ -597,6 +598,11 @@ export function createProjectsRouter(deps = {}) {
597
598
  return;
598
599
  }
599
600
 
601
+ if (pauseAfterKeyframes !== undefined) {
602
+ res.status(400).json({ error: 'pauseAfterKeyframes is not supported for targeted regeneration' });
603
+ return;
604
+ }
605
+
600
606
  const targetRequiresIndex = normalizedTargetType === 'keyframe' || normalizedTargetType === 'segment';
601
607
  if (targetRequiresIndex && !hasIndex) {
602
608
  res.status(400).json({ error: 'index is required for keyframe/segment targeted regeneration' });
@@ -628,9 +634,18 @@ export function createProjectsRouter(deps = {}) {
628
634
  return;
629
635
  }
630
636
 
637
+ if (pauseAfterKeyframes !== undefined && typeof pauseAfterKeyframes !== 'boolean') {
638
+ res.status(400).json({ error: 'pauseAfterKeyframes must be a boolean when provided' });
639
+ return;
640
+ }
641
+
631
642
  const story = await readProjectStory(project);
632
643
  const job = createJobFn({ story, project });
633
- setImmediate(() => runPipelineFn(job.id, { forceRestart: Boolean(forceRestart) }));
644
+ const pipelineOptions = { forceRestart: forceRestart === true };
645
+ if (pauseAfterKeyframes !== undefined) {
646
+ pipelineOptions.pauseAfterKeyframes = pauseAfterKeyframes;
647
+ }
648
+ setImmediate(() => runPipelineFn(job.id, pipelineOptions));
634
649
 
635
650
  res.status(202).json({
636
651
  jobId: job.id,
package/src/api/server.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import express from 'express';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
2
4
  import { pathToFileURL } from 'node:url';
3
5
  import { assertRequiredApiEnv, env } from '../config/env.js';
4
6
  import { createJobsRouter } from './routes/jobs.js';
5
7
  import { createProjectAssetsRouter } from './routes/projectAssets.js';
6
8
  import { createProjectsRouter } from './routes/projects.js';
7
9
  import { createWebhookRouter } from './routes/webhooks.js';
8
- import { requireBearerToken, requireBearerTokenOrAccessToken } from './middleware/auth.js';
10
+ import { createAuthGuards } from './middleware/auth.js';
9
11
  import { buildOpenApiSpec } from './openapi/spec.js';
10
12
 
11
13
  const swaggerUiHtml = `<!doctype html>
@@ -42,11 +44,29 @@ function getRequestBaseUrl(req, { fallbackPort = 3000 } = {}) {
42
44
  return `http://localhost:${fallbackPort}`;
43
45
  }
44
46
 
45
- export function createApiApp({ baseUrl } = {}) {
46
- assertRequiredApiEnv();
47
+ function hasDashboardBundle(dashboardDir) {
48
+ if (!dashboardDir || typeof dashboardDir !== 'string') {
49
+ return false;
50
+ }
51
+
52
+ return fs.existsSync(path.join(dashboardDir, 'index.html'));
53
+ }
54
+
55
+ export function createApiApp({ baseUrl, dashboardDir, auth } = {}) {
56
+ const authOptions = {
57
+ previewMode: Boolean(auth?.previewMode),
58
+ allowUnauthenticatedExposedPreview: Boolean(auth?.allowUnauthenticatedExposedPreview)
59
+ };
60
+ const shouldRequireApiToken = !authOptions.previewMode;
61
+ if (shouldRequireApiToken) {
62
+ assertRequiredApiEnv();
63
+ }
64
+
65
+ const guards = createAuthGuards(authOptions);
66
+ const dashboardEnabled = hasDashboardBundle(dashboardDir);
47
67
 
48
68
  const app = express();
49
- app.set('trust proxy', true);
69
+ app.set('trust proxy', !authOptions.previewMode);
50
70
  app.use(express.json({ limit: '2mb' }));
51
71
 
52
72
  app.get('/health', (_req, res) => {
@@ -67,16 +87,30 @@ export function createApiApp({ baseUrl } = {}) {
67
87
  });
68
88
 
69
89
  app.use('/webhooks', createWebhookRouter());
70
- app.use('/projects', requireBearerTokenOrAccessToken, createProjectAssetsRouter());
71
- app.use(requireBearerToken);
90
+ app.use('/projects', guards.requireBearerTokenOrAccessToken, createProjectAssetsRouter());
91
+ app.use(guards.requireBearerToken);
72
92
  app.use('/jobs', createJobsRouter());
73
93
  app.use('/projects', createProjectsRouter());
74
94
 
95
+ if (dashboardEnabled) {
96
+ app.use(express.static(dashboardDir));
97
+ app.get('/', (_req, res) => {
98
+ res.sendFile(path.join(dashboardDir, 'index.html'));
99
+ });
100
+ }
101
+
75
102
  return app;
76
103
  }
77
104
 
78
- export function startApiServer({ port = env.port, baseUrl } = {}) {
79
- const app = createApiApp({ baseUrl });
105
+ export function startApiServer({ port = env.port, host, baseUrl, dashboardDir, auth } = {}) {
106
+ const app = createApiApp({ baseUrl, dashboardDir, auth });
107
+
108
+ if (host) {
109
+ return app.listen(port, host, () => {
110
+ console.log(`rilo api listening on ${host}:${port}`);
111
+ });
112
+ }
113
+
80
114
  return app.listen(port, () => {
81
115
  console.log(`rilo api listening on :${port}`);
82
116
  });
@@ -0,0 +1,126 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import { startApiServer } from '../../api/server.js';
6
+ import { openPath } from './openHome.js';
7
+
8
+ function parsePort(value, fallback) {
9
+ if (!value) {
10
+ return fallback;
11
+ }
12
+
13
+ const parsed = Number(value);
14
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
15
+ throw new Error(`Invalid port: ${value}`);
16
+ }
17
+
18
+ return parsed;
19
+ }
20
+
21
+ function resolveDashboardDir() {
22
+ const commandDir = path.dirname(fileURLToPath(import.meta.url));
23
+ return path.resolve(commandDir, '../../../frontend/dist');
24
+ }
25
+
26
+ function resolveWorkerEntryPoint() {
27
+ const commandDir = path.dirname(fileURLToPath(import.meta.url));
28
+ return path.resolve(commandDir, '../../worker/processor.js');
29
+ }
30
+
31
+ function buildWorkerProcess() {
32
+ const workerPath = resolveWorkerEntryPoint();
33
+ return spawn(process.execPath, [workerPath], {
34
+ stdio: 'inherit'
35
+ });
36
+ }
37
+
38
+ function printUnsafeExposeWarning(url) {
39
+ console.warn('WARNING: running preview in exposed mode without auth.');
40
+ console.warn(`WARNING: dashboard and API are reachable from ${url}`);
41
+ console.warn('WARNING: use only on trusted networks or isolated containers.');
42
+ }
43
+
44
+ function resolveOpenUrl(host, port) {
45
+ if (host === '0.0.0.0' || host === '::') {
46
+ return `http://127.0.0.1:${port}`;
47
+ }
48
+
49
+ return `http://${host}:${port}`;
50
+ }
51
+
52
+ export async function startPreview({ args = process.argv.slice(3) } = {}) {
53
+ const expose = args.includes('--expose');
54
+ const unsafeNoAuth = args.includes('--unsafe-no-auth');
55
+ const noOpen = args.includes('--no-open');
56
+
57
+ if (!expose && unsafeNoAuth) {
58
+ throw new Error('--unsafe-no-auth can only be used with --expose');
59
+ }
60
+
61
+ if (expose && !unsafeNoAuth) {
62
+ throw new Error('Exposed preview requires --unsafe-no-auth');
63
+ }
64
+
65
+ const portArgIndex = args.indexOf('--port');
66
+ const portArg = portArgIndex === -1 ? null : args[portArgIndex + 1] || null;
67
+ const port = parsePort(portArg, 3000);
68
+
69
+ const hostArgIndex = args.indexOf('--host');
70
+ const hostArg = hostArgIndex === -1 ? null : args[hostArgIndex + 1] || null;
71
+ const host = hostArg || (expose ? '0.0.0.0' : '127.0.0.1');
72
+
73
+ const dashboardDir = resolveDashboardDir();
74
+ if (!fs.existsSync(path.join(dashboardDir, 'index.html'))) {
75
+ throw new Error('Dashboard bundle not found. Run `npm run frontend:build` first.');
76
+ }
77
+
78
+ const apiServer = startApiServer({
79
+ port,
80
+ host,
81
+ dashboardDir,
82
+ auth: {
83
+ previewMode: true,
84
+ allowUnauthenticatedExposedPreview: unsafeNoAuth
85
+ }
86
+ });
87
+
88
+ const worker = buildWorkerProcess();
89
+ const openUrl = resolveOpenUrl(host, port);
90
+
91
+ const stopAll = () => {
92
+ if (!worker.killed) {
93
+ worker.kill('SIGTERM');
94
+ }
95
+ apiServer.close();
96
+ };
97
+
98
+ process.once('SIGINT', () => {
99
+ stopAll();
100
+ process.exit(0);
101
+ });
102
+
103
+ process.once('SIGTERM', () => {
104
+ stopAll();
105
+ process.exit(0);
106
+ });
107
+
108
+ worker.once('exit', (code) => {
109
+ apiServer.close(() => {
110
+ if (typeof code === 'number' && code !== 0) {
111
+ process.exit(code);
112
+ }
113
+ });
114
+ });
115
+
116
+ if (unsafeNoAuth) {
117
+ printUnsafeExposeWarning(openUrl);
118
+ }
119
+
120
+ console.log(`Dashboard: ${openUrl}`);
121
+ console.log(`Docs: ${openUrl}/docs`);
122
+
123
+ if (!noOpen) {
124
+ await openPath(openUrl);
125
+ }
126
+ }
package/src/cli/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from '../store/projectStore.js';
15
15
  import { openSettings } from './commands/settingsFlow.js';
16
16
  import { openHome } from './commands/openHome.js';
17
+ import { startPreview } from './commands/preview.js';
17
18
  import { applyStoredSettings } from '../config/env.js';
18
19
 
19
20
  function getArg(flag) {
@@ -46,10 +47,11 @@ async function main() {
46
47
 
47
48
  if (process.argv.includes('--help')) {
48
49
  const helpText = `
49
- Rilo — Story-first vertical video generation
50
+ Rilo — Turn a story into a finished video
50
51
 
51
52
  USAGE
52
53
  rilo --project <name> [--story-file <path>] [--force]
54
+ rilo preview [--port <n>] [--host <host>] [--no-open] [--expose --unsafe-no-auth]
53
55
  rilo settings
54
56
  rilo home
55
57
  rilo --help
@@ -62,6 +64,7 @@ COMMANDS
62
64
  --project <name> Project identifier (required); creates projects/<name>/
63
65
  --story-file <path> Path to story text file (required on first run)
64
66
  --force Force restart from earlier stages (use after config changes)
67
+ --full-run Skip the pause after keyframe generation and run all the way through
65
68
 
66
69
  rilo settings
67
70
  Configure API tokens, timeouts, and binary paths interactively
@@ -69,9 +72,19 @@ COMMANDS
69
72
  rilo home
70
73
  Open ~/.rilo, the default home for projects and output files
71
74
 
75
+ rilo preview [--port <n>] [--host <host>] [--no-open] [--expose --unsafe-no-auth]
76
+ Start API, worker, and dashboard preview for local use
77
+
78
+ --port <n> API/dashboard port (default: 3000)
79
+ --host <host> Host bind address (default: 127.0.0.1)
80
+ --no-open Do not auto-open browser
81
+ --expose Bind preview for external/container access
82
+ --unsafe-no-auth Allow unauthenticated exposed preview (required with --expose)
83
+
72
84
  FLAGS
73
85
  --help Show this help message
74
86
  --version Show version information
87
+ --full-run Skip the keyframe review pause and run all pipeline stages
75
88
 
76
89
  EXAMPLES
77
90
  # First run: create project and generate
@@ -89,6 +102,12 @@ EXAMPLES
89
102
  # Open the default Rilo home folder
90
103
  rilo home
91
104
 
105
+ # Run local dashboard/API preview
106
+ rilo preview
107
+
108
+ # Expose preview over container or tunnel (unsafe)
109
+ rilo preview --expose --unsafe-no-auth --host 0.0.0.0 --port 3000
110
+
92
111
  # Using npx (no installation needed)
93
112
  npx @telepat/rilo --project wedding-case --story-file ./story.txt
94
113
  npx @telepat/rilo home
@@ -115,6 +134,9 @@ SETTINGS
115
134
  Configure via interactive menu:
116
135
  rilo settings
117
136
 
137
+ Recommended install:
138
+ npm install -g @telepat/rilo
139
+
118
140
  Or with environment variables:
119
141
  export RILO_REPLICATE_API_TOKEN=r8_xxxxx
120
142
  export RILO_MAX_RETRIES=5
@@ -129,7 +151,7 @@ SETTINGS
129
151
  INVOCATION METHODS
130
152
  Global install: rilo --project <name> --story-file <path>
131
153
  No install (npx): npx @telepat/rilo --project <name> --story-file <path>
132
- Local development: npm run dev -- --project <name> --story-file <path>
154
+ Contributor workflow: npm run dev -- --project <name> --story-file <path>
133
155
  `;
134
156
  console.log(helpText);
135
157
  return;
@@ -146,6 +168,12 @@ INVOCATION METHODS
146
168
  return;
147
169
  }
148
170
 
171
+ if (process.argv[2] === 'preview') {
172
+ await applyStoredSettings();
173
+ await startPreview();
174
+ return;
175
+ }
176
+
149
177
  // Merge stored settings (config.json + keystore) into env before running
150
178
  await applyStoredSettings();
151
179
 
@@ -156,6 +184,7 @@ INVOCATION METHODS
156
184
 
157
185
  const project = resolveProjectName(projectArg);
158
186
  const forceRestart = hasFlag('--force');
187
+ const fullRun = hasFlag('--full-run');
159
188
  const storyFile = getArg('--story-file');
160
189
 
161
190
  await ensureProject(project);
@@ -173,7 +202,17 @@ INVOCATION METHODS
173
202
  }
174
203
 
175
204
  const job = createJob({ story, project });
176
- const result = await runPipeline(job.id, { forceRestart });
205
+ const result = await runPipeline(job.id, { forceRestart, pauseAfterKeyframes: !fullRun });
206
+
207
+ if (result.status === 'paused') {
208
+ console.log(JSON.stringify({
209
+ jobId: result.id,
210
+ project,
211
+ status: 'paused',
212
+ message: `Keyframes generated. Review assets in projects/${project}/assets/, then run again to continue to video generation.`
213
+ }, null, 2));
214
+ return;
215
+ }
177
216
 
178
217
  if (result.status !== 'completed') {
179
218
  throw new Error(`Generation failed: ${result.error || 'unknown error'}`);
@@ -571,9 +571,10 @@ export async function regenerateProjectAsset(projectName, target, options = {})
571
571
  }
572
572
 
573
573
  const updatedAt = new Date().toISOString();
574
+ const nextStatus = targetType === 'keyframe' ? JobStatus.PAUSED : (runState.status || JobStatus.COMPLETED);
574
575
  await deps.persistArtifacts(project, artifacts);
575
576
  await deps.writeProjectRunState(project, {
576
- status: runState.status || JobStatus.COMPLETED,
577
+ status: nextStatus,
577
578
  error: runState.error || null,
578
579
  steps,
579
580
  artifacts,
@@ -629,6 +630,9 @@ export async function runPipeline(jobId, options = {}) {
629
630
  const projectDir = deps.getProjectDir(project);
630
631
  const targetDurationSec = resolveTargetDurationSec(projectConfig);
631
632
  const plannedShots = resolveShotCount(projectConfig);
633
+ const shouldPause = options.pauseAfterKeyframes !== undefined
634
+ ? Boolean(options.pauseAfterKeyframes)
635
+ : Boolean(projectConfig.pauseAfterKeyframes);
632
636
  const safeStory = deps.preprocessStory(job.payload.story);
633
637
  analyticsProjectDir = projectDir;
634
638
  const analyticsRunId = deps.createRunId();
@@ -1326,6 +1330,12 @@ export async function runPipeline(jobId, options = {}) {
1326
1330
  await finishStageAnalytics(JobStep.KEYFRAMES, { executed: true, status: 'succeeded', details: { mode: 'partial_regen', changedShots: changed } });
1327
1331
  await finishStageAnalytics(JobStep.SEGMENTS, { executed: true, status: 'succeeded', details: { mode: 'partial_regen' } });
1328
1332
  currentJob = getJob(jobId);
1333
+
1334
+ if (shouldPause) {
1335
+ const paused = updateJob(jobId, { status: JobStatus.PAUSED });
1336
+ await persistCheckpoint(paused, null, deps);
1337
+ return paused;
1338
+ }
1329
1339
  }
1330
1340
 
1331
1341
  if (!currentJob.steps[JobStep.KEYFRAMES]) {
@@ -1387,6 +1397,12 @@ export async function runPipeline(jobId, options = {}) {
1387
1397
  await persistCheckpoint(getJob(jobId), null, deps);
1388
1398
  await finishStageAnalytics(JobStep.KEYFRAMES, { executed: true, status: 'succeeded' });
1389
1399
  currentJob = getJob(jobId);
1400
+
1401
+ if (shouldPause) {
1402
+ const paused = updateJob(jobId, { status: JobStatus.PAUSED });
1403
+ await persistCheckpoint(paused, null, deps);
1404
+ return paused;
1405
+ }
1390
1406
  } else if (
1391
1407
  currentJob.artifacts.keyframeUrls.length > 0 &&
1392
1408
  (!currentJob.artifacts.keyframePaths || currentJob.artifacts.keyframePaths.length === 0)