openvibers/openvibe.live Commits
Public AI text rules come from openvibe-sdk/no-looks (v0.61.0, moved there verbatim); the boot repair's version follows the SDK's rules (#306)
Changes
@@@ -176,6 +176,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.209.0--- openvibe-sdk: v0.60.0++- openvibe-sdk: v0.61.0- openvibe-shared: v4.6.0<!-- versions:end -->@@@ -6,7 +6,7 @@"port": 3000,"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.209.0",-- "sdk": "openvibe-sdk v0.60.0",++ "sdk": "openvibe-sdk v0.61.0","shared": "openvibe-shared v4.6.0","capabilities": ["live.chat_context.read",@@@ -24,7 +24,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -2660,9 +2660,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.60.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",-- "integrity": "sha512-hhgpGeEZsoGh+mGWcY22ZOXL+0vTNX+q7PtaAzxwNtr57K2XgCCHl2WqRpjHjrzyxPsjOOr9LBo3VYnmNv18bw==",++ "version": "0.61.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",++ "integrity": "sha512-rhQKwM+yzVjhgUPTeqMOkL9N38fbSy8JYiEbJ8SwZk8LGcusde6LoQNhQ8UUxMJIwyY7xR1Rp5NVd6XRaVgTQQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -43,7 +43,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -384,109 +384,12 @@ function _plainProse(text) {.map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`)).join(' ');}--// Public AI text never describes how a person looks (frame notes show on VOD pages and--// feed every overview). Text written before the prompts said so ("A shirtless man sits…",--// "a young boy at a desk") has that person called "the streamer" (or "someone" when only--// an age is named), so a plain "a man" or "the streamer" stays as written.--const _LOOKS = 'shirtless|topless|bare-chested|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';--const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';--const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;--const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');--const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');--const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);--// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",--// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted--// speech ("Heard: "I like your hair"") is what was said and stays word for word.--const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';--const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';--const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';--const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds?|earphones?|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;--const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;--const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');--const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');--const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');--const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');--const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');--// "…, while the other wears a dusty t-shirt;" goes as a whole clause.--const _WEARS_CLAUSE = new RegExp(`,?\\s+(?:while|and|as|whereas)\\s+(?:the other|one|another|someone|he|she|they|the streamer|(?:a|the) (?:man|woman|person|guy))\\s+wears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*(?=\\s*(?:[;,.!?]|$))`, 'gi');--// "a plaid/checkered shirt": a slashed pair of words before what is worn reads as one word, so the rules below see it.--const _SLASHED_WORN = new RegExp(`\\b([\\w'-]+)/[\\w'-]+(?=\\s+(?:[\\w'-]+\\s+){0,2}?(?:${_WORN_ANY})\\b)`, 'gi');--// ", with a blue jacket partially visible": a comma-led clause about clothes, whoever wears them, goes (clothes only:--// "a desk with a headset" stays).--const _WITH_CLOTHES = /,\s*with\s+(?:(?:a|an|the|their|his|her)\s+)?(?:[\w'-]+\s+){0,2}?(?:t-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|outfits?|dress(?:es)?|jerseys?|suits?|robes?|outerwear)\b[^,.;!?]*(?:,(?=\s+(?:is|are|was|were)\s))?/gi;--function _noClothing(text) {-- return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part-- .replace(_SLASHED_WORN, '$1')-- .replace(_WITH_CLOTHES, '')-- // A parenthetical clause taken out of the middle ("A third person, with a red cap, is shown") leaves no ",,".-- .replace(/\s*,\s*,\s*/g, ' ')-- .replace(/\b[\w-]+-wearing\s+/gi, '')-- .replace(_WEARS_AND, '')-- .replace(_WEARS_CLAUSE, '')-- .replace(/\b(?:casually|smartly|neatly|warmly|brightly|formally|well)[\s-]+dressed\s+/gi, '')-- .replace(_PERSON_WITH_WORN, '$1')-- .replace(_WEARING, '')-- .replace(_IN_WORN, '')-- .replace(_WITH_FACE, '')-- .replace(/\s+([,.;:!?])/g, '$1')-- .replace(/,(?=[.;:!?])/g, '')-- .replace(/([:;]),/g, '$1')-- .replace(/ {2,}/g, ' '))).join('');--}--// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming--// clothing, hair, face, age or a person by gender is dropped.--const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');--// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.--const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');--const _hasLooks = (t) => _LOOKS_WORD.test(String(t || ''));--function _noLooksTags(tags) {-- return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));--}--// Body language is the person too ("reacting with a thoughtful pose", "in a relaxed posture"): the phrase goes.--const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:reacting|sitting|standing|leaning|posing|looking|resting)\s+)?(?:with|in)\s+(?:an?|their|his|her)\s+(?:[\w-]+\s+){0,2}(?:pose|posture|expression|facial expression|stance)\b/gi;--// A guessed gender is a description of the person too (owner's rule: no looks, body, clothing, hair, age or guessed--// gender): "a man in a kitchen" is "a person in a kitchen", "two men" are "two people", "a male or female host" is "a--// host". Only after an article or a count, so names (Iron Man, Pac-Man, Spice Girls) and quoted speech stay.--const _GENDERED = 'man|woman|guy|gal|lady|gentleman';--const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');--const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');--const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;--// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the--// counter") cannot be repaired word by word: the sentence goes.--const _GROOMING = new RegExp('\\b(?:(?:their|his|her)\\s+(?:own\\s+)?(?:hair|beard|face|makeup|clothing|clothes|outfit|appearance|body|figure|looks)'-- // The face and body described ("smiling broadly, displaying white teeth and a beard") and clothes the word lists do-- // not name ("blue outerwear", "in outdoor gear"): the sentence goes. Things a person holds or reviews (headphones, a-- // headset on a desk) are not here.-- + '|beards?|mustaches?|moustaches?|teeth|grin(?:s|ning)?|smil(?:e|es|ing)\\s+(?:broadly|widely|warmly|at)|freckles|complexion|wrinkles'-- + '|hairstyles?|haircuts?|outerwear|(?:in|wearing)\\s+(?:[\\w-]+\\s+){0,2}gear|face\\s+shields?)\\b', 'i');--function _noGender(text) {-- return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part-- .replace(_GENDER_ADJ, (m) => (/^an?\s/i.test(m) ? m.slice(0, m.indexOf(' ') + 1) : ''))-- .replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))-- .replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))-- .replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))-- .replace(/ {2,}/g, ' '))).join('');--}--function _noGrooming(text) {-- const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];-- return sentences.filter((s) => !_GROOMING.test(s.replace(/"[^"]*"?|“[^”]*”?/g, ''))).join('').trim();--}--function _noLooks(text) {-- if (!text || typeof text !== 'string') return text;-- return _noGrooming(_noGender(_noLooksWords(text)));--}--function _noLooksWords(text) {-- return _noClothing(text).replace(_POSE, '').replace(/\s+([,.;:!?])/g, '$1').replace(/ {2,}/g, ' ')-- // Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.-- .replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))-- .replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))-- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\s+and\s+/gi, ' ')-- .replace(/\b(?:shirtless|topless|bare-chested)\s+and\s+/gi, '')-- .replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')-- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\b/gi, ' is on camera')-- .replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')-- .replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');--}++// Public AI text never describes how a person looks (the owner's rule: no looks, body, clothing, hair, face, pose,++// age or guessed gender). Frame notes show on VOD pages and feed every overview, so every public AI string passes++// noLooks() (tags noLooksTags(); a short title that names looks is replaced, hasLooks()). The rules and their test++// corpus are openvibe-sdk/no-looks since SDK 0.61.0 (moved from here, Live#298–#302), shared with Community and++// MediaHub; test/ai-text-no-looks.test.js still checks Live's writers and the repair below.++const { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION: _LOOKS_RULES } = require('openvibe-sdk/no-looks');/*** A stream memory as the public sees it (home "Right now", a VOD's AI timeline, a streamer's AI profile): the* observation only. The " — heard: "…"" clause is raw speech-to-text kept for the AI's own context (often another@@@ -749,7 +652,7 @@ let _looksRulesVersion = null;// What the repair covers is part of its version: a change in scope runs the scan again even when the rules did not change.const _LOOKS_REPAIR_SCOPE = 'full and short columns, each on its own (2026-10-22)';function looksRulesVersion() {-- if (!_looksRulesVersion) _looksRulesVersion = require('crypto').createHash('sha1').update([_LOOKS_REPAIR_SCOPE, _WITH_CLOTHES, 'no double commas; earbud', _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _GENDER_ADJ, _GROOMING].map(String).join('|')).digest('hex').slice(0, 12);++ if (!_looksRulesVersion) _looksRulesVersion = require('crypto').createHash('sha1').update([_LOOKS_REPAIR_SCOPE, _LOOKS_RULES].join('|')).digest('hex').slice(0, 12);return _looksRulesVersion;}async function cleanupMalformedAiText({ force = false } = {}) {
@@ -176,6 +176,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -176,6 +176,6 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.209.0
- openvibe-contracts: v0.209.0
-- openvibe-sdk: v0.60.0
+- openvibe-sdk: v0.61.0
- openvibe-shared: v4.6.0
- openvibe-shared: v4.6.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.209.0",
"contracts": "openvibe-contracts v0.209.0",
- "sdk": "openvibe-sdk v0.60.0",
+ "sdk": "openvibe-sdk v0.61.0",
"shared": "openvibe-shared v4.6.0",
"shared": "openvibe-shared v4.6.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2660,9 +2660,9 @@
@@ -2660,9 +2660,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.60.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
- "integrity": "sha512-hhgpGeEZsoGh+mGWcY22ZOXL+0vTNX+q7PtaAzxwNtr57K2XgCCHl2WqRpjHjrzyxPsjOOr9LBo3VYnmNv18bw==",
+ "version": "0.61.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
+ "integrity": "sha512-rhQKwM+yzVjhgUPTeqMOkL9N38fbSy8JYiEbJ8SwZk8LGcusde6LoQNhQ8UUxMJIwyY7xR1Rp5NVd6XRaVgTQQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -43,7 +43,7 @@
@@ -43,7 +43,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -384,109 +384,12 @@ function _plainProse(text) {
@@ -384,109 +384,12 @@ function _plainProse(text) {
.map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`))
.map((l) => (/[.!?…"')\]]$/.test(l) ? l : `${l}.`))
.join(' ');
.join(' ');
}
}
-// Public AI text never describes how a person looks (frame notes show on VOD pages and
-// feed every overview). Text written before the prompts said so ("A shirtless man sits…",
-// "a young boy at a desk") has that person called "the streamer" (or "someone" when only
-// an age is named), so a plain "a man" or "the streamer" stays as written.
-const _LOOKS = 'shirtless|topless|bare-chested|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
-const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';
-const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
-const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
-const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
-const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
-// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",
-// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted
-// speech ("Heard: "I like your hair"") is what was said and stays word for word.
-const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';
-const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';
-const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';
-const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds?|earphones?|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;
-const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;
-const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
-const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN})\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
-const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');
-const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
-const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');
-// "…, while the other wears a dusty t-shirt;" goes as a whole clause.
-const _WEARS_CLAUSE = new RegExp(`,?\\s+(?:while|and|as|whereas)\\s+(?:the other|one|another|someone|he|she|they|the streamer|(?:a|the) (?:man|woman|person|guy))\\s+wears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*(?=\\s*(?:[;,.!?]|$))`, 'gi');
-// "a plaid/checkered shirt": a slashed pair of words before what is worn reads as one word, so the rules below see it.
-const _SLASHED_WORN = new RegExp(`\\b([\\w'-]+)/[\\w'-]+(?=\\s+(?:[\\w'-]+\\s+){0,2}?(?:${_WORN_ANY})\\b)`, 'gi');
-// ", with a blue jacket partially visible": a comma-led clause about clothes, whoever wears them, goes (clothes only:
-// "a desk with a headset" stays).
-const _WITH_CLOTHES = /,\s*with\s+(?:(?:a|an|the|their|his|her)\s+)?(?:[\w'-]+\s+){0,2}?(?:t-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|outfits?|dress(?:es)?|jerseys?|suits?|robes?|outerwear)\b[^,.;!?]*(?:,(?=\s+(?:is|are|was|were)\s))?/gi;
-function _noClothing(text) {
- return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
- .replace(_SLASHED_WORN, '$1')
- .replace(_WITH_CLOTHES, '')
- // A parenthetical clause taken out of the middle ("A third person, with a red cap, is shown") leaves no ",,".
- .replace(/\s*,\s*,\s*/g, ' ')
- .replace(/\b[\w-]+-wearing\s+/gi, '')
- .replace(_WEARS_AND, '')
- .replace(_WEARS_CLAUSE, '')
- .replace(/\b(?:casually|smartly|neatly|warmly|brightly|formally|well)[\s-]+dressed\s+/gi, '')
- .replace(_PERSON_WITH_WORN, '$1')
- .replace(_WEARING, '')
- .replace(_IN_WORN, '')
- .replace(_WITH_FACE, '')
- .replace(/\s+([,.;:!?])/g, '$1')
- .replace(/,(?=[.;:!?])/g, '')
- .replace(/([:;]),/g, '$1')
- .replace(/ {2,}/g, ' '))).join('');
-}
-// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming
-// clothing, hair, face, age or a person by gender is dropped.
-const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');
-// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.
-const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');
-const _hasLooks = (t) => _LOOKS_WORD.test(String(t || ''));
-function _noLooksTags(tags) {
- return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
-}
-// Body language is the person too ("reacting with a thoughtful pose", "in a relaxed posture"): the phrase goes.
-const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:reacting|sitting|standing|leaning|posing|looking|resting)\s+)?(?:with|in)\s+(?:an?|their|his|her)\s+(?:[\w-]+\s+){0,2}(?:pose|posture|expression|facial expression|stance)\b/gi;
-// A guessed gender is a description of the person too (owner's rule: no looks, body, clothing, hair, age or guessed
-// gender): "a man in a kitchen" is "a person in a kitchen", "two men" are "two people", "a male or female host" is "a
-// host". Only after an article or a count, so names (Iron Man, Pac-Man, Spice Girls) and quoted speech stay.
-const _GENDERED = 'man|woman|guy|gal|lady|gentleman';
-const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');
-const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');
-const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;
-// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the
-// counter") cannot be repaired word by word: the sentence goes.
-const _GROOMING = new RegExp('\\b(?:(?:their|his|her)\\s+(?:own\\s+)?(?:hair|beard|face|makeup|clothing|clothes|outfit|appearance|body|figure|looks)'
- // The face and body described ("smiling broadly, displaying white teeth and a beard") and clothes the word lists do
- // not name ("blue outerwear", "in outdoor gear"): the sentence goes. Things a person holds or reviews (headphones, a
- // headset on a desk) are not here.
- + '|beards?|mustaches?|moustaches?|teeth|grin(?:s|ning)?|smil(?:e|es|ing)\\s+(?:broadly|widely|warmly|at)|freckles|complexion|wrinkles'
- + '|hairstyles?|haircuts?|outerwear|(?:in|wearing)\\s+(?:[\\w-]+\\s+){0,2}gear|face\\s+shields?)\\b', 'i');
-function _noGender(text) {
- return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
- .replace(_GENDER_ADJ, (m) => (/^an?\s/i.test(m) ? m.slice(0, m.indexOf(' ') + 1) : ''))
- .replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))
- .replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))
- .replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))
- .replace(/ {2,}/g, ' '))).join('');
-}
-function _noGrooming(text) {
- const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];
- return sentences.filter((s) => !_GROOMING.test(s.replace(/"[^"]*"?|“[^”]*”?/g, ''))).join('').trim();
-}
-function _noLooks(text) {
- if (!text || typeof text !== 'string') return text;
- return _noGrooming(_noGender(_noLooksWords(text)));
-}
-function _noLooksWords(text) {
- return _noClothing(text).replace(_POSE, '').replace(/\s+([,.;:!?])/g, '$1').replace(/ {2,}/g, ' ')
- // Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
- .replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
- .replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\s+and\s+/gi, ' ')
- .replace(/\b(?:shirtless|topless|bare-chested)\s+and\s+/gi, '')
- .replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')
- .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested)\b/gi, ' is on camera')
- .replace(/\s*,\s*(?:shirtless|topless|bare-chested)\s*,/gi, '')
- .replace(/\b(?:shirtless|topless|bare-chested)\b,?\s*/gi, '');
-}
+// Public AI text never describes how a person looks (the owner's rule: no looks, body, clothing, hair, face, pose,
+// age or guessed gender). Frame notes show on VOD pages and feed every overview, so every public AI string passes
+// noLooks() (tags noLooksTags(); a short title that names looks is replaced, hasLooks()). The rules and their test
+// corpus are openvibe-sdk/no-looks since SDK 0.61.0 (moved from here, Live#298–#302), shared with Community and
+// MediaHub; test/ai-text-no-looks.test.js still checks Live's writers and the repair below.
+const { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION: _LOOKS_RULES } = require('openvibe-sdk/no-looks');
/**
/**
* A stream memory as the public sees it (home "Right now", a VOD's AI timeline, a streamer's AI profile): the
* A stream memory as the public sees it (home "Right now", a VOD's AI timeline, a streamer's AI profile): the
* observation only. The " — heard: "…"" clause is raw speech-to-text kept for the AI's own context (often another
* observation only. The " — heard: "…"" clause is raw speech-to-text kept for the AI's own context (often another
@@ -749,7 +652,7 @@ let _looksRulesVersion = null;
@@ -749,7 +652,7 @@ let _looksRulesVersion = null;
// What the repair covers is part of its version: a change in scope runs the scan again even when the rules did not change.
// What the repair covers is part of its version: a change in scope runs the scan again even when the rules did not change.
const _LOOKS_REPAIR_SCOPE = 'full and short columns, each on its own (2026-10-22)';
const _LOOKS_REPAIR_SCOPE = 'full and short columns, each on its own (2026-10-22)';
function looksRulesVersion() {
function looksRulesVersion() {
- if (!_looksRulesVersion) _looksRulesVersion = require('crypto').createHash('sha1').update([_LOOKS_REPAIR_SCOPE, _WITH_CLOTHES, 'no double commas; earbud', _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _GENDER_ADJ, _GROOMING].map(String).join('|')).digest('hex').slice(0, 12);
+ if (!_looksRulesVersion) _looksRulesVersion = require('crypto').createHash('sha1').update([_LOOKS_REPAIR_SCOPE, _LOOKS_RULES].join('|')).digest('hex').slice(0, 12);
return _looksRulesVersion;
return _looksRulesVersion;
}
}
async function cleanupMalformedAiText({ force = false } = {}) {
async function cleanupMalformedAiText({ force = false } = {}) {