mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
feat(mascot): load Human mascots from GitHub manifest (#4312)
This commit is contained in:
@@ -76,6 +76,18 @@ const GITHUB_REPO_SOURCE: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
|
||||
destinations: &["GitHub API (api.github.com)"],
|
||||
});
|
||||
|
||||
// Persona Pack fetches the published mascot manifest directly from GitHub raw
|
||||
// content, then downloads the selected runtime asset from the manifest's
|
||||
// declared file URL. The request is metadata-class (manifest and asset URLs),
|
||||
// but it does leave the device and bypasses the managed backend.
|
||||
const GITHUB_MASCOT_MANIFEST: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
|
||||
leaves_device: true,
|
||||
data_kind: PrivacyDataKind::Metadata,
|
||||
destinations: &[
|
||||
"GitHub raw content (raw.githubusercontent.com) and manifest-declared mascot asset hosts",
|
||||
],
|
||||
});
|
||||
|
||||
const SEARXNG_RAW_TO_CONFIGURED_INSTANCE: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
|
||||
leaves_device: true,
|
||||
data_kind: PrivacyDataKind::Raw,
|
||||
@@ -1352,7 +1364,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
description: "Personalize the assistant as one identity: set a display name and description, edit or reset the SOUL.md personality prompt, and reach mascot avatar and voice settings — all from a single Persona surface.",
|
||||
how_to: "Settings > Persona",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: None,
|
||||
privacy: GITHUB_MASCOT_MANIFEST,
|
||||
},
|
||||
Capability {
|
||||
id: "settings.manage_privacy_analytics",
|
||||
|
||||
@@ -102,6 +102,26 @@ fn local_only_capability_marks_no_destinations() {
|
||||
assert!(privacy.destinations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_pack_reports_github_mascot_manifest_destination() {
|
||||
let cap = lookup("settings.persona_pack").expect("persona pack capability exists");
|
||||
let privacy = cap.privacy.expect("persona pack is privacy-annotated");
|
||||
|
||||
assert!(
|
||||
privacy.leaves_device,
|
||||
"loading the mascot library fetches a GitHub-hosted manifest"
|
||||
);
|
||||
assert_eq!(privacy.data_kind, PrivacyDataKind::Metadata);
|
||||
let haystack = privacy.destinations.join(" | ").to_lowercase();
|
||||
assert!(
|
||||
haystack.contains("github")
|
||||
&& haystack.contains("raw.githubusercontent.com")
|
||||
&& haystack.contains("asset"),
|
||||
"destinations must disclose the GitHub raw manifest host and manifest asset hosts, got: {:?}",
|
||||
privacy.destinations
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unannotated_capability_serializes_without_privacy_field() {
|
||||
let cap = lookup("conversation.create").expect("capability exists");
|
||||
|
||||
@@ -294,11 +294,28 @@ fn parse_cue(v: &Value) -> Option<VisemeFrame> {
|
||||
if viseme.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let start = read_u64(v, &["start_ms", "time_ms", "t"]).unwrap_or(0);
|
||||
let end = read_u64(v, &["end_ms"])
|
||||
// Accept millisecond keys, or seconds keys (`startSeconds`/`endSeconds`, the
|
||||
// shape the cloud backend actually ships) converted to ms. Without the
|
||||
// seconds keys every frame collapsed to start=0/end=80 — the mascot mouth
|
||||
// then froze on the first viseme because the whole track had no real timing.
|
||||
let start = read_ms(
|
||||
v,
|
||||
&["start_ms", "time_ms", "t"],
|
||||
&["startSeconds", "start_seconds", "startSec"],
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let end = read_ms(v, &["end_ms"], &["endSeconds", "end_seconds", "endSec"])
|
||||
.or_else(|| {
|
||||
let t = read_u64(v, &["time_ms", "t"])?;
|
||||
let d = read_u64(v, &["duration_ms", "d"])?;
|
||||
let t = read_ms(
|
||||
v,
|
||||
&["time_ms", "t"],
|
||||
&["startSeconds", "start_seconds", "startSec"],
|
||||
)?;
|
||||
let d = read_ms(
|
||||
v,
|
||||
&["duration_ms", "d"],
|
||||
&["durationSeconds", "duration_seconds", "durationSec"],
|
||||
)?;
|
||||
Some(t + d)
|
||||
})
|
||||
.unwrap_or(start + 80);
|
||||
@@ -314,8 +331,8 @@ fn parse_cue(v: &Value) -> Option<VisemeFrame> {
|
||||
|
||||
fn parse_alignment(v: &Value) -> Option<AlignmentFrame> {
|
||||
let ch = v.get("char").and_then(Value::as_str)?.to_string();
|
||||
let start = read_u64(v, &["start_ms"])?;
|
||||
let end = read_u64(v, &["end_ms"])?;
|
||||
let start = read_ms(v, &["start_ms"], &["startSeconds", "start_seconds"])?;
|
||||
let end = read_ms(v, &["end_ms"], &["endSeconds", "end_seconds"])?;
|
||||
if end <= start {
|
||||
return None;
|
||||
}
|
||||
@@ -340,6 +357,27 @@ fn read_u64(v: &Value, keys: &[&str]) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a time value in milliseconds. Tries `ms_keys` first (integer or float
|
||||
/// ms), then `sec_keys` interpreted as seconds and converted to ms. This lets
|
||||
/// the parser tolerate both the `*_ms` contract and the backend's
|
||||
/// `*Seconds` shape without the caller caring which arrived.
|
||||
fn read_ms(v: &Value, ms_keys: &[&str], sec_keys: &[&str]) -> Option<u64> {
|
||||
if let Some(ms) = read_u64(v, ms_keys) {
|
||||
return Some(ms);
|
||||
}
|
||||
for k in sec_keys {
|
||||
if let Some(f) = v.get(*k).and_then(Value::as_f64) {
|
||||
if f.is_finite() && f >= 0.0 {
|
||||
return Some((f * 1000.0).round() as u64);
|
||||
}
|
||||
}
|
||||
if let Some(n) = v.get(*k).and_then(Value::as_u64) {
|
||||
return Some(n.saturating_mul(1000));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -383,6 +421,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_accepts_seconds_keys() {
|
||||
// The cloud backend ships per-frame timing as seconds (`startSeconds`/
|
||||
// `endSeconds`); the parser must convert to ms rather than dropping it
|
||||
// (which collapsed every frame to start=0/end=80 and froze the mouth).
|
||||
let raw = json!({
|
||||
"audio_base64": "AAA=",
|
||||
"visemes": [
|
||||
{ "viseme": "sil", "startSeconds": 0.0, "endSeconds": 0.12 },
|
||||
{ "viseme": "aa", "startSeconds": 0.12, "endSeconds": 0.45 },
|
||||
// A gap before the next cue (0.45 → 0.90) is a real pause: the
|
||||
// mouth rests there. Preserved because we keep the true ends.
|
||||
{ "viseme": "PP", "startSeconds": 0.9, "endSeconds": 1.05 },
|
||||
],
|
||||
"alignment": [
|
||||
{ "char": "h", "startSeconds": 0.0, "endSeconds": 0.05 },
|
||||
],
|
||||
});
|
||||
let r = normalize_response(&raw);
|
||||
assert_eq!(r.visemes.len(), 3);
|
||||
assert_eq!(
|
||||
r.visemes[0],
|
||||
VisemeFrame {
|
||||
viseme: "sil".into(),
|
||||
start_ms: 0,
|
||||
end_ms: 120
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
r.visemes[1],
|
||||
VisemeFrame {
|
||||
viseme: "aa".into(),
|
||||
start_ms: 120,
|
||||
end_ms: 450
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
r.visemes[2],
|
||||
VisemeFrame {
|
||||
viseme: "PP".into(),
|
||||
start_ms: 900,
|
||||
end_ms: 1050
|
||||
}
|
||||
);
|
||||
let alignment = r.alignment.expect("alignment present");
|
||||
assert_eq!(alignment.len(), 1);
|
||||
assert_eq!(alignment[0].start_ms, 0);
|
||||
assert_eq!(alignment[0].end_ms, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_accepts_short_seconds_duration_aliases() {
|
||||
let raw = json!({
|
||||
"audio_base64": "AAA=",
|
||||
"visemes": [
|
||||
{ "viseme": "aa", "startSec": 1.2, "durationSec": 0.15 },
|
||||
],
|
||||
});
|
||||
let r = normalize_response(&raw);
|
||||
assert_eq!(
|
||||
r.visemes,
|
||||
vec![VisemeFrame {
|
||||
viseme: "aa".into(),
|
||||
start_ms: 1200,
|
||||
end_ms: 1350
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_drops_malformed_cues() {
|
||||
let raw = json!({
|
||||
|
||||
Reference in New Issue
Block a user