Private
Public Access
Release 0.11.0 with guided calibration, source transitions and root repair
This commit is contained in:
1 parent
e55d37772a
commit
f74a658097
33 files changed
+855
-75
No files matched your search
@@ -39,3 +39,6 @@ test-diagnostics
|
|||||||
test-calibration
|
test-calibration
|
||||||
test-layout_refresh
|
test-layout_refresh
|
||||||
test-spatial_image
|
test-spatial_image
|
||||||
|
test-continuity
|
||||||
|
test-receiver_stream
|
||||||
|
test-source_bridge
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"id": "org.webosbrew.histogramhyperion",
|
"id": "org.webosbrew.histogramhyperion",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"type": "web",
|
"type": "web",
|
||||||
"title": "HyperDRM",
|
"title": "HyperDRM",
|
||||||
"appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR",
|
"appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR",
|
||||||
"iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png",
|
"iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png",
|
||||||
"sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM",
|
"sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM",
|
||||||
"rootRequired": true,
|
"rootRequired": true,
|
||||||
"ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.10.0_arm.ipk",
|
"ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.11.0_arm.ipk",
|
||||||
"ipkHash": {
|
"ipkHash": {
|
||||||
"sha256": "0ad040adfc0be402f5c83e783a8f9b4e78b33b59e5b04fa3d52652bf48ddda70"
|
"sha256": "4e2be7de5f6e06cca4a389d17b18c770aa47ef2d862aac651c16ed02f231d711"
|
||||||
},
|
},
|
||||||
"ipkSize": 1183836
|
"ipkSize": 1203394
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -9,6 +9,8 @@ Ambient Light for webOS, Hyperion and HyperHDR. An own webOS app and native ARM
|
|||||||
- Spatial colors from 16 LG histogram regions with interpolated transitions along the screen perimeter.
|
- Spatial colors from 16 LG histogram regions with interpolated transitions along the screen perimeter.
|
||||||
- LED count, ordering and screen rectangles read from the receiver at startup and refreshed every five seconds without capture restart. No fixed 200-LED layout; layouts with 50 and 500 LEDs were tested.
|
- LED count, ordering and screen rectangles read from the receiver at startup and refreshed every five seconds without capture restart. No fixed 200-LED layout; layouts with 50 and 500 LEDs were tested.
|
||||||
- Automatic detection of top/bottom black bars on the calibrated LG G4.
|
- Automatic detection of top/bottom black bars on the calibrated LG G4.
|
||||||
|
- Bounded last-color hold on empty/failed histogram reads, real black detection and 500 ms source transitions using a raw receiver LED subscription.
|
||||||
|
- Guided full-screen video calibration for additional TV models, with review before saving and a four-color validation video.
|
||||||
- Original saturation and brightness (both gains 1.0).
|
- Original saturation and brightness (both gains 1.0).
|
||||||
- Start, stop, status and persistent boot autostart switch in the TV app.
|
- Start, stop, status and persistent boot autostart switch in the TV app.
|
||||||
- Configurable IPv4/IPv6 receiver and JSON TCP port, optional token authentication, priority 149, up to 10 updates per second.
|
- Configurable IPv4/IPv6 receiver and JSON TCP port, optional token authentication, priority 149, up to 10 updates per second.
|
||||||
@@ -25,7 +27,7 @@ Only the own service is modified. Optional Auto PicCap can configure its receive
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The current package is [HyperDRM_0.10.0_arm.ipk](HyperDRM_0.10.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow.
|
The current package is [HyperDRM_0.11.0_arm.ipk](HyperDRM_0.11.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow.
|
||||||
|
|
||||||
Elevate the installed own service through Homebrew Channel:
|
Elevate the installed own service through Homebrew Channel:
|
||||||
|
|
||||||
@@ -139,3 +141,54 @@ While a capture session runs, a separate nonblocking authenticated JSON connecti
|
|||||||
Select Auto, LG G4 (measured), Average (uniform), or Custom model in TV calibration and select Save profile. The app obtains the model identifier through LG's system-property service. Auto selects an imported profile matching that exact identifier, then the built-in G4 series profile, otherwise uniform average colors. Unknown models are not assigned an invented spatial calibration. A manual G4 selection is available when model detection is unavailable.
|
Select Auto, LG G4 (measured), Average (uniform), or Custom model in TV calibration and select Save profile. The app obtains the model identifier through LG's system-property service. Auto selects an imported profile matching that exact identifier, then the built-in G4 series profile, otherwise uniform average colors. Unknown models are not assigned an invented spatial calibration. A manual G4 selection is available when model detection is unavailable.
|
||||||
|
|
||||||
Custom imports accept measured model JSON with 2-16 sorted clockwise perimeter points and histogram region indices 0-15. Profiles persist privately in the own service container and apply to the current session without restarting capture. See [profile format and calibration guidance](profiles/README.md). Only G4 hardware has been validated; importing a profile does not guarantee histogram support on another TV. Color calibration remains in Hyperion/HyperHDR.
|
Custom imports accept measured model JSON with 2-16 sorted clockwise perimeter points and histogram region indices 0-15. Profiles persist privately in the own service container and apply to the current session without restarting capture. See [profile format and calibration guidance](profiles/README.md). Only G4 hardware has been validated; importing a profile does not guarantee histogram support on another TV. Color calibration remains in Hyperion/HyperHDR.
|
||||||
|
|
||||||
|
## Color continuity and guided calibration
|
||||||
|
|
||||||
|
A populated histogram containing black pixels is a valid dark scene and is sent
|
||||||
|
immediately. Empty channel populations and failed reads are missing data, not
|
||||||
|
black. The last good colors are held for at most 1.5 seconds while capture remains
|
||||||
|
available; a longer failure triggers bounded capture recovery. Receiver failure
|
||||||
|
cannot be hidden by color holding. Real black does not wait for a recovery fade.
|
||||||
|
|
||||||
|
Source handovers retain the previous output briefly instead of clearing it before
|
||||||
|
PicCap connects. An independent nonblocking raw LED subscription provides the
|
||||||
|
previous PicCap colors for the return to HyperDRM, and a fresh PicCap target for
|
||||||
|
the forward transition. The transition lasts 500 ms, with a three-second overall
|
||||||
|
handover bound. Conversion from LED samples to histogram regions is approximate;
|
||||||
|
receiver color correction and configured smoothing remain in the receiver. If
|
||||||
|
streaming is unavailable, ordinary bounded source selection still works.
|
||||||
|
|
||||||
|
In **TV calibration**, select **Calibration** then **Start Auto Calibration**. The bundled H.264 video
|
||||||
|
shows a black reference and 32 white patches around all four edges. Keep the app
|
||||||
|
visible and the picture full-screen without zoom or bars. The service temporarily
|
||||||
|
disables PicCap handover and bar detection for this measurement, then restores
|
||||||
|
normal operation. Cancellation, Back, playback errors and a 110-second service
|
||||||
|
lease protect against leaving calibration active after the app closes.
|
||||||
|
|
||||||
|
Fresh measurements identify responsive edge regions. Global-only, absent or
|
||||||
|
ambiguous responses are rejected without overwriting a saved profile. Successful
|
||||||
|
measurements fill the Custom profile editor; choose **Save profile** to apply.
|
||||||
|
Select **Validate profile** afterwards: the top-left quadrant is red, top-right
|
||||||
|
green, bottom-right blue and bottom-left yellow. Check LED positions on every
|
||||||
|
edge. The measured profile is specific to the exact reported model; other models
|
||||||
|
still require their own measurement. This does not add HDR color calibration or
|
||||||
|
more independently captured regions.
|
||||||
|
|
||||||
|
An explicit app launch with `{"action":"calibrate"}` starts the same visible
|
||||||
|
wizard. The calibration video can be reproduced with
|
||||||
|
`python build_calibration_video.py --ffmpeg /path/to/ffmpeg`.
|
||||||
|
|
||||||
|
The TV UI separates Overview, Receiver and Calibration. Auto Calibration is also
|
||||||
|
linked from Overview. Fields use focusable selection frames; highlighting or
|
||||||
|
hovering a frame never focuses its input. OK or a click starts editing and opens
|
||||||
|
the TV keyboard. Keyboard dismissal or Back returns to the selection frame.
|
||||||
|
Only visible controls participate in arrow navigation. Custom profile JSON is
|
||||||
|
available through Edit profile JSON after selecting a custom profile.
|
||||||
|
|
||||||
|
If an update leaves an old process running without root, Overview shows Enable
|
||||||
|
Root Access. Changing autostart also handles a root-required reply automatically:
|
||||||
|
the app asks Homebrew Channel to elevate only its own service, restarts that
|
||||||
|
service, verifies UID 0, and retries the switch. Root and boot-hook failures have
|
||||||
|
separate messages. The explicit launch action `{"action":"repairRoot"}` invokes
|
||||||
|
the same visible repair flow. Homebrew Channel must be installed and permit the
|
||||||
|
request; denied elevation is reported without changing autostart.
|
||||||
+46
-7
@@ -1,18 +1,31 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
var pending=[];
|
var pending=[];
|
||||||
var targetDirty=false,receiver='Hyperion';
|
var targetDirty=false,receiver='Hyperion';
|
||||||
var profileDirty=false,profile='auto';
|
var profileDirty=false,profile='auto',advancedProfile=false;
|
||||||
var profileNames={auto:'Auto',g4:'LG G4 (measured)',average:'Average (uniform)',custom:'Custom model'};
|
var profileNames={auto:'Auto',g4:'LG G4 (measured)',average:'Average (uniform)',custom:'Custom model'};
|
||||||
function showProfile(){document.getElementById('profile').textContent='Profile: '+profileNames[profile];document.getElementById('customEditor').style.display=profile==='custom'?'block':'none';document.getElementById('customProfile').disabled=profile!=='custom';}
|
function showProfile(){
|
||||||
function call(method,done,data){
|
document.getElementById('profile').textContent='Profile: '+profileNames[profile];
|
||||||
|
var visible=profile==='custom' && advancedProfile;
|
||||||
|
document.getElementById('customEditor').style.display=visible?'block':'none';
|
||||||
|
document.getElementById('customProfile').disabled=!visible;
|
||||||
|
document.getElementById('editProfile').disabled=profile!=='custom';
|
||||||
|
document.getElementById('editProfile').textContent=visible?'Hide profile JSON':'Edit profile JSON';
|
||||||
|
}
|
||||||
|
|
||||||
|
function call(method,done,data){callUri('luna://org.webosbrew.histogramhyperion.service/'+method,done,data);}
|
||||||
|
function callUri(uri,done,data){
|
||||||
if(typeof PalmServiceBridge==='undefined'){done({returnValue:false,errorText:'This app requires an LG TV with the native service installed.'});return;}
|
if(typeof PalmServiceBridge==='undefined'){done({returnValue:false,errorText:'This app requires an LG TV with the native service installed.'});return;}
|
||||||
var bridge=new PalmServiceBridge(),timer,finished=false;
|
var bridge=new PalmServiceBridge(),timer,finished=false;
|
||||||
function finish(value){if(finished)return;finished=true;clearTimeout(timer);var i=pending.indexOf(bridge);if(i>=0)pending.splice(i,1);done(value);}
|
function finish(value){if(finished)return;finished=true;clearTimeout(timer);var i=pending.indexOf(bridge);if(i>=0)pending.splice(i,1);done(value);}
|
||||||
bridge.onservicecallback=function(raw){try{finish(JSON.parse(raw));}catch(e){finish({returnValue:false,errorText:'Invalid service response'});}};
|
bridge.onservicecallback=function(raw){try{finish(JSON.parse(raw));}catch(e){finish({returnValue:false,errorText:'Invalid service response'});}};
|
||||||
pending.push(bridge);timer=setTimeout(function(){bridge.cancel();finish({returnValue:false,errorText:'The service is not responding. Check the installation and service log.'});},10000);
|
pending.push(bridge);timer=setTimeout(function(){bridge.cancel();finish({returnValue:false,errorText:'The service is not responding. Check the installation and service log.'});},uri.indexOf('hbchannel')>=0?30000:10000);
|
||||||
bridge.call('luna://org.webosbrew.histogramhyperion.service/'+method,JSON.stringify(data||{}));
|
bridge.call(uri,JSON.stringify(data||{}));
|
||||||
}
|
}
|
||||||
function display(result){
|
function display(result){
|
||||||
|
if(typeof result.uid==='number'){
|
||||||
|
document.getElementById('grantRoot').style.display=result.uid===0?'none':'inline-block';
|
||||||
|
document.getElementById('rootHint').style.display=result.uid===0?'none':'block';
|
||||||
|
}
|
||||||
if(result.calibrationProfile && !profileDirty){
|
if(result.calibrationProfile && !profileDirty){
|
||||||
profile=result.calibrationProfile;showProfile();
|
profile=result.calibrationProfile;showProfile();
|
||||||
document.getElementById('customProfile').value=JSON.stringify({model:result.customModel||result.tvModel||'TV-MODEL',points:result.customPoints&&result.customPoints.length?result.customPoints:[]},null,2);
|
document.getElementById('customProfile').value=JSON.stringify({model:result.customModel||result.tvModel||'TV-MODEL',points:result.customPoints&&result.customPoints.length?result.customPoints:[]},null,2);
|
||||||
@@ -46,6 +59,8 @@ function display(result){
|
|||||||
else if(result.returnValue!==false)document.getElementById('status').textContent+=' · No current color output';
|
else if(result.returnValue!==false)document.getElementById('status').textContent+=' · No current color output';
|
||||||
if(result.blackBarDetection && result.running)document.getElementById('status').textContent+=result.letterboxDetected?' · Top/bottom black bars detected':' · Full-screen capture';
|
if(result.blackBarDetection && result.running)document.getElementById('status').textContent+=result.letterboxDetected?' · Top/bottom black bars detected':' · Full-screen capture';
|
||||||
if(result.spatialInterpolation)document.getElementById('status').textContent+=' · Smooth color transitions';
|
if(result.spatialInterpolation)document.getElementById('status').textContent+=' · Smooth color transitions';
|
||||||
|
if(result.holdingColors)document.getElementById('status').textContent+=' · Holding last valid colors (missing data)';
|
||||||
|
if(result.sourceFade)document.getElementById('status').textContent+=' · Blending capture sources';
|
||||||
if(result.retryScheduled)document.getElementById('status').textContent+=' · Retrying receiver or capture startup';
|
if(result.retryScheduled)document.getElementById('status').textContent+=' · Retrying receiver or capture startup';
|
||||||
if(result.retryPaused)document.getElementById('status').textContent+=' · Automatic retry paused until Start';
|
if(result.retryPaused)document.getElementById('status').textContent+=' · Automatic retry paused until Start';
|
||||||
var box=document.getElementById('regions');box.textContent='';
|
var box=document.getElementById('regions');box.textContent='';
|
||||||
@@ -57,7 +72,30 @@ document.getElementById('refresh').onclick=function(){call('status',display);};
|
|||||||
call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000);
|
call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000);
|
||||||
document.getElementById('start').focus();
|
document.getElementById('start').focus();
|
||||||
|
|
||||||
document.getElementById('autostart').onclick=function(){var button=this;var enabled=button.getAttribute('aria-checked')!=='true';button.disabled=true;call('autostart',function(r){button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:enabled});};
|
var repairingRoot=false,rootWaiters=[];
|
||||||
|
function repairRoot(done){
|
||||||
|
rootWaiters.push(done);if(repairingRoot)return;repairingRoot=true;document.getElementById('grantRoot').disabled=true;
|
||||||
|
document.getElementById('error').textContent='Granting root access to HyperDRM...';
|
||||||
|
function finish(error){repairingRoot=false;document.getElementById('grantRoot').disabled=false;if(error)document.getElementById('error').textContent=error;var waiters=rootWaiters;rootWaiters=[];waiters.forEach(function(callback){callback(!error);});}
|
||||||
|
callUri('luna://org.webosbrew.hbchannel.service/elevateService',function(r){
|
||||||
|
if(r.returnValue===false){finish('Homebrew Channel could not grant root access: '+(r.errorText||'Check Homebrew Channel installation.'));return;}
|
||||||
|
call('restartService',function(r){
|
||||||
|
if(r.returnValue===false){finish(r.errorText||'Could not restart the HyperDRM service.');return;}
|
||||||
|
var attempts=0;
|
||||||
|
function check(){call('status',function(s){if(s.uid===0){display(s);finish();}else if(++attempts<12)setTimeout(check,1000);else finish('The service is still missing root access. Elevate HyperDRM in Homebrew Channel and reopen the app.');});}
|
||||||
|
setTimeout(check,1500);
|
||||||
|
});
|
||||||
|
},{id:'org.webosbrew.histogramhyperion.service'});
|
||||||
|
}
|
||||||
|
document.getElementById('grantRoot').onclick=function(){repairRoot(function(ok){if(ok)call('status',display);});};
|
||||||
|
document.getElementById('autostart').onclick=function(){
|
||||||
|
var button=this,enabled=button.getAttribute('aria-checked')!=='true';button.disabled=true;
|
||||||
|
function save(){call('autostart',function(r){
|
||||||
|
if(r.rootRequired){repairRoot(function(ok){if(ok)save();else button.disabled=false;});return;}
|
||||||
|
button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);
|
||||||
|
},{enabled:enabled});}
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById('handover').onclick=function(){var button=this;button.disabled=true;call('handover',function(r){button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:button.getAttribute('aria-checked')!=='true'});};
|
document.getElementById('handover').onclick=function(){var button=this;button.disabled=true;call('handover',function(r){button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:button.getAttribute('aria-checked')!=='true'});};
|
||||||
['host','port','piccapPort','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});});
|
['host','port','piccapPort','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});});
|
||||||
@@ -75,4 +113,5 @@ document.getElementById('testConnection').onclick=function(){if(targetDirty){doc
|
|||||||
document.getElementById('profile').onclick=function(){var keys=['auto','g4','average','custom'];profile=keys[(keys.indexOf(profile)+1)%keys.length];profileDirty=true;showProfile();};
|
document.getElementById('profile').onclick=function(){var keys=['auto','g4','average','custom'];profile=keys[(keys.indexOf(profile)+1)%keys.length];profileDirty=true;showProfile();};
|
||||||
document.getElementById('customProfile').addEventListener('input',function(){profileDirty=true;});
|
document.getElementById('customProfile').addEventListener('input',function(){profileDirty=true;});
|
||||||
document.getElementById('saveProfile').onclick=function(){var data={profile:profile},button=this;if(profile==='custom'){try{var parsed=JSON.parse(document.getElementById('customProfile').value);data.model=parsed.model;data.points=parsed.points;}catch(e){document.getElementById('error').textContent='Custom profile must be valid JSON.';return;}}button.disabled=true;call('calibration',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}profileDirty=false;call('status',display);},data);};
|
document.getElementById('saveProfile').onclick=function(){var data={profile:profile},button=this;if(profile==='custom'){try{var parsed=JSON.parse(document.getElementById('customProfile').value);data.model=parsed.model;data.points=parsed.points;}catch(e){document.getElementById('error').textContent='Custom profile must be valid JSON.';return;}}button.disabled=true;call('calibration',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}profileDirty=false;call('status',display);},data);};
|
||||||
setupRemoteNavigation(['start','stop','refresh','autostart','handover','receiver','host','port','piccapPort','token','clearToken','saveTarget','testConnection','profile','customProfile','saveProfile'].map(function(id){return document.getElementById(id);}),document,window);
|
document.getElementById('editProfile').onclick=function(){advancedProfile=!advancedProfile;showProfile();if(advancedProfile)document.getElementById('customProfileField').focus();};
|
||||||
|
setupRemoteNavigation(['overviewTab','receiverTab','calibrationTab','grantRoot','start','stop','refresh','autostart','handover','calibrationShortcut','receiver','hostField','portField','piccapPortField','tokenField','clearToken','saveTarget','testConnection','calibrate','validateCalibration','profile','saveProfile','editProfile','customProfileField','cancelCalibration'].map(function(id){return document.getElementById(id);}),document,window);
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"id": "org.webosbrew.histogramhyperion", "version": "0.10.0", "vendor": "Local development", "type": "web", "main": "index.html", "title": "HyperDRM", "icon": "icon.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, "largeIcon": "large-icon.png", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
|
{"id": "org.webosbrew.histogramhyperion", "version": "0.11.0", "vendor": "Local development", "type": "web", "main": "index.html", "title": "HyperDRM", "icon": "icon.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, "largeIcon": "large-icon.png", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* Pure measurement processing; the video uses 32 clockwise edge patches. */
|
||||||
|
function calibrationFit(baseline,patches,model){
|
||||||
|
if(!model || baseline.length!==16 || patches.length!==32)throw new Error('Incomplete calibration measurements.');
|
||||||
|
var candidates=[],edges=[0,0,0,0];
|
||||||
|
for(var region=0;region<16;region++){
|
||||||
|
var responses=patches.map(function(p){if(!p || p.length!==16)throw new Error('Missing video measurements.');return Math.max(0,p[region]-baseline[region]);});
|
||||||
|
var peak=Math.max.apply(Math,responses),best=responses.indexOf(peak);
|
||||||
|
if(peak<8)continue;
|
||||||
|
// Reject regions responding equally around the screen (global histograms).
|
||||||
|
var strong=responses.filter(function(v){return v>=peak*.65;}).length;
|
||||||
|
if(strong>10)continue;
|
||||||
|
var distant=responses.some(function(v,i){var d=Math.min(Math.abs(i-best),32-Math.abs(i-best));return v>=peak*.65 && d>6;});
|
||||||
|
if(distant)continue;
|
||||||
|
var sum=0,weighted=0;
|
||||||
|
for(var offset=-3;offset<=3;offset++){
|
||||||
|
var index=(best+offset+32)%32,weight=Math.max(0,responses[index]-peak*.35);
|
||||||
|
weighted+=offset*weight;sum+=weight;
|
||||||
|
}
|
||||||
|
var position=((best+.5+weighted/sum)/8+4)%4;
|
||||||
|
candidates.push({position:position,region:region,score:peak/strong});
|
||||||
|
}
|
||||||
|
// Nested LG regions can measure the same edge position. Keep the most
|
||||||
|
// localized response rather than making duplicate interpolation anchors.
|
||||||
|
candidates.sort(function(a,b){return b.score-a.score || a.region-b.region;});
|
||||||
|
var points=[];
|
||||||
|
candidates.forEach(function(p){if(points.some(function(q){var d=Math.abs(p.position-q.position);return Math.min(d,4-d)<.06;}))return;points.push({position:p.position,region:p.region});edges[Math.floor(p.position)]++;});
|
||||||
|
points.sort(function(a,b){return a.position-b.position;});
|
||||||
|
if(points.length<4 || edges.some(function(n){return !n;}))throw new Error('No reliable spatial response on every edge. Keep the video full-screen; this TV may not expose local histogram regions. Your saved profile is unchanged.');
|
||||||
|
return {model:model,points:points};
|
||||||
|
}
|
||||||
|
function calibrationLuma(regions){return regions.map(function(c){return (c[0]+c[1]+c[2])/3;});}
|
||||||
|
function calibrationAverage(samples){
|
||||||
|
if(samples.length<3)throw new Error('Not enough fresh histogram samples. Keep the app visible and repeat calibration.');
|
||||||
|
return samples[0].map(function(_,r){return samples.reduce(function(sum,s){return sum+s[r];},0)/samples.length;});
|
||||||
|
}
|
||||||
Binary file not shown.
+58
-21
@@ -1,21 +1,58 @@
|
|||||||
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>HyperDRM</title>
|
<!doctype html>
|
||||||
<style>body{background:#101827;color:#f1f5ff;font:28px sans-serif;margin:70px;max-width:1400px}h1{font-size:52px;display:flex;align-items:center;gap:24px}h1 img{border-radius:20px}button{font:inherit;padding:20px 32px;margin:12px 12px 12px 0;background:#223654;color:white;border:2px solid #739be0;border-radius:12px}button:focus{outline:5px solid #66ddff}#regions{display:flex;flex-wrap:wrap;gap:8px}.swatch{width:64px;height:64px;border:1px solid #abc}p{line-height:1.5}#error{color:#ffbc99}small{color:#c0cbe0}fieldset{border:2px solid #739be0;border-radius:12px;padding:16px}label{display:inline-flex;flex-direction:column;font-size:24px;margin:8px 20px 8px 0}input,textarea{font:inherit;padding:12px;background:#223654;color:white;border:2px solid #739be0;border-radius:8px}input:focus,textarea:focus{outline:5px solid #66ddff}#host,#token{width:340px}#port,#piccapPort{width:140px}#clearToken{width:28px;height:28px}</style>
|
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>HyperDRM</title>
|
||||||
<h1><img src="logo.png" alt="" width="104" height="104">HyperDRM</h1><p>Ambient lighting for webOS, Hyperion and HyperHDR.</p><p id="status">Checking service …</p><p id="error" role="alert"></p>
|
<style>
|
||||||
<div role="group" aria-label="Service controls"><button id="start">Start</button><button id="stop">Stop</button><button id="refresh">Refresh status</button>
|
body{background:#101827;color:#f1f5ff;font:24px sans-serif;margin:36px 54px;max-width:1800px}
|
||||||
<button id="autostart" role="switch" aria-checked="false">Autostart: Off</button>
|
h1{font-size:40px;display:flex;align-items:center;gap:18px;margin:0 0 16px}h1 img{border-radius:16px}
|
||||||
<button id="handover" role="switch" aria-checked="false" disabled>Auto PicCap: Off</button></div><p><small>Use the arrow buttons to select a control and OK to activate it.</small></p><p><small>Autostart starts the service when the TV boots. Stop ends the current session. Auto PicCap uses installed PicCap for non-secure video and HyperDRM for protected or unknown playback. Keep PicCap's own autostart off. Stable unprotected playback selects PicCap; five seconds of connection loss restores HyperDRM, with a 30-second retry cooldown.</small></p>
|
button{font:inherit;padding:14px 24px;margin:8px 12px 8px 0;background:#223654;color:white;border:2px solid #739be0;border-radius:10px}
|
||||||
<fieldset><legend>Receiver settings</legend><button id="receiver" type="button">Receiver: Hyperion</button>
|
button:disabled{opacity:.45}button:focus,.field:focus{outline:4px solid #66ddff;outline-offset:3px}
|
||||||
<label>IP address <input id="host" type="text" maxlength="45" value="127.0.0.1" spellcheck="false"></label>
|
nav{border-bottom:1px solid #456185;padding-bottom:10px;margin-bottom:18px}nav button[aria-pressed="true"]{background:#126b85;border-color:#66ddff}
|
||||||
<label>JSON port <input id="port" type="number" min="1" max="65535" value="19444"></label>
|
.primary{background:#126b85;border-color:#66ddff}p{line-height:1.4;margin:12px 0}small{color:#c0cbe0;font-size:21px}
|
||||||
<label>PicCap FlatBuffer port <input id="piccapPort" type="number" min="1" max="65535" value="19400"></label>
|
#error{color:#ffbc99}fieldset{border:1px solid #456185;border-radius:12px;padding:18px 24px;margin:16px 0}
|
||||||
<label>API token (optional) <input id="token" type="password" maxlength="256" autocomplete="off"></label>
|
.fields{display:flex;flex-wrap:wrap;gap:18px;margin:18px 0}.field{border:2px solid #739be0;border-radius:10px;padding:12px;background:#1b2b43;width:360px;cursor:pointer}
|
||||||
<label><input id="clearToken" type="checkbox"> Remove saved token</label><button id="saveTarget" type="button">Save receiver</button><button id="testConnection" type="button">Test connection</button><p id="testResult" role="status">Connection test: Not tested</p>
|
.field label{display:flex;flex-direction:column;gap:10px;font-size:22px}.field input,.field textarea{font:inherit;color:white;background:transparent;border:0;outline:0;width:100%;padding:0;box-sizing:border-box}
|
||||||
<p><small>Use the receiver's JSON TCP port, usually 19444. IPv4 and IPv6 addresses are supported. Leave the token blank to keep a saved token. Save applies the new target to a running session. Use Up/Down to move between fields; Left/Right edit text.</small></p>
|
.field:focus-within{border-color:#66ddff}.field input:read-write,.field textarea:read-write{background:#223654;outline:2px solid #66ddff}
|
||||||
<p id="piccapTargetHint"><small>Auto PicCap uses the same receiver IP with its FlatBuffer port, usually 19400. Enable the FlatBuffer server on the receiver. When switching to PicCap, its saved address, port and Unix socket setting are updated; other PicCap preferences are preserved. PicCap supports IPv4 targets; remote IPv6 uses HyperDRM. The API token applies to JSON only.</small></p></fieldset>
|
.narrow{width:225px}.wide{width:95%;max-width:1200px}#clearToken{width:24px;height:24px}
|
||||||
<fieldset><legend>TV calibration</legend><button id="profile" type="button">Profile: Auto</button><button id="saveProfile" type="button">Save profile</button>
|
#regions{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}.swatch{width:50px;height:50px;border:1px solid #abc;border-radius:6px}
|
||||||
<p id="profileStatus">Detecting TV model...</p><div id="customEditor" style="display:none"><label>Custom model profile JSON <textarea id="customProfile" rows="5" cols="68" maxlength="2048" disabled spellcheck="false"></textarea></label></div>
|
#diagnostics{display:block;margin-top:14px;max-width:1500px}#cancelCalibration{display:none}
|
||||||
<p><small>Auto selects an imported profile matching this exact TV model, or the measured LG G4 profile. Other models use uniform average colors until calibrated. Select Custom and paste a measured model profile to import it. G4 can also be selected manually. Profiles change spatial mapping; adjust colors in Hyperion/HyperHDR.</small></p></fieldset>
|
#cancelCalibration:not(:disabled){display:block;position:fixed;right:24px;top:24px;z-index:101;font-size:20px;opacity:.65}
|
||||||
<p><small id="endpoint">Priority 149 · 10 updates per second</small></p>
|
#calibrationVideo{display:none;position:fixed;inset:0;width:100vw;height:100vh;object-fit:fill;background:black;z-index:100}
|
||||||
<p><small id="diagnostics"></small></p>
|
.steps{display:flex;gap:24px;flex-wrap:wrap}.steps p{flex:1;min-width:260px;color:#c0cbe0}.steps strong{display:block;color:#fff;margin-bottom:6px}
|
||||||
<div id="regions" aria-label="Histogram colors"></div><p><small>Adjust saturation, brightness and smoothing in your receiver. The receiver layout determines the number and positions of LEDs. Colors come from 16 LG histogram regions with interpolated edge colors. HyperHDR receives a generated color plane, which its image mapping converts to LED colors. The LED layout is checked every five seconds and updated automatically.</small></p>
|
</style>
|
||||||
<script src="remote.js"></script><script src="app.js"></script></html>
|
<h1><img src="logo.png" alt="" width="76" height="76">HyperDRM</h1>
|
||||||
|
<nav aria-label="App sections"><button id="overviewTab" aria-pressed="true">Overview</button><button id="receiverTab" aria-pressed="false">Receiver</button><button id="calibrationTab" aria-pressed="false">Calibration</button></nav>
|
||||||
|
<p id="status" role="status">Checking service …</p><p id="error" role="alert"></p>
|
||||||
|
<section id="overviewPanel" aria-label="Overview">
|
||||||
|
<button id="grantRoot" class="primary" style="display:none">Enable Root Access</button><p id="rootHint" style="display:none"><small>Homebrew Channel grants root to the HyperDRM service. The service then restarts with its updated permissions.</small></p>
|
||||||
|
<div role="group" aria-label="Service controls"><button id="start" class="primary">Start</button><button id="stop">Stop</button><button id="refresh">Refresh status</button></div>
|
||||||
|
<fieldset><legend>Automatic operation</legend><button id="autostart" role="switch" aria-checked="false">Autostart: Off</button><button id="handover" role="switch" aria-checked="false" disabled>Auto PicCap: Off</button>
|
||||||
|
<p><small>Autostart retries until the receiver is available. Auto PicCap uses PicCap for unprotected playback and HyperDRM for protected playback. Keep PicCap's own autostart off.</small></p></fieldset>
|
||||||
|
<button id="calibrationShortcut">Auto Calibration</button><small>Measure a spatial profile for your TV.</small>
|
||||||
|
<p><small id="endpoint">Priority 149 · 10 updates per second</small></p><div id="regions" aria-label="Histogram colors"></div>
|
||||||
|
<p><small>Adjust brightness, saturation and normal smoothing in Hyperion or HyperHDR.</small></p><small id="diagnostics"></small>
|
||||||
|
</section>
|
||||||
|
<section id="receiverPanel" aria-label="Receiver settings" style="display:none">
|
||||||
|
<fieldset><legend>Receiver connection</legend><button id="receiver">Receiver: Hyperion</button>
|
||||||
|
<p><small>Select a field with the arrow buttons. Press OK or click to open the TV keyboard.</small></p>
|
||||||
|
<div class="fields">
|
||||||
|
<div id="hostField" class="field" tabindex="0" role="button" aria-label="IP address, press OK to edit"><label>IP address<input id="host" tabindex="-1" readonly type="text" maxlength="45" value="127.0.0.1" spellcheck="false"></label></div>
|
||||||
|
<div id="portField" class="field narrow" tabindex="0" role="button" aria-label="JSON port, press OK to edit"><label>JSON port<input id="port" tabindex="-1" readonly type="number" min="1" max="65535" value="19444"></label></div>
|
||||||
|
<div id="piccapPortField" class="field narrow" tabindex="0" role="button" aria-label="FlatBuffer port, press OK to edit"><label>PicCap FlatBuffer port<input id="piccapPort" tabindex="-1" readonly type="number" min="1" max="65535" value="19400"></label></div>
|
||||||
|
<div id="tokenField" class="field" tabindex="0" role="button" aria-label="API token, press OK to edit"><label>API token (optional)<input id="token" tabindex="-1" readonly type="password" maxlength="256" autocomplete="off"></label></div>
|
||||||
|
</div>
|
||||||
|
<label><input id="clearToken" type="checkbox"> Remove saved token</label>
|
||||||
|
<div><button id="saveTarget" class="primary">Save receiver</button><button id="testConnection">Test connection</button></div><p id="testResult" role="status">Connection test: Not tested</p>
|
||||||
|
<p><small>The JSON TCP port is usually 19444. Leave the token blank to keep the saved token. Saving updates the current session.</small></p>
|
||||||
|
<p id="piccapTargetHint"><small>Auto PicCap uses the same IP and its FlatBuffer port, usually 19400. Enable the receiver's FlatBuffer server. Remote IPv6 uses HyperDRM.</small></p></fieldset>
|
||||||
|
</section>
|
||||||
|
<section id="calibrationPanel" aria-label="TV calibration" style="display:none">
|
||||||
|
<p id="profileStatus">Detecting TV model...</p>
|
||||||
|
<fieldset><legend>Auto Calibration</legend>
|
||||||
|
<div class="steps"><p><strong>1. Measure</strong>Play the built-in edge test. Keep the picture full-screen without bars.</p><p><strong>2. Save</strong>Review the result and save the measured profile.</p><p><strong>3. Check</strong>Validate all four colored screen corners against the LEDs.</p></div>
|
||||||
|
<button id="calibrate" class="primary">Start Auto Calibration</button><button id="validateCalibration">Validate profile</button><p id="wizardStatus" role="status">Measurement takes about 70 seconds. Your saved profile is preserved until you select Save profile.</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset><legend>Spatial profile</legend><button id="profile">Profile: Auto</button><button id="saveProfile" class="primary">Save profile</button><button id="editProfile" disabled>Edit profile JSON</button>
|
||||||
|
<p><small>Auto uses your measured model profile or the built-in LG G4 profile. Other uncalibrated TVs use average colors.</small></p>
|
||||||
|
<div id="customEditor" style="display:none"><div id="customProfileField" class="field wide" tabindex="0" role="button" aria-label="Custom profile JSON, press OK to edit"><label>Custom model profile JSON<textarea id="customProfile" tabindex="-1" readonly rows="4" maxlength="2048" disabled spellcheck="false"></textarea></label></div></div>
|
||||||
|
</fieldset></section>
|
||||||
|
<button id="cancelCalibration" disabled>Cancel calibration</button><video id="calibrationVideo" src="calibration.mp4" muted preload="auto" playsinline></video>
|
||||||
|
<script src="remote.js"></script><script src="calibration.js"></script><script src="navigation.js"></script><script src="app.js"></script><script src="wizard.js"></script></html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
function showPanel(name,focus){
|
||||||
|
['overview','receiver','calibration'].forEach(function(key){document.getElementById(key+'Panel').style.display=key===name?'block':'none';document.getElementById(key+'Tab').setAttribute('aria-pressed',String(key===name));});
|
||||||
|
if(focus!==false)document.getElementById({overview:'start',receiver:'receiver',calibration:'calibrate'}[name]).focus();
|
||||||
|
}
|
||||||
|
['overview','receiver','calibration'].forEach(function(name){document.getElementById(name+'Tab').onclick=function(){showPanel(name);};});
|
||||||
|
document.getElementById('calibrationShortcut').onclick=function(){showPanel('calibration');};
|
||||||
|
function setupFormEditors(ids,doc,win){
|
||||||
|
var pairs=ids.map(function(id){return {input:doc.getElementById(id),field:doc.getElementById(id+'Field')};});
|
||||||
|
pairs.forEach(function(pair){
|
||||||
|
var input=pair.input,field=pair.field;input.readOnly=true;
|
||||||
|
field.onclick=function(){if(input.disabled || !input.readOnly)return;input.readOnly=false;input.focus();};
|
||||||
|
input.addEventListener('blur',function(){input.readOnly=true;});
|
||||||
|
input.addEventListener('mousedown',function(event){if(input.readOnly)event.preventDefault();});
|
||||||
|
});
|
||||||
|
function finishEditing(){
|
||||||
|
pairs.forEach(function(pair){if(!pair.input.readOnly){pair.input.readOnly=true;pair.input.blur();pair.field.focus();}});
|
||||||
|
}
|
||||||
|
doc.addEventListener('keyboardStateChange',function(event){var detail=event.detail||{};if(detail.visibility===false || detail.isShowing===false)finishEditing();});
|
||||||
|
doc.addEventListener('keydown',function(event){if((event.keyCode===461 || event.key==='Escape') && pairs.some(function(pair){return !pair.input.readOnly;})){event.preventDefault();finishEditing();}});
|
||||||
|
}
|
||||||
|
setupFormEditors(['host','port','piccapPort','token','customProfile'],document,window);
|
||||||
+3
-3
@@ -11,10 +11,10 @@ function setupRemoteNavigation(buttons, doc, win) {
|
|||||||
event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0;
|
event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0;
|
||||||
var enter=event.key==='Enter'||code===13;
|
var enter=event.key==='Enter'||code===13;
|
||||||
if(!direction&&!enter)return;
|
if(!direction&&!enter)return;
|
||||||
var editing=doc.activeElement && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA');
|
var editing=doc.activeElement && !doc.activeElement.readOnly && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA');
|
||||||
if(editing && (enter||code===37||code===39||event.key==='ArrowLeft'||event.key==='ArrowRight'))return;
|
if(editing)return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
var enabled=buttons.filter(function(button){return !button.disabled;});
|
var enabled=buttons.filter(function(button){return !button.disabled && (typeof button.getClientRects!=='function' || button.getClientRects().length) && (!win.remoteNavigationScope || win.remoteNavigationScope.indexOf(button.id)>=0);});
|
||||||
if(!enabled.length)return;
|
if(!enabled.length)return;
|
||||||
var focused=doc.activeElement,index=enabled.indexOf(focused);
|
var focused=doc.activeElement,index=enabled.indexOf(focused);
|
||||||
if(direction){
|
if(direction){
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use strict';
|
||||||
|
var wizard=null;
|
||||||
|
var video=document.getElementById('calibrationVideo');
|
||||||
|
function wizardFinish(error){
|
||||||
|
if(!wizard)return;
|
||||||
|
clearInterval(wizard.timer);wizard=null;window.remoteNavigationScope=null;video.pause();video.style.display='none';
|
||||||
|
if(typeof video.removeAttribute==='function'){video.removeAttribute('src');video.load();}
|
||||||
|
document.getElementById('cancelCalibration').disabled=true;
|
||||||
|
document.getElementById('calibrate').disabled=false;document.getElementById('validateCalibration').disabled=false;
|
||||||
|
call('calibrationSession',function(r){if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:false});
|
||||||
|
call('calibrationResult',function(){},{message:error||document.getElementById('wizardStatus').textContent});
|
||||||
|
if(error)document.getElementById('wizardStatus').textContent=error;
|
||||||
|
document.getElementById('calibrate').focus();
|
||||||
|
}
|
||||||
|
function wizardSample(){
|
||||||
|
if(!wizard || wizard.busy)return;
|
||||||
|
if(Date.now()-wizard.started>100000){wizardFinish('Calibration timed out. Saved profile unchanged.');return;}
|
||||||
|
if(wizard.validation)return;
|
||||||
|
var t=video.currentTime;
|
||||||
|
if(t>=68){
|
||||||
|
try{
|
||||||
|
var measured=calibrationFit(calibrationAverage(wizard.baseline),wizard.patches.map(calibrationAverage),wizard.model);
|
||||||
|
profile='custom';profileDirty=true;showProfile();document.getElementById('customProfile').value=JSON.stringify(measured,null,2);
|
||||||
|
document.getElementById('wizardStatus').textContent='Measured '+measured.points.length+' edge regions. Select Save profile, then Validate profile to check all four colored edges. The previous saved profile is still active.';
|
||||||
|
wizard.busy=true;
|
||||||
|
call('calibrationDraft',function(r){if(wizard)wizardFinish(r.returnValue===false?r.errorText:undefined);},{profile:'custom',model:measured.model,points:measured.points});
|
||||||
|
}catch(e){wizardFinish(e.message);}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var step=t<4?-1:Math.floor((t-4)/2),local=t<4?t:(t-4)%2;
|
||||||
|
if(step<0 && (t<.8 || t>3.2) || step>=0 && (local<.65 || local>1.75))return;
|
||||||
|
wizard.busy=true;
|
||||||
|
call('status',function(s){
|
||||||
|
if(!wizard)return;
|
||||||
|
wizard.busy=false;
|
||||||
|
if(s.returnValue===false || !s.calibrating){wizardFinish(s.errorText||'Calibration session ended. Saved profile unchanged.');return;}
|
||||||
|
// Ignore duplicates, held output and replies crossing a stimulus boundary.
|
||||||
|
var current=video.currentTime,nowStep=current<4?-1:Math.floor((current-4)/2);
|
||||||
|
if(nowStep!==step || s.sampleSequence===wizard.sequence || s.sampleAgeMs<0 || s.sampleAgeMs>350 || !s.colorValid || !Array.isArray(s.regions) || s.regions.length!==16)return;
|
||||||
|
wizard.sequence=s.sampleSequence;
|
||||||
|
(step<0?wizard.baseline:wizard.patches[step]).push(calibrationLuma(s.regions));
|
||||||
|
document.getElementById('wizardStatus').textContent=step<0?'Measuring black reference...':'Measuring edge patch '+(step+1)+' of 32...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function wizardStart(validation){
|
||||||
|
if(wizard)return;
|
||||||
|
if(typeof showPanel==='function')showPanel('calibration',false);
|
||||||
|
if(targetDirty || profileDirty){document.getElementById('wizardStatus').textContent='Save receiver and profile edits before starting.';return;}
|
||||||
|
document.getElementById('calibrate').disabled=true;document.getElementById('validateCalibration').disabled=true;
|
||||||
|
document.getElementById('cancelCalibration').disabled=false;
|
||||||
|
window.remoteNavigationScope=['cancelCalibration'];
|
||||||
|
wizard={validation:validation,started:Date.now(),busy:false,sequence:-1,model:'',baseline:[],patches:Array.from({length:32},function(){return [];})};
|
||||||
|
document.getElementById('wizardStatus').textContent='Starting full-screen capture. Keep the app visible; do not change zoom or aspect ratio.';
|
||||||
|
call('calibrationSession',function(r){
|
||||||
|
if(!wizard)return;if(r.returnValue===false){wizardFinish(r.errorText);return;}
|
||||||
|
// Start the video plane before waiting for histogram data. Without another
|
||||||
|
// running video, waiting first would prevent calibration from ever starting.
|
||||||
|
video.src='calibration.mp4';if(typeof video.load==='function')video.load();
|
||||||
|
video.style.display='block';var warmup=video.play();
|
||||||
|
if(warmup && warmup.catch)warmup.catch(function(){wizardFinish('The TV could not play the calibration video. Saved profile unchanged.');});
|
||||||
|
call('start',function(){waitCapture();});
|
||||||
|
},{enabled:true});
|
||||||
|
function waitCapture(){
|
||||||
|
if(!wizard)return;
|
||||||
|
call('status',function(s){
|
||||||
|
if(!wizard)return;
|
||||||
|
if(s.running && s.activeSource!=='PicCap' && s.tvModel && s.colorValid){
|
||||||
|
wizard.model=s.tvModel;video.currentTime=validation?68:0;video.style.display='block';
|
||||||
|
document.getElementById('cancelCalibration').focus();
|
||||||
|
var playing=video.play();if(playing && playing.catch)playing.catch(function(){wizardFinish('The TV could not play the calibration video. Saved profile unchanged.');});
|
||||||
|
wizard.timer=setInterval(wizardSample,250);
|
||||||
|
}else if(Date.now()-wizard.started<15000)setTimeout(waitCapture,500);
|
||||||
|
else wizardFinish('No fresh full-screen histogram capture or TV model. Saved profile unchanged.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('calibrate').onclick=function(){wizardStart(false);};
|
||||||
|
document.getElementById('validateCalibration').onclick=function(){wizardStart(true);};
|
||||||
|
document.getElementById('cancelCalibration').onclick=function(){wizardFinish('Calibration cancelled. Saved profile unchanged.');};
|
||||||
|
video.addEventListener('ended',function(){if(wizard && wizard.validation)wizardFinish('Validation finished. Top-left red, top-right green, bottom-right blue, bottom-left yellow. Check matching LED positions.');else wizardSample();});
|
||||||
|
video.addEventListener('error',function(){wizardFinish('Calibration video playback failed. Saved profile unchanged.');});
|
||||||
|
document.addEventListener('keydown',function(e){if(wizard && (e.keyCode===461 || e.key==='Escape')){e.preventDefault();wizardFinish('Calibration cancelled. Saved profile unchanged.');}});
|
||||||
|
window.addEventListener('pagehide',function(){wizardFinish();});
|
||||||
|
// An explicit launch action also supports starting the TV's guided test remotely.
|
||||||
|
function wizardLaunch(raw){try{var params=typeof raw==='string'?JSON.parse(raw):raw;if(params && params.action==='calibrate')wizardStart(false);else if(params && params.action==='repairRoot'){showPanel('overview');repairRoot(function(ok){if(ok)call('status',display);});}}catch(e){document.getElementById('wizardStatus').textContent='Invalid launch parameters.';}}
|
||||||
|
if(typeof PalmSystem!=='undefined' && PalmSystem.launchParams)setTimeout(function(){wizardLaunch(PalmSystem.launchParams);},1000);
|
||||||
|
window.addEventListener('webOSRelaunch',function(){if(typeof PalmSystem!=='undefined')wizardLaunch(PalmSystem.launchParams);});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Build the deterministic native video-plane calibration stimulus (H.264)."""
|
||||||
|
import argparse, subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--ffmpeg',default='ffmpeg')
|
||||||
|
args=parser.parse_args()
|
||||||
|
root=Path(__file__).resolve().parent
|
||||||
|
width,height=1920,1080
|
||||||
|
filters=[]
|
||||||
|
for step in range(32):
|
||||||
|
edge,index=divmod(step,8)
|
||||||
|
center=(index+.5)/8
|
||||||
|
if edge==0: x,y,w,h=int(center*width-192),0,384,162
|
||||||
|
elif edge==1: x,y,w,h=1632,int(center*height-108),288,216
|
||||||
|
elif edge==2: x,y,w,h=int((1-center)*width-192),918,384,162
|
||||||
|
else: x,y,w,h=0,int((1-center)*height-108),288,216
|
||||||
|
left,top=max(0,x),max(0,y)
|
||||||
|
w,h=min(width,x+w)-left,min(height,y+h)-top
|
||||||
|
start=4+step*2
|
||||||
|
filters.append(f"drawbox=x={left}:y={top}:w={w}:h={h}:color=white:t=fill:enable='gte(t,{start})*lt(t,{start+2})'")
|
||||||
|
# Four color quadrants for visual validation after measurement.
|
||||||
|
for x,y,color in [(0,0,'red'),(960,0,'lime'),(960,540,'blue'),(0,540,'yellow')]:
|
||||||
|
filters.append(f"drawbox=x={x}:y={y}:w=960:h=540:color={color}:t=fill:enable='gte(t,68)'")
|
||||||
|
subprocess.run([args.ffmpeg,'-hide_banner','-loglevel','error','-y','-f','lavfi','-i',
|
||||||
|
'color=black:s=1920x1080:r=30:d=78','-vf',','.join(filters),
|
||||||
|
'-c:v','libx264','-preset','fast','-crf','16','-pix_fmt','yuv420p',
|
||||||
|
'-profile:v','main','-level','4.0','-g','30','-an','-movflags','+faststart',
|
||||||
|
str(root/'app/calibration.mp4')],check=True)
|
||||||
|
print(root/'app/calibration.mp4')
|
||||||
@@ -99,3 +99,40 @@ A separate nonblocking authenticated receiver probe refreshes the LED mapping ev
|
|||||||
The app automatically detected OLED65G42LW.DEUQLJP and selected the measured G4 profile. A live switch to Average produced one unique output color across four HyperHDR LED-stream samples; switching back to G4 produced 80-97 unique colors. Auto and the original receiver/handover preferences were restored afterward. Custom profiles validate 2-16 sorted rim positions, bounded region indices and model identifiers, persist privately, and are selected automatically only for the exact imported model identifier. No measured spatial profile for other hardware is claimed. Native tests passed profile parsing/persistence, model selection and average fallback. The synthetic-image test verifies geometry cache replacement after a custom-profile change.
|
The app automatically detected OLED65G42LW.DEUQLJP and selected the measured G4 profile. A live switch to Average produced one unique output color across four HyperHDR LED-stream samples; switching back to G4 produced 80-97 unique colors. Auto and the original receiver/handover preferences were restored afterward. Custom profiles validate 2-16 sorted rim positions, bounded region indices and model identifiers, persist privately, and are selected automatically only for the exact imported model identifier. No measured spatial profile for other hardware is claimed. Native tests passed profile parsing/persistence, model selection and average fallback. The synthetic-image test verifies geometry cache replacement after a custom-profile change.
|
||||||
|
|
||||||
The receiver UI and remote-navigation tests passed, including custom JSON input and profile controls. Diagnosis export was deliberately excluded from this release. Cold boot and other TV models remain unverified.
|
The receiver UI and remote-navigation tests passed, including custom JSON input and profile controls. Diagnosis export was deliberately excluded from this release. Cold boot and other TV models remain unverified.
|
||||||
|
|
||||||
|
## Color continuity, calibration wizard and TV navigation (0.11.0)
|
||||||
|
|
||||||
|
Empty histogram populations and failed reads are distinguished from populated
|
||||||
|
black frames. A bounded 1.5-second hold expires into capture recovery; valid dark
|
||||||
|
frames bypass the 500 ms recovery blend without altering their RGB values. ARM
|
||||||
|
tests verify hold timing, black populations, recovery mixing and near-black codec
|
||||||
|
rounding. An independent nonblocking raw receiver LED subscription accepts old
|
||||||
|
result and current data envelopes, authenticates when required, handles fragmented
|
||||||
|
messages, validates array values/counts and rebuilds mappings on layout/profile
|
||||||
|
changes. Source bridge tests cover both directions, fresh-sample gating, midpoint
|
||||||
|
and completion, genuine black, stale samples, timeout, connection setup and Stop.
|
||||||
|
|
||||||
|
The bundled reproducible H.264 calibration video measures 32 edge positions.
|
||||||
|
PicCap handover and bar detection are suppressed only for the bounded calibration
|
||||||
|
session. The wizard starts the video plane before waiting for capture, requires
|
||||||
|
fresh samples, rejects global/multimodal responses and selects the most localized
|
||||||
|
anchor when nested regions overlap. The live G4 run produced ten sorted anchors
|
||||||
|
covering all four edges and preserved the saved Auto/G4 profile. One source
|
||||||
|
revocation recovered in 252 ms. Other TV hardware remains unverified; candidates
|
||||||
|
require user review/save and four-corner validation. Failed measurements and
|
||||||
|
cancellation preserve the saved profile.
|
||||||
|
|
||||||
|
Overview, Receiver and Calibration are separate sections. Start Auto Calibration
|
||||||
|
is directly visible in Calibration and linked from Overview. Focusable field
|
||||||
|
frames keep native inputs unfocused during highlighting; OK/click explicitly
|
||||||
|
starts keyboard editing. The documented keyboardStateChange event and Back
|
||||||
|
restore frame navigation. Tests cover section selection, hidden/disabled controls,
|
||||||
|
input focus only after activation, keyboard dismissal, wizard scope and rejection,
|
||||||
|
proposal versus saved state, JSON validation and root-repair retries/denials.
|
||||||
|
|
||||||
|
The root repair targets only org.webosbrew.histogramhyperion.service via Homebrew
|
||||||
|
Channel and restarts only this service. The TV's current root process and boot
|
||||||
|
symlink were verified. Actual autostart disable/enable requests succeeded, with
|
||||||
|
Autostart restored to On. No receiver color correction, WLED configuration or
|
||||||
|
PicCap autostart setting was changed. A local browser preview was unavailable
|
||||||
|
because the browser policy rejected file URLs; no alternate browser path was used.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#ifndef HYPERDRM_CONTINUITY_H
|
||||||
|
#define HYPERDRM_CONTINUITY_H
|
||||||
|
/* Zero population is missing data, while populated bin zero is real black. */
|
||||||
|
static inline bool histogram_populated(const Histogram hist[16]){
|
||||||
|
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++){
|
||||||
|
uint64_t total=0;for(unsigned b=0;b<256;b++)total+=hist[r].bins[c][b];
|
||||||
|
if(!total)return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
typedef struct {unsigned char last[16][3],from[16][3];uint64_t valid_at,fade_at,missing_at;bool available,fading,holding;} Continuity;
|
||||||
|
static inline void continuity_seed(Continuity *s,const unsigned char colors[16][3],uint64_t now){
|
||||||
|
memcpy(s->last,colors,sizeof(s->last));memcpy(s->from,colors,sizeof(s->from));s->valid_at=now;s->available=true;s->fading=true;s->fade_at=0;s->missing_at=0;s->holding=false;
|
||||||
|
}
|
||||||
|
static inline void continuity_mix(const unsigned char from[16][3],const unsigned char to[16][3],unsigned elapsed,unsigned char out[16][3]){
|
||||||
|
if(elapsed>500)elapsed=500;
|
||||||
|
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)out[r][c]=(from[r][c]*(500-elapsed)+to[r][c]*elapsed+250)/500;
|
||||||
|
}
|
||||||
|
static inline bool continuity_frame(Continuity *s,bool valid,const unsigned char input[16][3],uint64_t now,unsigned char out[16][3]){
|
||||||
|
if(!valid){
|
||||||
|
if(!s->missing_at)s->missing_at=now;
|
||||||
|
s->holding=s->available && now-s->missing_at<1500;
|
||||||
|
if(s->holding)memcpy(out,s->last,sizeof(s->last));
|
||||||
|
return s->holding;
|
||||||
|
}
|
||||||
|
if(s->holding){memcpy(s->from,s->last,sizeof(s->from));s->fading=true;s->fade_at=0;}
|
||||||
|
s->holding=false;s->missing_at=0;
|
||||||
|
/* Near-black codec rounding is valid darkness, not a lost measurement. */
|
||||||
|
bool black=true;for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)if(input[r][c]>3)black=false;
|
||||||
|
if(black)s->fading=false;
|
||||||
|
if(s->fading){if(!s->fade_at)s->fade_at=now;continuity_mix(s->from,input,(unsigned)(now-s->fade_at),out);if(now-s->fade_at>=500)s->fading=false;}
|
||||||
|
else memcpy(out,input,sizeof(s->last));
|
||||||
|
memcpy(s->last,out,sizeof(s->last));s->available=true;s->valid_at=now;return true;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+5
-2
@@ -1,6 +1,9 @@
|
|||||||
#ifndef HYPERDRM_HANDOVER_H
|
#ifndef HYPERDRM_HANDOVER_H
|
||||||
#define HYPERDRM_HANDOVER_H
|
#define HYPERDRM_HANDOVER_H
|
||||||
#include "playback.h"
|
#include "playback.h"
|
||||||
|
#ifndef CALIBRATION_ACTIVE
|
||||||
|
#define CALIBRATION_ACTIVE false
|
||||||
|
#endif
|
||||||
#ifndef PICCAP_BINARY
|
#ifndef PICCAP_BINARY
|
||||||
#define PICCAP_BINARY "/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos"
|
#define PICCAP_BINARY "/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos"
|
||||||
#endif
|
#endif
|
||||||
@@ -107,11 +110,11 @@ static bool handover_tick(void){
|
|||||||
error_free(&e);next_media_probe=now+1000;
|
error_free(&e);next_media_probe=now+1000;
|
||||||
}
|
}
|
||||||
if(protection==PLAYBACK_CLEAR && now-protection_at>=3000){protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;}
|
if(protection==PLAYBACK_CLEAR && now-protection_at>=3000){protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;}
|
||||||
bool session=handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked;
|
bool session=!CALIBRATION_ACTIVE && handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked;
|
||||||
bool clear=session && protection==PLAYBACK_CLEAR && now-protection_at<3000;
|
bool clear=session && protection==PLAYBACK_CLEAR && now-protection_at<3000;
|
||||||
bool stable=clear && clear_samples>=3 && now-clear_since>=CLEAR_STABLE_MS && now>=handover_cooldown;
|
bool stable=clear && clear_samples>=3 && now-clear_since>=CLEAR_STABLE_MS && now>=handover_cooldown;
|
||||||
if(capture_source==SOURCE_HISTOGRAM && stable){
|
if(capture_source==SOURCE_HISTOGRAM && stable){
|
||||||
piccap_target=target;piccap_error[0]=0;piccap_disconnected_since=now;stop_capture();retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING;
|
piccap_target=target;piccap_error[0]=0;piccap_disconnected_since=now;release_capture(false);retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING;
|
||||||
if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS;}
|
if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS;}
|
||||||
}
|
}
|
||||||
bool grace=protection==PLAYBACK_UNKNOWN && unknown_since && now-unknown_since<UNKNOWN_GRACE_MS && capture_source==SOURCE_PICCAP;
|
bool grace=protection==PLAYBACK_UNKNOWN && unknown_since && now-unknown_since<UNKNOWN_GRACE_MS && capture_source==SOURCE_PICCAP;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#ifndef HYPERDRM_RECEIVER_STREAM_H
|
||||||
|
#define HYPERDRM_RECEIVER_STREAM_H
|
||||||
|
#ifndef RECEIVER_LAYOUT_REVISION
|
||||||
|
#define RECEIVER_LAYOUT_REVISION 0
|
||||||
|
#endif
|
||||||
|
/* Independent raw LED subscription: bounded, nonblocking, before receiver
|
||||||
|
* color correction. Old result and newer data envelopes are both accepted. */
|
||||||
|
typedef struct {int fd,stage;uint64_t deadline,retry_at,updated;unsigned sequence,count,revision,layout_revision;LedBlend *mapping;char *data,*request;size_t used,sent,length;unsigned char colors[16][3];bool valid;} ReceiverStream;
|
||||||
|
static ReceiverStream stream={.fd=-1};
|
||||||
|
static void stream_close(uint64_t now){if(stream.fd>=0)close(stream.fd);free(stream.mapping);free(stream.data);free(stream.request);stream=(ReceiverStream){.fd=-1,.retry_at=now+5000};}
|
||||||
|
static bool stream_queue(const char *line){
|
||||||
|
if(stream.fd<0 || stream.request)return false;
|
||||||
|
stream.request=strdup(line);if(!stream.request)return false;stream.length=strlen(line);stream.sent=0;return true;
|
||||||
|
}
|
||||||
|
static void stream_start(uint64_t now){
|
||||||
|
stream.fd=target_open(&target);if(stream.fd<0){stream.retry_at=now+5000;return;}
|
||||||
|
stream.data=malloc(262145);if(!stream.data){stream_close(now);return;}stream.stage=1;stream.deadline=now+3000;stream.revision=calibration_revision();stream.layout_revision=RECEIVER_LAYOUT_REVISION;
|
||||||
|
}
|
||||||
|
static bool stream_colors(PlaybackJson *j,void *root,uint64_t now){
|
||||||
|
void *body=playback_field(j,root,"result");if(!body)body=playback_field(j,root,"data");
|
||||||
|
void *leds=playback_field(j,body,"leds");
|
||||||
|
if(!leds || j->type(leds)!=5)return false;
|
||||||
|
if(j->length(leds)!=(size_t)stream.count*3){stream.revision=0;return false;}
|
||||||
|
uint64_t sums[16][3]={{0}},weights[16]={0};
|
||||||
|
for(unsigned i=0;i<stream.count;i++){
|
||||||
|
unsigned values[3];for(unsigned c=0;c<3;c++){void *v=j->at(leds,i*3+c);if(!v || j->type(v)!=3)return false;char *end;long n=strtol(j->string(v),&end,10);if(*end || n<0 || n>255)return false;values[c]=(unsigned)n;}
|
||||||
|
LedBlend b=stream.mapping[i];unsigned w[2]={65535-b.weight,b.weight},r[2]={b.a,b.b};
|
||||||
|
for(unsigned k=0;k<2;k++){weights[r[k]]+=w[k];for(unsigned c=0;c<3;c++)sums[r[k]][c]+=(uint64_t)values[c]*w[k];}
|
||||||
|
}
|
||||||
|
unsigned char mean[3]={0};uint64_t total=0,all[3]={0};
|
||||||
|
for(unsigned r=0;r<16;r++){total+=weights[r];for(unsigned c=0;c<3;c++)all[c]+=sums[r][c];}
|
||||||
|
if(!total)return false;
|
||||||
|
for(unsigned c=0;c<3;c++)mean[c]=(unsigned char)(all[c]/total);
|
||||||
|
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)stream.colors[r][c]=weights[r]?(unsigned char)(sums[r][c]/weights[r]):mean[c];
|
||||||
|
stream.valid=true;stream.updated=now;stream.sequence++;return true;
|
||||||
|
}
|
||||||
|
static void stream_tick(PlaybackJson *j,uint64_t now,bool enabled){
|
||||||
|
if(!enabled){if(stream.fd>=0)stream_close(now);return;}
|
||||||
|
if(stream.fd>=0 && (stream.revision!=calibration_revision() || stream.layout_revision!=RECEIVER_LAYOUT_REVISION)){stream_close(now);stream.retry_at=now;}
|
||||||
|
if(stream.fd<0){if(now>=stream.retry_at)stream_start(now);return;}
|
||||||
|
if(stream.stage<4 && now>=stream.deadline){stream_close(now);return;}
|
||||||
|
if(stream.stage==1){
|
||||||
|
struct pollfd p={stream.fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error);if(poll(&p,1,0)<=0)return;
|
||||||
|
if(getsockopt(stream.fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){stream_close(now);return;}
|
||||||
|
char line[384];if(target.token[0]){snprintf(line,sizeof(line),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",target.token);stream.stage=2;}
|
||||||
|
else {snprintf(line,sizeof(line),"{\"command\":\"serverinfo\"}\n");stream.stage=3;}
|
||||||
|
if(!stream_queue(line)){stream_close(now);return;}
|
||||||
|
}
|
||||||
|
if(stream.request){
|
||||||
|
ssize_t n=send(stream.fd,stream.request+stream.sent,stream.length-stream.sent,MSG_NOSIGNAL);
|
||||||
|
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return;
|
||||||
|
if(n<=0){stream_close(now);return;}stream.sent+=(size_t)n;
|
||||||
|
if(stream.sent==stream.length){free(stream.request);stream.request=NULL;}
|
||||||
|
}
|
||||||
|
if(stream.used==262144){stream_close(now);return;}
|
||||||
|
size_t available=262144-stream.used;if(available>65536)available=65536;
|
||||||
|
ssize_t n=recv(stream.fd,stream.data+stream.used,available,0);
|
||||||
|
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return;
|
||||||
|
if(n<=0){stream_close(now);return;}stream.used+=(size_t)n;stream.data[stream.used]=0;
|
||||||
|
for(unsigned messages=0;messages<8;messages++){
|
||||||
|
char *end=strchr(stream.data,'\n');if(!end)break;*end=0;void *root=j->parse(stream.data);
|
||||||
|
if(stream.stage==2){if(!playback_true(j,root,"success")){if(root)j->put(root);stream_close(now);return;}stream.stage=3;stream_queue("{\"command\":\"serverinfo\"}\n");stream.deadline=now+3000;}
|
||||||
|
else if(stream.stage==3){
|
||||||
|
LedBlend *mapping=NULL;unsigned count=0;if(!layout_from_json(stream.data,&mapping,&count)){if(root)j->put(root);stream_close(now);return;}
|
||||||
|
stream.mapping=mapping;stream.count=count;stream.stage=4;stream_queue("{\"command\":\"ledcolors\",\"subcommand\":\"ledstream-start\",\"interval\":100}\n");
|
||||||
|
}else if(playback_is(j,root,"command","ledcolors-ledstream-update"))stream_colors(j,root,now);
|
||||||
|
if(root)j->put(root);
|
||||||
|
size_t consumed=(size_t)(end-stream.data)+1;memmove(stream.data,stream.data+consumed,stream.used-consumed);stream.used-=consumed;stream.data[stream.used]=0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+87
-27
@@ -27,6 +27,7 @@
|
|||||||
#include "target.h"
|
#include "target.h"
|
||||||
#include "spatial_image.h"
|
#include "spatial_image.h"
|
||||||
#include "diagnostics.h"
|
#include "diagnostics.h"
|
||||||
|
#include "continuity.h"
|
||||||
#define SERVICE "org.webosbrew.histogramhyperion.service"
|
#define SERVICE "org.webosbrew.histogramhyperion.service"
|
||||||
#define PRIORITY 149
|
#define PRIORITY 149
|
||||||
typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError;
|
typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError;
|
||||||
@@ -60,6 +61,10 @@ static unsigned letterbox_mode,settling;
|
|||||||
static int pending_letterbox=-1;
|
static int pending_letterbox=-1;
|
||||||
static uint64_t candidate_since,next_border_probe;
|
static uint64_t candidate_since,next_border_probe;
|
||||||
static bool color_valid;
|
static bool color_valid;
|
||||||
|
static Continuity continuity;
|
||||||
|
static uint64_t sample_at,calibration_until;
|
||||||
|
static unsigned sample_sequence;
|
||||||
|
#define CALIBRATION_ACTIVE (calibration_until>milliseconds())
|
||||||
static unsigned led_count;
|
static unsigned led_count;
|
||||||
static LedBlend *led_blends;
|
static LedBlend *led_blends;
|
||||||
static bool interpolation_enabled;
|
static bool interpolation_enabled;
|
||||||
@@ -108,6 +113,12 @@ static int send_line(const char *line){
|
|||||||
while(left){ssize_t n=send(output,p,left,MSG_NOSIGNAL);if(n<=0)return -1;p+=n;left-=n;}
|
while(left){ssize_t n=send(output,p,left,MSG_NOSIGNAL);if(n<=0)return -1;p+=n;left-=n;}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
static bool receiver_ack(void){
|
||||||
|
char answer[4096];size_t used=0;bool complete=false;
|
||||||
|
while(used+1<sizeof(answer)){ssize_t n=recv(output,answer+used,sizeof(answer)-used-1,0);if(n<=0)break;used+=(size_t)n;answer[used]=0;if(strchr(answer,'\n')){complete=true;break;}}
|
||||||
|
PlaybackJson json={0};if(!complete || !playback_json_init(&json))return false;
|
||||||
|
void *root=json.parse(answer);bool ok=playback_true(&json,root,"success");if(root)json.put(root);dlclose(json.lib);return ok;
|
||||||
|
}
|
||||||
static bool read_layout(void){
|
static bool read_layout(void){
|
||||||
const size_t capacity=2*1024*1024;char *data=malloc(capacity+1);if(!data)return false;
|
const size_t capacity=2*1024*1024;char *data=malloc(capacity+1);if(!data)return false;
|
||||||
bool complete=false;size_t used=0;
|
bool complete=false;size_t used=0;
|
||||||
@@ -141,6 +152,34 @@ static void stop_capture(void){release_capture(true);}
|
|||||||
static bool respond(void *h,void *m,const char *s){if(!h||!m)return true;LSError e;error_init(&e);bool ok=reply(h,m,s,&e);error_free(&e);return ok;}
|
static bool respond(void *h,void *m,const char *s){if(!h||!m)return true;LSError e;error_init(&e);bool ok=reply(h,m,s,&e);error_free(&e);return ok;}
|
||||||
#include "handover.h"
|
#include "handover.h"
|
||||||
#include "layout_refresh.h"
|
#include "layout_refresh.h"
|
||||||
|
#define RECEIVER_LAYOUT_REVISION layout_updates
|
||||||
|
#include "receiver_stream.h"
|
||||||
|
#include "source_bridge.h"
|
||||||
|
static bool calibration_session(void *h,void *m,void *ctx){
|
||||||
|
(void)ctx;const char *p=payload(m);
|
||||||
|
if(!strcmp(p,"{\"enabled\":true}")){
|
||||||
|
calibration_until=milliseconds()+110000;
|
||||||
|
if(capture && vt_preset(capture,0)!=0){calibration_until=0;return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot select full-screen calibration capture\"}");}
|
||||||
|
capture_preset=letterbox_mode=0;settling=2;pending_letterbox=-1;
|
||||||
|
}else if(!strcmp(p,"{\"enabled\":false}"))calibration_until=0;
|
||||||
|
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}");
|
||||||
|
return respond(h,m,"{\"returnValue\":true}");
|
||||||
|
}
|
||||||
|
static Calibration calibration_draft;
|
||||||
|
static char calibration_message[256];
|
||||||
|
static bool report_calibration(void *h,void *m,void *ctx){
|
||||||
|
if(!playback_json.lib)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"JSON-C is unavailable\"}");
|
||||||
|
(void)ctx;void *root=playback_json.parse(payload(m)),*message=playback_field(&playback_json,root,"message");
|
||||||
|
bool valid=message && playback_json.type(message)==6 && strlen(playback_json.string(message))<sizeof(calibration_message);
|
||||||
|
if(valid)snprintf(calibration_message,sizeof(calibration_message),"%s",playback_json.string(message));
|
||||||
|
if(root)playback_json.put(root);
|
||||||
|
return respond(h,m,valid?"{\"returnValue\":true}":"{\"returnValue\":false,\"errorText\":\"Invalid calibration result message\"}");
|
||||||
|
}
|
||||||
|
static bool draft_calibration(void *h,void *m,void *ctx){
|
||||||
|
(void)ctx;Calibration next={0};
|
||||||
|
if(!playback_json.lib || !calibration_parse(&playback_json,payload(m),&next) || next.kind!=CAL_CUSTOM || next.count<4 || strcmp(next.model,tv_model))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Invalid measured model profile\"}");
|
||||||
|
calibration_draft=next;return respond(h,m,"{\"returnValue\":true}");
|
||||||
|
}
|
||||||
static bool model_busy;
|
static bool model_busy;
|
||||||
static unsigned long model_token;
|
static unsigned long model_token;
|
||||||
static uint64_t model_deadline,next_model_probe;
|
static uint64_t model_deadline,next_model_probe;
|
||||||
@@ -192,17 +231,31 @@ static bool start(void *h,void *m,void *ctx){
|
|||||||
if(recovery_started){last_recovery_ms=milliseconds()-recovery_started;recovery_started=0;}
|
if(recovery_started){last_recovery_ms=milliseconds()-recovery_started;recovery_started=0;}
|
||||||
return respond(h,m,"{\"returnValue\":true,\"running\":true}");
|
return respond(h,m,"{\"returnValue\":true,\"running\":true}");
|
||||||
}
|
}
|
||||||
static bool stop(void *h,void *m,void *ctx){(void)ctx;retry_pause(&retries);stop_capture();return respond(h,m,"{\"returnValue\":true,\"running\":false}");}
|
static bool stop(void *h,void *m,void *ctx){
|
||||||
|
(void)ctx;calibration_until=0;retry_pause(&retries);
|
||||||
|
bool bridging=bridge_phase!=0;stream_close(milliseconds());bridge_phase=0;
|
||||||
|
memset(&continuity,0,sizeof(continuity));stop_capture();
|
||||||
|
if(bridging){
|
||||||
|
output=target_connect(&target);
|
||||||
|
if(output>=0){if(target_login())send_line("{\"command\":\"clear\",\"priority\":149}\n");close(output);output=-1;}
|
||||||
|
}
|
||||||
|
return respond(h,m,"{\"returnValue\":true,\"running\":false}");
|
||||||
|
}
|
||||||
static bool autostart(void *h,void *m,void *ctx){
|
static bool autostart(void *h,void *m,void *ctx){
|
||||||
(void)ctx;const char *p=m?payload(m):NULL;bool enabled;
|
(void)ctx;const char *p=m?payload(m):NULL;bool enabled;
|
||||||
if(p && strcmp(p,"{\"enabled\":true}")==0)enabled=true;
|
if(p && strcmp(p,"{\"enabled\":true}")==0)enabled=true;
|
||||||
else if(p && strcmp(p,"{\"enabled\":false}")==0)enabled=false;
|
else if(p && strcmp(p,"{\"enabled\":false}")==0)enabled=false;
|
||||||
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}");
|
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}");
|
||||||
if(geteuid()!=0 || !set_boot(enabled))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Autostart could not be saved. Check own service root elevation and boot hook.\"}");
|
if(geteuid()!=0)return respond(h,m,"{\"returnValue\":false,\"rootRequired\":true,\"errorText\":\"Root access is required to change autostart. Select Enable Root Access in Overview.\"}");
|
||||||
|
if(!set_boot(enabled))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot update the HyperDRM boot hook. Check the installed boot script and whether another file occupies the hook path.\"}");
|
||||||
if(enabled)retry_reset(&retries);
|
if(enabled)retry_reset(&retries);
|
||||||
retry_update(&retries,enabled,capture!=NULL,milliseconds());
|
retry_update(&retries,enabled,capture!=NULL,milliseconds());
|
||||||
return respond(h,m,enabled?"{\"returnValue\":true,\"autostart\":true}":"{\"returnValue\":true,\"autostart\":false}");
|
return respond(h,m,enabled?"{\"returnValue\":true,\"autostart\":true}":"{\"returnValue\":true,\"autostart\":false}");
|
||||||
}
|
}
|
||||||
|
static bool restart_service(void *h,void *m,void *ctx){
|
||||||
|
(void)ctx;if(strcmp(payload(m),"{}"))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Restart expects an empty object\"}");
|
||||||
|
bool ok=respond(h,m,"{\"returnValue\":true,\"restarting\":true}");quitting=1;return ok;
|
||||||
|
}
|
||||||
static bool handover(void *h,void *m,void *ctx){
|
static bool handover(void *h,void *m,void *ctx){
|
||||||
(void)ctx;const char *p=payload(m);bool enabled;
|
(void)ctx;const char *p=payload(m);bool enabled;
|
||||||
if(strcmp(p,"{\"enabled\":true}")==0)enabled=true;
|
if(strcmp(p,"{\"enabled\":true}")==0)enabled=true;
|
||||||
@@ -219,7 +272,7 @@ static bool settings(void *h,void *m,void *ctx){
|
|||||||
if(!playback_json.lib || !target_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Enter a valid IPv4/IPv6 address, integer port (1-65535), receiver and optional API token\"}");
|
if(!playback_json.lib || !target_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Enter a valid IPv4/IPv6 address, integer port (1-65535), receiver and optional API token\"}");
|
||||||
if(!target_save(TARGET_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}");
|
if(!target_save(TARGET_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}");
|
||||||
bool active=capture || piccap_owned || retries.due;
|
bool active=capture || piccap_owned || retries.due;
|
||||||
stop_capture();test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=0;
|
stop_capture();stream_close(milliseconds());bridge_phase=0;memset(&continuity,0,sizeof(continuity));test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=0;
|
||||||
if(active && !retries.paused && !retries.blocked)retries.due=milliseconds();
|
if(active && !retries.paused && !retries.blocked)retries.due=milliseconds();
|
||||||
return respond(h,m,"{\"returnValue\":true}");
|
return respond(h,m,"{\"returnValue\":true}");
|
||||||
}
|
}
|
||||||
@@ -230,15 +283,21 @@ static bool test_connection(void *h,void *m,void *ctx){
|
|||||||
return respond(h,m,"{\"returnValue\":true,\"testing\":true}");
|
return respond(h,m,"{\"returnValue\":true,\"testing\":true}");
|
||||||
}
|
}
|
||||||
static bool status(void *h,void *m,void *ctx){
|
static bool status(void *h,void *m,void *ctx){
|
||||||
(void)ctx;char json[4096],safe[512],endpoint[80];size_t n=0;
|
(void)ctx;char json[8192],safe[512],endpoint[80];size_t n=0;
|
||||||
snprintf(endpoint,sizeof(endpoint),strchr(target.host,':')?"[%s]:%u":"%s:%u",target.host,target.port);
|
snprintf(endpoint,sizeof(endpoint),strchr(target.host,':')?"[%s]:%u":"%s:%u",target.host,target.port);
|
||||||
for(size_t i=0;last_error[i] && n+2<sizeof(safe);i++){char c=last_error[i];if(c=='"'||c=='\\')safe[n++]='\\';if((unsigned char)c>=32)safe[n++]=c;}safe[n]=0;
|
for(size_t i=0;last_error[i] && n+2<sizeof(safe);i++){char c=last_error[i];if(c=='"'||c=='\\')safe[n++]='\\';if((unsigned char)c>=32)safe[n++]=c;}safe[n]=0;
|
||||||
uint64_t now=milliseconds();
|
uint64_t now=milliseconds();
|
||||||
|
char report[512];size_t k=0;for(size_t i=0;calibration_message[i] && k+2<sizeof(report);i++){char c=calibration_message[i];if(c=='"'||c=='\\')report[k++]='\\';if((unsigned char)c>=32)report[k++]=c;}report[k]=0;
|
||||||
int used=snprintf(json,sizeof(json),"{\"calibrationProfile\":\"%s\",\"effectiveCalibration\":\"%s\",\"tvModel\":\"%s\",\"customModel\":\"%s\",\"customPoints\":[",calibration_key(calibration.kind),calibration_key(calibration_effective()),tv_model,calibration.model);
|
int used=snprintf(json,sizeof(json),"{\"calibrationProfile\":\"%s\",\"effectiveCalibration\":\"%s\",\"tvModel\":\"%s\",\"customModel\":\"%s\",\"customPoints\":[",calibration_key(calibration.kind),calibration_key(calibration_effective()),tv_model,calibration.model);
|
||||||
for(unsigned i=0;i<calibration.count;i++)used+=snprintf(json+used,sizeof(json)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",calibration.points[i].position,calibration.points[i].region);
|
for(unsigned i=0;i<calibration.count;i++)used+=snprintf(json+used,sizeof(json)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",calibration.points[i].position,calibration.points[i].region);
|
||||||
used+=snprintf(json+used,sizeof(json)-used,"],\"layoutUpdates\":%u,\"lastLayoutCheckAgeMs\":%lld,\"layoutError\":\"%s\",\"piccapFallbacks\":%u,\"piccapCooldownMs\":%llu,",layout_updates,last_layout_check?(long long)(now-last_layout_check):-1,layout_error,piccap_fallbacks,(unsigned long long)(handover_cooldown>now?handover_cooldown-now:0));
|
used+=snprintf(json+used,sizeof(json)-used,"],\"layoutUpdates\":%u,\"lastLayoutCheckAgeMs\":%lld,\"layoutError\":\"%s\",\"piccapFallbacks\":%u,\"piccapCooldownMs\":%llu,",layout_updates,last_layout_check?(long long)(now-last_layout_check):-1,layout_error,piccap_fallbacks,(unsigned long long)(handover_cooldown>now?handover_cooldown-now:0));
|
||||||
|
used+=snprintf(json+used,sizeof(json)-used,"\"calibrationMessage\":\"%s\",",report);
|
||||||
|
used+=snprintf(json+used,sizeof(json)-used,"\"calibrationDraft\":{\"model\":\"%s\",\"points\":[",calibration_draft.model);
|
||||||
|
for(unsigned i=0;i<calibration_draft.count;i++)used+=snprintf(json+used,sizeof(json)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",calibration_draft.points[i].position,calibration_draft.points[i].region);
|
||||||
|
used+=snprintf(json+used,sizeof(json)-used,"]},\"receiverStreamConnected\":%s,",stream.stage==4?"true":"false");
|
||||||
|
used+=snprintf(json+used,sizeof(json)-used,"\"sampleSequence\":%u,\"sampleAgeMs\":%lld,\"holdingColors\":%s,\"sourceFade\":%s,\"calibrating\":%s,",sample_sequence,sample_at?(long long)(now-sample_at):-1,continuity.holding?"true":"false",(continuity.fading || bridge_phase==3)?"true":"false",CALIBRATION_ACTIVE?"true":"false");
|
||||||
used+=snprintf(json+used,sizeof(json)-used,"\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\",\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",",target.piccap_port,test_stage_name(),connection_test.leds,(unsigned long long)connection_test.elapsed,connection_test.error,last_output_at?(long long)(now-last_output_at):-1,(unsigned long long)(retries.due>now?retries.due-now:0),capture_source==SOURCE_PICCAP_STARTING?"Starting PicCap":capture_source==SOURCE_PICCAP_STOPPING?"Stopping PicCap":piccap_owned?"PicCap":capture?"Histogram":"Stopped",piccap_error);
|
used+=snprintf(json+used,sizeof(json)-used,"\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\",\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",",target.piccap_port,test_stage_name(),connection_test.leds,(unsigned long long)connection_test.elapsed,connection_test.error,last_output_at?(long long)(now-last_output_at):-1,(unsigned long long)(retries.due>now?retries.due-now:0),capture_source==SOURCE_PICCAP_STARTING?"Starting PicCap":capture_source==SOURCE_PICCAP_STOPPING?"Stopping PicCap":piccap_owned?"PicCap":capture?"Histogram":"Stopped",piccap_error);
|
||||||
used+=snprintf(json+used,sizeof(json)-used,"\"version\":\"0.10.0\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s,\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,endpoint,target.host,target.port,target.receiver,target.token[0]?"true":"false",target_piccap_compatible(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe);
|
used+=snprintf(json+used,sizeof(json)-used,"\"version\":\"0.11.0\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s,\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,endpoint,target.host,target.port,target.receiver,target.token[0]?"true":"false",target_piccap_compatible(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe);
|
||||||
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s[%u,%u,%u]",i?",":"",rgb[i][0],rgb[i][1],rgb[i][2]);
|
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s[%u,%u,%u]",i?",":"",rgb[i][0],rgb[i][1],rgb[i][2]);
|
||||||
used+=snprintf(json+used,sizeof(json)-used,"],\"ledCount\":%u,\"spatialOutput\":%s,\"blackPercent\":[",led_count,led_count?"true":"false");
|
used+=snprintf(json+used,sizeof(json)-used,"],\"ledCount\":%u,\"spatialOutput\":%s,\"blackPercent\":[",led_count,led_count?"true":"false");
|
||||||
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s%u",i?",":"",black_percent[i]);
|
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s%u",i?",":"",black_percent[i]);
|
||||||
@@ -248,7 +307,9 @@ static int tick(void *unused){
|
|||||||
(void)unused;
|
(void)unused;
|
||||||
uint64_t now=milliseconds();
|
uint64_t now=milliseconds();
|
||||||
model_tick(now);test_tick(&playback_json,now);layout_refresh_tick(&playback_json,now,capture || capture_source==SOURCE_PICCAP);
|
model_tick(now);test_tick(&playback_json,now);layout_refresh_tick(&playback_json,now,capture || capture_source==SOURCE_PICCAP);
|
||||||
if(handover_tick())return 1;
|
stream_tick(&playback_json,now,handover_enabled && !retries.paused && !retries.blocked);
|
||||||
|
bool owned=handover_tick();bridge_tick(now);
|
||||||
|
if(owned)return 1;
|
||||||
if(!capture){
|
if(!capture){
|
||||||
retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds());
|
retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds());
|
||||||
if(retry_take(&retries,milliseconds())){
|
if(retry_take(&retries,milliseconds())){
|
||||||
@@ -259,12 +320,22 @@ static int tick(void *unused){
|
|||||||
}
|
}
|
||||||
if(revoked){set_error("LG revoked capture; recovering after source transition");capture_recoveries++;recovery_started=milliseconds();release_capture(false);retry_source_transition(&retries,milliseconds());return 1;}
|
if(revoked){set_error("LG revoked capture; recovering after source transition");capture_recoveries++;recovery_started=milliseconds();release_capture(false);retry_source_transition(&retries,milliseconds());return 1;}
|
||||||
if(settling){settling--;return 1;}
|
if(settling){settling--;return 1;}
|
||||||
if(letterbox_mode && capture_preset==1 && milliseconds()>=next_border_probe){
|
if(!CALIBRATION_ACTIVE && letterbox_mode && capture_preset==1 && milliseconds()>=next_border_probe){
|
||||||
if(vt_preset(capture,0)!=0){set_error("Border probe preset failed");stop_capture();return 1;}
|
if(vt_preset(capture,0)!=0){set_error("Border probe preset failed");stop_capture();return 1;}
|
||||||
capture_preset=0;settling=2;return 1;
|
capture_preset=0;settling=2;return 1;
|
||||||
}
|
}
|
||||||
Histogram hist[16]={0};int rc=vt_get(capture,hist);
|
Histogram hist[16]={0};int rc=vt_get(capture,hist);
|
||||||
if(rc){set_error("LG histogram read failed; streaming stopped");stop_capture();return 1;}
|
bool populated=!rc && histogram_populated(hist);
|
||||||
|
if(!populated){
|
||||||
|
unsigned char held[16][3];
|
||||||
|
color_valid=false;
|
||||||
|
if(!continuity_frame(&continuity,false,rgb,now,held)){set_error("Histogram data unavailable beyond hold window; recovering capture");release_capture(false);retry_source_transition(&retries,now);return 1;}
|
||||||
|
char *line=region_command(held,led_blends,led_count,(unsigned)(1500-(now-continuity.missing_at)));
|
||||||
|
if(!line || send_line(line)){free(line);set_error("Receiver connection lost while holding colors");stop_capture();return 1;}free(line);
|
||||||
|
if(!receiver_ack()){set_error("Receiver did not acknowledge held colors");stop_capture();return 1;}
|
||||||
|
last_output_at=now;return 1;
|
||||||
|
}
|
||||||
|
sample_at=now;sample_sequence++;
|
||||||
unsigned aggregate[3]={0};
|
unsigned aggregate[3]={0};
|
||||||
for(int i=0;i<16;i++){
|
for(int i=0;i<16;i++){
|
||||||
black_percent[i]=100;
|
black_percent[i]=100;
|
||||||
@@ -278,7 +349,7 @@ static int tick(void *unused){
|
|||||||
for(int i=0;i<16;i++)for(int c=0;c<3;c++){
|
for(int i=0;i<16;i++)for(int c=0;c<3;c++){
|
||||||
rgb[i][c]=histogram_mean(hist[i].bins[c]);aggregate[c]+=rgb[i][c];
|
rgb[i][c]=histogram_mean(hist[i].bins[c]);aggregate[c]+=rgb[i][c];
|
||||||
}
|
}
|
||||||
if(capture_preset==0){
|
if(!CALIBRATION_ACTIVE && capture_preset==0){
|
||||||
int candidate=letterbox_candidate(black_percent,rgb);uint64_t now=milliseconds();
|
int candidate=letterbox_candidate(black_percent,rgb);uint64_t now=milliseconds();
|
||||||
if(candidate<0)pending_letterbox=-1;
|
if(candidate<0)pending_letterbox=-1;
|
||||||
else if(candidate!=pending_letterbox){pending_letterbox=candidate;candidate_since=now;}
|
else if(candidate!=pending_letterbox){pending_letterbox=candidate;candidate_since=now;}
|
||||||
@@ -290,26 +361,15 @@ static int tick(void *unused){
|
|||||||
}
|
}
|
||||||
for(int c=0;c<3;c++)sent_rgb[c]=(unsigned char)(aggregate[c]/16);
|
for(int c=0;c<3;c++)sent_rgb[c]=(unsigned char)(aggregate[c]/16);
|
||||||
unsigned char boosted_mean[3];boost_color(sent_rgb,boosted_mean);memcpy(sent_rgb,boosted_mean,3);
|
unsigned char boosted_mean[3];boost_color(sent_rgb,boosted_mean);memcpy(sent_rgb,boosted_mean,3);
|
||||||
unsigned char mapped_rgb[16][3];calibration_colors(rgb,mapped_rgb);
|
unsigned char mapped_rgb[16][3],stable_rgb[16][3];calibration_colors(rgb,mapped_rgb);
|
||||||
|
continuity_frame(&continuity,true,mapped_rgb,now,stable_rgb);
|
||||||
interpolation_enabled=led_count && calibration_effective()!=CAL_AVERAGE;
|
interpolation_enabled=led_count && calibration_effective()!=CAL_AVERAGE;
|
||||||
bool image_output=strcmp(target.receiver,"HyperHDR")==0;
|
char *line=region_command(stable_rgb,led_blends,led_count,1500);
|
||||||
size_t capacity=256+(size_t)led_count*12;char *line=image_output?spatial_image_command(mapped_rgb,PRIORITY):malloc(capacity);
|
|
||||||
if(!line){set_error("Color buffer allocation failed");stop_capture();return 1;}
|
if(!line){set_error("Color buffer allocation failed");stop_capture();return 1;}
|
||||||
if(!image_output){
|
|
||||||
size_t written=(size_t)snprintf(line,capacity,"{\"command\":\"color\",\"priority\":%d,\"duration\":1500,\"origin\":\"HyperDRM\",\"color\":[",PRIORITY);
|
|
||||||
for(unsigned i=0;i<led_count;i++){
|
|
||||||
unsigned char interpolated[3],boosted[3];LedBlend blend=led_blends[i];
|
|
||||||
blend_color(mapped_rgb[blend.a],mapped_rgb[blend.b],blend.weight,interpolated);boost_color(interpolated,boosted);
|
|
||||||
written+=(size_t)snprintf(line+written,capacity-written,"%s%u,%u,%u",i?",":"",boosted[0],boosted[1],boosted[2]);
|
|
||||||
}
|
|
||||||
snprintf(line+written,capacity-written,"]}\n");
|
|
||||||
}
|
|
||||||
int failed=send_line(line);free(line);
|
int failed=send_line(line);free(line);
|
||||||
if(failed){set_error("Receiver connection lost");stop_capture();return 1;}
|
if(failed){set_error("Receiver connection lost");stop_capture();return 1;}
|
||||||
char answer[4096];size_t used=0;bool complete=false;
|
if(!receiver_ack()){set_error("Receiver did not acknowledge color data");stop_capture();return 1;}
|
||||||
while(used+1<sizeof(answer)){ssize_t len=recv(output,answer+used,sizeof(answer)-used-1,0);if(len<=0)break;used+=(size_t)len;answer[used]=0;if(strchr(answer,'\n')){complete=true;break;}}
|
last_error[0]=0;
|
||||||
if(!complete){set_error("Receiver did not acknowledge color data");stop_capture();return 1;}
|
|
||||||
if(!strstr(answer,"\"success\":true")&&!strstr(answer,"\"success\": true")){set_error("Receiver rejected the color command");stop_capture();return 1;}
|
|
||||||
color_valid=true;last_output_at=milliseconds();frames++;return 1;
|
color_valid=true;last_output_at=milliseconds();frames++;return 1;
|
||||||
}
|
}
|
||||||
static void *symbol(void *lib,const char *name){void *s=dlsym(lib,name);if(!s){fprintf(stderr,"Missing symbol %s\n",name);exit(2);}return s;}
|
static void *symbol(void *lib,const char *name){void *s=dlsym(lib,name);if(!s){fprintf(stderr,"Missing symbol %s\n",name);exit(2);}return s;}
|
||||||
@@ -332,13 +392,13 @@ int main(void){
|
|||||||
unsigned (*timer)(unsigned,int(*)(void*),void*)=symbol(glib,"g_timeout_add");
|
unsigned (*timer)(unsigned,int(*)(void*),void*)=symbol(glib,"g_timeout_add");
|
||||||
void (*loop_unref)(void*)=symbol(glib,"g_main_loop_unref");
|
void (*loop_unref)(void*)=symbol(glib,"g_main_loop_unref");
|
||||||
void *handle=NULL,*loop=loop_new(NULL,0);LSError e;error_init(&e);
|
void *handle=NULL,*loop=loop_new(NULL,0);LSError e;error_init(&e);
|
||||||
Method methods[]={{"start",start,0},{"stop",stop,0},{"status",status,0},{"autostart",autostart,0},{"handover",handover,0},{"settings",settings,0},{"testConnection",test_connection,0},{"calibration",set_calibration,0},{NULL,NULL,0}};
|
Method methods[]={{"start",start,0},{"stop",stop,0},{"status",status,0},{"autostart",autostart,0},{"restartService",restart_service,0},{"handover",handover,0},{"settings",settings,0},{"testConnection",test_connection,0},{"calibration",set_calibration,0},{"calibrationSession",calibration_session,0},{"calibrationDraft",draft_calibration,0},{"calibrationResult",report_calibration,0},{NULL,NULL,0}};
|
||||||
if(!reg(SERVICE,&handle,&e)||!category(handle,"/",methods,NULL,NULL,&e)||!attach(handle,loop,&e)){fprintf(stderr,"Luna registration failed: %s\n",e.message?e.message:"unknown");error_free(&e);return 3;}
|
if(!reg(SERVICE,&handle,&e)||!category(handle,"/",methods,NULL,NULL,&e)||!attach(handle,loop,&e)){fprintf(stderr,"Luna registration failed: %s\n",e.message?e.message:"unknown");error_free(&e);return 3;}
|
||||||
service_handle=handle;
|
service_handle=handle;
|
||||||
if(!boot_enabled())retry_pause(&retries);
|
if(!boot_enabled())retry_pause(&retries);
|
||||||
timer(100,tick,NULL);
|
timer(100,tick,NULL);
|
||||||
while(!quitting){iterate(NULL,0);usleep(10000);}
|
while(!quitting){iterate(NULL,0);usleep(10000);}
|
||||||
test_close();stop_capture();
|
stream_close(milliseconds());test_close();stop_capture();
|
||||||
if(piccap_owned){
|
if(piccap_owned){
|
||||||
handover_enabled=false;retry_pause(&retries);
|
handover_enabled=false;retry_pause(&retries);
|
||||||
uint64_t deadline=milliseconds()+4000;
|
uint64_t deadline=milliseconds()+4000;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#ifndef HYPERDRM_SOURCE_BRIDGE_H
|
||||||
|
#define HYPERDRM_SOURCE_BRIDGE_H
|
||||||
|
static unsigned bridge_phase,bridge_sequence;
|
||||||
|
static uint64_t bridge_started,bridge_clear_at,bridge_fade_at,bridge_last_sent;
|
||||||
|
static unsigned char bridge_from[16][3],bridge_to[16][3];
|
||||||
|
static CaptureSource bridge_source;
|
||||||
|
static char *region_command(const unsigned char colors[16][3],const LedBlend *mapping,unsigned count,unsigned duration){
|
||||||
|
if(!count || !mapping)return NULL;
|
||||||
|
if(!strcmp(target.receiver,"HyperHDR"))return spatial_image_command_duration(colors,PRIORITY,duration);
|
||||||
|
size_t capacity=256+(size_t)count*12;char *line=malloc(capacity);if(!line)return NULL;
|
||||||
|
size_t n=(size_t)snprintf(line,capacity,"{\"command\":\"color\",\"priority\":%d,\"duration\":%u,\"origin\":\"HyperDRM\",\"color\":[",PRIORITY,duration);
|
||||||
|
for(unsigned i=0;i<count;i++){unsigned char value[3];LedBlend b=mapping[i];blend_color(colors[b.a],colors[b.b],b.weight,value);n+=(size_t)snprintf(line+n,capacity-n,"%s%u,%u,%u",i?",":"",value[0],value[1],value[2]);}
|
||||||
|
snprintf(line+n,capacity-n,"]}\n");return line;
|
||||||
|
}
|
||||||
|
static bool bridge_clear(void){return stream.stage==4 && stream_queue("{\"command\":\"clear\",\"priority\":149}\n");}
|
||||||
|
static bool bridge_send(const unsigned char colors[16][3]){
|
||||||
|
if(stream.stage!=4)return false;
|
||||||
|
char *line=region_command(colors,stream.mapping,stream.count,1500);if(!line)return false;
|
||||||
|
bool ok=stream_queue(line);free(line);return ok;
|
||||||
|
}
|
||||||
|
static void bridge_tick(uint64_t now){
|
||||||
|
if(retries.paused || retries.blocked){if(bridge_phase)bridge_clear();bridge_phase=0;bridge_source=capture_source;return;}
|
||||||
|
if(capture_source!=bridge_source){
|
||||||
|
if(capture_source==SOURCE_PICCAP_STARTING){
|
||||||
|
bridge_phase=continuity.available && now-continuity.valid_at<2000?1:0;bridge_started=now;memcpy(bridge_from,continuity.last,sizeof(bridge_from));
|
||||||
|
}else if(capture_source==SOURCE_PICCAP_STOPPING || (bridge_source==SOURCE_PICCAP && capture_source==SOURCE_HISTOGRAM)){
|
||||||
|
bridge_phase=0;
|
||||||
|
if(stream.valid && now-stream.updated<1000){continuity_seed(&continuity,stream.colors,now);bridge_send(stream.colors);}
|
||||||
|
}
|
||||||
|
bridge_source=capture_source;
|
||||||
|
}
|
||||||
|
if(bridge_phase && now-bridge_started>=3000){if(bridge_clear())bridge_phase=0;return;}
|
||||||
|
if(bridge_phase==1 && capture_source==SOURCE_PICCAP && piccap_connected && stream.stage==4){
|
||||||
|
if(bridge_clear()){bridge_sequence=stream.sequence;bridge_clear_at=now;bridge_phase=2;}
|
||||||
|
}else if(bridge_phase==1 && now-bridge_last_sent>=100 && stream.stage==4){
|
||||||
|
if(bridge_send(bridge_from))bridge_last_sent=now;
|
||||||
|
}else if(bridge_phase==2 && stream.valid && stream.sequence>bridge_sequence && stream.updated>=bridge_clear_at+150){
|
||||||
|
memcpy(bridge_to,stream.colors,sizeof(bridge_to));bridge_fade_at=now;bridge_phase=3;
|
||||||
|
bool black=true;for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)if(bridge_to[r][c]>3)black=false;
|
||||||
|
if(black)bridge_phase=0;
|
||||||
|
}
|
||||||
|
if(bridge_phase==3 && now-bridge_last_sent>=100){
|
||||||
|
if(now-bridge_fade_at>=500){if(bridge_clear())bridge_phase=0;}
|
||||||
|
else {unsigned char colors[16][3];continuity_mix(bridge_from,bridge_to,(unsigned)(now-bridge_fade_at),colors);if(bridge_send(colors))bridge_last_sent=now;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -9,7 +9,7 @@ enum {SPATIAL_WIDTH=160,SPATIAL_HEIGHT=90,SPATIAL_PIXELS=SPATIAL_WIDTH*SPATIAL_H
|
|||||||
/* Synthetic color plane from histogram samples, never captured video pixels.
|
/* Synthetic color plane from histogram samples, never captured video pixels.
|
||||||
* HyperHDR 22 treats JSON color as a solid color; image input retains space.
|
* HyperHDR 22 treats JSON color as a solid color; image input retains space.
|
||||||
* The receiver samples this plane using its configured LED rectangles. */
|
* The receiver samples this plane using its configured LED rectangles. */
|
||||||
static char *spatial_image_command(const unsigned char regions[16][3],unsigned priority){
|
static char *spatial_image_command_duration(const unsigned char regions[16][3],unsigned priority,unsigned duration){
|
||||||
static LedBlend blends[SPATIAL_PIXELS];static unsigned generation;
|
static LedBlend blends[SPATIAL_PIXELS];static unsigned generation;
|
||||||
if(generation!=calibration_generation){
|
if(generation!=calibration_generation){
|
||||||
for(unsigned y=0;y<SPATIAL_HEIGHT;y++)for(unsigned x=0;x<SPATIAL_WIDTH;x++)
|
for(unsigned y=0;y<SPATIAL_HEIGHT;y++)for(unsigned x=0;x<SPATIAL_WIDTH;x++)
|
||||||
@@ -18,7 +18,7 @@ static char *spatial_image_command(const unsigned char regions[16][3],unsigned p
|
|||||||
}
|
}
|
||||||
const char alphabet[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const char alphabet[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
size_t capacity=SPATIAL_PIXELS*4+256;char *line=malloc(capacity);if(!line)return NULL;
|
size_t capacity=SPATIAL_PIXELS*4+256;char *line=malloc(capacity);if(!line)return NULL;
|
||||||
size_t used=snprintf(line,capacity,"{\"command\":\"image\",\"priority\":%u,\"duration\":1500,\"origin\":\"HyperDRM\",\"imagewidth\":%u,\"imageheight\":%u,\"format\":\"rgb\",\"name\":\"Histogram colors\",\"imagedata\":\"",priority,SPATIAL_WIDTH,SPATIAL_HEIGHT);
|
size_t used=snprintf(line,capacity,"{\"command\":\"image\",\"priority\":%u,\"duration\":%u,\"origin\":\"HyperDRM\",\"imagewidth\":%u,\"imageheight\":%u,\"format\":\"rgb\",\"name\":\"Histogram colors\",\"imagedata\":\"",priority,duration,SPATIAL_WIDTH,SPATIAL_HEIGHT);
|
||||||
for(unsigned i=0;i<SPATIAL_PIXELS;i++){
|
for(unsigned i=0;i<SPATIAL_PIXELS;i++){
|
||||||
unsigned char rgb[3],color[3];LedBlend b=blends[i];blend_color(regions[b.a],regions[b.b],b.weight,rgb);boost_color(rgb,color);
|
unsigned char rgb[3],color[3];LedBlend b=blends[i];blend_color(regions[b.a],regions[b.b],b.weight,rgb);boost_color(rgb,color);
|
||||||
line[used++]=alphabet[color[0]>>2];line[used++]=alphabet[((color[0]&3)<<4)|(color[1]>>4)];
|
line[used++]=alphabet[color[0]>>2];line[used++]=alphabet[((color[0]&3)<<4)|(color[1]>>4)];
|
||||||
@@ -26,4 +26,5 @@ static char *spatial_image_command(const unsigned char regions[16][3],unsigned p
|
|||||||
}
|
}
|
||||||
line[used++]='"';line[used++]='}';line[used++]='\n';line[used]=0;return line;
|
line[used++]='"';line[used++]='}';line[used++]='\n';line[used]=0;return line;
|
||||||
}
|
}
|
||||||
|
static inline char *spatial_image_command(const unsigned char regions[16][3],unsigned priority){return spatial_image_command_duration(regions,priority,1500);}
|
||||||
#endif
|
#endif
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "histogram.h"
|
||||||
|
#include "continuity.h"
|
||||||
|
int main(void){
|
||||||
|
Histogram hist[16]={0};assert(!histogram_populated(hist));
|
||||||
|
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)hist[r].bins[c][0]=100;
|
||||||
|
assert(histogram_populated(hist));hist[4].bins[2][0]=0;assert(!histogram_populated(hist));
|
||||||
|
Continuity s={0};unsigned char red[16][3]={{0}},blue[16][3]={{0}},black[16][3]={{0}},out[16][3];
|
||||||
|
for(unsigned r=0;r<16;r++){red[r][0]=200;blue[r][2]=200;}
|
||||||
|
assert(!continuity_frame(&s,false,red,1000,out));
|
||||||
|
assert(continuity_frame(&s,true,red,1100,out));assert(!memcmp(out,red,sizeof(out)));
|
||||||
|
assert(continuity_frame(&s,false,black,1200,out));assert(!memcmp(out,red,sizeof(out)));
|
||||||
|
assert(continuity_frame(&s,false,black,2699,out));assert(!continuity_frame(&s,false,black,2700,out));
|
||||||
|
assert(continuity_frame(&s,true,blue,2800,out));
|
||||||
|
continuity_seed(&s,red,3000);continuity_frame(&s,true,blue,3100,out);assert(out[0][0]==200 && out[0][2]==0);
|
||||||
|
continuity_frame(&s,true,blue,3350,out);assert(out[0][0]==100 && out[0][2]==100);
|
||||||
|
continuity_frame(&s,true,blue,3600,out);assert(out[0][0]==0 && out[0][2]==200 && !s.fading);
|
||||||
|
continuity_seed(&s,red,4000);continuity_frame(&s,true,black,4100,out);assert(!memcmp(out,black,sizeof(out)) && !s.fading);
|
||||||
|
continuity_frame(&s,false,red,4200,out);assert(!memcmp(out,black,sizeof(out)));
|
||||||
|
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)black[r][c]=2;
|
||||||
|
continuity_seed(&s,red,4300);continuity_frame(&s,true,black,4400,out);assert(out[0][0]==2 && out[0][1]==2 && !s.fading);
|
||||||
|
puts("PASS: empty data versus populated black, bounded hold, recovery fade, immediate real black");return 0;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ static uint64_t clock_ms=10000;
|
|||||||
static unsigned stopped,called,canceled;
|
static unsigned stopped,called,canceled;
|
||||||
static char last_uri[128],last_data[256];
|
static char last_uri[128],last_data[256];
|
||||||
static uint64_t milliseconds(void){return clock_ms;}
|
static uint64_t milliseconds(void){return clock_ms;}
|
||||||
static void stop_capture(void){stopped++;}
|
static void release_capture(bool clear){(void)clear;stopped++;}
|
||||||
static const char *payload(void *m){return m;}
|
static const char *payload(void *m){return m;}
|
||||||
static void error_init(LSError *e){(void)e;}
|
static void error_init(LSError *e){(void)e;}
|
||||||
static void error_free(LSError *e){(void)e;}
|
static void error_free(LSError *e){(void)e;}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#define _DEFAULT_SOURCE
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include "target.h"
|
||||||
|
#include "layout.h"
|
||||||
|
#include "receiver_stream.h"
|
||||||
|
static void fixture(PlaybackJson *json,bool modern,bool login){
|
||||||
|
int server=socket(AF_INET,SOCK_STREAM,0);assert(server>=0);
|
||||||
|
struct sockaddr_in address={.sin_family=AF_INET,.sin_addr={htonl(INADDR_LOOPBACK)}};
|
||||||
|
assert(!bind(server,(void*)&address,sizeof(address)) && !listen(server,1));socklen_t size=sizeof(address);assert(!getsockname(server,(void*)&address,&size));
|
||||||
|
target.port=ntohs(address.sin_port);snprintf(target.token,sizeof(target.token),"%s",login?"fixture-token":"");
|
||||||
|
pid_t child=fork();assert(child>=0);
|
||||||
|
if(!child){
|
||||||
|
int fd=accept(server,NULL,NULL);if(fd<0)_exit(1);FILE *input=fdopen(dup(fd),"r");char request[512];
|
||||||
|
if(login){if(!fgets(request,sizeof(request),input) || !strstr(request,"authorize"))_exit(2);write(fd,"{\"success\":true}\n",17);}
|
||||||
|
if(!fgets(request,sizeof(request),input) || !strstr(request,"serverinfo"))_exit(3);
|
||||||
|
const char *layout="{\"success\":true,\"info\":{\"leds\":[{\"hmin\":0,\"hmax\":1,\"vmin\":0,\"vmax\":0.1}]}}\n";write(fd,layout,strlen(layout));
|
||||||
|
if(!fgets(request,sizeof(request),input) || !strstr(request,"ledstream-start"))_exit(4);
|
||||||
|
const char *reply=modern?"{\"success\":true,\"command\":\"ledcolors-ledstream-update\",\"data\":{\"leds\":[20,30,40]}}\n":"{\"success\":true,\"command\":\"ledcolors-ledstream-update\",\"result\":{\"leds\":[20,30,40]}}\n";
|
||||||
|
write(fd,reply,13);usleep(20000);write(fd,reply+13,strlen(reply)-13);usleep(500000);close(fd);fclose(input);close(server);_exit(0);
|
||||||
|
}
|
||||||
|
stream_close(0);stream.retry_at=0;
|
||||||
|
for(unsigned i=0;i<500 && !stream.valid;i++){stream_tick(json,1000+i*5,true);usleep(1000);}
|
||||||
|
assert(stream.valid && stream.sequence==1 && stream.count==1);
|
||||||
|
for(unsigned r=0;r<16;r++){assert(stream.colors[r][0]==20 && stream.colors[r][1]==30 && stream.colors[r][2]==40);}
|
||||||
|
void *bad=json->parse("{\"result\":{\"leds\":[0,0,999]}}");assert(!stream_colors(json,bad,2000));json->put(bad);assert(stream.colors[0][2]==40);
|
||||||
|
bad=json->parse("{\"data\":{\"leds\":[0,0,0]}}");assert(stream_colors(json,bad,2000));json->put(bad);assert(stream.colors[0][2]==0);
|
||||||
|
bad=json->parse("{\"data\":{\"leds\":[1,2,3,4,5,6]}}");assert(!stream_colors(json,bad,2000) && !stream.revision);json->put(bad);
|
||||||
|
stream_tick(json,2001,false);assert(stream.fd<0 && !stream.valid);int status;waitpid(child,&status,0);assert(WIFEXITED(status) && !WEXITSTATUS(status));close(server);
|
||||||
|
}
|
||||||
|
int main(void){
|
||||||
|
PlaybackJson json={0};assert(playback_json_init(&json));fixture(&json,false,false);fixture(&json,true,true);
|
||||||
|
stream_start(1000);stream_tick(&json,5000,true);assert(stream.fd<0);stream_close(0);
|
||||||
|
TargetConfig copy=target;assert(target_valid(©));assert(target_piccap_compatible(©));assert(!target_piccap_local(©));assert(target_piccap_host(©));
|
||||||
|
assert(!target_parse(&json,"{}",©));assert(!target_save("/no/such/profile",©));assert(!target_load(&json,"/no/such/profile",©));
|
||||||
|
int fd=target_connect(©);if(fd>=0)close(fd);
|
||||||
|
dlclose(json.lib);puts("PASS: fragmented raw LED subscription, token login, old/new envelopes, invalid samples, real black, timeout and cleanup");return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "histogram.h"
|
||||||
|
#include "continuity.h"
|
||||||
|
#include "interpolation.h"
|
||||||
|
#define PRIORITY 149
|
||||||
|
typedef struct {unsigned char a,b;unsigned weight;} LedBlend;
|
||||||
|
typedef enum {SOURCE_HISTOGRAM,SOURCE_PICCAP_STARTING,SOURCE_PICCAP,SOURCE_PICCAP_STOPPING} CaptureSource;
|
||||||
|
static CaptureSource capture_source;
|
||||||
|
static struct {bool paused,blocked;} retries;
|
||||||
|
static struct {char receiver[16];} target={"Hyperion"};
|
||||||
|
static struct {int stage;bool valid;uint64_t updated;unsigned sequence,count;LedBlend *mapping;unsigned char colors[16][3];} stream;
|
||||||
|
static Continuity continuity;
|
||||||
|
static bool piccap_connected;
|
||||||
|
static char *queued;
|
||||||
|
static bool stream_queue(const char *line){if(queued)return false;queued=strdup(line);return queued!=NULL;}
|
||||||
|
static char *spatial_image_command_duration(const unsigned char colors[16][3],unsigned priority,unsigned duration){(void)colors;(void)priority;(void)duration;return strdup("image");}
|
||||||
|
#include "source_bridge.h"
|
||||||
|
static void consume(void){free(queued);queued=NULL;}
|
||||||
|
int main(void){
|
||||||
|
LedBlend mapping={0,0,0};stream.mapping=&mapping;stream.count=1;stream.stage=4;
|
||||||
|
for(unsigned r=0;r<16;r++){continuity.last[r][0]=200;stream.colors[r][2]=200;}continuity.available=true;continuity.valid_at=1000;
|
||||||
|
capture_source=SOURCE_PICCAP_STARTING;bridge_tick(1000);assert(bridge_phase==1 && strstr(queued,"200,0,0"));consume();
|
||||||
|
capture_source=SOURCE_PICCAP;piccap_connected=true;bridge_tick(1200);assert(bridge_phase==2 && strstr(queued,"clear"));consume();
|
||||||
|
stream.valid=true;stream.sequence=1;stream.updated=1300;bridge_tick(1300);assert(bridge_phase==2);
|
||||||
|
stream.sequence=2;stream.updated=1400;bridge_tick(1400);assert(bridge_phase==3 && strstr(queued,"200,0,0"));consume();
|
||||||
|
bridge_tick(1650);assert(strstr(queued,"100,0,100"));consume();
|
||||||
|
bridge_tick(1900);assert(!bridge_phase && strstr(queued,"clear"));consume();
|
||||||
|
capture_source=SOURCE_PICCAP_STOPPING;stream.updated=2000;bridge_tick(2000);assert(continuity.fading && continuity.last[0][2]==200 && strstr(queued,"0,0,200"));consume();
|
||||||
|
capture_source=SOURCE_HISTOGRAM;bridge_tick(2100);assert(!queued);
|
||||||
|
capture_source=SOURCE_PICCAP_STARTING;continuity.valid_at=3000;bridge_tick(3000);consume();bridge_tick(6000);assert(!bridge_phase && strstr(queued,"clear"));consume();
|
||||||
|
capture_source=SOURCE_PICCAP_STARTING;bridge_source=SOURCE_HISTOGRAM;continuity.valid_at=7000;bridge_tick(7000);consume();retries.paused=true;bridge_tick(7100);assert(!bridge_phase && strstr(queued,"clear"));consume();
|
||||||
|
retries.paused=false;bridge_source=SOURCE_HISTOGRAM;continuity.valid_at=8000;capture_source=SOURCE_PICCAP_STARTING;bridge_tick(8000);consume();
|
||||||
|
capture_source=SOURCE_PICCAP;bridge_tick(8100);consume();stream.sequence++;stream.updated=8300;memset(stream.colors,0,sizeof(stream.colors));bridge_tick(8300);assert(!bridge_phase && !queued);
|
||||||
|
bridge_source=SOURCE_HISTOGRAM;capture_source=SOURCE_PICCAP_STARTING;continuity.valid_at=1000;bridge_tick(9000);assert(!bridge_phase && !queued);
|
||||||
|
stream.stage=1;bridge_phase=1;bridge_started=9000;bridge_tick(12000);assert(!queued);retries.paused=true;bridge_tick(12100);assert(!bridge_phase && !queued);
|
||||||
|
strcpy(target.receiver,"HyperHDR");char *line=region_command(stream.colors,&mapping,1,500);assert(!strcmp(line,"image"));free(line);assert(!region_command(stream.colors,NULL,0,500));
|
||||||
|
puts("PASS: both source transitions, fresh sample gate, fade midpoint/end, bounded timeout, manual-stop cleanup");return 0;
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"id": "org.webosbrew.histogramhyperion", "version": "0.10.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
|
{"id": "org.webosbrew.histogramhyperion", "version": "0.11.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
|
||||||
@@ -33,3 +33,13 @@ SoC or firmware; document that test context with contributed profiles.
|
|||||||
Profiles only control spatial region assignment. Saturation, brightness and
|
Profiles only control spatial region assignment. Saturation, brightness and
|
||||||
smoothing remain receiver settings. Runtime selection invalidates the generated
|
smoothing remain receiver settings. Runtime selection invalidates the generated
|
||||||
HyperHDR geometry and refreshes the Hyperion LED mapping without capture restart.
|
HyperHDR geometry and refreshes the Hyperion LED mapping without capture restart.
|
||||||
|
|
||||||
|
## Guided measurement
|
||||||
|
|
||||||
|
The app's auto calibration uses an actual local video plane, not UI overlays.
|
||||||
|
It collects a black reference and fresh samples from 32 perimeter patches,
|
||||||
|
rejects global responses, and derives sorted positions from each region's local
|
||||||
|
response peak. A candidate must cover all four edges. The resulting JSON is a
|
||||||
|
proposal until Save profile is selected. Use Validate profile after saving and
|
||||||
|
check all four color quadrants before contributing the profile. Cancellation and
|
||||||
|
failed measurement preserve the saved profile.
|
||||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert=require('assert'),fs=require('fs'),vm=require('vm');
|
||||||
|
const c={};vm.createContext(c);vm.runInContext(fs.readFileSync('app/calibration.js','utf8'),c);
|
||||||
|
const baseline=Array(16).fill(10);
|
||||||
|
const patches=Array.from({length:32},(_,i)=>Array.from({length:16},(_,r)=>{
|
||||||
|
const center=r*2+.5,distance=Math.min(Math.abs(i-center),32-Math.abs(i-center));
|
||||||
|
return 10+80*Math.max(0,1-distance/2);
|
||||||
|
}));
|
||||||
|
const fit=c.calibrationFit(baseline,patches,'OTHER-TV');assert.equal(fit.points.length,16);
|
||||||
|
fit.points.forEach((p,i)=>{assert.equal(p.region,i);assert(Math.abs(p.position-(i*2+1)/8)<.05);});
|
||||||
|
assert.throws(()=>c.calibrationFit(baseline,Array.from({length:32},()=>Array(16).fill(90)),'OTHER-TV'),/reliable spatial/);
|
||||||
|
assert.throws(()=>c.calibrationFit(baseline,Array.from({length:32},()=>baseline),'OTHER-TV'),/reliable spatial/);
|
||||||
|
assert.throws(()=>c.calibrationFit(baseline,patches.slice(1),'OTHER-TV'),/Incomplete/);
|
||||||
|
assert.throws(()=>c.calibrationAverage([baseline,baseline]),/fresh/);
|
||||||
|
const nested=patches.map(p=>p.map((v,r)=>r===15?p[0]:v));
|
||||||
|
const reduced=c.calibrationFit(baseline,nested,'NESTED-TV');assert.equal(reduced.points.length,15);assert(!reduced.points.some(p=>p.region===15));
|
||||||
|
const duplicate=patches.map(p=>Array(16).fill(p[0]));assert.throws(()=>c.calibrationFit(baseline,duplicate,'DUPLICATE-TV'),/reliable spatial/);
|
||||||
|
assert.deepEqual(Array.from(c.calibrationAverage([baseline,baseline,baseline])),baseline);
|
||||||
|
console.log('PASS: measured edge positions, global/absent response rejection and fresh sample minimum');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert=require('assert'),fs=require('fs'),vm=require('vm');
|
||||||
|
const events={},windowEvents={},elements={};let inputFocuses=0;
|
||||||
|
const doc={activeElement:null,getElementById(id){return elements[id];},addEventListener(k,f){(events[k]||(events[k]=[])).push(f);}};
|
||||||
|
function element(id,input){return {id,tagName:input?'INPUT':'DIV',type:'text',readOnly:true,disabled:false,style:{},attributes:{},handlers:{},
|
||||||
|
addEventListener(k,f){this.handlers[k]=f;},setAttribute(k,v){this.attributes[k]=v;},
|
||||||
|
focus(){if(doc.activeElement && doc.activeElement!==this)doc.activeElement.blur();doc.activeElement=this;if(input)inputFocuses++;},
|
||||||
|
blur(){if(this.handlers.blur)this.handlers.blur();if(doc.activeElement===this)doc.activeElement=null;}
|
||||||
|
};}
|
||||||
|
['host','port','piccapPort','token','customProfile'].forEach(id=>{elements[id]=element(id,true);elements[id+'Field']=element(id+'Field',false);});
|
||||||
|
['overview','receiver','calibration'].forEach(key=>{elements[key+'Panel']=element(key+'Panel');elements[key+'Tab']=element(key+'Tab');});
|
||||||
|
['start','receiver','calibrate','calibrationShortcut'].forEach(id=>elements[id]=element(id));
|
||||||
|
const win={addEventListener(k,f){windowEvents[k]=f;}};
|
||||||
|
const c={document:doc,window:win};vm.createContext(c);vm.runInContext(fs.readFileSync('app/navigation.js','utf8'),c);
|
||||||
|
elements.receiverTab.onclick();assert.equal(elements.receiverPanel.style.display,'block');assert.equal(elements.overviewPanel.style.display,'none');assert.equal(doc.activeElement.id,'receiver');
|
||||||
|
elements.hostField.focus();assert.equal(inputFocuses,0);assert(elements.host.readOnly);
|
||||||
|
elements.hostField.onclick();assert.equal(inputFocuses,1);assert(!elements.host.readOnly);assert.equal(doc.activeElement.id,'host');
|
||||||
|
events.keyboardStateChange[0]({detail:{visibility:false}});assert(elements.host.readOnly);assert.equal(doc.activeElement.id,'hostField');
|
||||||
|
elements.portField.focus();assert.equal(inputFocuses,1);elements.portField.onclick();assert.equal(inputFocuses,2);
|
||||||
|
let prevented=false;events.keydown[0]({keyCode:461,preventDefault(){prevented=true;}});assert(prevented && elements.port.readOnly && doc.activeElement.id==='portField');
|
||||||
|
elements.token.disabled=true;elements.tokenField.onclick();assert.equal(inputFocuses,2);
|
||||||
|
elements.calibrationShortcut.onclick();assert.equal(doc.activeElement.id,'calibrate');assert.equal(elements.calibrationPanel.style.display,'block');assert.equal(elements.receiverPanel.style.display,'none');
|
||||||
|
elements.customProfileField.onclick();assert.equal(inputFocuses,3);elements.start.focus();assert(elements.customProfile.readOnly);
|
||||||
|
console.log('PASS: three sections, calibration shortcut, highlight without input focus, explicit editing, keyboard close/Back and disabled input');
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const assert=require('assert'),fs=require('fs'),vm=require('vm');
|
const assert=require('assert'),fs=require('fs'),vm=require('vm');
|
||||||
const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics','profile','customEditor','customProfile','saveProfile','profileStatus'];
|
const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics','profile','customEditor','customProfile','saveProfile','profileStatus','calibrate','validateCalibration','cancelCalibration','editProfile','customProfileField','overviewTab','receiverTab','calibrationTab','calibrationShortcut','hostField','portField','piccapPortField','tokenField','grantRoot','rootHint'];
|
||||||
const elements={};
|
const elements={};
|
||||||
ids.forEach(id=>elements[id]={value:'',textContent:'',disabled:false,checked:false,attributes:{},style:{},addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}});
|
ids.forEach(id=>elements[id]={value:'',textContent:'',disabled:false,checked:false,attributes:{},style:{},addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}});
|
||||||
const requests=[];
|
const requests=[];
|
||||||
@@ -14,7 +14,7 @@ const context={PalmServiceBridge:Bridge,document:{getElementById:id=>elements[id
|
|||||||
vm.runInNewContext(fs.readFileSync('app/app.js','utf8'),context);
|
vm.runInNewContext(fs.readFileSync('app/app.js','utf8'),context);
|
||||||
assert(elements.testResult.textContent.includes('120 LEDs'));
|
assert(elements.testResult.textContent.includes('120 LEDs'));
|
||||||
assert(elements.diagnostics.textContent.includes('Root service'));
|
assert(elements.diagnostics.textContent.includes('Root service'));
|
||||||
assert(controls.includes(elements.piccapPort)&&controls.includes(elements.testConnection));
|
assert(controls.includes(elements.piccapPortField)&&controls.includes(elements.testConnection));
|
||||||
context.targetDirty=true;elements.testConnection.onclick();assert(elements.error.textContent.includes('Save receiver'));
|
context.targetDirty=true;elements.testConnection.onclick();assert(elements.error.textContent.includes('Save receiver'));
|
||||||
elements.piccapPort.value='0';elements.saveTarget.onclick();assert(elements.error.textContent.includes('FlatBuffer port'));
|
elements.piccapPort.value='0';elements.saveTarget.onclick();assert(elements.error.textContent.includes('FlatBuffer port'));
|
||||||
elements.piccapPort.value='19446';elements.host.value='10.1.0.100';elements.token.value='';elements.saveTarget.onclick();
|
elements.piccapPort.value='19446';elements.host.value='10.1.0.100';elements.token.value='';elements.saveTarget.onclick();
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ buttons[4].disabled=false;buttons[3].focus();key(39);assert.equal(doc.activeElem
|
|||||||
key(39);assert.equal(doc.activeElement.id,'start');key(37);assert.equal(doc.activeElement.id,'handover');
|
key(39);assert.equal(doc.activeElement.id,'start');key(37);assert.equal(doc.activeElement.id,'handover');
|
||||||
buttons[2].tagName='INPUT';buttons[2].type='text';buttons[2].focus();
|
buttons[2].tagName='INPUT';buttons[2].type='text';buttons[2].focus();
|
||||||
assert(!key(37));assert(!key(39));assert(!key(13));assert.equal(doc.activeElement.id,'refresh');
|
assert(!key(37));assert(!key(39));assert(!key(13));assert.equal(doc.activeElement.id,'refresh');
|
||||||
key(40);assert.equal(doc.activeElement.id,'autostart');key(38);assert.equal(doc.activeElement.id,'refresh');
|
assert(!key(40));assert(!key(38));assert.equal(doc.activeElement.id,'refresh');
|
||||||
buttons[2].tagName='TEXTAREA';buttons[2].focus();
|
buttons[2].tagName='TEXTAREA';buttons[2].focus();
|
||||||
assert(!key(37));assert(!key(39));assert(!key(13));
|
assert(!key(37));assert(!key(39));assert(!key(13));
|
||||||
key(40);assert.equal(doc.activeElement.id,'autostart');
|
assert(!key(40));assert(!key(38));assert.equal(doc.activeElement.id,'refresh');
|
||||||
key(38);assert.equal(doc.activeElement.id,'refresh');
|
buttons[2].readOnly=true;assert(key(40));assert.equal(doc.activeElement.id,'autostart');
|
||||||
buttons[2].tagName='BUTTON';
|
buttons[2].tagName='BUTTON';
|
||||||
buttons.forEach(button=>button.disabled=true);assert(key(39));
|
buttons.forEach(button=>button.disabled=true);assert(key(39));
|
||||||
console.log('PASS: arrow navigation, wraparound, disabled controls, OK once per press, pointer handoff, focus recovery, Back preserved');
|
console.log('PASS: arrow navigation, wraparound, disabled controls, OK once per press, pointer handoff, focus recovery, Back preserved');
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert=require('assert'),fs=require('fs'),vm=require('vm');
|
||||||
|
const elements={},requests=[],timers=new Map();let next=0,uid=1000,enabled=false,deny=false,delayRoot=false,pendingRoot=null;
|
||||||
|
for(const match of fs.readFileSync('app/index.html','utf8').matchAll(/id="([^"]+)"/g))elements[match[1]]={style:{},value:'',textContent:'',attributes:{},disabled:false,addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}};
|
||||||
|
class Bridge {
|
||||||
|
call(uri,raw){
|
||||||
|
const data=JSON.parse(raw);requests.push({uri,data});let r={returnValue:true};
|
||||||
|
if(uri.endsWith('/status'))r={returnValue:true,uid,autostart:enabled,piccapInstalled:true,connectionTest:{state:'Not tested'},calibrationProfile:'auto',effectiveCalibration:'g4',customPoints:[],tvModel:'OLED65G42LW.DEUQLJP'};
|
||||||
|
if(uri.endsWith('/autostart')){if(uid)r={returnValue:false,rootRequired:true};else enabled=data.enabled;}
|
||||||
|
if(uri.includes('hbchannel')){if(delayRoot){pendingRoot=this;return;}if(deny)r={returnValue:false,errorText:'Denied by Homebrew Channel'};}
|
||||||
|
if(uri.endsWith('/restartService'))uid=0;
|
||||||
|
this.onservicecallback(JSON.stringify(r));
|
||||||
|
}
|
||||||
|
cancel(){}
|
||||||
|
}
|
||||||
|
const c={document:{getElementById(id){assert(elements[id],id);return elements[id];},createElement(){return {};}},window:{},PalmServiceBridge:Bridge,setupRemoteNavigation(){},setInterval(){},setTimeout(fn){const id=++next;timers.set(id,fn);return id;},clearTimeout(id){timers.delete(id);}};
|
||||||
|
vm.createContext(c);vm.runInContext(fs.readFileSync('app/app.js','utf8'),c);
|
||||||
|
function flush(){for(let i=0;timers.size && i<20;i++){const [id,fn]=timers.entries().next().value;timers.delete(id);fn();}assert(!timers.size);}
|
||||||
|
assert.equal(elements.grantRoot.style.display,'inline-block');elements.autostart.onclick();flush();
|
||||||
|
assert(enabled && !elements.autostart.disabled && uid===0);assert.equal(elements.grantRoot.style.display,'none');
|
||||||
|
const grant=requests.find(r=>r.uri.includes('hbchannel'));assert.deepEqual(grant.data,{id:'org.webosbrew.histogramhyperion.service'});assert(requests.some(r=>r.uri.endsWith('/restartService')));
|
||||||
|
const grantCount=requests.filter(r=>r.uri.includes('hbchannel')).length;
|
||||||
|
elements.autostart.onclick();flush();assert(!enabled);assert.equal(requests.filter(r=>r.uri.includes('hbchannel')).length,grantCount);
|
||||||
|
uid=1000;deny=true;elements.autostart.onclick();flush();assert(!enabled && !elements.autostart.disabled && !elements.grantRoot.disabled);assert(elements.error.textContent.includes('could not grant root'));
|
||||||
|
deny=false;delayRoot=true;elements.grantRoot.onclick();elements.autostart.onclick();assert(pendingRoot && c.rootWaiters.length===2);
|
||||||
|
delayRoot=false;pendingRoot.onservicecallback(JSON.stringify({returnValue:true}));flush();assert(enabled && !elements.autostart.disabled && !c.rootWaiters.length);
|
||||||
|
console.log('PASS: root-required autostart repairs only own service, confirms root after restart, retries setting, avoids unnecessary elevation and reports denial');
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use strict';
|
||||||
|
const assert=require('assert'),fs=require('fs'),vm=require('vm');
|
||||||
|
const elements={},events={},requests=[];let active=false,sequence=0,globalResponse=false;
|
||||||
|
['calibrationVideo','cancelCalibration','calibrate','validateCalibration','wizardStatus','customProfile'].forEach(id=>elements[id]={style:{},disabled:false,currentTime:0,textContent:'',value:'',events:{},focus(){},pause(){},play(){return Promise.resolve();},addEventListener(k,f){this.events[k]=f;}});
|
||||||
|
const c={profile:'auto',profileDirty:false,targetDirty:false,showProfile(){},display(){},setInterval(){return 1;},clearInterval(){},setTimeout(){},Date,Array,JSON,
|
||||||
|
window:{addEventListener(k,f){events[k]=f;}},document:{getElementById(id){return elements[id];},addEventListener(k,f){events[k]=f;}},
|
||||||
|
call(method,done,data){
|
||||||
|
requests.push({method,data});if(method==='calibrationSession')active=data.enabled;
|
||||||
|
const t=elements.calibrationVideo.currentTime,step=t<4?-1:Math.floor((t-4)/2);
|
||||||
|
const regions=Array.from({length:16},(_,r)=>{
|
||||||
|
const center=r*2+.5,d=Math.min(Math.abs(step-center),32-Math.abs(step-center));
|
||||||
|
const value=step<0?10:10+80*(globalResponse?1:Math.max(0,1-d/2));return [value,value,value];
|
||||||
|
});
|
||||||
|
done(method==='status'?{returnValue:true,running:true,activeSource:'HyperDRM',tvModel:'OTHER-TV',colorValid:true,calibrating:active,sampleSequence:++sequence,sampleAgeMs:10,regions}:{returnValue:true});
|
||||||
|
}
|
||||||
|
};vm.createContext(c);
|
||||||
|
vm.runInContext(fs.readFileSync('app/calibration.js','utf8'),c);vm.runInContext(fs.readFileSync('app/wizard.js','utf8'),c);
|
||||||
|
function measure(){
|
||||||
|
elements.calibrate.onclick();assert(active);assert.deepEqual(c.window.remoteNavigationScope,['cancelCalibration']);
|
||||||
|
[1,1.5,2].forEach(t=>{elements.calibrationVideo.currentTime=t;c.wizardSample();});
|
||||||
|
for(let step=0;step<32;step++)[.8,1.1,1.4].forEach(t=>{elements.calibrationVideo.currentTime=4+step*2+t;c.wizardSample();});
|
||||||
|
elements.calibrationVideo.currentTime=68;c.wizardSample();
|
||||||
|
assert(!active && c.wizard===null && c.window.remoteNavigationScope===null);
|
||||||
|
}
|
||||||
|
measure();assert.equal(c.profile,'custom');assert(c.profileDirty);
|
||||||
|
const proposal=JSON.parse(elements.customProfile.value);assert.equal(proposal.points.length,16);
|
||||||
|
assert(requests.some(r=>r.method==='calibrationDraft'));assert(!requests.some(r=>r.method==='calibration'));
|
||||||
|
c.profileDirty=false;globalResponse=true;measure();assert(elements.wizardStatus.textContent.includes('reliable spatial'));assert.equal(elements.customProfile.value,JSON.stringify(proposal,null,2));
|
||||||
|
globalResponse=false;elements.calibrate.onclick();elements.cancelCalibration.onclick();assert(!active && !c.wizard);
|
||||||
|
elements.validateCalibration.onclick();assert.equal(elements.calibrationVideo.currentTime,68);elements.calibrationVideo.events.ended();assert(!active && elements.wizardStatus.textContent.includes('Top-left red'));
|
||||||
|
elements.calibrate.onclick();let prevented=false;events.keydown({keyCode:461,preventDefault(){prevented=true;}});assert(prevented && !active);
|
||||||
|
console.log('PASS: guided measurement, proposal without save, invalid-response preservation, validation, cancel/Back and scoped remote navigation');
|
||||||
Reference in new issue
Block a user